Skip to content

Commit 8d3a31f

Browse files
ehsan6shaclaude
andauthored
fix multi-layer false-positive diagnoses (lab-observed 2026-05-26) (#2)
Five distinct bugs found while debugging a healthy lab device that the AI plugin kept (loudly) reporting as broken. Each layer compounded the next, producing a high-confidence "restart_fula" recommendation when the device was actually fine. 1. diag/internet false "discovery unreachable" (HTTP 403 misclassified) _helpers.py:https_head returned ok=False for HTTP 403, treating a "server responded — you can't HEAD this resource" answer as a network failure. discovery.fula.network's /relays only accepts POST; HEAD legitimately returns 403, yet the server is alive and the network path is fine. Lab ground truth: TLS handshake OK, ping 3ms, HTTP 403 — REACHABLE. Fix: new https_reachable() that returns ok=True on ANY HTTP response (2xx/3xx/4xx/5xx). Only network-level failures (DNS, TCP, TLS, timeout) count as unreachable. diag/internet now uses https_reachable for the discovery probe; https_head retained for google.com (the "internet itself works" canary). Regression test test_internet_discovery_403_is_reachable_not_captive guards against re-introducing the bug. 2. diag/summary's power threshold turned 1 yellow event into nuclear red summary.py had `red if ue > 0 else green` — a single transient undervoltage event flipped power to red and dominated the AI's verdict. Graduated thresholds: 0=green, 1-2=yellow (acknowledge but don't panic), 3+=red (real PSU issue). 3. Schema-invalid backend event killed the entire stream tool_call_loop.py returned on first validation failure, so when the 1.5B Qwen hallucinated a tool name (e.g. "diag/discovery"), the user's session bombed with [SCHEMA_VIOLATION] and no recommendations rendered. Fix: invalid tool_call now yields a recoverable error event + synthesizes a tool_result with ok=false + "unknown tool 'X'" message, so the model can self-correct on its next turn AND the UI keeps the session alive. Other invalid events yield a non-fatal error and the bridge continues. Regression tests: test_schema_invalid_tool_call_yields_synthetic_tool_result + updated test_schema_invalid_backend_event_emits_synthetic_error. 4. System prompt let the model pre-confabulate + invent action names rkllm_runtime.py SYSTEM_PROMPT_TEMPLATE strengthened with three new hard rules: - Rule 5: ANY action MUST be in a <recommendation> XML block, never markdown prose (prose has no Approve button). - Rule 6: Read tool_response field by field — quote the actual field name. Don't confuse internet.latency_ms_avg with a clock offset (lab observed). - Rule 8: If user reports a symptom but diagnostics CONTRADICT it (e.g. user says disconnected but heartbeat.status=green http_status=200), ASK via <user_question> before acting. - Rule 9: NEVER emit a tier-2/3 destructive action at confidence > 0.7 when severity != "red". - Rule 10: relay.reservation_count=0 + wireguard.active=false are NOT problems on their own (normal for LAN-only devices). Plus BAD/GOOD examples showing what NOT to do. 5. Server-side guardrails for when the model ignores rules 8/9 anyway 1.5B Qwen still pattern-matches "user said disconnected + relay yellow → restart_fula at 95%" even with strengthened prompt. Belt-and-suspenders defense: apply_recommendation_guardrails() runs after recommendation parsing, BEFORE emission: - DROPS restart_fula entirely when heartbeat.status=green http_status=200 AND user prompt mentions disconnect / unreachable / can't see / offline. Restarting fula when the device IS heartbeating would create the very disconnect the user complained about (self-fulfilling bug). - CAPS confidence to 0.6 on restart-class actions (restart_fula, reset, wireguard.bounce, docker.restart, systemctl.restart) when verdict.severity is yellow/green. These actions should not be high-confidence on non-red severity. Six regression-guard tests in test_rkllm_runtime.py cover the exact lab scenario + adjacent cases (red severity passes, non-restart-class actions unaffected, non-connectivity prompts don't trigger the drop, etc.). Tests: 230/230 pass. End-to-end verified on lab pi@192.168.2.159 via hot-patch (before container recreation wiped the patches): diag/internet correctly reported discovery reachable, diag/summary returned power=green, AI emitted proper verdict + recommended_action with HMAC token, and the model began ASKING about WiFi configuration instead of jumping to restart_fula. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a02ef34 commit 8d3a31f

8 files changed

Lines changed: 540 additions & 21 deletions

File tree

src/runtime/rkllm_runtime.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,37 @@ def destroy(self) -> None:
405405
2. After you have called the diagnostic tools you need (typically 1-3 calls), you MUST emit a <verdict> based on the results.
406406
3. NEVER call tools indefinitely. After 1-2 follow-up calls, finalize.
407407
4. NEVER output a turn that is prose-only with no <tool_call> AND no <verdict>. Every turn must contain at least one XML block.
408+
5. **ANY action you suggest MUST be emitted as a <recommendation> XML block — NEVER as a markdown numbered list, bullet, or table.** If the user can't tap "Approve" on it, it didn't happen. Prose-only suggestions are invisible to the app. Translate every fix you have in mind into one <recommendation> block per fix.
409+
6. Read tool_response JSON FIELD BY FIELD. Do NOT confuse `internet.latency_ms_avg` with a clock offset. Do NOT call a subsystem "red" if its `status` field says "green". Quote the actual field name you're basing your conclusion on.
410+
7. NEVER use markdown headings (###), markdown bold (**...**), or numbered lists like "1. ntp.resync — ...". Those render as plain text in the chat; only <recommendation> blocks produce an Approve button.
411+
8. **If the user reports a symptom but the diagnostic data CONTRADICTS it, ASK before acting.** Specifically:
412+
- User says "device disconnected" / "not reachable" / "app can't see my blox" BUT `heartbeat.status` is "green" with `http_status: 200`. → Device IS reachable from the cloud (heartbeat is the canonical "I'm alive" signal posting to discovery.fula.network). The disconnect they see is almost certainly phone-side (app cache, NetInfo wrong, WiFi switched, captive portal). DO NOT recommend restart_fula. INSTEAD emit a <user_question> like: {{"question":"Your device is currently posting heartbeats successfully (heartbeat.status=green, http_status=200). The connection issue may be phone-side. What error message do you see, and is your phone on the same WiFi as your Blox?","options":["Same WiFi","Cellular","Different WiFi","Don't know"]}}
413+
- User says "slow" but `containers.status: green, oom_count: 0, storage.status: green`. → ASK what specifically is slow.
414+
- User says "not earning" but `relay.reservation_count > 0` AND `heartbeat.status: green`. → Device IS connected. ASK if they've actually joined a pool.
415+
9. **NEVER emit a tier-2 or tier-3 destructive action (`restart_fula`, `docker.restart`, `systemctl.restart`, `wireguard.bounce`, `reset`) with confidence > 0.7 when severity is "yellow" or "green".** Yellow signals can be normal — relay=yellow on a LAN-only device is expected. Acting on yellow with high confidence creates self-fulfilling problems (the action briefly DISCONNECTS the device, "confirming" the false diagnosis). Confidence > 0.7 on these actions requires severity="red" AND a specific failing subsystem named in the reasoning.
416+
10. `relay.reservation_count: 0` is NOT a problem on its own — it only matters if the user is trying to be reached from outside their LAN. `wireguard.active: false` is NOT a problem unless the user explicitly set up WG. Mention these only as "informational" in your verdict, never as the root cause unless other evidence points to them.
417+
418+
# BAD vs GOOD examples
419+
420+
❌ BAD — prose recommendations get NO Approve button, user can take no action:
421+
422+
### Tier 2 Actions:
423+
1. **ntp.resync** - Resync the clock.
424+
2. **docker.restart container=ipfs_host** - Restart the container.
425+
426+
✅ GOOD — each recommendation is its own XML block:
427+
428+
<recommendation>{{"action_name":"ntp.resync","args":{{}},"reasoning":"Clock is unsynced.","confidence":0.85,"tier":2}}</recommendation>
429+
<recommendation>{{"action_name":"docker.restart","args":{{"container":"ipfs_host"}},"reasoning":"ipfs_host restart-looping.","confidence":0.75,"tier":2}}</recommendation>
430+
431+
❌ BAD — making up a field:
432+
433+
"Time Status: Clock offset is significant (93 ms)"
434+
(when tool_response actually said `time.status: green, synced: true` and the 93 was `internet.latency_ms_avg`)
435+
436+
✅ GOOD — quote what you actually read:
437+
438+
"time.status is green (synced=true). internet.latency_ms_avg is 93ms — that's network latency, not a clock offset."
408439
409440
# AVAILABLE TOOLS (read-only)
410441
@@ -593,6 +624,98 @@ def parse_recommendations(raw_text: str) -> list[dict]:
593624
return out
594625

595626

627+
# Tier-2/3 destructive actions that should NOT be recommended at high
628+
# confidence unless severity is "red". Empirically observed (2026-05-26
629+
# lab): 1.5B Qwen pattern-matches "user said disconnected → relay
630+
# yellow → restart_fula at 95%" even when heartbeat is green
631+
# (contradicting evidence of actual reachability). The post-processor
632+
# below caps confidence to 0.6 on these names when the verdict's
633+
# severity is not red, AND additionally suppresses them entirely when
634+
# heartbeat is green but the user's prompt mentioned a connectivity
635+
# complaint (the AI is acting on false-positive contradictory data).
636+
RESTART_CLASS_ACTIONS = frozenset({
637+
"restart_fula", "reset", "wireguard.bounce",
638+
"docker.restart", "systemctl.restart",
639+
})
640+
641+
642+
def apply_recommendation_guardrails(
643+
recommendations: list[dict],
644+
verdict: Optional[dict],
645+
last_summary_payload: Optional[dict] = None,
646+
user_prompt: Optional[str] = None,
647+
) -> tuple[list[dict], list[str]]:
648+
"""Cap or drop misbehaving recommendations from the model output.
649+
650+
Two rules (in order):
651+
652+
1. CONFIDENCE CAP: restart-class action (restart_fula, reset,
653+
wireguard.bounce, docker.restart, systemctl.restart) with
654+
confidence > 0.7 AND verdict.severity != "red" → cap confidence
655+
at 0.6. Reasoning: yellow/green severity + high-confidence
656+
destructive action is the false-positive pattern.
657+
658+
2. CONTRADICTION SUPPRESS: if the user's prompt mentions a
659+
CONNECTIVITY symptom ("disconnect", "not reachable", "can't see",
660+
"offline") BUT the last diag/summary's heartbeat.status is
661+
"green" with http_status 200, suppress restart_fula entirely.
662+
The device IS reachable from the cloud; restarting it would
663+
create the very disconnect the user is complaining about.
664+
665+
Returns (filtered_recommendations, list_of_human_readable_reasons)
666+
so the caller can log/surface why something was dropped or capped.
667+
"""
668+
notes: list[str] = []
669+
severity = (verdict or {}).get("severity") if verdict else None
670+
671+
# Did the user actually complain about connectivity?
672+
prompt_lc = (user_prompt or "").lower()
673+
is_connectivity_complaint = any(
674+
kw in prompt_lc
675+
for kw in ("disconnect", "not reachable", "unreachable",
676+
"can't see", "cannot see", "offline", "can't reach",
677+
"cannot reach", "showing as disconnected", "shows offline")
678+
)
679+
# Heartbeat ground truth: device IS reachable iff posted recent 200.
680+
hb = (last_summary_payload or {}).get("subsystems", {}).get("heartbeat", {})
681+
heartbeat_green_with_200 = (
682+
hb.get("status") == "green"
683+
and hb.get("key_metrics", {}).get("http_status") == 200
684+
)
685+
686+
out: list[dict] = []
687+
for rec in recommendations:
688+
name = rec.get("action_name", "")
689+
conf = float(rec.get("confidence", 0.5))
690+
691+
# Rule 2: drop restart_fula on contradiction (most aggressive).
692+
if (
693+
name == "restart_fula"
694+
and is_connectivity_complaint
695+
and heartbeat_green_with_200
696+
):
697+
notes.append(
698+
f"dropped action={name!r} (confidence {conf:.2f}): user reported "
699+
f"a connectivity issue but heartbeat.status=green http_status=200, "
700+
f"so device IS reachable — restart_fula would create a real "
701+
f"disconnect from a non-existent problem"
702+
)
703+
continue
704+
705+
# Rule 1: cap restart-class confidence when severity is not red.
706+
if name in RESTART_CLASS_ACTIONS and conf > 0.7 and severity != "red":
707+
capped = 0.6
708+
notes.append(
709+
f"capped action={name!r} confidence from {conf:.2f} to "
710+
f"{capped:.2f}: severity={severity!r} is not red, so this "
711+
f"action should not be presented as high-confidence"
712+
)
713+
rec = {**rec, "confidence": capped}
714+
715+
out.append(rec)
716+
return out, notes
717+
718+
596719
def strip_blocks(raw_text: str) -> str:
597720
"""Remove tool_call/verdict/recommendation blocks. What's left is
598721
the model's prose 'thought' content. Also strips any UNCLOSED
@@ -699,6 +822,11 @@ async def run_troubleshoot(
699822
emitted_verdict = False
700823
force_verdict_attempted = False
701824
loop = asyncio.get_event_loop()
825+
# Last diag/summary result + the user's original prompt are needed
826+
# by the recommendation guardrails to detect false positives like
827+
# "user says disconnected but heartbeat is green → suppress restart_fula".
828+
last_summary_payload: Optional[dict] = None
829+
original_user_prompt = prompt
702830

703831
for turn in range(MAX_TURNS):
704832
# Last-chance: at MAX_TURNS-1 without a verdict, inject the
@@ -793,6 +921,12 @@ async def run_troubleshoot(
793921
)
794922
yield tr_event
795923

924+
# Capture the diag/summary result for the guardrail
925+
# check below. Lets us detect "heartbeat green but
926+
# user said disconnected" false-positive pattern.
927+
if ok and tc["tool"] == "diag/summary" and isinstance(result, dict):
928+
last_summary_payload = result
929+
796930
# Add tool responses to history for the next turn
797931
history.append({
798932
"role": "tool",
@@ -804,6 +938,20 @@ async def run_troubleshoot(
804938
emitted_verdict = True
805939
yield {"type": "verdict", "payload": verdict}
806940

941+
# Server-side guardrails on recommendations (caps + drops
942+
# the false-positive patterns the 1.5B model produces).
943+
# Logged to stderr so operators can see WHY a recommendation
944+
# was dropped/capped.
945+
if recommendations:
946+
recommendations, guardrail_notes = apply_recommendation_guardrails(
947+
recommendations,
948+
verdict=verdict,
949+
last_summary_payload=last_summary_payload,
950+
user_prompt=original_user_prompt,
951+
)
952+
for note in guardrail_notes:
953+
logger.warning("recommendation guardrail: %s", note)
954+
807955
# Emit recommendations with real HMAC tokens
808956
if recommendations and self._action_signer is not None:
809957
for i, rec in enumerate(recommendations):

src/session/tool_call_loop.py

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,50 @@ async def stream_troubleshoot(
6969
"""
7070
async for event in backend_events:
7171
if not _validate(event, validator):
72-
yield _schema_violation_error(event)
73-
return
72+
# Bug fix 2026-05-26: previously this killed the stream
73+
# (`return`), so a single hallucinated event from the model
74+
# — e.g. `tool_call` with an invalid tool name like
75+
# `diag/discovery` — bombed the entire session. The user
76+
# saw `[SCHEMA_VIOLATION]` instead of any recommendations.
77+
#
78+
# New behavior: if the offending event is a tool_call,
79+
# synthesize a tool_result with ok=false + an error message
80+
# naming the bad tool. This lets the backend's tool-call
81+
# loop continue and gives the LLM a chance to self-correct
82+
# (it can read "unknown tool 'diag/discovery'" and retry
83+
# with a valid name). For OTHER event types (verdict,
84+
# thought, etc.) we still surface a non-fatal error event
85+
# but don't end the stream — the backend may yield more
86+
# valid events afterward.
87+
invalid_evtype = event.get("type", "<missing>")
88+
invalid_call_id = event.get("call_id")
89+
yield _validation_error_event(
90+
offending_type=invalid_evtype,
91+
call_id=invalid_call_id,
92+
fatal=False,
93+
)
94+
if invalid_evtype == "tool_call" and invalid_call_id:
95+
bad_tool = (
96+
event.get("payload", {}).get("tool")
97+
if isinstance(event.get("payload"), dict) else None
98+
)
99+
synth = {
100+
"type": "tool_result",
101+
"call_id": invalid_call_id,
102+
"ok": False,
103+
"payload": None,
104+
"error": (
105+
f"unknown or unsupported tool name "
106+
f"{bad_tool!r}. Pick one of the diag/* tools "
107+
f"listed in your system prompt."
108+
),
109+
}
110+
# Best-effort: emit ONLY if it itself validates.
111+
# Otherwise we'd loop indefinitely.
112+
if _validate(synth, validator):
113+
yield synth
114+
# Move on to the next event from the backend.
115+
continue
74116

75117
yield event
76118

@@ -179,5 +221,31 @@ def _schema_violation_error(offending: dict) -> dict:
179221
}
180222

181223

224+
def _validation_error_event(
225+
offending_type: str,
226+
call_id: str | None,
227+
fatal: bool,
228+
) -> dict:
229+
"""Non-fatal variant of the schema-violation error.
230+
231+
Used when the bridge can RECOVER from an invalid backend event
232+
(e.g. by synthesizing a tool_result with ok=false). Marked
233+
`recoverable: True` so the UI knows to keep the chat alive and
234+
not show a terminal-error state.
235+
"""
236+
msg = (
237+
f"backend emitted an invalid {offending_type!s} event; "
238+
f"continuing — the model may self-correct."
239+
)
240+
if call_id:
241+
msg += f" (call_id={call_id!r})"
242+
return {
243+
"type": "error",
244+
"code": "SCHEMA_VIOLATION_RECOVERED" if not fatal else "SCHEMA_VIOLATION",
245+
"message": msg,
246+
"recoverable": not fatal,
247+
}
248+
249+
182250
def _truncate(s: str, n: int) -> str:
183251
return s if len(s) <= n else (s[: n - 1] + "…")

src/tools/diag_impls/_helpers.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ def https_head(url: str, timeout_s: float = 5.0) -> tuple[bool, int | None, floa
7373
`ok` is True iff the request completed AND status is 2xx/3xx. Latency
7474
is wall-clock from request start to response. Stdlib urllib — no
7575
runtime requests/httpx dependency.
76+
77+
Note: this answers "did the request SUCCEED?" — for reachability
78+
checks, prefer https_reachable() (treats 4xx/5xx as 'host is up
79+
and responded, you just can't access this resource').
7680
"""
7781
import time
7882
start = time.monotonic()
@@ -90,6 +94,39 @@ def https_head(url: str, timeout_s: float = 5.0) -> tuple[bool, int | None, floa
9094
return False, None, latency
9195

9296

97+
def https_reachable(url: str, timeout_s: float = 5.0) -> tuple[bool, int | None, float]:
98+
"""Reachability check — distinguishes 'host is unreachable' from
99+
'host responded with an HTTP error'.
100+
101+
`ok` is True iff we got ANY valid HTTP response (2xx, 3xx, 4xx, or
102+
5xx). A 403/404/500 means the server is alive, TCP+TLS worked, the
103+
network path is fine — we just don't have access to that specific
104+
URL. `ok` is False ONLY when we got NO HTTP response (DNS failure,
105+
connection refused, TLS handshake fail, timeout).
106+
107+
Bug fix 2026-05-26: previously diag/internet used https_head() with
108+
`ok = 200 <= status < 400`, so a 403 on https://discovery.fula.network/relays
109+
(which requires POST not HEAD) was reported as 'discovery
110+
unreachable' — pure false positive. Lab observed: HTTP 403, TLS
111+
ok, ping 3ms RTT, yet diag/internet returned discovery_https_ok=false.
112+
"""
113+
import time
114+
start = time.monotonic()
115+
req = urllib.request.Request(url, method="HEAD")
116+
try:
117+
with urllib.request.urlopen(req, timeout=timeout_s) as resp:
118+
latency = (time.monotonic() - start) * 1000
119+
return True, resp.status, latency
120+
except urllib.error.HTTPError as e:
121+
# Server responded with an HTTP error — host IS reachable.
122+
latency = (time.monotonic() - start) * 1000
123+
return True, e.code, latency
124+
except (urllib.error.URLError, OSError, TimeoutError):
125+
# No HTTP response at all (DNS / TCP / TLS / timeout).
126+
latency = (time.monotonic() - start) * 1000
127+
return False, None, latency
128+
129+
93130
def dns_lookup(host: str, timeout_s: float = 3.0) -> bool:
94131
"""True iff `host` resolves. socket.gethostbyname has no per-call
95132
timeout option in the stdlib, but it's bounded by the system resolver."""

src/tools/diag_impls/internet.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33

44
import time
55

6-
from src.tools.diag_impls._helpers import dns_lookup, https_head, now_iso
6+
from src.tools.diag_impls._helpers import (
7+
dns_lookup,
8+
https_head,
9+
https_reachable,
10+
now_iso,
11+
)
712

813

914
# Targets: google.com is the "the internet itself works" canary; the
@@ -16,13 +21,26 @@
1621

1722
def diag_internet() -> dict:
1823
dns_ok = dns_lookup(GOOGLE_HOST) and dns_lookup(DISCOVERY_HOST)
24+
# google.com: regular https_head (200 expected on root) — this is
25+
# the "internet itself works" canary.
1926
g_ok, _, g_lat = https_head(f"https://{GOOGLE_HOST}", timeout_s=5.0)
20-
d_ok, _, d_lat = https_head(f"https://{DISCOVERY_HOST}/relays", timeout_s=5.0)
27+
# discovery.fula.network: use https_reachable, not https_head.
28+
# The /relays endpoint requires POST (server returns HTTP 403 on
29+
# HEAD/GET — that's a server-up response, NOT a network failure).
30+
# Bug fix 2026-05-26: previously used https_head which classified
31+
# 403 as ok=false → 'discovery unreachable' false-positive that
32+
# the AI then reported as a connectivity problem. Lab confirmed:
33+
# ping 3ms, TLS handshake completes, server returns 403 → IS
34+
# reachable. Now we ask the right question.
35+
d_ok, _, d_lat = https_reachable(
36+
f"https://{DISCOVERY_HOST}/relays", timeout_s=5.0,
37+
)
2138
avg_lat = (g_lat + d_lat) / 2 if (g_lat + d_lat) > 0 else 0.0
2239
# Captive-portal heuristic: DNS works AND google https returns OK
23-
# AND latency is suspiciously low (a portal usually intercepts at the
24-
# router with sub-50ms RTT) AND discovery is blocked. False positives
25-
# acceptable — the AI cites this as one signal among many.
40+
# AND latency is suspiciously low (a portal usually intercepts at
41+
# the router with sub-50ms RTT) AND discovery is genuinely blocked
42+
# (no HTTP response at all, not just 4xx). Without the
43+
# https_reachable fix this false-fired constantly.
2644
captive = dns_ok and g_ok and not d_ok and avg_lat < 50
2745
return {
2846
"dns_ok": dns_ok,

src/tools/diag_impls/summary.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,9 +93,25 @@ def _scorecard(name: str, raw: dict) -> dict:
9393
"key_metrics": {"synced": bool(raw.get("synced"))},
9494
}
9595
if name == "power":
96+
# Lab observed 2026-05-26: a SINGLE undervoltage event in 24h
97+
# would flip status=red and feed the AI a panic-level signal,
98+
# which then dominated the verdict. Real PSU failures show
99+
# repeated events; isolated transients (one bad cable wiggle,
100+
# one boot brownout) should be a yellow, not a red.
101+
#
102+
# Tiers:
103+
# 0 events → green
104+
# 1-2 events → yellow (note but don't panic; could be transient)
105+
# 3+ events → red (real PSU issue, recommend power-cable check)
96106
ue = raw.get("undervoltage_events_24h", 0)
107+
if ue == 0:
108+
status = "green"
109+
elif ue <= 2:
110+
status = "yellow"
111+
else:
112+
status = "red"
97113
return {
98-
"status": "red" if ue > 0 else "green",
114+
"status": status,
99115
"key_metrics": {
100116
"uptime_s": raw.get("uptime_s", 0),
101117
"undervoltage_events_24h": ue,

0 commit comments

Comments
 (0)