diff --git a/solixauto/cli.py b/solixauto/cli.py index b77503b..1ca8454 100644 --- a/solixauto/cli.py +++ b/solixauto/cli.py @@ -372,7 +372,10 @@ def cmd_monitor(args): try: server, sampler = monitor_module.serve( - port=args.port, interval=args.interval, host=args.host + port=args.port, + interval=args.interval, + host=args.host, + allow_remote_edit=args.allow_remote_edit, ) except OSError as err: fail( @@ -388,6 +391,12 @@ def cmd_monitor(args): print(" Anker box says it has no live data:") print(f" {paths.command('service ')}") print() + if monitor_module.Handler.allow_edit: + print(" Click a device name to rename it.") + else: + print(" Renaming is disabled because this is reachable from other") + print(" machines. Add --allow-remote-edit to permit it.") + print() print(" Press Ctrl-C to stop.") if not args.no_open: @@ -1384,6 +1393,11 @@ def build_parser(): monitor_parser.add_argument( "--no-open", action="store_true", help="do not open a browser" ) + monitor_parser.add_argument( + "--allow-remote-edit", + action="store_true", + help="permit renaming devices even when reachable from other machines", + ) monitor_parser.set_defaults(func=cmd_monitor) service_parser = subparsers.add_parser( diff --git a/solixauto/monitor.py b/solixauto/monitor.py index fbeaf6a..c9ed959 100644 --- a/solixauto/monitor.py +++ b/solixauto/monitor.py @@ -7,7 +7,7 @@ from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from . import paths -from .profiles import list_profiles, load_yaml +from .profiles import list_profiles, load_yaml, save_yaml, slugify HISTORY_SIZE = 2880 POLL_TIMEOUT = 3 @@ -51,6 +51,68 @@ class Sampler: data["fresh"] = True return data + def rename(self, kind, path_name, new_name, on_device=False): + directory = ( + paths.ANKER_PROFILE_DIR if kind == "anker" else paths.SHELLY_PROFILE_DIR + ) + path = directory / path_name + + if path.parent.resolve() != directory.resolve() or not path.exists(): + raise ValueError("unknown device") + + new_name = str(new_name).strip() + if not new_name: + raise ValueError("name cannot be empty") + if len(new_name) > 60: + raise ValueError("name is too long") + + profile = load_yaml(path) + identity = profile.setdefault("identity", {}) + old_name = identity.get("name") or path.stem + identity["name"] = new_name + + aliases = list(profile.get("aliases") or []) + for candidate in (new_name, old_name, path.stem): + if candidate and candidate not in aliases: + aliases.append(candidate) + profile["aliases"] = aliases + + destination = directory / f"{slugify(new_name).lower()}.yaml" + if destination != path and destination.exists(): + raise ValueError(f"{destination.name} already exists") + + save_yaml(path, profile) + if destination != path: + path.replace(destination) + + wrote_device = False + if on_device and kind == "shelly": + wrote_device = self._write_shelly_name(profile, new_name) + + self._sample() + return {"file": destination.name, "name": new_name, "on_device": wrote_device} + + def _write_shelly_name(self, profile, new_name): + access = profile.get("access") or {} + identity = profile.get("identity") or {} + host = access.get("host") + generation = int(identity.get("generation", 1) or 1) + + if not host or generation < 2: + return False + + payload = json.dumps({"config": {"device": {"name": new_name}}}).encode() + request = urllib.request.Request( + f"http://{host}/rpc/Sys.SetConfig", + data=payload, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=POLL_TIMEOUT) as response: + return response.status == 200 + except Exception: + return False + def _anker_devices(self): devices = [] for path in list_profiles(paths.ANKER_PROFILE_DIR): @@ -65,6 +127,7 @@ class Sampler: devices.append( { + "file": path.name, "name": identity.get("name") or identity.get("model") or path.stem, "model": identity.get("model") or identity.get("part_number") or "", "serial": serial, @@ -121,6 +184,7 @@ class Sampler: devices.append( { + "file": path.name, "name": identity.get("name") or path.stem, "model": identity.get("model") or "", "host": host, @@ -296,6 +360,52 @@ PAGE = """ .device-name { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; } + .editable { + cursor: text; + border-bottom: 1px dashed transparent; + } + + .editable:hover, .editable:focus-visible { + border-bottom-color: var(--ink-soft); + outline: none; + } + + .editable::after { + content: " \270E"; + font-size: 0.7em; + color: var(--ink-soft); + opacity: 0; + transition: opacity .15s ease; + } + + .editable:hover::after, .editable:focus-visible::after { opacity: 1; } + + .name-input { + font: inherit; + color: var(--ink); + background: var(--panel); + border: 1px solid var(--solar); + padding: 1px 5px; + width: 22ch; + max-width: 100%; + } + + .name-input:focus { outline: none; } + + .edit-hint { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 10px; + color: var(--ink-soft); + margin-left: 8px; + } + + .edit-error { + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; + color: var(--alert); + margin-top: 6px; + } + .device-meta { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; @@ -524,6 +634,124 @@ function busBar(pv, ac, out) { + ''; } +let canEdit = false; + +function esc(s) { + return String(s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + })[c]); +} + +function nameCell(d, kind, cls) { + const classes = (cls ? cls + ' ' : '') + (canEdit ? 'editable' : ''); + const attrs = canEdit + ? ' tabindex="0" role="button" title="Click to rename"' + + ' data-kind="' + kind + '" data-file="' + esc(d.file) + '"' + : ''; + return '' + esc(d.name) + ''; +} + +function startEdit(span) { + if (span.dataset.editing) return; + span.dataset.editing = '1'; + + const kind = span.dataset.kind; + const file = span.dataset.file; + const current = span.textContent; + const parent = span.parentNode; + + const input = document.createElement('input'); + input.className = 'name-input'; + input.value = current; + input.maxLength = 60; + + const hint = document.createElement('span'); + hint.className = 'edit-hint'; + hint.textContent = 'enter to save, esc to cancel'; + + const wrap = document.createElement('span'); + wrap.appendChild(input); + wrap.appendChild(hint); + + let device = null; + if (kind === 'shelly') { + const label = document.createElement('label'); + label.className = 'edit-hint'; + device = document.createElement('input'); + device.type = 'checkbox'; + label.appendChild(device); + label.appendChild(document.createTextNode(' also write to the plug')); + wrap.appendChild(label); + } + + parent.replaceChild(wrap, span); + input.focus(); + input.select(); + + let done = false; + + const restore = () => { + if (done) return; + done = true; + paused = false; + refresh(); + }; + + const save = async () => { + if (done) return; + const value = input.value.trim(); + if (!value || value === current) { restore(); return; } + done = true; + input.disabled = true; + hint.textContent = 'saving...'; + try { + const response = await fetch('/api/rename', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + kind: kind, file: file, name: value, + on_device: device ? device.checked : false + }) + }); + const result = await response.json(); + if (!response.ok) { + const problem = document.createElement('div'); + problem.className = 'edit-error'; + problem.textContent = result.error || 'rename failed'; + wrap.appendChild(problem); + hint.textContent = ''; + setTimeout(() => { paused = false; refresh(); }, 4000); + return; + } + } catch (err) { + hint.textContent = 'could not reach the monitor'; + setTimeout(() => { paused = false; refresh(); }, 4000); + return; + } + paused = false; + refresh(); + }; + + input.addEventListener('keydown', event => { + if (event.key === 'Enter') { event.preventDefault(); save(); } + if (event.key === 'Escape') { event.preventDefault(); restore(); } + }); + input.addEventListener('blur', () => setTimeout(save, 120)); + + paused = true; +} + +document.addEventListener('click', event => { + const span = event.target.closest('.editable'); + if (span) startEdit(span); +}); + +document.addEventListener('keydown', event => { + if (event.key !== 'Enter') return; + const span = event.target.closest && event.target.closest('.editable'); + if (span) { event.preventDefault(); startEdit(span); } +}); + function renderAnker(devices) { const box = document.getElementById('anker'); if (!devices.length) { @@ -534,14 +762,14 @@ function renderAnker(devices) { box.innerHTML = devices.map(d => { if (!d.live) { return '
' - + '' + d.name + '' + + nameCell(d, 'anker', 'device-name') + '' + d.model + '
' + '

No live readings. Live data comes from a running ' + 'automation.
Start one with solixauto service <profile>.

'; } return '
' + '
' - + '' + d.name + '' + + nameCell(d, 'anker', 'device-name') + '' + d.model + ' · ' + (d.profile || '') + '' + (d.floor_latched ? 'FLOOR LATCHED' : '') + '
' @@ -570,7 +798,7 @@ function renderShelly(devices) { + devices.map(d => { const cls = d.state === true ? 'on' : (d.state === false ? 'off' : 'unknown'); const text = d.state === true ? 'ON' : (d.state === false ? 'OFF' : 'NO REPLY'); - return '' + d.name + '' + return '' + nameCell(d, 'shelly', '') + '' + '' + (d.host || '--') + ' ch' + d.channel + '' + '' + (d.watts === null || d.watts === undefined ? '--' : Math.round(d.watts) + ' W') + '' + '' + text + ''; @@ -666,6 +894,7 @@ function drawChart(history, devices) { } let latest = null; +let paused = false; function applyTheme(mode) { document.documentElement.setAttribute('data-theme', mode); @@ -688,10 +917,12 @@ document.getElementById('darkBtn').addEventListener('click', () => applyTheme('d let failures = 0; async function refresh() { + if (paused) return; try { const response = await fetch('/api/data', { cache: 'no-store' }); const data = await response.json(); latest = data; + canEdit = !!data.can_edit; failures = 0; document.getElementById('stamp').textContent = data.updated || '--:--:--'; document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale'; @@ -718,6 +949,7 @@ window.addEventListener('resize', () => refresh()); class Handler(BaseHTTPRequestHandler): sampler = None + allow_edit = True def log_message(self, *args): pass @@ -733,22 +965,74 @@ class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path.startswith("/api/data"): - self._send( - 200, json.dumps(Handler.sampler.payload()), "application/json" - ) + payload = Handler.sampler.payload() + payload["can_edit"] = Handler.allow_edit + self._send(200, json.dumps(payload), "application/json") return if self.path in ("/", "/index.html"): self._send(200, PAGE, "text/html; charset=utf-8") return self._send(404, "not found", "text/plain") + def do_POST(self): + if self.path != "/api/rename": + self._send(404, json.dumps({"error": "not found"}), "application/json") + return -def serve(port=8765, interval=5.0, host="127.0.0.1"): + if not Handler.allow_edit: + self._send( + 403, + json.dumps( + { + "error": "Editing is disabled because this monitor is " + "reachable from other machines. Restart it without " + "--host, or add --allow-remote-edit." + } + ), + "application/json", + ) + return + + try: + length = int(self.headers.get("Content-Length") or 0) + if length > 4096: + raise ValueError("request too large") + body = json.loads(self.rfile.read(length).decode("utf-8")) + except Exception: + self._send(400, json.dumps({"error": "bad request"}), "application/json") + return + + try: + result = Handler.sampler.rename( + body.get("kind"), + body.get("file"), + body.get("name"), + bool(body.get("on_device")), + ) + except ValueError as err: + self._send(400, json.dumps({"error": str(err)}), "application/json") + return + except Exception as err: + self._send( + 500, + json.dumps({"error": f"{type(err).__name__}: {err}"}), + "application/json", + ) + return + + self._send(200, json.dumps(result), "application/json") + + +def serve(port=8765, interval=5.0, host="127.0.0.1", allow_remote_edit=False): paths.ensure_dirs() sampler = Sampler(interval=interval) sampler.start() + local_only = host in ("127.0.0.1", "localhost", "::1") + Handler.sampler = sampler + Handler.allow_edit = local_only or allow_remote_edit + server = ThreadingHTTPServer((host, port), Handler) return server, sampler