This repository has no description
14 kB
392 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.internet_<id>
35 sensors = {
36 latency = {
37 name = "Latency";
38 query = internet.latency;
39 unit = "ms";
40 icon = "mdi:timer-outline";
41 };
42 latency_p95 = {
43 name = "Latency p95";
44 query = internet.p95;
45 unit = "ms";
46 icon = "mdi:timer-alert-outline";
47 };
48 loss = {
49 name = "Packet loss";
50 query = internet.loss;
51 unit = "%";
52 icon = "mdi:lan-disconnect";
53 };
54 cmts_latency = {
55 name = "Cable latency";
56 query = cmts.latency;
57 unit = "ms";
58 icon = "mdi:timer-outline";
59 };
60 cmts_loss = {
61 name = "Cable packet loss";
62 query = cmts.loss;
63 unit = "%";
64 icon = "mdi:lan-disconnect";
65 };
66 station_latency = {
67 name = "Station latency";
68 query = station.latency;
69 unit = "ms";
70 icon = "mdi:router-network";
71 };
72 station_loss = {
73 name = "Station packet loss";
74 query = station.loss;
75 unit = "%";
76 icon = "mdi:router-network";
77 };
78 download = {
79 name = "Download";
80 query = wan "receive";
81 unit = "Mbit/s";
82 device_class = "data_rate";
83 icon = "mdi:download";
84 };
85 upload = {
86 name = "Upload";
87 query = wan "transmit";
88 unit = "Mbit/s";
89 device_class = "data_rate";
90 icon = "mdi:upload";
91 };
92 # hourly, ./internet-speedtest.nix; last result within 2h
93 speedtest_download = {
94 name = "Speedtest download";
95 query = ''last_over_time(speedtest_download_bits_per_second{host="acheron"}[2h]) / 1e6'';
96 unit = "Mbit/s";
97 device_class = "data_rate";
98 icon = "mdi:speedometer";
99 };
100 speedtest_upload = {
101 name = "Speedtest upload";
102 query = ''last_over_time(speedtest_upload_bits_per_second{host="acheron"}[2h]) / 1e6'';
103 unit = "Mbit/s";
104 device_class = "data_rate";
105 icon = "mdi:speedometer";
106 };
107 speedtest_latency = {
108 name = "Speedtest latency";
109 query = ''last_over_time(speedtest_latency_seconds{host="acheron"}[2h]) * 1000'';
110 unit = "ms";
111 icon = "mdi:timer-outline";
112 };
113 ds_snr_min = {
114 name = "Downstream SNR min";
115 query = ''min(vodafone_station_docsis_snr_db{direction="downstream",host="acheron"})'';
116 unit = "dB";
117 icon = "mdi:signal";
118 };
119 ds_power_min = {
120 name = "Downstream power min";
121 query = ''min(vodafone_station_docsis_power_dbmv{direction="downstream",${lib.removePrefix "{" docsis})'';
122 unit = "dBmV";
123 icon = "mdi:arrow-down-bold";
124 };
125 ds_power_max = {
126 name = "Downstream power max";
127 query = ''max(vodafone_station_docsis_power_dbmv{direction="downstream",${lib.removePrefix "{" docsis})'';
128 unit = "dBmV";
129 icon = "mdi:arrow-down-bold";
130 };
131 us_power_max = {
132 name = "Upstream power max";
133 query = ''max(vodafone_station_docsis_power_dbmv{direction="upstream",${lib.removePrefix "{" docsis})'';
134 unit = "dBmV";
135 icon = "mdi:arrow-up-bold";
136 };
137 unlocked = {
138 name = "Unlocked channels";
139 query = ''count(vodafone_station_docsis_locked{host="acheron"} == 0) or vector(0)'';
140 icon = "mdi:lock-open-alert";
141 };
142 docsis_online = {
143 name = "DOCSIS online";
144 query = ''max(vodafone_station_docsis_online{host="acheron"})'';
145 icon = "mdi:cable-data";
146 };
147 station_up = {
148 name = "Station scrape";
149 query = ''max(vodafone_station_up{host="acheron"})'';
150 icon = "mdi:router-wireless";
151 };
152 station_uptime = {
153 name = "Station uptime";
154 query = ''max(vodafone_station_uptime_seconds{host="acheron"})'';
155 unit = "s";
156 device_class = "duration";
157 icon = "mdi:restart";
158 };
159 };
160
161 restEntry = id: s: {
162 resource = vm;
163 params.query = s.query;
164 scan_interval = 60;
165 timeout = 20;
166 sensor = [
167 (
168 {
169 name = "Internet ${s.name}";
170 unique_id = "gg23_internet_${id}";
171 # no series yet (e.g. rate() right after a restart): unavailable
172 availability = "{{ value_json is defined and value_json.data.result | length > 0 }}";
173 value_template = "{{ (value_json.data.result[0].value[1] | float) | round(2) }}";
174 state_class = "measurement";
175 inherit (s) icon;
176 }
177 // lib.optionalAttrs (s ? unit) { unit_of_measurement = s.unit; }
178 // lib.optionalAttrs (s ? device_class) { inherit (s) device_class; }
179 )
180 ];
181 };
182
183 # HA derives the entity id from the name ("Internet Latency p95" ->
184 # sensor.internet_latency_p95); unique_id doesn't influence it.
185 e =
186 id:
187 "sensor.internet_" + lib.toLower (lib.replaceStrings [ " " ] [ "_" ] (sensors.${id}.name or id));
188
189 # Each check: the problem it names, in the order things are upstream of
190 # each other (LAN cable -> Station -> cable segment -> backbone). Thresholds
191 # for DOCSIS levels follow the usual Vodafone cable level tables.
192 diagnosis = ''
193 {%- set n = namespace(p=[]) -%}
194 {%- if states('${e "station_up"}') in ['0.0', '0'] -%}
195 {%- set n.p = n.p + ['Station web UI not scrapeable'] -%}
196 {%- endif -%}
197 {%- if states('${e "docsis_online"}') in ['0.0', '0'] -%}
198 {%- set n.p = n.p + ['Cable modem not DOCSIS online'] -%}
199 {%- endif -%}
200 {%- if states('${e "station_loss"}') | float(0) > 1 -%}
201 {%- set n.p = n.p + ['Loss acheron->Station ' ~ states('${e "station_loss"}') ~ '% (LAN cable/port)'] -%}
202 {%- endif -%}
203 {%- if states('${e "unlocked"}') | float(0) > 0 -%}
204 {%- set n.p = n.p + [states('${e "unlocked"}') | int ~ ' DOCSIS channels unlocked'] -%}
205 {%- endif -%}
206 {%- if states('${e "ds_snr_min"}') | float(99) < 33 -%}
207 {%- set n.p = n.p + ['Downstream SNR low (' ~ states('${e "ds_snr_min"}') ~ ' dB)'] -%}
208 {%- endif -%}
209 {%- if states('${e "ds_power_min"}') | float(0) < -4 or states('${e "ds_power_max"}') | float(0) > 13 -%}
210 {%- set n.p = n.p + ['Downstream level out of range (' ~ states('${e "ds_power_min"}') ~ '..' ~ states('${e "ds_power_max"}') ~ ' dBmV)'] -%}
211 {%- endif -%}
212 {%- if states('${e "us_power_max"}') | float(0) > 51 -%}
213 {%- set n.p = n.p + ['Upstream level high (' ~ states('${e "us_power_max"}') ~ ' dBmV): return path attenuated/noisy'] -%}
214 {%- endif -%}
215 {%- if states('${e "cmts_loss"}') | float(0) > 1 -%}
216 {%- set n.p = n.p + ['Loss on the cable segment ' ~ states('${e "cmts_loss"}') ~ '%'] -%}
217 {%- endif -%}
218 {%- if states('${e "cmts_latency"}') | float(0) > 30 -%}
219 {%- set n.p = n.p + ['Cable segment slow (' ~ states('${e "cmts_latency"}') ~ ' ms to CMTS): node congested'] -%}
220 {%- endif -%}
221 {%- if states('${e "loss"}') | float(0) > 1 and states('${e "cmts_loss"}') | float(0) <= 1 -%}
222 {%- set n.p = n.p + ['Loss behind the CMTS ' ~ states('${e "loss"}') ~ '% (Vodafone backbone)'] -%}
223 {%- endif -%}
224 {#- the hourly speedtest saturates the line itself: skip the 5 min window after a result -#}
225 {%- set st = states['${e "speedtest_download"}'] -%}
226 {%- set after_speedtest = st is not none and (now() - st.last_changed).total_seconds() < 420 -%}
227 {%- if states('${e "latency_p95"}') | float(0) > 60 and not after_speedtest -%}
228 {%- 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'] -%}
229 {%- endif -%}
230 {%- if states('${e "station_uptime"}') | float(99999) < 3600 -%}
231 {%- set n.p = n.p + ['Station rebooted in the last hour'] -%}
232 {%- endif -%}
233 {%- if states('${e "speedtest_download"}') | float(9999) < 0.3 * ${toString line.down} -%}
234 {%- set n.p = n.p + ['Speedtest only ' ~ states('${e "speedtest_download"}') ~ ' of ${toString line.down} Mbit/s down'] -%}
235 {%- endif -%}
236 {%- if states('${e "speedtest_upload"}') | float(9999) < 0.3 * ${toString line.up} -%}
237 {%- set n.p = n.p + ['Speedtest only ' ~ states('${e "speedtest_upload"}') ~ ' of ${toString line.up} Mbit/s up'] -%}
238 {%- endif -%}
239 '';
240
241 package = {
242 rest = lib.mapAttrsToList restEntry sensors;
243 template = [
244 {
245 sensor = [
246 {
247 name = "Internet diagnosis";
248 unique_id = "gg23_internet_diagnosis";
249 icon = "mdi:stethoscope";
250 # the problem itself when there is one, else the count
251 state = "${diagnosis}{{ 'OK' if not n.p else n.p[0] if n.p | count == 1 else n.p | count ~ ' problems' }}";
252 attributes.problems = "${diagnosis}{{ ('- ' ~ n.p | join('\\n- ')) if n.p else 'nothing found' }}";
253 }
254 ];
255 }
256 ];
257 lovelace.dashboards.internet-gg23 = {
258 mode = "yaml";
259 title = "Internet";
260 icon = "mdi:wan";
261 show_in_sidebar = true;
262 filename = "dashboards/internet.yaml";
263 };
264 };
265
266 graph = title: hours: ids: {
267 type = "history-graph";
268 inherit title;
269 hours_to_show = hours;
270 entities = map e ids;
271 };
272
273 dashboard = {
274 title = "Internet";
275 views = [
276 {
277 title = "Internet";
278 path = "internet";
279 type = "sections";
280 max_columns = 3;
281 sections = [
282 {
283 type = "grid";
284 cards = [
285 {
286 type = "markdown";
287 title = "Why does it suck?";
288 content = ''
289 **{{ states('${e "diagnosis"}') }}**
290
291 {{ state_attr('${e "diagnosis"}', 'problems') }}
292
293 Path: acheron → Station (LAN) → CMTS (cable) → internet.
294 Loss/latency appearing first at a hop points at that segment.
295 '';
296 }
297 {
298 type = "entities";
299 title = "Now (5 min)";
300 entities = map e [
301 "download"
302 "upload"
303 "latency"
304 "latency_p95"
305 "loss"
306 "cmts_latency"
307 "cmts_loss"
308 "station_latency"
309 "station_loss"
310 ];
311 }
312 ];
313 }
314 {
315 type = "grid";
316 cards = [
317 (graph "Latency" 24 [
318 "latency"
319 "latency_p95"
320 "cmts_latency"
321 "station_latency"
322 ])
323 (graph "Packet loss" 24 [
324 "loss"
325 "cmts_loss"
326 "station_loss"
327 ])
328 (graph "Throughput (WAN)" 24 [
329 "download"
330 "upload"
331 ])
332 {
333 type = "entities";
334 title = "Speedtest (hourly, from acheron)";
335 entities = map e [
336 "speedtest_download"
337 "speedtest_upload"
338 "speedtest_latency"
339 ];
340 }
341 (graph "Speedtest" 168 [
342 "speedtest_download"
343 "speedtest_upload"
344 ])
345 ];
346 }
347 {
348 type = "grid";
349 cards = [
350 {
351 type = "entities";
352 title = "Cable modem";
353 entities = map e [
354 "docsis_online"
355 "unlocked"
356 "ds_snr_min"
357 "ds_power_min"
358 "ds_power_max"
359 "us_power_max"
360 "station_uptime"
361 "station_up"
362 ];
363 }
364 (graph "DOCSIS levels" 168 [
365 "ds_snr_min"
366 "ds_power_min"
367 "ds_power_max"
368 "us_power_max"
369 ])
370 {
371 type = "markdown";
372 content = ''
373 Good ranges: downstream -4..13 dBmV, SNR ≥ 33 dB (256QAM),
374 upstream 35..51 dBmV. Per channel and longer history:
375 Grafana "Internet" on styx.r:${toString ports.grafana}.
376 '';
377 }
378 ];
379 }
380 ];
381 }
382 ];
383 };
384
385 json = name: value: pkgs.writeText name (builtins.toJSON value);
386in
387{
388 virtualisation.oci-containers.containers.homeassistant.volumes = [
389 "${json "internet.yaml" package}:/config/packages/internet.yaml:ro"
390 "${json "internet-dashboard.yaml" dashboard}:/config/dashboards/internet.yaml:ro"
391 ];
392}