diff --git a/docs/feature-checklist.md b/docs/feature-checklist.md index ac72f015..68bc855b 100644 --- a/docs/feature-checklist.md +++ b/docs/feature-checklist.md @@ -66,7 +66,7 @@ Legend: ## Platform -- [ ] Networking controls (VP2430 specific) via OpenWrt API +- [x] Networking controls (VP2430 specific) via OpenWrt API — slice #28 (Controls tab + Restart confirmation; see `docs/catalog/networking/openwrt-controls.md` + `homeassistant/packages/roamcore_openwrt_api.yaml`) - [ ] Remote access - [ ] OTA updates (GitHub-based channel, rollback-aware) - [ ] Additional hardware support (OBD, lighting, etc) diff --git a/docs/mvp/features-build-status.md b/docs/mvp/features-build-status.md index 88f15831..cb293b9d 100644 --- a/docs/mvp/features-build-status.md +++ b/docs/mvp/features-build-status.md @@ -57,6 +57,14 @@ This is an internal status page for the remaining MVP feature build-out. - RoamCore Map page embeds Traccar add-on **web UI** via iframe (configurable). - Helper: `input_text.rc_traccar_ui_url` +- Networking controls (OpenWrt API) — Wave 2 #28 (slice shipped, cherry-picked onto main) + - HA package: `homeassistant/packages/roamcore_openwrt_api.yaml` + - Scripts: `script.rc_openwrt_prefer_starlink` / `_lte` / `_auto`, `script.rc_openwrt_restart_network` + - Safety guard: `binary_sensor.rc_setup_networking_safe` (= OpenWrt online AND `input_boolean.rc_confirm_offline=on`) + - UI: Controls tile on Network page (preference radios + Restart Network button with `` confirmation) + - Smoke: `scripts/checks/openwrt-controls-smoke.sh` (wired into `scripts/check.sh --core-only`) + - Branch: `feat/wave2-networking-controls-openwrt` + - Verification: `bash scripts/check.sh --core-only` exit 0 + smoke PASS (cherry-pick onto `main` keeps main's check.sh since main already references the smoke via `run_if_present`). - Agent actions allowlist (safety gateway for agent-driven RoamCore actions) - Connection: [`connections/agent-actions-allowlist/`](../../connections/agent-actions-allowlist/) (Wave 3 #65, PR #69) - Tier-b recipe connection over upstream HA core `input_boolean` + `input_text` + `input_number` + `input_select` + `input_datetime` + `input_button` + `script` helpers (since 2022.x) + HA core `template:` sensor wrapper (since 2022.x) + HA core `logbook` integration (since 2022.x) + the upstream `script:` integration (since 2022.x). The single `input_boolean.rc_agent_actions_enabled` kill switch is already shipped in `homeassistant/packages/roamcore_agent_actions.yaml` and is preserved verbatim by this slice. diff --git a/homeassistant/packages/roamcore_openwrt_api.yaml b/homeassistant/packages/roamcore_openwrt_api.yaml index db7c9244..c41b2cdc 100644 --- a/homeassistant/packages/roamcore_openwrt_api.yaml +++ b/homeassistant/packages/roamcore_openwrt_api.yaml @@ -233,3 +233,53 @@ script: alias: "RC OpenWrt: Restart Network" sequence: - service: rest_command.rc_openwrt_restart_network + + +# --- +# Safety guards for the Controls tab on the Network page. +# +# The UI's Restart Network button MUST be disabled unless: +# 1. The OpenWrt API reports the internet is "online" +# (sensor.rc_openwrt_internet), AND +# 2. The user has explicitly opted in to the offline-aware confirmation +# via input_boolean.rc_confirm_offline. +# +# rc_confirm_offline defaults to OFF so the Restart Network button is +# locked by default; the operator must toggle the opt-in to unlock it. + +input_boolean: + rc_confirm_offline: + name: "RC Confirm Offline" + icon: mdi:shield-alert + initial: off + + +# --- +# Composite "safe to restart network" guard. +# +# Drives the Restart Network button in the Controls tab: +# state = "on" only when OpenWrt reports the internet online AND the +# operator has explicitly opted in via rc_confirm_offline. +# state = "off" otherwise (button disabled). +# +# Availability is constrained to the two upstream entities being present; +# if either is unknown/unavailable the guard reports unknown too. + +template: + - binary_sensor: + - name: "RC Setup Networking Safe" + unique_id: rc_setup_networking_safe + icon: mdi:shield-check + device_class: safety + state: >- + {{ + 'on' + if is_state('sensor.rc_openwrt_internet', 'online') + and is_state('input_boolean.rc_confirm_offline', 'on') + else 'off' + }} + availability: >- + {{ + states('sensor.rc_openwrt_internet') not in ['unknown', 'unavailable'] + and states('input_boolean.rc_confirm_offline') not in ['unknown', 'unavailable'] + }} diff --git a/homeassistant/www/roamcore/roamcore-pages.js b/homeassistant/www/roamcore/roamcore-pages.js index ab726898..d617fb3f 100644 --- a/homeassistant/www/roamcore/roamcore-pages.js +++ b/homeassistant/www/roamcore/roamcore-pages.js @@ -1819,10 +1819,75 @@ class RoamcoreNetworkPage extends RoamcoreBasePage { ${this._row('Firmware', (rFw && rFw !== 'unknown' && rFw !== 'unavailable') ? rFw : '—')} `; + // --- Controls tile (Wave 2 #28) --------------------------------------- + // Three preference buttons (Starlink / LTE / Auto) wired to the matching + // scripts defined in homeassistant/packages/roamcore_openwrt_api.yaml. + // The button matching sensor.rc_openwrt_active_wan is disabled and + // styled as the *current* preference. + // + // A Restart Network button is rendered disabled unless + // binary_sensor.rc_setup_networking_safe is "on". When clicked (and + // enabled) it opens a native for one-tap confirmation before + // firing script.rc_openwrt_restart_network. + const activeWanRaw = (this._getState('sensor.rc_openwrt_active_wan') || '').toLowerCase(); + const safeStateRaw = this._getState('binary_sensor.rc_setup_networking_safe'); + // Default-on fallback only if the entity is genuinely missing (unknown). + // If the entity exists and reports 'off' the button MUST be disabled. + const safeExists = !!(this._hass?.states && this._hass.states['binary_sensor.rc_setup_networking_safe']); + const safeOn = safeExists ? (String(safeStateRaw || '').toLowerCase() === 'on') : false; + const restartDisabled = !safeOn; + const prefBtn = (key, label, icon, entId) => { + const isCurrent = activeWanRaw === key; + const disabled = isCurrent ? ' disabled' : ''; + const currentStyle = isCurrent + ? 'background: rgba(255,255,255,0.10); border-color: var(--rc-good); color: var(--rc-good); font-weight:700;' + : ''; + const title = isCurrent ? `${label} (current)` : `Prefer ${label}`; + return ` + + `; + }; + const controls = ` +
+ ${prefBtn('starlink', 'Starlink', '🛰', 'script.rc_openwrt_prefer_starlink')} + ${prefBtn('lte', 'LTE', '⋮', 'script.rc_openwrt_prefer_lte')} + ${prefBtn('auto', 'Auto', '⟳', 'script.rc_openwrt_prefer_auto')} +
+
+
+ Restart Network${safeOn ? '' : ' (locked — toggle rc_confirm_offline to unlock)'} +
+ +
+ +
Restart the network?
+
Connected clients will drop for ~30 s.
+
+ + +
+
+ `; + this._root.innerHTML = `
${this._header('Network')}
+ ${this._tile({title:'Controls', icon:'◎', content: controls, className:'span-2'})} ${this._tile({title:'Connection Status', icon:'⌁', content: connection, className:'span-2'})} ${this._tile({title:'Performance', icon:'⟲', content: perf})} ${this._tile({title:'Data Usage', icon:'⛁', content: dataUsage})} @@ -1834,6 +1899,51 @@ class RoamcoreNetworkPage extends RoamcoreBasePage {
`; + // Wire up the preference buttons + restart confirmation AFTER innerHTML + // is set. We bind with addEventListener to keep the inline HTML clean + // and avoid inline onclick handlers (CSP-friendly). + try { + const prefButtons = this._root.querySelectorAll('button[data-rc-pref]'); + prefButtons.forEach((btn) => { + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + const ent = btn.getAttribute('data-rc-entity'); + if (!ent || !this._hass || typeof this._hass.callService !== 'function') return; + if (btn.hasAttribute('disabled')) return; + this._hass.callService('script', 'turn_on', { entity_id: ent }); + }); + }); + + const restartBtn = this._root.querySelector('#rc-net-restart'); + const dlg = this._root.querySelector('#rc-net-restart-dialog'); + const cancelBtn = this._root.querySelector('button[data-rc-cancel]'); + const confirmBtn = this._root.querySelector('#rc-net-restart-confirm'); + if (restartBtn && dlg && typeof dlg.showModal === 'function') { + restartBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + if (restartBtn.hasAttribute('disabled')) return; + try { dlg.showModal(); } catch (e) { /* ignore */ } + }); + } + if (cancelBtn && dlg) { + cancelBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + try { dlg.close('cancel'); } catch (e) { /* ignore */ } + }); + } + if (confirmBtn && dlg) { + confirmBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + const ent = confirmBtn.getAttribute('data-rc-entity'); + if (!ent || !this._hass || typeof this._hass.callService !== 'function') return; + this._hass.callService('script', 'turn_on', { entity_id: ent }); + try { dlg.close('confirm'); } catch (e) { /* ignore */ } + }); + } + } catch (e) { + console.warn('Network controls wiring failed', e); + } + } } diff --git a/scripts/checks/openwrt-controls-smoke.sh b/scripts/checks/openwrt-controls-smoke.sh new file mode 100755 index 00000000..1f9f7872 --- /dev/null +++ b/scripts/checks/openwrt-controls-smoke.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +# RoamCore — Networking controls (OpenWrt API) smoke check. +# +# Validates the Wave 2 #28 slice is present and consistent in the repo: +# 1. All 4 user-facing scripts in roamcore_openwrt_api.yaml exist with an +# `alias:` field documented. +# 2. binary_sensor.rc_setup_networking_safe (template sensor) is defined +# with the documented formula; input_boolean.rc_confirm_offline exists +# and defaults OFF. +# 3. The Network page renders a Controls tile containing the 4 entity +# IDs above and uses a confirmation dialog for the restart action. +# 4. The Restart Network button is rendered disabled when the safety +# guard is OFF. +# +# This script is purely static — it never reaches out to a running HA or +# OpenWrt VM. It exits non-zero on the first failed assertion. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +API_PKG="homeassistant/packages/roamcore_openwrt_api.yaml" +NET_JS="homeassistant/www/roamcore/roamcore-pages.js" + +fail() { echo "ERROR: $*" >&2; exit 1; } +pass() { echo " PASS: $*"; } + +# --- Pre-flight ----------------------------------------------------------- +[ -f "$API_PKG" ] || fail "missing $API_PKG" +[ -f "$NET_JS" ] || fail "missing $NET_JS" + +# Ensure the YAML actually parses (HA refuses to load malformed YAML). +python3 -c 'import sys,yaml; yaml.safe_load(open(sys.argv[1],"r",encoding="utf-8"))' "$API_PKG" \ + || fail "YAML parse failed: $API_PKG" + +# --- 1. Scripts in roamcore_openwrt_api.yaml ----------------------------- +echo "== scripts (with alias:) ==" +SCRIPTS=(rc_openwrt_prefer_starlink rc_openwrt_prefer_lte rc_openwrt_prefer_auto rc_openwrt_restart_network) +# Scope to the YAML `script:` block only. rest_command.* keys can share +# the same identifier (e.g. rc_openwrt_restart_network), so we explicitly +# slice the file between the `script:` and the next top-level key. +SCRIPT_BLOCK="$( + awk ' + /^script:[[:space:]]*$/ { in_script = 1; next } + in_script && /^[a-zA-Z_]/ { exit } + in_script { print } + ' "$API_PKG" +)" +[ -n "$SCRIPT_BLOCK" ] || fail "no `script:` block found in $API_PKG" +for s in "${SCRIPTS[@]}"; do + # Block-style script definition: ':' on its own line followed (later) + # by an 'alias:' line. + if ! grep -qE "^ ${s}:[[:space:]]*$" <<<"$SCRIPT_BLOCK"; then + fail "script.${s} not declared in $API_PKG" + fi + # Pull the alias text between ":" and the next sibling key. + alias_text="$( + printf '%s\n' "$SCRIPT_BLOCK" | awk -v id=" ${s}:" ' + $0 == id { in_block = 1; next } + in_block && /^ [a-zA-Z_][a-zA-Z0-9_]*:[^[:space:]]/ { exit } + in_block && /^ alias:/ { sub(/^ alias:[[:space:]]*/, ""); print; exit } + ' + )" + if [ -z "$alias_text" ]; then + fail "script.${s} has no alias: field" + fi + pass "script.${s} declared with alias=\"${alias_text}\"" +done + +# --- 2. Safety guard binary_sensor + opt-in input_boolean ---------------- +echo "== safety guard ==" +if ! grep -qE '^input_boolean:' "$API_PKG"; then + fail "no input_boolean: section in $API_PKG" +fi +if ! grep -qE '^ rc_confirm_offline:' "$API_PKG"; then + fail "input_boolean.rc_confirm_offline not declared in $API_PKG" +fi +# Must default OFF so the Restart Network button is locked by default. +if ! grep -qE '^[[:space:]]+rc_confirm_offline:[[:space:]]*$' "$API_PKG" \ + || ! grep -qE 'initial:[[:space:]]+off' "$API_PKG"; then + # Be lenient: the id line + an "initial: off" anywhere in the file is OK. + fail "input_boolean.rc_confirm_offline must default to off (initial: off)" +fi +pass "input_boolean.rc_confirm_offline defined and defaults OFF" + +if ! grep -qE 'unique_id:[[:space:]]+rc_setup_networking_safe' "$API_PKG"; then + fail "binary_sensor.rc_setup_networking_safe (unique_id) not declared in $API_PKG" +fi +# Documented formula: ON iff OpenWrt internet is online AND confirm OFFLINE opt-in is ON. +if ! grep -qE "is_state\\('sensor\\.rc_openwrt_internet',[[:space:]]*'online'\\)" "$API_PKG"; then + fail "rc_setup_networking_safe formula missing 'sensor.rc_openwrt_internet == online' clause" +fi +if ! grep -qE "is_state\\('input_boolean\\.rc_confirm_offline',[[:space:]]*'on'\\)" "$API_PKG"; then + fail "rc_setup_networking_safe formula missing 'input_boolean.rc_confirm_offline == on' clause" +fi +pass "binary_sensor.rc_setup_networking_safe defined with documented formula" + +# --- 3. Controls tile rendered on the Network page ---------------------- +echo "== Controls tile on Network page ==" +# Confirm the network page class exists. +if ! grep -qE '^class RoamcoreNetworkPage' "$NET_JS"; then + fail "RoamcoreNetworkPage class not found in $NET_JS" +fi + +# Confirm a Controls tile is rendered with the four entity ids in scope of +# the _render() method (the same method that owns the grid). +# We bound the search to the NetworkPage _render body. +net_block="$( + awk ' + /^class RoamcoreNetworkPage/ { capture = 1 } + capture { print } + capture && /^class RoamcorePowerPage/ { exit } + ' "$NET_JS" +)" +[ -n "$net_block" ] || fail "could not isolate RoamcoreNetworkPage block" + +# Title "Controls" must appear on a tile. +if ! grep -qE "title:'Controls'" <<<"$net_block"; then + fail "Controls tile (title:'Controls') not present in RoamcoreNetworkPage._render()" +fi + +for ent in rc_openwrt_prefer_starlink rc_openwrt_prefer_lte rc_openwrt_prefer_auto rc_openwrt_restart_network; do + if ! grep -qF "$ent" <<<"$net_block"; then + fail "entity id ${ent} not referenced in RoamcoreNetworkPage._render()" + fi +done +pass "Controls tile references all 4 script entity ids" + +# The Restart button MUST be guarded by a confirmation flow: we accept +# either an inline `confirm(` JS prompt OR a `` element with a +# Cancel/Restart pattern. This slice uses a native . +if grep -qF ' element" +elif grep -qE 'confirm\(' <<<"$net_block"; then + pass "Restart confirmation uses confirm(...) prompt" +else + fail "Restart confirmation flow not found (expected or confirm(...) in Controls tile)" +fi + +# --- 4. Safety guard wiring (positive + negative) ------------------------ +echo "== Restart button disabled when guard is OFF ==" +# Positive: with rc_setup_networking_safe === 'on', the button must NOT be disabled. +# Negative: with rc_setup_networking_safe === 'off', the button MUST be disabled. +# We assert the negative case directly (the only thing that can be checked +# statically): the render() output must include the literal 'disabled' on +# the restart button when the safety state is off. We probe the JS source +# for the conditional branch. +if ! grep -qE "restartDisabled[[:space:]]*=[[:space:]]*!safeOn" <<<"$net_block"; then + fail "Restart button disabled-state logic 'restartDisabled = !safeOn' not found" +fi +# Also confirm the literal 'disabled' attribute appears on the Restart button +# markup and is conditional on safeOn (not a constant). +if ! grep -qE "id=\"rc-net-restart\"" <<<"$net_block"; then + fail "Restart button id \"rc-net-restart\" not found" +fi +if ! grep -qE 'data-rc-entity="script.rc_openwrt_restart_network"' <<<"$net_block"; then + fail "Restart button data-rc-entity binding to script.rc_openwrt_restart_network not found" +fi +pass "Restart button is disabled when binary_sensor.rc_setup_networking_safe is off" + +echo "All openwrt-controls smoke checks passed." \ No newline at end of file