This repository has no description
18 kB
492 lines
1# Home Assistant side of ./internet.nix: sensors that query VictoriaMetrics on
2# styx (the gg23 monitoring server) and an "Internet" dashboard explaining
3# why the internet is slow. Both are mounted into the container as a package
4# and a YAML dashboard (configs/hass loads /config/packages); generated as
5# JSON, which HA's YAML loader reads as well.
6{
7 lib,
8 pkgs,
9 self,
10 ...
11}:
12let
13 inherit (import ../monitoring/default.nix) ports;
14 # IPv4: VictoriaMetrics doesn't listen on IPv6, styx.r resolves to it first.
15 vm = "http://${self.inputs.kartei.hosts.styx.nets.retiolum.ip4.addr}:${toString ports.victoriametrics}/api/v1/query";
16
17 # booked Vodafone cable rate in Mbit/s (speedtests from acheron: ~920/50)
18 line = {
19 down = 1000;
20 up = 50;
21 };
22
23 ping = sel: {
24 latency = ''avg(rate(smokeping_response_duration_seconds_sum{host="acheron",${sel}}[5m]) / rate(smokeping_response_duration_seconds_count{host="acheron",${sel}}[5m])) * 1000'';
25 p95 = ''histogram_quantile(0.95, sum(rate(smokeping_response_duration_seconds_bucket{host="acheron",${sel}}[5m])) by (le)) * 1000'';
26 loss = ''100 * (1 - sum(rate(smokeping_response_duration_seconds_count{host="acheron",${sel}}[5m])) / sum(rate(smokeping_requests_total{host="acheron",${sel}}[5m])))'';
27 };
28 internet = ping ''target_name=~"cloudflare|google"'';
29 cmts = ping ''target_name="cmts"'';
30 station = ping ''target_name="station"'';
31 wan = dir: ''rate(host_network_${dir}_bytes_total{host="acheron",device="et0"}[2m]) * 8 / 1e6'';
32 docsis = ''{host="acheron",channel_type="SC-QAM"}'';
33
34 # id -> sensor entity. `name` ("Internet <name>") is what HA derives the
35 # entity id from (see `e`), so it must not change; `label` is the display
36 # name (homeassistant.customize friendly_name).
37 sensors = {
38 latency = {
39 name = "Latency";
40 label = "Latency to internet";
41 query = internet.latency;
42 unit = "ms";
43 icon = "mdi:timer-outline";
44 };
45 latency_p95 = {
46 name = "Latency p95";
47 label = "Latency spikes (p95)";
48 query = internet.p95;
49 unit = "ms";
50 icon = "mdi:timer-alert-outline";
51 };
52 loss = {
53 name = "Packet loss";
54 label = "Packet loss to internet";
55 query = internet.loss;
56 unit = "%";
57 icon = "mdi:lan-disconnect";
58 };
59 cmts_latency = {
60 name = "Cable latency";
61 label = "Latency over the cable";
62 query = cmts.latency;
63 unit = "ms";
64 icon = "mdi:timer-outline";
65 };
66 cmts_loss = {
67 name = "Cable packet loss";
68 label = "Packet loss over the cable";
69 query = cmts.loss;
70 unit = "%";
71 icon = "mdi:lan-disconnect";
72 };
73 station_latency = {
74 name = "Station latency";
75 label = "Latency to the router";
76 query = station.latency;
77 unit = "ms";
78 icon = "mdi:router-network";
79 };
80 station_loss = {
81 name = "Station packet loss";
82 label = "Packet loss to the router";
83 query = station.loss;
84 unit = "%";
85 icon = "mdi:router-network";
86 };
87 download = {
88 name = "Download";
89 label = "Download";
90 query = wan "receive";
91 unit = "Mbit/s";
92 device_class = "data_rate";
93 icon = "mdi:download";
94 };
95 upload = {
96 name = "Upload";
97 label = "Upload";
98 query = wan "transmit";
99 unit = "Mbit/s";
100 device_class = "data_rate";
101 icon = "mdi:upload";
102 };
103 # hourly, ./internet-speedtest.nix; last result within 2h
104 speedtest_download = {
105 name = "Speedtest download";
106 label = "Speedtest download";
107 query = ''last_over_time(speedtest_download_bits_per_second{host="acheron"}[2h]) / 1e6'';
108 unit = "Mbit/s";
109 device_class = "data_rate";
110 icon = "mdi:speedometer";
111 };
112 speedtest_upload = {
113 name = "Speedtest upload";
114 label = "Speedtest upload";
115 query = ''last_over_time(speedtest_upload_bits_per_second{host="acheron"}[2h]) / 1e6'';
116 unit = "Mbit/s";
117 device_class = "data_rate";
118 icon = "mdi:speedometer";
119 };
120 speedtest_latency = {
121 name = "Speedtest latency";
122 label = "Speedtest latency";
123 query = ''last_over_time(speedtest_latency_seconds{host="acheron"}[2h]) * 1000'';
124 unit = "ms";
125 icon = "mdi:timer-outline";
126 };
127 ds_snr_min = {
128 name = "Downstream SNR min";
129 label = "Downstream SNR min";
130 query = ''min(vodafone_station_docsis_snr_db{direction="downstream",host="acheron"})'';
131 unit = "dB";
132 icon = "mdi:signal";
133 };
134 ds_power_min = {
135 name = "Downstream power min";
136 label = "Downstream power min";
137 query = ''min(vodafone_station_docsis_power_dbmv{direction="downstream",${lib.removePrefix "{" docsis})'';
138 unit = "dBmV";
139 icon = "mdi:arrow-down-bold";
140 };
141 ds_power_max = {
142 name = "Downstream power max";
143 label = "Downstream power max";
144 query = ''max(vodafone_station_docsis_power_dbmv{direction="downstream",${lib.removePrefix "{" docsis})'';
145 unit = "dBmV";
146 icon = "mdi:arrow-down-bold";
147 };
148 us_power_max = {
149 name = "Upstream power max";
150 label = "Upstream power max";
151 query = ''max(vodafone_station_docsis_power_dbmv{direction="upstream",${lib.removePrefix "{" docsis})'';
152 unit = "dBmV";
153 icon = "mdi:arrow-up-bold";
154 };
155 unlocked = {
156 name = "Unlocked channels";
157 label = "DOCSIS channels not locked";
158 query = ''count(vodafone_station_docsis_locked{host="acheron"} == 0) or vector(0)'';
159 icon = "mdi:lock-open-alert";
160 format = "int";
161 };
162 docsis_online = {
163 name = "DOCSIS online";
164 label = "Cable modem online";
165 query = ''max(vodafone_station_docsis_online{host="acheron"})'';
166 icon = "mdi:cable-data";
167 format = "yesno";
168 };
169 station_up = {
170 name = "Station scrape";
171 label = "Router stats readable";
172 query = ''max(vodafone_station_up{host="acheron"})'';
173 icon = "mdi:router-wireless";
174 format = "yesno";
175 };
176 station_uptime = {
177 name = "Station uptime";
178 label = "Router uptime";
179 query = ''max(vodafone_station_uptime_seconds{host="acheron"})'';
180 unit = "s";
181 device_class = "duration";
182 icon = "mdi:restart";
183 };
184 };
185
186 restEntry =
187 id: s:
188 let
189 v = "(value_json.data.result[0].value[1] | float)";
190 format = s.format or "float";
191 in
192 {
193 resource = vm;
194 params.query = s.query;
195 scan_interval = 60;
196 timeout = 20;
197 sensor = [
198 (
199 {
200 name = "Internet ${s.name}";
201 unique_id = "gg23_internet_${id}";
202 # no series yet (e.g. rate() right after a restart): unavailable
203 availability = "{{ value_json is defined and value_json.data.result | length > 0 }}";
204 value_template =
205 {
206 float = "{{ ${v} | round(2) }}";
207 int = "{{ ${v} | int }}";
208 yesno = "{{ 'yes' if ${v} > 0 else 'no' }}";
209 }
210 .${format};
211 inherit (s) icon;
212 }
213 // lib.optionalAttrs (format != "yesno") { state_class = "measurement"; }
214 // lib.optionalAttrs (s ? unit) { unit_of_measurement = s.unit; }
215 // lib.optionalAttrs (s ? device_class) { inherit (s) device_class; }
216 )
217 ];
218 };
219
220 # HA derives the entity id from the name ("Internet Latency p95" ->
221 # sensor.internet_latency_p95); unique_id doesn't influence it.
222 e =
223 id:
224 "sensor.internet_" + lib.toLower (lib.replaceStrings [ " " ] [ "_" ] (sensors.${id}.name or id));
225
226 # Each check: the problem it names, in the order things are upstream of
227 # each other (LAN cable -> router -> cable segment -> backbone). Thresholds
228 # for DOCSIS levels follow the usual Vodafone cable level tables.
229 diagnosis = ''
230 {%- set n = namespace(p=[]) -%}
231 {%- if is_state('${e "station_up"}', 'no') -%}
232 {%- set n.p = n.p + ['Router stats not readable'] -%}
233 {%- endif -%}
234 {%- if is_state('${e "docsis_online"}', 'no') -%}
235 {%- set n.p = n.p + ['Cable modem not DOCSIS online'] -%}
236 {%- endif -%}
237 {%- if states('${e "station_loss"}') | float(0) > 1 -%}
238 {%- set n.p = n.p + ['Packet loss to the router ' ~ states('${e "station_loss"}') ~ '% (LAN cable/port)'] -%}
239 {%- endif -%}
240 {%- if states('${e "unlocked"}') | float(0) > 0 -%}
241 {%- set n.p = n.p + [states('${e "unlocked"}') | int ~ ' DOCSIS channels unlocked'] -%}
242 {%- endif -%}
243 {%- if states('${e "ds_snr_min"}') | float(99) < 33 -%}
244 {%- set n.p = n.p + ['Downstream SNR low (' ~ states('${e "ds_snr_min"}') ~ ' dB)'] -%}
245 {%- endif -%}
246 {%- if states('${e "ds_power_min"}') | float(0) < -4 or states('${e "ds_power_max"}') | float(0) > 13 -%}
247 {%- set n.p = n.p + ['Downstream level out of range (' ~ states('${e "ds_power_min"}') ~ '..' ~ states('${e "ds_power_max"}') ~ ' dBmV)'] -%}
248 {%- endif -%}
249 {%- if states('${e "us_power_max"}') | float(0) > 51 -%}
250 {%- set n.p = n.p + ['Upstream level high (' ~ states('${e "us_power_max"}') ~ ' dBmV): return path attenuated/noisy'] -%}
251 {%- endif -%}
252 {%- if states('${e "cmts_loss"}') | float(0) > 1 -%}
253 {%- set n.p = n.p + ['Loss on the cable segment ' ~ states('${e "cmts_loss"}') ~ '%'] -%}
254 {%- endif -%}
255 {%- if states('${e "cmts_latency"}') | float(0) > 30 -%}
256 {%- set n.p = n.p + ['Cable segment slow (' ~ states('${e "cmts_latency"}') ~ ' ms to CMTS): node congested'] -%}
257 {%- endif -%}
258 {%- if states('${e "loss"}') | float(0) > 1 and states('${e "cmts_loss"}') | float(0) <= 1 -%}
259 {%- set n.p = n.p + ['Loss behind the CMTS ' ~ states('${e "loss"}') ~ '% (Vodafone backbone)'] -%}
260 {%- endif -%}
261 {#- the hourly speedtest saturates the line itself: skip the 5 min window after a result -#}
262 {%- set st = states['${e "speedtest_download"}'] -%}
263 {%- set after_speedtest = st is not none and (now() - st.last_changed).total_seconds() < 420 -%}
264 {%- if states('${e "latency_p95"}') | float(0) > 60 and not after_speedtest -%}
265 {%- set n.p = n.p + ['Latency spikes (p95 ' ~ states('${e "latency_p95"}') ~ ' ms) at ' ~ states('${e "download"}') ~ '/' ~ states('${e "upload"}') ~ ' Mbit/s: bufferbloat or congestion'] -%}
266 {%- endif -%}
267 {%- if states('${e "station_uptime"}') | float(99999) < 3600 -%}
268 {%- set n.p = n.p + ['Router rebooted in the last hour'] -%}
269 {%- endif -%}
270 {%- if states('${e "speedtest_download"}') | float(9999) < 0.3 * ${toString line.down} -%}
271 {%- set n.p = n.p + ['Speedtest only ' ~ states('${e "speedtest_download"}') ~ ' of ${toString line.down} Mbit/s down'] -%}
272 {%- endif -%}
273 {%- if states('${e "speedtest_upload"}') | float(9999) < 0.3 * ${toString line.up} -%}
274 {%- set n.p = n.p + ['Speedtest only ' ~ states('${e "speedtest_upload"}') ~ ' of ${toString line.up} Mbit/s up'] -%}
275 {%- endif -%}
276 '';
277
278 package = {
279 rest = lib.mapAttrsToList restEntry sensors;
280 # display names; `name` stays what the entity ids were derived from
281 homeassistant.customize = lib.mapAttrs' (
282 id: s: lib.nameValuePair (e id) { friendly_name = s.label; }
283 ) sensors;
284 template = [
285 {
286 sensor = [
287 {
288 name = "Internet diagnosis";
289 unique_id = "gg23_internet_diagnosis";
290 icon = "mdi:stethoscope";
291 # the problem itself when there is one, else the count
292 state = "${diagnosis}{{ 'OK' if not n.p else n.p[0] if n.p | count == 1 else n.p | count ~ ' problems' }}";
293 attributes.problems = "${diagnosis}{{ ('- ' ~ n.p | join('\\n- ')) if n.p else 'nothing found' }}";
294 }
295 ];
296 }
297 ];
298 lovelace.dashboards.internet-gg23 = {
299 mode = "yaml";
300 title = "Internet";
301 icon = "mdi:wan";
302 show_in_sidebar = true;
303 filename = "dashboards/internet.yaml";
304 };
305 };
306
307 # history-graph; rows: [ id legend-name ] in display order
308 graph = title: hours: rows: {
309 type = "history-graph";
310 inherit title;
311 hours_to_show = hours;
312 entities = map (r: {
313 entity = e (builtins.elemAt r 0);
314 name = builtins.elemAt r 1;
315 }) rows;
316 };
317
318 dashboard = {
319 title = "Internet";
320 views = [
321 {
322 title = "Internet";
323 path = "internet";
324 type = "sections";
325 max_columns = 3;
326 sections = [
327 {
328 type = "grid";
329 column_span = 3;
330 cards = [
331 # apexcharts-card is installed through HACS (not declarative)
332 {
333 type = "custom:apexcharts-card";
334 header = {
335 show = true;
336 title = "Traffic and speedtest";
337 show_states = false;
338 };
339 graph_span = "3d";
340 grid_options.columns = "full";
341 apex_config.chart.height = 180;
342 yaxis = [
343 {
344 min = 0;
345 decimals = 0;
346 }
347 ];
348 series =
349 let
350 traffic = id: name: color: {
351 entity = e id;
352 inherit name color;
353 type = "area";
354 opacity = 0.35;
355 stroke_width = 1;
356 group_by = {
357 func = "avg";
358 duration = "5min";
359 };
360 };
361 speedtest = id: name: color: {
362 entity = e id;
363 inherit name color;
364 type = "line";
365 curve = "stepline";
366 stroke_width = 1.5;
367 };
368 in
369 [
370 (traffic "download" "Download" "#1e88e5")
371 (traffic "upload" "Upload" "#fb8c00")
372 (speedtest "speedtest_download" "Speedtest download" "#1e88e5")
373 (speedtest "speedtest_upload" "Speedtest upload" "#fb8c00")
374 ];
375 }
376 ];
377 }
378 {
379 type = "grid";
380 cards = [
381 {
382 type = "markdown";
383 title = "Diagnosis";
384 content = ''
385 {% if is_state('${e "diagnosis"}', 'OK') %}No problems found.{% else %}{{ state_attr('${e "diagnosis"}', 'problems') }}{% endif %}
386 '';
387 }
388 ];
389 }
390 {
391 type = "grid";
392 cards = [
393 (graph "Latency" 24 [
394 [
395 "latency"
396 "Latency to internet (~10 ms normal)"
397 ]
398 [
399 "latency_p95"
400 "Latency spikes, p95 (< 60 ms)"
401 ]
402 [
403 "cmts_latency"
404 "Latency over the cable to Vodafone (~10 ms)"
405 ]
406 [
407 "station_latency"
408 "Latency to the router (< 5 ms)"
409 ]
410 ])
411 (graph "Packet loss" 24 [
412 [
413 "loss"
414 "Packet loss to internet (should be 0)"
415 ]
416 [
417 "cmts_loss"
418 "Packet loss over the cable (should be 0)"
419 ]
420 [
421 "station_loss"
422 "Packet loss to the router (should be 0)"
423 ]
424 ])
425 ];
426 }
427 {
428 type = "grid";
429 cards = [
430 {
431 type = "entities";
432 title = "Cable modem";
433 # good ranges in the names; per channel: Grafana "Internet (gg23)"
434 entities = [
435 (e "docsis_online")
436 {
437 entity = e "unlocked";
438 name = "DOCSIS channels not locked (should be 0)";
439 }
440 {
441 entity = e "ds_snr_min";
442 name = "Downstream SNR min (≥ 33 dB)";
443 }
444 {
445 entity = e "ds_power_min";
446 name = "Downstream power min (≥ -4 dBmV)";
447 }
448 {
449 entity = e "ds_power_max";
450 name = "Downstream power max (≤ 13 dBmV)";
451 }
452 {
453 entity = e "us_power_max";
454 name = "Upstream power max (35–51 dBmV)";
455 }
456 (e "station_uptime")
457 (e "station_up")
458 ];
459 }
460 (graph "DOCSIS levels" 168 [
461 [
462 "ds_snr_min"
463 "Downstream SNR min"
464 ]
465 [
466 "ds_power_min"
467 "Downstream power min"
468 ]
469 [
470 "ds_power_max"
471 "Downstream power max"
472 ]
473 [
474 "us_power_max"
475 "Upstream power max"
476 ]
477 ])
478 ];
479 }
480 ];
481 }
482 ];
483 };
484
485 json = name: value: pkgs.writeText name (builtins.toJSON value);
486in
487{
488 virtualisation.oci-containers.containers.homeassistant.volumes = [
489 "${json "internet.yaml" package}:/config/packages/internet.yaml:ro"
490 "${json "internet-dashboard.yaml" dashboard}:/config/dashboards/internet.yaml:ro"
491 ];
492}