This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / configs / gg23 / internet-hass.nix
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}