#!/usr/bin/env python3 """Daily, REPORT-ONLY drift check of the Shield TV Pro units against `ops/shield/profile.json`. Telegrams ONLY when something changes. Never changes a device (an automatic debloat once broke Play Store and voice search): the only adb commands are `adb connect`, `adb devices` and ONE `adb shell` per device running `pm list packages`, `pm list packages -d` and `settings get`. Each finding gets a remediation HINT line in the log, printed and never executed. Do not add an apply mode. Every command in the shell is wrapped in a marker line (per-run random nonce, so device output can't forge one) and an exit-status line. A failed or status-less command, or cut-short output, makes the device unreachable for that run: an empty list or setting is never taken on faith. CHECKED (`_doc` in the profile has the details) disabled_packages must be disabled IF installed (absent is fine) required_enabled_packages must be installed and not disabled absent_packages must not be installed settings exact `settings get ` strings component_lists colon-separated component settings; every listed component must be present (`pkg/.Cls` equals `pkg/pkg.Cls`) Device-specific settings (CEC, IR, volume, network, audio, display, name) are not in the profile, so never flagged. CONFIGURATION `SHIELD_DEVICES` in `.env`: space-separated `name=host:port` entries, e.g. `living=192.0.2.10:5555 bedroom=192.0.2.11:5555`. Addresses live ONLY there (public repo). A malformed entry or duplicate name exits 2 and sends nothing; unset or empty means nothing to check (exit 0). `ADB` overrides the adb binary (default: `adb` from PATH). ALERTING (at most ONE Telegram message per run, combining devices) * a device's finding set differs from the last run's: its CURRENT findings (or "back in line with the profile"). A first run alerts only a device with findings. * unreachable: alert once when the consecutive count reaches 3 (daily runs), and once on recovery after such an alert. Unreachable runs leave stored findings alone. * `unauthorized` (TV awaiting the ADB debugging prompt) is a finding, not unreachable. * State (`var/state/shield-check.json`: per device, the sorted findings and the unreachable counter) advances only after the alert was delivered (or none was needed), so a failed send is retried next run. A missing or corrupt state file is logged and treated as empty. * An alert describes the CURRENT condition against the last DELIVERED state; there is no queue, so a condition that clears before delivery is never reported. * The message is a summary: per device the finding count, outage or recovery notice, and a pointer to the log. The full findings and hints are written to the log on every run. Exit codes: 0 every checked device matches the profile 1 drift or an unreachable device 2 configuration error (SHIELD_DEVICES, the profile, an unknown --device) 75 another run holds the lock (EX_TEMPFAIL); nothing was checked `--no-notify` prints findings and hints but sends nothing and writes no state (nor takes the run lock). `--device NAME` checks one device and leaves the stored state of the others alone. """ from __future__ import annotations import argparse import hashlib import ipaddress import json import os import re import secrets import shlex import subprocess import sys from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Callable sys.path.insert(0, str(Path(__file__).resolve().parent)) from media_stack.clients.telegram import send as telegram_send # noqa: E402 from media_stack.locking import acquire_singleton_lock # noqa: E402 from media_stack.paths import ( # noqa: E402 MEDIA_STACK_ROOT, VAR_RUN, VAR_STATE, ensure_var_dirs, load_env_file, ) from media_stack.state import load_state_locked, save_state # noqa: E402 PROFILE_PATH = Path(__file__).resolve().parent / "ops" / "shield" / "profile.json" STATE_FILE = VAR_STATE / "shield-check.json" LOCK_FILE = VAR_RUN / "shield-check.lock" # Every adb call is bounded and never retried. CONNECT_TIMEOUT_S = 20.0 DEVICES_TIMEOUT_S = 10.0 PROBE_TIMEOUT_S = 60.0 UNREACHABLE_ALERT_AT = 3 MAX_VALUE_CHARS = 80 LOG_POINTER = "Findings and remediation hints are in var/log/shield-check.log" EXIT_BUSY = 75 # sysexits.h EX_TEMPFAIL NAMESPACES = ("global", "secure", "system") PROFILE_KEYS = frozenset({ "_doc", "disabled_packages", "required_enabled_packages", "absent_packages", "settings", "component_lists", }) PACKAGE_LISTS = ("disabled_packages", "required_enabled_packages", "absent_packages") # Pairs that cannot both hold for one package. CONFLICTING_LISTS = (("disabled_packages", "required_enabled_packages"), ("absent_packages", "required_enabled_packages")) DEVICE_NAME_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,30}") HOSTNAME_RE = re.compile( r"(?=.{1,253}$)[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?" r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*") PACKAGE_RE = re.compile(r"[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+") SETTING_KEY_RE = re.compile(r"[A-Za-z0-9_.]+") COMPONENT_RE = re.compile(r"[A-Za-z0-9_.]+/[A-Za-z0-9_.$]+") # Marker-line prefix; each run appends a random nonce. MARK = "@@" UNAUTHORIZED_FINDING = "unauthorized: accept the debugging prompt on the TV" def log(msg: str) -> None: print(f"{datetime.now():%Y-%m-%d %H:%M:%S} {msg}", flush=True) def _snip(text: str, limit: int = 200) -> str: flat = " ".join(text.split()) return flat if len(flat) <= limit else flat[: limit - 3] + "..." def _lines(out: str) -> list[str]: """Split on LF, CRLF or the CRCRLF some adb/pty combinations emit.""" return re.sub(r"\r*\n|\r", "\n", out).split("\n") def _show(value: str) -> str: """ASCII-escaped and bounded; a cut value gets a digest of the FULL value, since the finding text is what state compares.""" text = ascii(value) if len(text) <= MAX_VALUE_CHARS: return text digest = hashlib.sha256(value.encode("utf-8", "backslashreplace")).hexdigest()[:8] return f"{text[: MAX_VALUE_CHARS - 3]}...[sha256:{digest}]" @dataclass(frozen=True) class Device: name: str host: str port: int @property def serial(self) -> str: return f"{self.host}:{self.port}" def _valid_host(host: str) -> bool: """IPv4 address or RFC 1123 hostname; digits-and-dots must be a real IPv4 (`999.1.1.1` is not).""" if re.fullmatch(r"[0-9.]+", host): try: ipaddress.IPv4Address(host) except ValueError: return False return True return HOSTNAME_RE.fullmatch(host) is not None def parse_devices(raw: str) -> tuple[list[Device], list[str]]: devices: list[Device] = [] problems: list[str] = [] seen: set[str] = set() for entry in raw.split(): name, eq, addr = entry.partition("=") host, colon, port_s = addr.rpartition(":") if not (eq and colon): problems.append(f"{entry!r}: expected name=host:port") continue entry_problems = [] if not DEVICE_NAME_RE.fullmatch(name): entry_problems.append("name must match [a-z0-9][a-z0-9-]{0,30}") if not _valid_host(host): entry_problems.append("host must be an IPv4 address or a hostname") if not (re.fullmatch(r"[0-9]{1,5}", port_s) and 1 <= int(port_s) <= 65535): entry_problems.append("port must be 1-65535") if entry_problems: problems.append(f"{entry!r}: " + "; ".join(entry_problems)) continue if name in seen: problems.append(f"{entry!r}: duplicate device name {name!r}") continue seen.add(name) devices.append(Device(name, host, int(port_s))) return devices, problems class ProfileError(Exception): """The profile cannot be used; the message says why.""" @dataclass(frozen=True) class Profile: disabled: tuple[str, ...] = () required: tuple[str, ...] = () absent: tuple[str, ...] = () settings: dict[str, dict[str, str]] = field(default_factory=dict) components: dict[str, dict[str, tuple[str, ...]]] = field(default_factory=dict) def _all_match(value: object, pattern: re.Pattern[str]) -> bool: return isinstance(value, list) and all( isinstance(item, str) and pattern.fullmatch(item) for item in value) def _namespaced(raw: dict, key: str, valid: Callable[[object], bool], what: str) -> dict[str, dict]: """The `key` section, `{namespace: {setting name: value}}`. Namespaces and setting names are interpolated into `settings get ` in the adb shell, so both are allowlisted.""" section = raw.get(key, {}) if not isinstance(section, dict): raise ProfileError(f"{key}: must be an object keyed by namespace") for ns, entries in section.items(): if ns not in NAMESPACES: raise ProfileError( f"{key}: unknown namespace {ns!r} (expected one of {', '.join(NAMESPACES)})") if not (isinstance(entries, dict) and all( SETTING_KEY_RE.fullmatch(name) and valid(value) for name, value in entries.items())): raise ProfileError( f"{key}.{ns}: setting names must match [A-Za-z0-9_.]+ and values must be {what}") return section def parse_profile(raw: object) -> Profile: """The first problem raises ProfileError. Package, namespace and setting names are checked because they reach the adb shell command or the printed hints.""" if not isinstance(raw, dict): raise ProfileError("the profile must be a JSON object") unknown = sorted(set(raw) - PROFILE_KEYS) if unknown: raise ProfileError(f"unknown top-level key(s): {', '.join(unknown)}") for key in PACKAGE_LISTS: if not _all_match(raw.get(key, []), PACKAGE_RE): raise ProfileError(f"{key}: must be a list of package names") lists = {key: tuple(raw.get(key, [])) for key in PACKAGE_LISTS} for first, second in CONFLICTING_LISTS: both = sorted(set(lists[first]) & set(lists[second])) if both: raise ProfileError(f"in both {first} and {second}: " + ", ".join(both)) settings = _namespaced(raw, "settings", lambda v: isinstance(v, str), "strings") components = _namespaced(raw, "component_lists", lambda v: _all_match(v, COMPONENT_RE), "lists of pkg/Class components") return Profile( lists["disabled_packages"], lists["required_enabled_packages"], lists["absent_packages"], settings, {ns: {name: tuple(wanted) for name, wanted in entries.items()} for ns, entries in components.items()}) def load_profile(path: Path) -> Profile: try: raw = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise ProfileError(f"cannot read {path.name}: {type(exc).__name__}: {exc}") from exc return parse_profile(raw) @dataclass class AdbResult: rc: int out: str err: str = "" class AdbError(Exception): """adb could not be run or did not finish in time.""" AdbRunner = Callable[[list[str], float], AdbResult] def make_adb_runner(binary: str) -> AdbRunner: def run(args: list[str], timeout: float) -> AdbResult: try: p = subprocess.run( [binary, *args], capture_output=True, encoding="utf-8", errors="replace", timeout=timeout, stdin=subprocess.DEVNULL) except subprocess.TimeoutExpired as exc: raise AdbError(f"{binary} {args[0]} timed out after {timeout:g}s") from exc except OSError as exc: raise AdbError(f"cannot run {binary}: {type(exc).__name__}: {exc}") from exc return AdbResult(p.returncode, p.stdout, p.stderr) return run class ProbeError(Exception): """The probe output was incomplete or implausible.""" @dataclass(frozen=True) class Snapshot: installed: frozenset[str] disabled: frozenset[str] settings: dict[tuple[str, str], str] def wanted_settings(profile: Profile) -> list[tuple[str, str]]: keys: list[tuple[str, str]] = [] for ns in NAMESPACES: keys += [(ns, key) for key in profile.settings.get(ns, {})] keys += [(ns, key) for key in profile.components.get(ns, {})] return list(dict.fromkeys(keys)) def probe_steps(keys: list[tuple[str, str]]) -> list[tuple[str, str]]: return ([("packages", "pm list packages"), ("disabled", "pm list packages -d")] + [(f"s:{ns}:{key}", f"settings get {ns} {key}") for ns, key in keys]) def build_probe(keys: list[tuple[str, str]], nonce: str) -> str: """Read-only; namespaces and keys were validated at profile load.""" mark = f"{MARK}{nonce}:" parts = [] for name, command in probe_steps(keys): parts += [f"echo {mark}{name}", command, f"echo {mark}rc $?"] parts.append(f"echo {mark}end") return "; ".join(parts) def parse_probe(out: str, keys: list[tuple[str, str]], nonce: str) -> Snapshot: """Only lines starting with this run's `MARK + nonce + ":"` are markers. Raises ProbeError on cut-short output, a missing or non-zero status, or no packages (a broken `pm` must not read as "every required package missing").""" prefix = f"{MARK}{nonce}:" steps = probe_steps(keys) names = {name for name, _ in steps} sections: dict[str, list[str]] = {} status: dict[str, int] = {} current: str | None = None ended = False for line in _lines(out): if not line.startswith(prefix): if current is not None: sections[current].append(line) continue tag = line[len(prefix):] rc = re.fullmatch(r"rc ([0-9]+)", tag) if tag == "end": ended = True break if rc: if current is None: raise ProbeError("exit status line without a command") status[current] = int(rc.group(1)) current = None elif tag in names: current = tag sections[tag] = [] else: raise ProbeError(f"unexpected marker {tag!r}") if not ended: raise ProbeError("output was cut short (no end marker)") for name, command in steps: if name not in sections: raise ProbeError(f"no output for `{command}`") if name not in status: raise ProbeError(f"no exit status for `{command}`") if status[name] != 0: raise ProbeError(f"`{command}` exited {status[name]}") def packages(name: str) -> frozenset[str]: listed = (ln.strip() for ln in sections[name]) return frozenset(n[len("package:"):] for n in listed if n.startswith("package:")) installed = packages("packages") if not installed: raise ProbeError("`pm list packages` returned no packages") values = {(ns, key): "\n".join(sections[f"s:{ns}:{key}"]) for ns, key in keys} return Snapshot(installed, packages("disabled"), values) def parse_device_state(out: str, serial: str) -> str | None: for line in _lines(out): parts = line.split() if len(parts) >= 2 and parts[0] == serial: return parts[1] return None @dataclass(frozen=True) class Finding: text: str # stable, sortable; what the state and Telegram hold hint: str # printed to the log, never executed def normalize_component(component: str) -> str: """`pkg/.Cls` -> `pkg/pkg.Cls` (Android's ComponentName expansion).""" component = component.strip() pkg, slash, cls = component.partition("/") if slash and cls.startswith("."): return f"{pkg}/{pkg}{cls}" return component def parse_component_list(raw: str) -> list[str]: """`null` and empty give []. Entries are not normalized.""" if raw.strip() in ("", "null"): return [] return [c.strip() for c in raw.split(":") if c.strip()] def evaluate(profile: Profile, snap: Snapshot, serial: str) -> list[Finding]: adb = f"adb -s {serial} shell" found: dict[str, Finding] = {} def add(text: str, hint: str) -> None: found[text] = Finding(text, hint) for pkg in profile.disabled: if pkg in snap.installed and pkg not in snap.disabled: add(f"disabled_packages:{pkg} is enabled", f"{adb} pm disable-user --user 0 {pkg}") for pkg in profile.required: if pkg not in snap.installed: add(f"required_enabled_packages:{pkg} is not installed", f"install {pkg} from the Play Store, or copy the APK from the other Shield") elif pkg in snap.disabled: add(f"required_enabled_packages:{pkg} is disabled", f"{adb} pm enable {pkg}") for pkg in profile.absent: if pkg in snap.installed: add(f"absent_packages:{pkg} is installed", f"{adb} pm uninstall {pkg}") for ns, entries in profile.settings.items(): for key, expected in entries.items(): got = snap.settings[(ns, key)] if got != expected: if expected == "null": fix = f"{adb} settings delete {ns} {key}" else: fix = f"{adb} settings put {ns} {key} {shlex.quote(expected)}" add(f"settings.{ns}.{key}: expected {_show(expected)}, got {_show(got)}", fix) for ns, lists in profile.components.items(): for key, wanted in lists.items(): have = parse_component_list(snap.settings[(ns, key)]) have_norm = {normalize_component(c) for c in have} missing = [c for c in wanted if normalize_component(c) not in have_norm] merged = shlex.quote(":".join([*have, *(normalize_component(c) for c in missing)])) for component in missing: add(f"component_lists.{ns}.{key}: missing {component}", f"{adb} settings put {ns} {key} {merged}") return sorted(found.values(), key=lambda f: f.text) @dataclass class DeviceReport: device: Device reachable: bool findings: list[Finding] = field(default_factory=list) detail: str = "" # why unreachable (log only) @property def clean(self) -> bool: return self.reachable and not self.findings def check_device(device: Device, profile: Profile, adb: AdbRunner) -> DeviceReport: """Never raises for a device problem: any adb failure makes it unreachable.""" serial = device.serial def down(why: str) -> DeviceReport: return DeviceReport(device, False, [], why) try: conn = adb(["connect", serial], CONNECT_TIMEOUT_S) log(f"[{device.name}] adb connect: {_snip(conn.out or conn.err)}") listing = adb(["devices"], DEVICES_TIMEOUT_S) if listing.rc != 0: return down(f"adb devices failed rc={listing.rc}: {_snip(listing.err or listing.out)}") state = parse_device_state(listing.out, serial) if state == "unauthorized": return DeviceReport(device, True, [Finding( UNAUTHORIZED_FINDING, "accept the debugging prompt on the TV, then run shield-check.py " f"--device {device.name} again")]) if state != "device": return down(f"adb state is {state}" if state else "not listed by adb devices") keys = wanted_settings(profile) nonce = secrets.token_hex(8) shell = adb(["-s", serial, "shell", build_probe(keys, nonce)], PROBE_TIMEOUT_S) if shell.rc != 0: return down(f"adb shell failed rc={shell.rc}: {_snip(shell.err or shell.out)}") snap = parse_probe(shell.out, keys, nonce) except (AdbError, ProbeError) as exc: return down(str(exc)) log(f"[{device.name}] read {len(snap.installed)} packages " f"({len(snap.disabled)} disabled) and {len(snap.settings)} settings") return DeviceReport(device, True, evaluate(profile, snap, serial)) def log_report(rep: DeviceReport) -> None: name = rep.device.name if not rep.reachable: log(f"[{name}] UNREACHABLE: {rep.detail}") elif not rep.findings: log(f"[{name}] OK: matches the profile") else: log(f"[{name}] DRIFT: {len(rep.findings)} finding(s)") hinted: set[str] = set() for f in rep.findings: log(f"[{name}] {f.text}") if f.hint not in hinted: hinted.add(f.hint) log(f"[{name}] HINT {f.hint}") @dataclass(frozen=True) class DeviceState: """Last delivered run's sorted findings and consecutive-unreachable count. No entry equals the default, so a first run needs no special case.""" findings: tuple[str, ...] = () unreachable: int = 0 def _state_entry(entry: object) -> DeviceState | None: if not isinstance(entry, dict): return None findings, count = entry.get("findings"), entry.get("unreachable") if not isinstance(findings, list) or not all(isinstance(f, str) for f in findings): return None if isinstance(count, bool) or not isinstance(count, int) or count < 0: return None return DeviceState(tuple(sorted(set(findings))), count) def load_states(state_file: Path) -> dict[str, DeviceState]: """Never raises: an unreadable file is empty and an invalid entry is skipped (both logged).""" try: raw = load_state_locked(state_file) except OSError as exc: log(f"state: {exc}; treating it as empty") return {} states: dict[str, DeviceState] = {} for name, entry in raw.items(): state = _state_entry(entry) if DEVICE_NAME_RE.fullmatch(str(name)) else None if state is None: log(f"state: ignoring invalid entry {name!r}") else: states[name] = state return states def save_states(state_file: Path, states: dict[str, DeviceState]) -> None: payload = {name: {"findings": list(s.findings), "unreachable": s.unreachable} for name, s in sorted(states.items())} try: save_state(state_file, payload) except OSError as exc: log(f"ERROR: could not write {state_file.name}: {type(exc).__name__}: {exc}; " f"the next run will repeat today's alerts") def advance(prev: DeviceState, rep: DeviceReport) -> tuple[DeviceState, list[str]]: """`(state to store once delivered, alert lines)`: counts and recovery notices, never the finding text (that is in the log).""" if not rep.reachable: count = prev.unreachable + 1 lines = [] if count == UNREACHABLE_ALERT_AT: lines.append(f"unreachable for {count} consecutive daily runs") return DeviceState(prev.findings, count), lines lines = [] if prev.unreachable >= UNREACHABLE_ALERT_AT: lines.append("reachable again") current = tuple(sorted({f.text for f in rep.findings})) if current != prev.findings: if current: lines.append(f"{len(current)} finding(s) vs the profile") else: lines.append("back in line with the profile") return DeviceState(current, 0), lines def build_message(alerts: dict[str, list[str]]) -> str: """Summary only: per changed device its name and the alert lines (finding count, outage or recovery notice), then a pointer to the log. No finding text, address or hint.""" lines = [f"Shield check: {len(alerts)} device(s) changed"] lines += [f"{name}: {line}" for name, notes in alerts.items() for line in notes] return "\n".join([*lines, "", LOG_POINTER]) def telegram_credentials() -> tuple[str, str]: """`(token, chat_id)`, either possibly empty. Process env wins; the Telegram plugin's `.env` (`TELEGRAM_ENV`, never the repo's `.env`) fills in the rest.""" token = os.environ.get("TELEGRAM_BOT_TOKEN", "") chat = os.environ.get("TELEGRAM_CHAT_ID", "") if token and chat: return token, chat env_path = None try: env_path = Path(os.environ.get("TELEGRAM_ENV") or Path.home() / ".claude" / "channels" / "telegram" / ".env") vals = load_env_file(env_path) except Exception as exc: log(f"notify: could not read the Telegram .env ({env_path}): " f"{type(exc).__name__}: {exc}") return token, chat return token or vals.get("TELEGRAM_BOT_TOKEN", ""), chat or vals.get("TELEGRAM_CHAT_ID", "") def send_alert(text: str) -> bool: """NEVER raises: missing credentials or a failed send return False and the caller keeps its state.""" token, chat = telegram_credentials() if not token or not chat: log("notify skipped: no Telegram credentials") return False try: ok = telegram_send(token, chat, text) except Exception as exc: log(f"notify error (non-fatal): {type(exc).__name__}") return False log("notify sent" if ok else "notify failed (non-fatal)") return ok def main(argv: list[str] | None = None, *, adb: AdbRunner | None = None, send: Callable[[str], bool] | None = None) -> int: ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--device", metavar="NAME", help="check only this device; the stored state of the others is left as is") ap.add_argument("--no-notify", action="store_true", help="print findings and hints only: no Telegram, no state write") args = ap.parse_args(argv) # Process env wins over .env even when empty (empty SHIELD_DEVICES = nothing configured). env = {**load_env_file(MEDIA_STACK_ROOT / ".env"), **os.environ} raw_devices = env.get("SHIELD_DEVICES", "") configured, problems = parse_devices(raw_devices) if problems: for problem in problems: log(f"ERROR: SHIELD_DEVICES: {problem}") return 2 if not configured: log("no Shield devices configured (SHIELD_DEVICES is unset or empty)") return 0 selected = configured if args.device: selected = [d for d in configured if d.name == args.device] if not selected: log(f"ERROR: --device {args.device!r} is not in SHIELD_DEVICES " f"({', '.join(d.name for d in configured)})") return 2 try: profile = load_profile(PROFILE_PATH) except ProfileError as exc: log(f"ERROR: profile: {exc}") return 2 if adb is None: adb = make_adb_runner(env.get("ADB") or "adb") notify = not args.no_notify lock_fp = None # keep referenced: the flock goes with it stored: dict[str, DeviceState] = {} if notify: # Read state, maybe send, write state: one run at a time. ensure_var_dirs() lock_fp = acquire_singleton_lock(LOCK_FILE) if lock_fp is None: log("another shield-check run holds the lock; nothing was checked") return EXIT_BUSY stored = load_states(STATE_FILE) else: log("--no-notify: nothing will be sent and no state written") reports = [] for device in selected: report = check_device(device, profile, adb) log_report(report) reports.append(report) status = 0 if all(r.clean for r in reports) else 1 if notify: alerts: dict[str, list[str]] = {} advanced: dict[str, DeviceState] = {} for report in reports: name = report.device.name advanced[name], lines = advance(stored.get(name, DeviceState()), report) if lines: alerts[name] = lines # A failed send keeps the old state, so a persisting condition is re-alerted. delivered = True if alerts: delivered = (send or send_alert)(build_message(alerts)) if not delivered: log(f"alert not delivered; the stored state of {', '.join(alerts)} is kept " f"so the next run retries") updated = dict(stored) for name, state in advanced.items(): if delivered or name not in alerts: updated[name] = state if not args.device: updated = {n: s for n, s in updated.items() if n in {d.name for d in configured}} if updated != stored: save_states(STATE_FILE, updated) return status if __name__ == "__main__": sys.exit(main())