v2 host: portable desktop app (tray + FastAPI + WebView2 panel), unified config, HA control
Fold the Windows tray/daemon into one self-contained Clawdmeter.exe with a settings UI, and wire the host side of the watch features: - config.py: single %LOCALAPPDATA%\Clawdmeter\config.json (ha/buttons/settings), atomic writes, auto-migration from the old ha_config.json. - server.py: local FastAPI (127.0.0.1:8723) — GET/PUT /api/config (token masked), POST /api/ha/test, GET /api/ha/entities, GET /api/status. - web/index.html: brand-styled settings panel (Status/HA/Buttons/Settings tabs). - panel.py: pywebview/WebView2 window, launched as its own process (pywebview and pystray both want the main thread); tray "Settings" opens it via --panel. - daemon: HA command dispatch (toggle/bri/ct), dynamic button labels + index→ action mapping, watch-battery low warning toast, and the dimmer "dim" snapshot (dimreq → light_snapshot of the first entity) so the watch dial seeds from HA. - clawdmeter.spec / requirements: bundle fastapi+uvicorn+pywebview+webview backend. - build-exe.ps1: ASCII-only (Windows PowerShell 5.1 mangles em-dashes under cp1251). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,3 +30,8 @@ __pycache__/
|
|||||||
|
|
||||||
# Local Home Assistant config (holds a secret token - never commit)
|
# Local Home Assistant config (holds a secret token - never commit)
|
||||||
daemon/ha_config.json
|
daemon/ha_config.json
|
||||||
|
|
||||||
|
# Session scratch — BLE daemon run logs + one-off on-device QA screenshots
|
||||||
|
/m3_daemon*.log
|
||||||
|
/dimmer_qa.png
|
||||||
|
/build-exe.log
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
# build-exe.ps1 — build the standalone Clawdmeter.exe (PyInstaller).
|
# build-exe.ps1 - build the standalone Clawdmeter.exe (PyInstaller).
|
||||||
#
|
#
|
||||||
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable
|
# Produces dist\Clawdmeter.exe: a single self-contained tray + daemon executable
|
||||||
# that runs on ANY Windows 11 machine with no Python install and no pip. Build it
|
# that runs on ANY Windows 11 machine with no Python install and no pip. Build it
|
||||||
@@ -26,7 +26,7 @@ Log "=== Clawdmeter exe build ==="
|
|||||||
if (-not (Test-Path $PythonExe)) {
|
if (-not (Test-Path $PythonExe)) {
|
||||||
Log "Creating virtual environment at .venv ..."
|
Log "Creating virtual environment at .venv ..."
|
||||||
& python -m venv $VenvDir
|
& python -m venv $VenvDir
|
||||||
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) — is Python on PATH?" }
|
if ($LASTEXITCODE -ne 0) { throw "venv creation failed (exit $LASTEXITCODE) - is Python on PATH?" }
|
||||||
}
|
}
|
||||||
|
|
||||||
# 2. Runtime dependencies + PyInstaller (the only build-time extra)
|
# 2. Runtime dependencies + PyInstaller (the only build-time extra)
|
||||||
@@ -45,4 +45,4 @@ $ExePath = Join-Path $RepoRoot "dist\Clawdmeter.exe"
|
|||||||
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
|
if (-not (Test-Path $ExePath)) { throw "Build reported success but $ExePath is missing" }
|
||||||
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
|
$sizeMB = [math]::Round((Get-Item $ExePath).Length / 1MB, 1)
|
||||||
Log "Build complete: $ExePath ($sizeMB MB)"
|
Log "Build complete: $ExePath ($sizeMB MB)"
|
||||||
Log "Distribute this exe via a Gitea release — it is intentionally not committed to git."
|
Log "Distribute this exe via a Gitea release - it is intentionally not committed to git."
|
||||||
|
|||||||
+21
-2
@@ -21,18 +21,37 @@
|
|||||||
|
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
datas = [('firmware/src/logo.h', 'firmware/src')] # tray icon parsed at runtime
|
datas = [
|
||||||
|
('firmware/src/logo.h', 'firmware/src'), # tray icon parsed at runtime
|
||||||
|
('daemon/web', 'daemon/web'), # Phase 7 control-panel UI (server._web_dir)
|
||||||
|
]
|
||||||
binaries = []
|
binaries = []
|
||||||
hiddenimports = [
|
hiddenimports = [
|
||||||
# Imported lazily inside tray_windows.main(), so name them explicitly.
|
# Imported lazily inside tray_windows.main(), so name them explicitly.
|
||||||
'daemon.claude_usage_daemon_windows',
|
'daemon.claude_usage_daemon_windows',
|
||||||
'daemon.autostart_windows',
|
'daemon.autostart_windows',
|
||||||
'daemon.icon_assets',
|
'daemon.icon_assets',
|
||||||
|
# Phase 7 control panel (lazy string-imports inside tray_windows / server).
|
||||||
|
'daemon.config',
|
||||||
|
'daemon.server',
|
||||||
|
'daemon.panel',
|
||||||
|
'daemon.ha_client',
|
||||||
# The exact winrt media modules read_now_playing() pulls in.
|
# The exact winrt media modules read_now_playing() pulls in.
|
||||||
'winrt.windows.media',
|
'winrt.windows.media',
|
||||||
'winrt.windows.media.control',
|
'winrt.windows.media.control',
|
||||||
|
# pywebview's Windows backend + pythonnet bridge load these dynamically.
|
||||||
|
'webview.platforms.edgechromium',
|
||||||
|
'clr',
|
||||||
]
|
]
|
||||||
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL'):
|
# Phase 7 adds the FastAPI control panel + the pywebview WebView2 window. uvicorn
|
||||||
|
# and pywebview both import their submodules (loop/protocol pickers; the
|
||||||
|
# edgechromium backend + bundled WebView2 DLLs) dynamically, which PyInstaller's
|
||||||
|
# static analysis misses — collect_all pulls submodules, binaries and data files.
|
||||||
|
# clr_loader/pythonnet ship the .NET runtime-config JSON pywebview's WinForms host
|
||||||
|
# needs. If a package is absent the build fails loudly (better than a silent
|
||||||
|
# blank window at runtime).
|
||||||
|
for _pkg in ('winrt', 'bleak', 'pystray', 'PIL',
|
||||||
|
'fastapi', 'uvicorn', 'webview', 'clr_loader', 'pythonnet'):
|
||||||
_d, _b, _h = collect_all(_pkg)
|
_d, _b, _h = collect_all(_pkg)
|
||||||
datas += _d
|
datas += _d
|
||||||
binaries += _b
|
binaries += _b
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ DEVICE_ADDRESS = os.environ.get("CLAWDMETER_ADDRESS")
|
|||||||
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
|
SERVICE_UUID = "4c41555a-4465-7669-6365-000000000001"
|
||||||
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
|
RX_CHAR_UUID = "4c41555a-4465-7669-6365-000000000002"
|
||||||
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
|
REQ_CHAR_UUID = "4c41555a-4465-7669-6365-000000000004"
|
||||||
|
CMD_CHAR_UUID = "4c41555a-4465-7669-6365-000000000005" # watch → host commands (Phase 6 HA)
|
||||||
|
|
||||||
|
# Phase 7 M2 — dynamic watch buttons. The daemon pushes up to this many button
|
||||||
|
# labels (each truncated) in the RX payload's "btns" array on the ~60s heartbeat;
|
||||||
|
# the watch renders a grid and reports only the pressed index. Bounds keep the
|
||||||
|
# merged BLE payload comfortably under the firmware's 512-byte RX buffer.
|
||||||
|
WATCH_MAX_BUTTONS = 6
|
||||||
|
WATCH_LABEL_MAX = 16
|
||||||
|
|
||||||
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
|
POLL_INTERVAL = 60 # Anthropic rate-limit / usage poll cadence (seconds)
|
||||||
NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
|
NOWPLAYING_INTERVAL = 3 # Windows media-session read cadence (seconds). The
|
||||||
@@ -296,9 +304,58 @@ async def scan_for_device():
|
|||||||
|
|
||||||
|
|
||||||
class Session:
|
class Session:
|
||||||
def __init__(self, client: BleakClient) -> None:
|
def __init__(self, client: BleakClient, tray_state=None) -> None:
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self.tray_state = tray_state
|
||||||
self.refresh_requested = asyncio.Event()
|
self.refresh_requested = asyncio.Event()
|
||||||
|
self.dim_requested = asyncio.Event() # watch opened the Dimmer screen → push a fresh light snapshot
|
||||||
|
self.ha = None # HAClient once a valid HA config is loaded
|
||||||
|
self.ha_entities: list = [] # controllable entity_ids from the config
|
||||||
|
self._loop = None # event loop captured for thread-safe BLE dispatch
|
||||||
|
self.low_bat_pct = 15 # warn at/below this watch battery %
|
||||||
|
self._battery_warned = False
|
||||||
|
self._last_logged_bat = None
|
||||||
|
self.buttons = [] # config buttons; list index == watch button index
|
||||||
|
|
||||||
|
def reload_buttons(self) -> None:
|
||||||
|
"""Re-read the config's button list so panel edits propagate to the watch
|
||||||
|
(called each poll). The list index is the index the watch reports back."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.config import load_config
|
||||||
|
except ImportError:
|
||||||
|
from config import load_config
|
||||||
|
self.buttons = load_config().get("buttons") or []
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Button reload failed: {e!r}")
|
||||||
|
|
||||||
|
def button_labels(self) -> list:
|
||||||
|
"""Compact label list for the RX 'btns' payload (count + length capped so
|
||||||
|
the merged BLE write stays under the firmware's 512-byte buffer)."""
|
||||||
|
out = []
|
||||||
|
for b in self.buttons[:WATCH_MAX_BUTTONS]:
|
||||||
|
lbl = str(b.get("label") or b.get("entity") or "Button").strip()
|
||||||
|
out.append(lbl[:WATCH_LABEL_MAX])
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def dim_snapshot(self) -> dict | None:
|
||||||
|
"""Compact live state of the tilt-dimmer's light for the RX 'dim' object,
|
||||||
|
so the watch dial seeds from reality. The dimmer targets the first HA
|
||||||
|
entity (same default the bri/ct commands fall back to). Returns None when
|
||||||
|
HA is off or the read fails — the watch then just shows its last value."""
|
||||||
|
if not self.ha or not self.ha_entities:
|
||||||
|
return None
|
||||||
|
snap = await self.ha.light_snapshot(self.ha_entities[0])
|
||||||
|
if not snap:
|
||||||
|
return None
|
||||||
|
out = {"on": 1 if snap["on"] else 0,
|
||||||
|
"mink": int(snap["min_kelvin"]),
|
||||||
|
"maxk": int(snap["max_kelvin"])}
|
||||||
|
if snap["brightness_pct"] is not None:
|
||||||
|
out["bri"] = int(snap["brightness_pct"])
|
||||||
|
if snap["color_temp_kelvin"] is not None:
|
||||||
|
out["ct"] = int(snap["color_temp_kelvin"])
|
||||||
|
return out
|
||||||
|
|
||||||
def _on_refresh(self, _char, _data: bytearray) -> None:
|
def _on_refresh(self, _char, _data: bytearray) -> None:
|
||||||
log("Refresh requested by device")
|
log("Refresh requested by device")
|
||||||
@@ -316,6 +373,112 @@ class Session:
|
|||||||
except (BleakError, ValueError, OSError) as e:
|
except (BleakError, ValueError, OSError) as e:
|
||||||
log(f"Refresh subscription unavailable: {e}")
|
log(f"Refresh subscription unavailable: {e}")
|
||||||
|
|
||||||
|
async def setup_command_subscription(self) -> None:
|
||||||
|
# Watch → host channel (…0005): battery telemetry (always) plus HA commands
|
||||||
|
# (when HA is configured). We subscribe regardless of HA so the low-battery
|
||||||
|
# warning works even with Home Assistant unset. Imports are lazy so a
|
||||||
|
# missing config/dep can never break daemon startup, and so they resolve
|
||||||
|
# after `log` is defined (ha_client imports it).
|
||||||
|
self._loop = asyncio.get_running_loop() # thread-safe dispatch from the BLE callback
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
from daemon.ha_client import HAClient
|
||||||
|
from daemon.config import load_config, ha_settings
|
||||||
|
except ImportError:
|
||||||
|
from ha_client import HAClient
|
||||||
|
from config import load_config, ha_settings
|
||||||
|
cfg = load_config()
|
||||||
|
self.low_bat_pct = int(cfg.get("settings", {}).get("low_battery_pct", 15))
|
||||||
|
self.buttons = cfg.get("buttons") or []
|
||||||
|
ha = ha_settings(cfg)
|
||||||
|
if ha:
|
||||||
|
self.ha = HAClient(ha["url"], ha["token"])
|
||||||
|
self.ha_entities = ha["entities"]
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Config/HA init failed ({e!r}); HA control disabled")
|
||||||
|
try:
|
||||||
|
await self.client.start_notify(CMD_CHAR_UUID, self._on_command)
|
||||||
|
log(f"Command channel ready (HA={'on' if self.ha else 'off'}, "
|
||||||
|
f"low-batt={self.low_bat_pct}%, entities={self.ha_entities})")
|
||||||
|
except (BleakError, ValueError, OSError) as e:
|
||||||
|
log(f"Command subscription unavailable: {e}")
|
||||||
|
|
||||||
|
def _on_command(self, _char, data: bytearray) -> None:
|
||||||
|
try:
|
||||||
|
payload = json.loads(bytes(data).decode("utf-8", "replace"))
|
||||||
|
except (ValueError, UnicodeDecodeError):
|
||||||
|
log(f"Watch msg: bad payload {bytes(data)!r}")
|
||||||
|
return
|
||||||
|
# Battery telemetry ({"bat":pct,"mv":..,"chg":0/1}) is handled inline (quick,
|
||||||
|
# no await). HA commands ({"cmd":...}) are dispatched onto the loop because
|
||||||
|
# bleak may deliver this callback on a non-loop thread.
|
||||||
|
if "bat" in payload:
|
||||||
|
self._handle_battery(payload)
|
||||||
|
return
|
||||||
|
# Dimmer screen opened (M3): ask the poll loop to push a fresh light
|
||||||
|
# snapshot on its next tick (~3s). asyncio.Event isn't thread-safe and
|
||||||
|
# this callback may run off-loop, so flip it via the loop.
|
||||||
|
if payload.get("cmd") == "dimreq":
|
||||||
|
loop = self._loop
|
||||||
|
if loop is not None:
|
||||||
|
loop.call_soon_threadsafe(self.dim_requested.set)
|
||||||
|
return
|
||||||
|
# Phase 7 M2: a watch button press carries only its index — map it to the
|
||||||
|
# configured action/entity/value here (the watch stays dumb).
|
||||||
|
if payload.get("cmd") == "btn":
|
||||||
|
i = payload.get("i")
|
||||||
|
if not isinstance(i, int) or not (0 <= i < len(self.buttons)):
|
||||||
|
log(f"Watch button {i}: out of range (have {len(self.buttons)})")
|
||||||
|
return
|
||||||
|
b = self.buttons[i]
|
||||||
|
payload = {"cmd": b.get("action") or "toggle", "e": b.get("entity")}
|
||||||
|
if b.get("value") is not None:
|
||||||
|
payload["v"] = b["value"]
|
||||||
|
log(f"Watch button {i} -> {payload}")
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None:
|
||||||
|
return
|
||||||
|
asyncio.run_coroutine_threadsafe(self._dispatch_command(payload), loop)
|
||||||
|
|
||||||
|
def _handle_battery(self, payload: dict) -> None:
|
||||||
|
pct = payload.get("bat")
|
||||||
|
charging = bool(payload.get("chg", 0))
|
||||||
|
if not isinstance(pct, int):
|
||||||
|
return
|
||||||
|
if pct != self._last_logged_bat:
|
||||||
|
self._last_logged_bat = pct
|
||||||
|
log(f"Watch battery: {pct}%{' (charging)' if charging else ''}")
|
||||||
|
if self.tray_state is not None:
|
||||||
|
self.tray_state.battery_pct = pct
|
||||||
|
# Warn once on the way down; re-arm when charging or comfortably recovered.
|
||||||
|
if charging or pct > self.low_bat_pct + 10:
|
||||||
|
self._battery_warned = False
|
||||||
|
if not charging and pct <= self.low_bat_pct and not self._battery_warned:
|
||||||
|
self._battery_warned = True
|
||||||
|
log(f"Watch battery low: {pct}% (<= {self.low_bat_pct}%)")
|
||||||
|
if self.tray_state is not None:
|
||||||
|
self.tray_state.toasts.put(
|
||||||
|
("Clawdmeter", f"Watch battery low - {pct}%. Time to charge it."))
|
||||||
|
|
||||||
|
async def _dispatch_command(self, payload: dict) -> None:
|
||||||
|
if not self.ha:
|
||||||
|
return
|
||||||
|
cmd = payload.get("cmd")
|
||||||
|
entity = payload.get("e") or (self.ha_entities[0] if self.ha_entities else None)
|
||||||
|
if not entity:
|
||||||
|
log("HA cmd: no entity to target")
|
||||||
|
return
|
||||||
|
if cmd == "toggle":
|
||||||
|
ok = await self.ha.toggle(entity)
|
||||||
|
elif cmd == "bri": # step 3: brightness %, joystick
|
||||||
|
ok = await self.ha.set_brightness(entity, float(payload.get("v", 0)))
|
||||||
|
elif cmd == "ct": # step 3: color temperature, kelvin
|
||||||
|
ok = await self.ha.set_color_temp(entity, float(payload.get("v", 0)))
|
||||||
|
else:
|
||||||
|
log(f"HA cmd: unknown cmd {cmd!r}")
|
||||||
|
return
|
||||||
|
log(f"HA cmd {cmd} -> {'ok' if ok else 'FAIL'}")
|
||||||
|
|
||||||
async def write_payload(self, payload: dict) -> bool:
|
async def write_payload(self, payload: dict) -> bool:
|
||||||
# ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char)
|
# ensure_ascii=False keeps non-Latin titles as compact UTF-8 (~2 bytes/char)
|
||||||
# instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
|
# instead of \uXXXX (6 bytes), which roughly thirds the size of a Cyrillic
|
||||||
@@ -661,8 +824,9 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
log("Connected")
|
log("Connected")
|
||||||
session = Session(client)
|
session = Session(client, tray_state)
|
||||||
await session.setup_refresh_subscription()
|
await session.setup_refresh_subscription()
|
||||||
|
await session.setup_command_subscription()
|
||||||
|
|
||||||
# Two cadences share one connection: the Anthropic usage / rate-limit poll runs
|
# Two cadences share one connection: the Anthropic usage / rate-limit poll runs
|
||||||
# every POLL_INTERVAL (60s) while the Windows media session is read every
|
# every POLL_INTERVAL (60s) while the Windows media session is read every
|
||||||
@@ -680,10 +844,14 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
now = time.time()
|
now = time.time()
|
||||||
claude_due = (session.refresh_requested.is_set()
|
claude_due = (session.refresh_requested.is_set()
|
||||||
or (now - last_claude_poll) >= POLL_INTERVAL)
|
or (now - last_claude_poll) >= POLL_INTERVAL)
|
||||||
|
dim_due = session.dim_requested.is_set() # watch opened the Dimmer screen
|
||||||
|
if dim_due:
|
||||||
|
session.dim_requested.clear()
|
||||||
auth_problem = False
|
auth_problem = False
|
||||||
|
|
||||||
if claude_due:
|
if claude_due:
|
||||||
session.refresh_requested.clear()
|
session.refresh_requested.clear()
|
||||||
|
session.reload_buttons() # pick up desktop-panel edits to the button set
|
||||||
|
|
||||||
# Local token usage (Session screen) needs no network — compute it
|
# Local token usage (Session screen) needs no network — compute it
|
||||||
# every cycle so the watch keeps updating even with no/expired token.
|
# every cycle so the watch keeps updating even with no/expired token.
|
||||||
@@ -744,13 +912,29 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
log(f"Now-playing skipped: {e!r}")
|
log(f"Now-playing skipped: {e!r}")
|
||||||
np = {"np": 0}
|
np = {"np": 0}
|
||||||
|
|
||||||
|
# Light snapshot for the tilt-dimmer dial — fetched only when the watch
|
||||||
|
# opens the Dimmer screen (dim_due), to seed the dial from reality. No
|
||||||
|
# per-heartbeat fetch: during a session the watch is authoritative (it
|
||||||
|
# streams absolute values), and it re-requests on every re-open. Best-
|
||||||
|
# effort — a slow/dead HA just omits the field, never stalls the loop.
|
||||||
|
dim_snap = None
|
||||||
|
if dim_due:
|
||||||
|
try:
|
||||||
|
dim_snap = await session.dim_snapshot()
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Dim snapshot skipped: {e!r}")
|
||||||
|
|
||||||
# Write when the usage data was just refreshed (this is also the ~60s
|
# Write when the usage data was just refreshed (this is also the ~60s
|
||||||
# heartbeat) or when the track / playback state changed since the last
|
# heartbeat), when the track / playback state changed since the last
|
||||||
# write — skip otherwise so the link, and the field log, stay quiet
|
# write, or when the watch asked for a dimmer snapshot — skip otherwise
|
||||||
# between changes instead of repeating an identical payload every 3s.
|
# so the link, and the field log, stay quiet between changes.
|
||||||
if claude_due or np != last_np_sent:
|
if claude_due or dim_due or np != last_np_sent:
|
||||||
payload = dict(cached)
|
payload = dict(cached)
|
||||||
payload.update(np)
|
payload.update(np)
|
||||||
|
if claude_due:
|
||||||
|
payload["btns"] = session.button_labels() # ~60s heartbeat only
|
||||||
|
if dim_snap is not None:
|
||||||
|
payload["dim"] = dim_snap
|
||||||
if await session.write_payload(payload):
|
if await session.write_payload(payload):
|
||||||
used_successfully = True
|
used_successfully = True
|
||||||
consecutive_failures = 0 # D-03: reset on success
|
consecutive_failures = 0 # D-03: reset on success
|
||||||
@@ -790,6 +974,11 @@ async def connect_and_run(device, stop_event: asyncio.Event, tray_state=None) ->
|
|||||||
await client.disconnect()
|
await client.disconnect()
|
||||||
except (BleakError, OSError):
|
except (BleakError, OSError):
|
||||||
pass
|
pass
|
||||||
|
if session.ha:
|
||||||
|
try:
|
||||||
|
await session.ha.aclose()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
log("Device disconnected" if not stop_event.is_set() else "Stopping")
|
log("Device disconnected" if not stop_event.is_set() else "Stopping")
|
||||||
return used_successfully
|
return used_successfully
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Unified Clawdmeter configuration (Phase 7).
|
||||||
|
|
||||||
|
Single source of truth at ``%LOCALAPPDATA%\\Clawdmeter\\config.json``, shared by
|
||||||
|
the tray daemon and the FastAPI control panel. Replaces the Phase-6
|
||||||
|
``ha_config.json`` (auto-migrated on first load).
|
||||||
|
|
||||||
|
Shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"ha": {"url": str, "token": str, "entities": [str, ...]},
|
||||||
|
"buttons": [{"id": str, "label": str, "icon": str,
|
||||||
|
"action": "toggle"|"bri"|"ct", "entity": str, "value": int|None}],
|
||||||
|
"settings": {"device_address": str, "autostart": bool}
|
||||||
|
}
|
||||||
|
|
||||||
|
The token is a secret: it lives only in this file (outside the repo, gitignored)
|
||||||
|
and is NEVER logged (only its length) — same discipline as ha_client.py. The
|
||||||
|
control panel masks it in API responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CONFIG_VERSION = 1
|
||||||
|
DEFAULT_PORT = 8723 # FastAPI control panel — bound to 127.0.0.1 only
|
||||||
|
VALID_ACTIONS = ("toggle", "bri", "ct")
|
||||||
|
|
||||||
|
|
||||||
|
def _dir() -> Path:
|
||||||
|
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
||||||
|
return base / "Clawdmeter"
|
||||||
|
|
||||||
|
|
||||||
|
def config_path() -> Path:
|
||||||
|
if override := os.environ.get("CLAWDMETER_CONFIG"):
|
||||||
|
return Path(override)
|
||||||
|
return _dir() / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_ha_path() -> Path:
|
||||||
|
return _dir() / "ha_config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def default_config() -> dict:
|
||||||
|
return {
|
||||||
|
"version": CONFIG_VERSION,
|
||||||
|
"ha": {"url": "", "token": "", "entities": []},
|
||||||
|
"buttons": [],
|
||||||
|
"settings": {"device_address": "", "autostart": False, "low_battery_pct": 15},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy(cfg: dict) -> dict:
|
||||||
|
"""Import the Phase-6 ha_config.json into the unified config when the unified
|
||||||
|
HA section is still empty. Non-destructive: the old file is left in place."""
|
||||||
|
legacy = _legacy_ha_path()
|
||||||
|
if cfg["ha"]["url"] or not legacy.exists():
|
||||||
|
return cfg
|
||||||
|
try:
|
||||||
|
# utf-8-sig: PowerShell's Out-File writes a BOM that json.loads chokes on.
|
||||||
|
old = json.loads(legacy.read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return cfg
|
||||||
|
cfg["ha"]["url"] = (old.get("url") or "").strip().rstrip("/")
|
||||||
|
cfg["ha"]["token"] = (old.get("token") or "").strip()
|
||||||
|
cfg["ha"]["entities"] = list(old.get("entities") or [])
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
"""Load the unified config, filling defaults for any missing section so new
|
||||||
|
keys added in later versions always resolve. Never raises."""
|
||||||
|
cfg = default_config()
|
||||||
|
try:
|
||||||
|
loaded = json.loads(config_path().read_text(encoding="utf-8-sig"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
loaded = None
|
||||||
|
if isinstance(loaded, dict):
|
||||||
|
for section in ("ha", "settings"):
|
||||||
|
if isinstance(loaded.get(section), dict):
|
||||||
|
cfg[section].update(loaded[section])
|
||||||
|
if isinstance(loaded.get("buttons"), list):
|
||||||
|
cfg["buttons"] = loaded["buttons"]
|
||||||
|
if not cfg["ha"]["url"] and not cfg["ha"]["token"]:
|
||||||
|
cfg = _migrate_legacy(cfg)
|
||||||
|
# Normalize the HA section the same way ha_client expects it.
|
||||||
|
cfg["ha"]["url"] = (cfg["ha"].get("url") or "").strip().rstrip("/")
|
||||||
|
cfg["ha"]["token"] = (cfg["ha"].get("token") or "").strip()
|
||||||
|
cfg["ha"]["entities"] = list(cfg["ha"].get("entities") or [])
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg: dict) -> None:
|
||||||
|
"""Persist atomically (temp file + os.replace) so a crash mid-write can't
|
||||||
|
leave a truncated config."""
|
||||||
|
path = config_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def ha_settings(cfg: dict | None = None) -> dict | None:
|
||||||
|
"""Return {url, token, entities} when HA is configured (url + real token),
|
||||||
|
else None — the shape the daemon needs to build an HAClient. Logging of the
|
||||||
|
token is the caller's responsibility (ha_client logs only the length)."""
|
||||||
|
if cfg is None:
|
||||||
|
cfg = load_config()
|
||||||
|
ha = cfg.get("ha", {})
|
||||||
|
url = (ha.get("url") or "").strip().rstrip("/")
|
||||||
|
token = (ha.get("token") or "").strip()
|
||||||
|
if not url or not token or token.startswith("PASTE_"):
|
||||||
|
return None
|
||||||
|
return {"url": url, "token": token, "entities": list(ha.get("entities") or [])}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""WebView2 settings window for Clawdmeter (Phase 7, M1).
|
||||||
|
|
||||||
|
Opens a native Windows WebView2 window (via pywebview) onto the local control
|
||||||
|
panel the tray process already serves on 127.0.0.1. Launched as a SEPARATE
|
||||||
|
PROCESS from the tray — ``Clawdmeter.exe --panel`` when frozen, ``python -m
|
||||||
|
daemon.panel`` in source — because pywebview and pystray each need to own the
|
||||||
|
main thread and cannot coexist in one process (see tray_windows._on_settings).
|
||||||
|
|
||||||
|
The port comes from CLAWDMETER_PANEL_PORT (set by the tray when it spawns us)
|
||||||
|
and falls back to the config default, so the window always points at the server
|
||||||
|
the tray actually started.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Make the `daemon` package importable when run as a bare script or frozen exe,
|
||||||
|
# mirroring tray_windows.py's bootstrap (logon autostart starts us with cwd =
|
||||||
|
# System32, and the frozen exe loads the package from the bundle root).
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
_REPO_ROOT = sys._MEIPASS # type: ignore[attr-defined]
|
||||||
|
else:
|
||||||
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if _REPO_ROOT not in sys.path:
|
||||||
|
sys.path.insert(0, _REPO_ROOT)
|
||||||
|
|
||||||
|
_VENV_SITE = os.path.join(_REPO_ROOT, ".venv", "Lib", "site-packages")
|
||||||
|
if os.path.isdir(_VENV_SITE):
|
||||||
|
import site
|
||||||
|
site.addsitedir(_VENV_SITE)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from daemon import config as cfgmod
|
||||||
|
except ImportError: # running as a plain script (cwd on path)
|
||||||
|
import config as cfgmod
|
||||||
|
|
||||||
|
WINDOW_TITLE = "Clawdmeter"
|
||||||
|
WINDOW_W = 860
|
||||||
|
WINDOW_H = 720
|
||||||
|
WINDOW_MIN = (640, 560)
|
||||||
|
BRAND_BG = "#131211" # paint the chrome brand-dark so there's no white flash
|
||||||
|
|
||||||
|
|
||||||
|
def _port() -> int:
|
||||||
|
raw = os.environ.get("CLAWDMETER_PANEL_PORT")
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return cfgmod.DEFAULT_PORT
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_server(url: str, timeout: float = 6.0) -> bool:
|
||||||
|
"""Poll the server's status endpoint until it answers or the timeout elapses.
|
||||||
|
|
||||||
|
The tray starts the HTTP server in a thread a moment before it can spawn us,
|
||||||
|
so a freshly-clicked Settings might briefly beat the socket. Polling avoids a
|
||||||
|
blank window in that race; a miss just means we open anyway and the UI's own
|
||||||
|
fetch retries.
|
||||||
|
"""
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
probe = url.rstrip("/") + "/api/status"
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(probe, timeout=1.0) as r:
|
||||||
|
if r.status == 200:
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
time.sleep(0.25)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Open the control-panel window and block until the user closes it."""
|
||||||
|
import webview # imported here so the tray never pays for it unless --panel
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{_port()}/"
|
||||||
|
_wait_for_server(url)
|
||||||
|
webview.create_window(
|
||||||
|
WINDOW_TITLE, url,
|
||||||
|
width=WINDOW_W, height=WINDOW_H,
|
||||||
|
min_size=WINDOW_MIN,
|
||||||
|
background_color=BRAND_BG,
|
||||||
|
)
|
||||||
|
# gui defaults to auto-detect; on Windows 11 that resolves to EdgeChromium
|
||||||
|
# (WebView2), which ships with the OS — the modern engine the brand CSS needs.
|
||||||
|
# start() blocks on the native GUI loop until the window closes.
|
||||||
|
webview.start()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
run()
|
||||||
@@ -9,3 +9,10 @@ Pillow
|
|||||||
# media-control projection. pip resolves them to the same winrt-runtime as bleak.
|
# media-control projection. pip resolves them to the same winrt-runtime as bleak.
|
||||||
winrt-Windows.Media
|
winrt-Windows.Media
|
||||||
winrt-Windows.Media.Control
|
winrt-Windows.Media.Control
|
||||||
|
# Phase 7 control panel: a local FastAPI server (bound to 127.0.0.1) serves the
|
||||||
|
# brand UI, and pywebview hosts it in a native Edge WebView2 window. pywebview
|
||||||
|
# pulls pythonnet (clr) on Windows for the EdgeChromium backend. httpx (above)
|
||||||
|
# is reused for the HA test/entities calls.
|
||||||
|
fastapi
|
||||||
|
uvicorn
|
||||||
|
pywebview
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Local control-panel HTTP API for the Clawdmeter app (Phase 7, M1).
|
||||||
|
|
||||||
|
A FastAPI app bound to 127.0.0.1 only. Serves the brand-styled web UI (``web/``)
|
||||||
|
and a small REST API over the unified config (``config.py``). Runs in a daemon
|
||||||
|
thread alongside the tray and the BLE daemon — one process, one exe.
|
||||||
|
|
||||||
|
Security: bound to loopback; the HA token is masked in GET responses and never
|
||||||
|
logged (mirrors ha_client / config).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
try:
|
||||||
|
from daemon import config as cfgmod
|
||||||
|
except ImportError: # running as a plain script (cwd on path)
|
||||||
|
import config as cfgmod
|
||||||
|
|
||||||
|
MASK = "********" # what the UI sees instead of the real token; echo it back to keep it
|
||||||
|
|
||||||
|
|
||||||
|
def _web_dir() -> Path:
|
||||||
|
"""The static UI directory: next to this file in source, under the PyInstaller
|
||||||
|
bundle root when frozen (added via clawdmeter.spec datas)."""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return Path(sys._MEIPASS) / "daemon" / "web" # type: ignore[attr-defined]
|
||||||
|
return Path(__file__).parent / "web"
|
||||||
|
|
||||||
|
|
||||||
|
def _masked(cfg: dict) -> dict:
|
||||||
|
c = copy.deepcopy(cfg)
|
||||||
|
if c.get("ha", {}).get("token"):
|
||||||
|
c["ha"]["token"] = MASK
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Clawdmeter")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/config")
|
||||||
|
def get_config() -> dict:
|
||||||
|
return _masked(cfgmod.load_config())
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIn(BaseModel):
|
||||||
|
ha: dict | None = None
|
||||||
|
buttons: list | None = None
|
||||||
|
settings: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.put("/api/config")
|
||||||
|
def put_config(incoming: ConfigIn) -> dict:
|
||||||
|
cfg = cfgmod.load_config()
|
||||||
|
data = incoming.model_dump(exclude_none=True)
|
||||||
|
if "ha" in data:
|
||||||
|
ha = dict(data["ha"])
|
||||||
|
# Mask echoed back unchanged => keep the stored token (UI never holds it).
|
||||||
|
if ha.get("token") == MASK:
|
||||||
|
ha["token"] = cfg["ha"]["token"]
|
||||||
|
cfg["ha"].update(ha)
|
||||||
|
if "settings" in data:
|
||||||
|
cfg["settings"].update(data["settings"])
|
||||||
|
if "buttons" in data:
|
||||||
|
cfg["buttons"] = data["buttons"]
|
||||||
|
cfgmod.save_config(cfg)
|
||||||
|
return _masked(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_token(token: str) -> str:
|
||||||
|
return cfgmod.load_config()["ha"]["token"] if token == MASK else token
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/ha/test")
|
||||||
|
async def ha_test(body: dict) -> dict:
|
||||||
|
url = (body.get("url") or "").strip().rstrip("/")
|
||||||
|
token = _resolve_token((body.get("token") or "").strip())
|
||||||
|
if not url or not token:
|
||||||
|
raise HTTPException(status_code=400, detail="url and token are required")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.get(f"{url}/api/",
|
||||||
|
headers={"Authorization": f"Bearer {token}"})
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
if resp.status_code == 200:
|
||||||
|
try:
|
||||||
|
msg = resp.json().get("message", "API running")
|
||||||
|
except ValueError:
|
||||||
|
msg = "API running"
|
||||||
|
return {"ok": True, "message": msg}
|
||||||
|
return {"ok": False, "error": f"HTTP {resp.status_code}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/ha/entities")
|
||||||
|
async def ha_entities() -> dict:
|
||||||
|
ha = cfgmod.ha_settings()
|
||||||
|
if not ha:
|
||||||
|
return {"entities": [], "error": "Home Assistant not configured"}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||||
|
resp = await client.get(f"{ha['url']}/api/states",
|
||||||
|
headers={"Authorization": f"Bearer {ha['token']}"})
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
return {"entities": [], "error": str(e)}
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return {"entities": [], "error": f"HTTP {resp.status_code}"}
|
||||||
|
try:
|
||||||
|
states = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"entities": [], "error": "bad response"}
|
||||||
|
lights = [s["entity_id"] for s in states
|
||||||
|
if isinstance(s, dict) and str(s.get("entity_id", "")).startswith("light.")]
|
||||||
|
return {"entities": sorted(lights)}
|
||||||
|
|
||||||
|
|
||||||
|
_status_provider = None # set by the tray to expose live BLE/daemon state
|
||||||
|
|
||||||
|
|
||||||
|
def set_status_provider(fn) -> None:
|
||||||
|
"""The tray injects a callable returning the live status dict (connected,
|
||||||
|
battery, state). Kept out of import-time so server.py runs standalone."""
|
||||||
|
global _status_provider
|
||||||
|
_status_provider = fn
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/status")
|
||||||
|
def get_status() -> dict:
|
||||||
|
if _status_provider is not None:
|
||||||
|
try:
|
||||||
|
return _status_provider()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"connected": False, "battery": None, "state": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
# Static UI mounted LAST so the /api/* routes above take precedence.
|
||||||
|
_wd = _web_dir()
|
||||||
|
if _wd.exists():
|
||||||
|
app.mount("/", StaticFiles(directory=str(_wd), html=True), name="web")
|
||||||
|
|
||||||
|
|
||||||
|
def serve_in_thread(port: int | None = None) -> threading.Thread:
|
||||||
|
"""Start uvicorn on 127.0.0.1 in a daemon thread; return the thread."""
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
p = port or cfgmod.DEFAULT_PORT
|
||||||
|
server = uvicorn.Server(uvicorn.Config(
|
||||||
|
app, host="127.0.0.1", port=p, log_level="warning"))
|
||||||
|
t = threading.Thread(target=server.run, daemon=True, name="clawd-http")
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Dev: run the server in the foreground with autoreload-free uvicorn.
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run(app, host="127.0.0.1", port=cfgmod.DEFAULT_PORT, log_level="info")
|
||||||
@@ -18,6 +18,8 @@ Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -67,6 +69,8 @@ class TrayState:
|
|||||||
self.state: str = "scanning" # "connected" | "scanning" | "error"
|
self.state: str = "scanning" # "connected" | "scanning" | "error"
|
||||||
self.reason: str = "" # error reason string (D-04)
|
self.reason: str = "" # error reason string (D-04)
|
||||||
self.last_sync: float | None = None # time.time() of last successful write
|
self.last_sync: float | None = None # time.time() of last successful write
|
||||||
|
self.battery_pct: int | None = None # latest watch battery %, from the …0005 channel
|
||||||
|
self.toasts: "queue.Queue" = queue.Queue() # (title, message) toasts for the tray to show
|
||||||
|
|
||||||
# Populated by daemon main() at startup:
|
# Populated by daemon main() at startup:
|
||||||
self.loop = None # asyncio running loop (for call_soon_threadsafe)
|
self.loop = None # asyncio running loop (for call_soon_threadsafe)
|
||||||
@@ -162,6 +166,34 @@ def _acquire_single_instance():
|
|||||||
return handle
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# control-panel glue (Phase 7): live status feed + settings-window subprocess
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _status_dict(ts: TrayState) -> dict:
|
||||||
|
"""Live status for the control panel's GET /api/status. Shape matches what
|
||||||
|
web/index.html reads (connected / state / battery / last_sync); a pure read
|
||||||
|
of TrayState scalars, safe to call from the server's request thread."""
|
||||||
|
return {
|
||||||
|
"connected": ts.state == "connected",
|
||||||
|
"state": ts.state,
|
||||||
|
"reason": ts.reason,
|
||||||
|
"battery": ts.battery_pct,
|
||||||
|
"last_sync": ts.last_sync,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _panel_argv() -> list:
|
||||||
|
"""Command that launches the settings window as a SEPARATE process. Frozen:
|
||||||
|
re-invoke this same exe with --panel. Source: run panel.py by ABSOLUTE path —
|
||||||
|
not ``-m daemon.panel``, which would break under autostart (cwd = System32).
|
||||||
|
panel.py rebuilds its own sys.path from __file__, so cwd doesn't matter. Kept
|
||||||
|
a separate process because pywebview wants the main thread, which pystray owns."""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
return [sys.executable, "--panel"]
|
||||||
|
return [sys.executable, os.path.join(_REPO_ROOT, "daemon", "panel.py")]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# main() — tray entry (pystray on main thread, daemon loop in bg thread)
|
# main() — tray entry (pystray on main thread, daemon loop in bg thread)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -173,6 +205,14 @@ def main() -> None:
|
|||||||
so the module can be imported on a GTK-less Linux dev box for unit tests
|
so the module can be imported on a GTK-less Linux dev box for unit tests
|
||||||
of the pure helpers (TrayState, header_text) without pystray failing.
|
of the pure helpers (TrayState, header_text) without pystray failing.
|
||||||
"""
|
"""
|
||||||
|
# --panel: we ARE the settings window, launched as a child of the tray. Open
|
||||||
|
# it and exit WITHOUT touching the single-instance mutex or the BLE daemon —
|
||||||
|
# pywebview owns this process's main thread; the tray owns the other one.
|
||||||
|
if "--panel" in sys.argv:
|
||||||
|
from daemon.panel import run as run_panel
|
||||||
|
run_panel()
|
||||||
|
return
|
||||||
|
|
||||||
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE
|
# Single-instance guard FIRST — before icons, the daemon thread, or any BLE
|
||||||
# work. If another tray already owns the session mutex (e.g. ARSO restored a
|
# work. If another tray already owns the session mutex (e.g. ARSO restored a
|
||||||
# console instance and the headless autostart also fired), exit silently.
|
# console instance and the headless autostart also fired), exit silently.
|
||||||
@@ -227,6 +267,25 @@ def main() -> None:
|
|||||||
daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
|
daemon_thread = threading.Thread(target=_run_daemon, daemon=True)
|
||||||
daemon_thread.start()
|
daemon_thread.start()
|
||||||
|
|
||||||
|
# --- control-panel HTTP server (one process, one exe) ---
|
||||||
|
# Serve the brand UI + REST API on 127.0.0.1 in a daemon thread, and feed it
|
||||||
|
# live BLE/daemon status. Best-effort: a server failure must never stop the
|
||||||
|
# tray itself from coming up (the watch sync is the primary job).
|
||||||
|
panel_port = None
|
||||||
|
try:
|
||||||
|
from daemon import server as panel_server
|
||||||
|
from daemon.config import DEFAULT_PORT
|
||||||
|
panel_port = DEFAULT_PORT
|
||||||
|
panel_server.set_status_provider(lambda: _status_dict(ts))
|
||||||
|
panel_server.serve_in_thread(panel_port)
|
||||||
|
daemon_log(f"Control panel: http://127.0.0.1:{panel_port}")
|
||||||
|
except Exception as e:
|
||||||
|
daemon_log(f"Control panel unavailable: {e!r}")
|
||||||
|
|
||||||
|
# Holds the settings-window child process so we don't stack windows and can
|
||||||
|
# tear it down on Quit. Mutated by _on_settings / _on_quit below.
|
||||||
|
_panel = {"proc": None}
|
||||||
|
|
||||||
# --- menu ---
|
# --- menu ---
|
||||||
def _on_quit(icon_ref, _item) -> None:
|
def _on_quit(icon_ref, _item) -> None:
|
||||||
# NEVER call ts.stop_event.set() directly from the tray thread;
|
# NEVER call ts.stop_event.set() directly from the tray thread;
|
||||||
@@ -246,6 +305,13 @@ def main() -> None:
|
|||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass # loop already closed (e.g. mid-restart) — quit_evt handles it
|
pass # loop already closed (e.g. mid-restart) — quit_evt handles it
|
||||||
daemon_thread.join(timeout=6.0)
|
daemon_thread.join(timeout=6.0)
|
||||||
|
# Close the settings window too, if the user left it open.
|
||||||
|
proc = _panel["proc"]
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
try:
|
||||||
|
proc.terminate()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
icon_ref.stop()
|
icon_ref.stop()
|
||||||
|
|
||||||
def _on_toggle(_icon_ref, _item) -> None:
|
def _on_toggle(_icon_ref, _item) -> None:
|
||||||
@@ -258,9 +324,30 @@ def main() -> None:
|
|||||||
autostart.enable(tray_script=os.path.abspath(__file__))
|
autostart.enable(tray_script=os.path.abspath(__file__))
|
||||||
icon.update_menu()
|
icon.update_menu()
|
||||||
|
|
||||||
|
def _on_settings(_icon_ref, _item) -> None:
|
||||||
|
# Open the WebView2 settings window as a child process. If one is already
|
||||||
|
# alive, leave it — re-spawning would stack duplicate windows.
|
||||||
|
proc = _panel["proc"]
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
return
|
||||||
|
env = dict(os.environ)
|
||||||
|
if panel_port:
|
||||||
|
env["CLAWDMETER_PANEL_PORT"] = str(panel_port)
|
||||||
|
kwargs = {}
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# No phantom console window for the child (it's a GUI of its own).
|
||||||
|
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
||||||
|
try:
|
||||||
|
_panel["proc"] = subprocess.Popen(_panel_argv(), env=env, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
daemon_log(f"Could not open settings window: {e!r}")
|
||||||
|
|
||||||
icon.menu = Menu(
|
icon.menu = Menu(
|
||||||
# Non-clickable status header; text updates via update_menu() on state change.
|
# Non-clickable status header; text updates via update_menu() on state change.
|
||||||
MenuItem(lambda _item: header_text(ts), None, enabled=False),
|
MenuItem(lambda _item: header_text(ts), None, enabled=False),
|
||||||
|
# Settings = the WebView2 control panel. default=True opens it on a plain
|
||||||
|
# left-click of the tray icon (right-click still shows the full menu).
|
||||||
|
MenuItem("Settings", _on_settings, default=True),
|
||||||
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
|
# Start-at-login toggle: checked= is a CALLABLE for live query (Pitfall 6).
|
||||||
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
|
MenuItem("Start at login", _on_toggle, checked=lambda _item: autostart.is_enabled()),
|
||||||
MenuItem("Quit", _on_quit),
|
MenuItem("Quit", _on_quit),
|
||||||
@@ -290,6 +377,14 @@ def main() -> None:
|
|||||||
prev_state["state"] = current
|
prev_state["state"] = current
|
||||||
prev_state["last_sync"] = last_sync
|
prev_state["last_sync"] = last_sync
|
||||||
_icon.update_menu()
|
_icon.update_menu()
|
||||||
|
# Drain daemon-queued toasts (e.g. low watch battery) — runs every
|
||||||
|
# tick regardless of state change.
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
_title, _msg = ts.toasts.get_nowait()
|
||||||
|
_icon.notify(_msg, _title)
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
# Blocks the main thread until icon.stop() is called from _on_quit.
|
# Blocks the main thread until icon.stop() is called from _on_quit.
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Clawdmeter</title>
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--bg:#131211; --panel:#1f1f1e; --panel2:#232220; --text:#faf9f5; --dim:#b0aea5;
|
||||||
|
--accent:#d97757; --accent-text:#4a1b0c; --green:#788c5d; --red:#c0392b;
|
||||||
|
--border:rgba(255,255,255,.09);
|
||||||
|
}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
html,body{margin:0;height:100%}
|
||||||
|
body{background:var(--bg);color:var(--text);font-family:"Segoe UI",system-ui,sans-serif;font-size:14px}
|
||||||
|
.app{display:flex;height:100vh}
|
||||||
|
.nav{width:188px;flex-shrink:0;background:#161514;border-right:1px solid var(--border);padding:14px 10px;display:flex;flex-direction:column;gap:3px}
|
||||||
|
.brand{display:flex;align-items:center;gap:9px;padding:4px 10px 14px;font-weight:500}
|
||||||
|
.brand .dot{width:11px;height:11px;border-radius:50%;background:var(--accent)}
|
||||||
|
.nav button.tabbtn{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;border:none;background:transparent;color:var(--dim);font-size:14px;cursor:pointer;text-align:left;width:100%}
|
||||||
|
.nav button.tabbtn:hover{background:#1c1b1a}
|
||||||
|
.nav button.tabbtn.active{background:var(--panel2);color:var(--text)}
|
||||||
|
.nav button.tabbtn.active svg{color:var(--accent)}
|
||||||
|
.nav svg{width:18px;height:18px;flex-shrink:0}
|
||||||
|
.navstatus{margin-top:auto;display:flex;align-items:center;gap:8px;padding:10px;font-size:12px;color:var(--dim)}
|
||||||
|
.led{width:8px;height:8px;border-radius:50%;background:#555;flex-shrink:0}
|
||||||
|
.led.on{background:var(--green)}
|
||||||
|
.content{flex:1;overflow-y:auto;padding:22px 26px}
|
||||||
|
h1{font-size:18px;font-weight:500;margin:0}
|
||||||
|
.sub{font-size:13px;color:var(--dim);margin:3px 0 18px}
|
||||||
|
label{font-size:12px;color:var(--dim);display:block;margin:14px 0 6px}
|
||||||
|
input[type=text],input[type=password],input[type=number],select{width:100%;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 12px;color:var(--text);font-size:14px;outline:none}
|
||||||
|
input:focus,select:focus{border-color:var(--accent)}
|
||||||
|
button.primary{background:var(--accent);color:var(--accent-text);border:none;border-radius:8px;padding:9px 18px;font-size:14px;font-weight:500;cursor:pointer}
|
||||||
|
button.ghost{background:transparent;border:1px solid var(--border);color:var(--dim);border-radius:8px;padding:8px 12px;font-size:13px;cursor:pointer}
|
||||||
|
button.ghost:hover{color:var(--text);border-color:rgba(255,255,255,.2)}
|
||||||
|
.row{display:flex;gap:10px;align-items:center}
|
||||||
|
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:999px;padding:4px 11px;font-size:12px;margin-top:8px}
|
||||||
|
.pill.ok{background:rgba(120,140,93,.16);color:#9bb074}
|
||||||
|
.pill.err{background:rgba(192,57,43,.16);color:#e3897f}
|
||||||
|
.chips{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px}
|
||||||
|
.chip{display:inline-flex;align-items:center;gap:8px;background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:13px}
|
||||||
|
.chip b{cursor:pointer;color:#7d7b74;font-weight:400}
|
||||||
|
.card{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px;margin-bottom:10px}
|
||||||
|
.grid{display:grid;grid-template-columns:1.4fr 1fr 90px auto;gap:10px;align-items:end}
|
||||||
|
.tab{display:none}
|
||||||
|
.tab.active{display:block}
|
||||||
|
.actions{margin-top:22px;display:flex;justify-content:flex-end;gap:10px}
|
||||||
|
.statgrid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px}
|
||||||
|
.stat{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:14px}
|
||||||
|
.stat .k{font-size:12px;color:var(--dim)}
|
||||||
|
.stat .v{font-size:22px;font-weight:500;margin-top:4px}
|
||||||
|
.muted{color:var(--dim);font-size:12.5px}
|
||||||
|
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:var(--panel2);border:1px solid var(--border);color:var(--text);padding:10px 16px;border-radius:8px;font-size:13px;opacity:0;pointer-events:none;transition:opacity .2s}
|
||||||
|
#toast.show{opacity:1}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="brand"><span class="dot"></span>Clawdmeter</div>
|
||||||
|
<button class="tabbtn active" data-tab="status"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h4l3 8 4-16 3 8h4"/></svg>Status</button>
|
||||||
|
<button class="tabbtn" data-tab="ha"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 21h4M12 3a6 6 0 0 1 4 10 4 4 0 0 0-1 3H9a4 4 0 0 0-1-3 6 6 0 0 1 4-10z"/></svg>Home Assistant</button>
|
||||||
|
<button class="tabbtn" data-tab="buttons"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>Buttons</button>
|
||||||
|
<button class="tabbtn" data-tab="settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h16"/><circle cx="9" cy="6" r="2" fill="var(--bg)"/><circle cx="15" cy="12" r="2" fill="var(--bg)"/><circle cx="8" cy="18" r="2" fill="var(--bg)"/></svg>Settings</button>
|
||||||
|
<div class="navstatus"><span class="led" id="navled"></span><span id="navstate">Connecting…</span></div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<!-- STATUS -->
|
||||||
|
<section class="tab active" data-tab="status">
|
||||||
|
<h1>Status</h1>
|
||||||
|
<div class="sub">Watch connection and live readings</div>
|
||||||
|
<div class="statgrid">
|
||||||
|
<div class="stat"><div class="k">Watch</div><div class="v" id="st-conn">—</div></div>
|
||||||
|
<div class="stat"><div class="k">Battery</div><div class="v" id="st-batt">—</div></div>
|
||||||
|
<div class="stat"><div class="k">Daemon</div><div class="v" id="st-state">—</div></div>
|
||||||
|
<div class="stat"><div class="k">Last update</div><div class="v" id="st-sync">—</div></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- HOME ASSISTANT -->
|
||||||
|
<section class="tab" data-tab="ha">
|
||||||
|
<h1>Home Assistant</h1>
|
||||||
|
<div class="sub">Connection & devices</div>
|
||||||
|
<label>Server URL</label>
|
||||||
|
<input type="text" id="ha-url" placeholder="https://homeassistant.local:8123" autocomplete="off">
|
||||||
|
<label>Long-lived access token</label>
|
||||||
|
<div class="row">
|
||||||
|
<input type="password" id="ha-token" placeholder="Paste token" autocomplete="off">
|
||||||
|
<button class="ghost" id="ha-test">Test</button>
|
||||||
|
</div>
|
||||||
|
<div id="ha-testresult"></div>
|
||||||
|
<label>Controlled entities</label>
|
||||||
|
<div class="chips" id="ha-chips"></div>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<select id="ha-picker"><option value="">Load devices to add…</option></select>
|
||||||
|
<button class="ghost" id="ha-load">Load devices</button>
|
||||||
|
<button class="ghost" id="ha-add">Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions"><button class="primary" id="ha-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- BUTTONS -->
|
||||||
|
<section class="tab" data-tab="buttons">
|
||||||
|
<h1>Buttons</h1>
|
||||||
|
<div class="sub">Actions you can fire (and, later, show on the watch)</div>
|
||||||
|
<div id="btn-list"></div>
|
||||||
|
<button class="ghost" id="btn-add">+ Add button</button>
|
||||||
|
<div class="actions"><button class="primary" id="btn-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SETTINGS -->
|
||||||
|
<section class="tab" data-tab="settings">
|
||||||
|
<h1>Settings</h1>
|
||||||
|
<div class="sub">App behaviour</div>
|
||||||
|
<label>Low-battery warning at (%)</label>
|
||||||
|
<input type="number" id="set-lowbatt" min="1" max="100" step="1">
|
||||||
|
<label>Watch BLE address <span class="muted">(optional — for bonded, non-advertising watches)</span></label>
|
||||||
|
<input type="text" id="set-addr" placeholder="44:1B:F6:85:1E:51" autocomplete="off">
|
||||||
|
<div class="actions"><button class="primary" id="set-save">Save</button></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MASK = "********";
|
||||||
|
let cfg = null;
|
||||||
|
let entities = []; // current controlled entities (chips)
|
||||||
|
let lights = []; // available light.* from HA
|
||||||
|
let buttons = []; // current button defs
|
||||||
|
|
||||||
|
const $ = s => document.querySelector(s);
|
||||||
|
async function api(path, method="GET", body=null){
|
||||||
|
const opt = {method, headers:{}};
|
||||||
|
if(body){opt.headers["Content-Type"]="application/json"; opt.body=JSON.stringify(body);}
|
||||||
|
const r = await fetch(path, opt);
|
||||||
|
if(!r.ok) throw new Error("HTTP "+r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
function toast(msg){const t=$("#toast");t.textContent=msg;t.classList.add("show");clearTimeout(t._h);t._h=setTimeout(()=>t.classList.remove("show"),1800);}
|
||||||
|
|
||||||
|
// ---- tabs ----
|
||||||
|
document.querySelectorAll(".tabbtn").forEach(b=>b.onclick=()=>{
|
||||||
|
document.querySelectorAll(".tabbtn").forEach(x=>x.classList.toggle("active",x===b));
|
||||||
|
const id=b.dataset.tab;
|
||||||
|
document.querySelectorAll(".tab").forEach(s=>s.classList.toggle("active",s.dataset.tab===id));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- load ----
|
||||||
|
async function loadConfig(){
|
||||||
|
cfg = await api("/api/config");
|
||||||
|
$("#ha-url").value = cfg.ha.url || "";
|
||||||
|
$("#ha-token").value = cfg.ha.token || ""; // server sends MASK when a token is set
|
||||||
|
entities = (cfg.ha.entities||[]).slice();
|
||||||
|
buttons = (cfg.buttons||[]).map(b=>Object.assign({}, b));
|
||||||
|
$("#set-lowbatt").value = cfg.settings.low_battery_pct ?? 15;
|
||||||
|
$("#set-addr").value = cfg.settings.device_address || "";
|
||||||
|
renderChips(); renderButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- HA ----
|
||||||
|
function renderChips(){
|
||||||
|
const c=$("#ha-chips"); c.innerHTML="";
|
||||||
|
if(!entities.length){c.innerHTML='<span class="muted">No devices yet — add some below.</span>';}
|
||||||
|
entities.forEach(e=>{
|
||||||
|
const s=document.createElement("span"); s.className="chip";
|
||||||
|
s.innerHTML = e+' <b title="Remove">✕</b>';
|
||||||
|
s.querySelector("b").onclick=()=>{entities=entities.filter(x=>x!==e);renderChips();renderButtons();};
|
||||||
|
c.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$("#ha-test").onclick = async ()=>{
|
||||||
|
const box=$("#ha-testresult"); box.innerHTML='<span class="muted">Testing…</span>';
|
||||||
|
try{
|
||||||
|
const r = await api("/api/ha/test","POST",{url:$("#ha-url").value.trim(), token:$("#ha-token").value});
|
||||||
|
box.innerHTML = r.ok ? '<span class="pill ok">✓ '+(r.message||"Connected")+'</span>'
|
||||||
|
: '<span class="pill err">✕ '+(r.error||"Failed")+'</span>';
|
||||||
|
}catch(e){box.innerHTML='<span class="pill err">✕ '+e.message+'</span>';}
|
||||||
|
};
|
||||||
|
$("#ha-load").onclick = async ()=>{
|
||||||
|
// Persist url+token first so the server can query HA with them.
|
||||||
|
await saveHA(true);
|
||||||
|
const r = await api("/api/ha/entities");
|
||||||
|
lights = r.entities||[];
|
||||||
|
const sel=$("#ha-picker"); sel.innerHTML="";
|
||||||
|
if(!lights.length){sel.innerHTML='<option value="">'+(r.error||"No lights found")+'</option>';return;}
|
||||||
|
sel.appendChild(new Option("Select a device…",""));
|
||||||
|
lights.forEach(l=>sel.appendChild(new Option(l,l)));
|
||||||
|
toast(lights.length+" devices loaded");
|
||||||
|
};
|
||||||
|
$("#ha-add").onclick = ()=>{
|
||||||
|
const v=$("#ha-picker").value; if(!v) return;
|
||||||
|
if(!entities.includes(v)){entities.push(v);renderChips();renderButtons();}
|
||||||
|
};
|
||||||
|
async function saveHA(silent){
|
||||||
|
const ha={url:$("#ha-url").value.trim(), token:$("#ha-token").value, entities};
|
||||||
|
cfg = await api("/api/config","PUT",{ha});
|
||||||
|
$("#ha-token").value = cfg.ha.token; // re-mask
|
||||||
|
if(!silent) toast("Home Assistant saved");
|
||||||
|
}
|
||||||
|
$("#ha-save").onclick = ()=>saveHA(false).catch(e=>toast("Error: "+e.message));
|
||||||
|
|
||||||
|
// ---- Buttons ----
|
||||||
|
const ACTIONS=[["toggle","Toggle on/off"],["bri","Set brightness %"],["ct","Set color temp K"]];
|
||||||
|
function renderButtons(){
|
||||||
|
const list=$("#btn-list"); list.innerHTML="";
|
||||||
|
if(!buttons.length){list.insertAdjacentHTML("beforeend",'<div class="muted" style="margin-bottom:10px">No buttons yet.</div>');}
|
||||||
|
buttons.forEach((b,i)=>{
|
||||||
|
const card=document.createElement("div"); card.className="card";
|
||||||
|
const entOpts = entities.map(e=>`<option value="${e}" ${e===b.entity?"selected":""}>${e}</option>`).join("");
|
||||||
|
const actOpts = ACTIONS.map(([v,l])=>`<option value="${v}" ${v===b.action?"selected":""}>${l}</option>`).join("");
|
||||||
|
const showVal = b.action==="bri"||b.action==="ct";
|
||||||
|
card.innerHTML=`
|
||||||
|
<div class="grid">
|
||||||
|
<div><label>Label</label><input type="text" data-i="${i}" data-f="label" value="${(b.label||"").replace(/"/g,'"')}"></div>
|
||||||
|
<div><label>Device</label><select data-i="${i}" data-f="entity">${entOpts||'<option value="">— add a device first —</option>'}</select></div>
|
||||||
|
<div><label>Action</label><select data-i="${i}" data-f="action">${actOpts}</select></div>
|
||||||
|
<div><button class="ghost" data-del="${i}">Remove</button></div>
|
||||||
|
</div>
|
||||||
|
<div data-val="${i}" style="${showVal?"":"display:none"};margin-top:8px">
|
||||||
|
<label>${b.action==="ct"?"Kelvin (2000–6500)":"Brightness %"}</label>
|
||||||
|
<input type="number" data-i="${i}" data-f="value" value="${b.value??(b.action==="ct"?3000:60)}" style="max-width:160px">
|
||||||
|
</div>`;
|
||||||
|
list.appendChild(card);
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-f]").forEach(el=>el.oninput=()=>{
|
||||||
|
const i=+el.dataset.i, f=el.dataset.f;
|
||||||
|
buttons[i][f] = f==="value" ? +el.value : el.value;
|
||||||
|
if(f==="action"){ buttons[i].icon=el.value; renderButtons(); }
|
||||||
|
});
|
||||||
|
list.querySelectorAll("[data-del]").forEach(el=>el.onclick=()=>{buttons.splice(+el.dataset.del,1);renderButtons();});
|
||||||
|
}
|
||||||
|
$("#btn-add").onclick = ()=>{
|
||||||
|
buttons.push({id:"b"+Date.now().toString(36), label:"Light", icon:"toggle",
|
||||||
|
action:"toggle", entity:entities[0]||"", value:null});
|
||||||
|
renderButtons();
|
||||||
|
};
|
||||||
|
$("#btn-save").onclick = async ()=>{
|
||||||
|
try{ cfg = await api("/api/config","PUT",{buttons}); toast("Buttons saved"); }
|
||||||
|
catch(e){ toast("Error: "+e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Settings ----
|
||||||
|
$("#set-save").onclick = async ()=>{
|
||||||
|
try{
|
||||||
|
cfg = await api("/api/config","PUT",{settings:{
|
||||||
|
low_battery_pct:+$("#set-lowbatt").value, device_address:$("#set-addr").value.trim()}});
|
||||||
|
toast("Settings saved");
|
||||||
|
}catch(e){ toast("Error: "+e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Status poll ----
|
||||||
|
async function pollStatus(){
|
||||||
|
try{
|
||||||
|
const s = await api("/api/status");
|
||||||
|
const conn = s.connected;
|
||||||
|
$("#navled").classList.toggle("on",conn);
|
||||||
|
$("#navstate").textContent = conn?"Watch connected":(s.state==="unknown"?"Daemon off":"Scanning…");
|
||||||
|
$("#st-conn").textContent = conn?"Connected":"Disconnected";
|
||||||
|
$("#st-batt").textContent = (s.battery==null)?"—":s.battery+"%";
|
||||||
|
$("#st-state").textContent = s.state||"—";
|
||||||
|
$("#st-sync").textContent = s.last_sync? new Date(s.last_sync*1000).toLocaleTimeString():"—";
|
||||||
|
}catch(e){ $("#navstate").textContent="Panel only"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig().catch(e=>toast("Load failed: "+e.message));
|
||||||
|
pollStatus(); setInterval(pollStatus,3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user