Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/feature-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions docs/mvp/features-build-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<dialog>` 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.
Expand Down
50 changes: 50 additions & 0 deletions homeassistant/packages/roamcore_openwrt_api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}}
110 changes: 110 additions & 0 deletions homeassistant/www/roamcore/roamcore-pages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dialog> 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 `
<button
type="button"
class="rc-btn"
data-rc-pref="${key}"
data-rc-entity="${entId}"
title="${title}"
style="flex:1; min-width:0; ${currentStyle}"
${disabled}>${icon || ''} ${label}${isCurrent ? ' ✓' : ''}</button>
`;
};
const controls = `
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom: 12px;">
${prefBtn('starlink', 'Starlink', '🛰', 'script.rc_openwrt_prefer_starlink')}
${prefBtn('lte', 'LTE', '⋮', 'script.rc_openwrt_prefer_lte')}
${prefBtn('auto', 'Auto', '⟳', 'script.rc_openwrt_prefer_auto')}
</div>
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap;">
<div class="rc-label" style="flex:1; min-width:0;">
Restart Network${safeOn ? '' : ' (locked — toggle rc_confirm_offline to unlock)'}
</div>
<button
type="button"
class="rc-btn"
id="rc-net-restart"
data-rc-entity="script.rc_openwrt_restart_network"
title="Restart the OpenWrt network stack (clients drop ~30 s)"
${restartDisabled ? 'disabled' : ''}
style="${restartDisabled ? 'opacity:0.55; cursor:not-allowed;' : ''}">⟲ Restart Network</button>
</div>
<dialog id="rc-net-restart-dialog" style="border:1px solid rgba(255,255,255,0.18); border-radius:12px; background:var(--rc-card, rgba(32,32,32,0.92)); color:inherit; padding:18px; max-width:320px;">
<div style="font-weight:800; font-size:16px; margin-bottom:8px;">Restart the network?</div>
<div style="margin-bottom:14px; opacity:0.85;">Connected clients will drop for ~30 s.</div>
<div style="display:flex; gap:8px; justify-content:flex-end;">
<button type="button" class="rc-btn" data-rc-cancel="1" style="opacity:0.7;">Cancel</button>
<button type="button" class="rc-btn" id="rc-net-restart-confirm" data-rc-entity="script.rc_openwrt_restart_network" style="font-weight:700;">Restart</button>
</div>
</dialog>
`;

this._root.innerHTML = `
<div class="rc-page">
${this._header('Network')}
<div class="rc-grid">
${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})}
Expand All @@ -1834,6 +1899,51 @@ class RoamcoreNetworkPage extends RoamcoreBasePage {
</div>
`;

// 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);
}

}
}

Expand Down
162 changes: 162 additions & 0 deletions scripts/checks/openwrt-controls-smoke.sh
Original file line number Diff line number Diff line change
@@ -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: '<id>:' 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 "<id>:" 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 `<dialog>` element with a
# Cancel/Restart pattern. This slice uses a native <dialog>.
if grep -qF '<dialog' <<<"$net_block"; then
pass "Restart confirmation uses <dialog> element"
elif grep -qE 'confirm\(' <<<"$net_block"; then
pass "Restart confirmation uses confirm(...) prompt"
else
fail "Restart confirmation flow not found (expected <dialog> 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."