Allow renaming devices from the monitor, localhost only by default
This commit is contained in:
1 parent
4320f55bd4
commit
a28db4d1c9
2 files changed
+307
-9
No files matched your search
+15
-1
@@ -372,7 +372,10 @@ def cmd_monitor(args):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
server, sampler = monitor_module.serve(
|
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:
|
except OSError as err:
|
||||||
fail(
|
fail(
|
||||||
@@ -388,6 +391,12 @@ def cmd_monitor(args):
|
|||||||
print(" Anker box says it has no live data:")
|
print(" Anker box says it has no live data:")
|
||||||
print(f" {paths.command('service <profile>')}")
|
print(f" {paths.command('service <profile>')}")
|
||||||
print()
|
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.")
|
print(" Press Ctrl-C to stop.")
|
||||||
|
|
||||||
if not args.no_open:
|
if not args.no_open:
|
||||||
@@ -1384,6 +1393,11 @@ def build_parser():
|
|||||||
monitor_parser.add_argument(
|
monitor_parser.add_argument(
|
||||||
"--no-open", action="store_true", help="do not open a browser"
|
"--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)
|
monitor_parser.set_defaults(func=cmd_monitor)
|
||||||
|
|
||||||
service_parser = subparsers.add_parser(
|
service_parser = subparsers.add_parser(
|
||||||
|
|||||||
+292
-8
@@ -7,7 +7,7 @@ from collections import deque
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
from . import paths
|
from . import paths
|
||||||
from .profiles import list_profiles, load_yaml
|
from .profiles import list_profiles, load_yaml, save_yaml, slugify
|
||||||
|
|
||||||
HISTORY_SIZE = 2880
|
HISTORY_SIZE = 2880
|
||||||
POLL_TIMEOUT = 3
|
POLL_TIMEOUT = 3
|
||||||
@@ -51,6 +51,68 @@ class Sampler:
|
|||||||
data["fresh"] = True
|
data["fresh"] = True
|
||||||
return data
|
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):
|
def _anker_devices(self):
|
||||||
devices = []
|
devices = []
|
||||||
for path in list_profiles(paths.ANKER_PROFILE_DIR):
|
for path in list_profiles(paths.ANKER_PROFILE_DIR):
|
||||||
@@ -65,6 +127,7 @@ class Sampler:
|
|||||||
|
|
||||||
devices.append(
|
devices.append(
|
||||||
{
|
{
|
||||||
|
"file": path.name,
|
||||||
"name": identity.get("name") or identity.get("model") or path.stem,
|
"name": identity.get("name") or identity.get("model") or path.stem,
|
||||||
"model": identity.get("model") or identity.get("part_number") or "",
|
"model": identity.get("model") or identity.get("part_number") or "",
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
@@ -121,6 +184,7 @@ class Sampler:
|
|||||||
|
|
||||||
devices.append(
|
devices.append(
|
||||||
{
|
{
|
||||||
|
"file": path.name,
|
||||||
"name": identity.get("name") or path.stem,
|
"name": identity.get("name") or path.stem,
|
||||||
"model": identity.get("model") or "",
|
"model": identity.get("model") or "",
|
||||||
"host": host,
|
"host": host,
|
||||||
@@ -296,6 +360,52 @@ PAGE = """<!doctype html>
|
|||||||
|
|
||||||
.device-name { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
|
.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 {
|
.device-meta {
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -524,6 +634,124 @@ function busBar(pv, ac, out) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 '<span class="' + classes.trim() + '"' + attrs + '>' + esc(d.name) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function renderAnker(devices) {
|
||||||
const box = document.getElementById('anker');
|
const box = document.getElementById('anker');
|
||||||
if (!devices.length) {
|
if (!devices.length) {
|
||||||
@@ -534,14 +762,14 @@ function renderAnker(devices) {
|
|||||||
box.innerHTML = devices.map(d => {
|
box.innerHTML = devices.map(d => {
|
||||||
if (!d.live) {
|
if (!d.live) {
|
||||||
return '<div class="device"><div class="device-head">'
|
return '<div class="device"><div class="device-head">'
|
||||||
+ '<span class="device-name">' + d.name + '</span>'
|
+ nameCell(d, 'anker', 'device-name')
|
||||||
+ '<span class="device-meta">' + d.model + '</span></div>'
|
+ '<span class="device-meta">' + d.model + '</span></div>'
|
||||||
+ '<p class="empty">No live readings. Live data comes from a running '
|
+ '<p class="empty">No live readings. Live data comes from a running '
|
||||||
+ 'automation.<br>Start one with <b>solixauto service <profile></b>.</p></div>';
|
+ 'automation.<br>Start one with <b>solixauto service <profile></b>.</p></div>';
|
||||||
}
|
}
|
||||||
return '<div class="device">'
|
return '<div class="device">'
|
||||||
+ '<div class="device-head">'
|
+ '<div class="device-head">'
|
||||||
+ '<span class="device-name">' + d.name + '</span>'
|
+ nameCell(d, 'anker', 'device-name')
|
||||||
+ '<span class="device-meta">' + d.model + ' · ' + (d.profile || '') + '</span>'
|
+ '<span class="device-meta">' + d.model + ' · ' + (d.profile || '') + '</span>'
|
||||||
+ (d.floor_latched ? '<span class="pill unknown">FLOOR LATCHED</span>' : '')
|
+ (d.floor_latched ? '<span class="pill unknown">FLOOR LATCHED</span>' : '')
|
||||||
+ '</div>'
|
+ '</div>'
|
||||||
@@ -570,7 +798,7 @@ function renderShelly(devices) {
|
|||||||
+ devices.map(d => {
|
+ devices.map(d => {
|
||||||
const cls = d.state === true ? 'on' : (d.state === false ? 'off' : 'unknown');
|
const cls = d.state === true ? 'on' : (d.state === false ? 'off' : 'unknown');
|
||||||
const text = d.state === true ? 'ON' : (d.state === false ? 'OFF' : 'NO REPLY');
|
const text = d.state === true ? 'ON' : (d.state === false ? 'OFF' : 'NO REPLY');
|
||||||
return '<tr><td>' + d.name + '</td>'
|
return '<tr><td>' + nameCell(d, 'shelly', '') + '</td>'
|
||||||
+ '<td class="mono">' + (d.host || '--') + ' ch' + d.channel + '</td>'
|
+ '<td class="mono">' + (d.host || '--') + ' ch' + d.channel + '</td>'
|
||||||
+ '<td class="mono">' + (d.watts === null || d.watts === undefined ? '--' : Math.round(d.watts) + ' W') + '</td>'
|
+ '<td class="mono">' + (d.watts === null || d.watts === undefined ? '--' : Math.round(d.watts) + ' W') + '</td>'
|
||||||
+ '<td><span class="pill ' + cls + '">' + text + '</span></td></tr>';
|
+ '<td><span class="pill ' + cls + '">' + text + '</span></td></tr>';
|
||||||
@@ -666,6 +894,7 @@ function drawChart(history, devices) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let latest = null;
|
let latest = null;
|
||||||
|
let paused = false;
|
||||||
|
|
||||||
function applyTheme(mode) {
|
function applyTheme(mode) {
|
||||||
document.documentElement.setAttribute('data-theme', mode);
|
document.documentElement.setAttribute('data-theme', mode);
|
||||||
@@ -688,10 +917,12 @@ document.getElementById('darkBtn').addEventListener('click', () => applyTheme('d
|
|||||||
let failures = 0;
|
let failures = 0;
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
|
if (paused) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/data', { cache: 'no-store' });
|
const response = await fetch('/api/data', { cache: 'no-store' });
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
latest = data;
|
latest = data;
|
||||||
|
canEdit = !!data.can_edit;
|
||||||
failures = 0;
|
failures = 0;
|
||||||
document.getElementById('stamp').textContent = data.updated || '--:--:--';
|
document.getElementById('stamp').textContent = data.updated || '--:--:--';
|
||||||
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
|
document.getElementById('dot').className = data.engine ? 'dot' : 'dot stale';
|
||||||
@@ -718,6 +949,7 @@ window.addEventListener('resize', () => refresh());
|
|||||||
|
|
||||||
class Handler(BaseHTTPRequestHandler):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
sampler = None
|
sampler = None
|
||||||
|
allow_edit = True
|
||||||
|
|
||||||
def log_message(self, *args):
|
def log_message(self, *args):
|
||||||
pass
|
pass
|
||||||
@@ -733,22 +965,74 @@ class Handler(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path.startswith("/api/data"):
|
if self.path.startswith("/api/data"):
|
||||||
self._send(
|
payload = Handler.sampler.payload()
|
||||||
200, json.dumps(Handler.sampler.payload()), "application/json"
|
payload["can_edit"] = Handler.allow_edit
|
||||||
)
|
self._send(200, json.dumps(payload), "application/json")
|
||||||
return
|
return
|
||||||
if self.path in ("/", "/index.html"):
|
if self.path in ("/", "/index.html"):
|
||||||
self._send(200, PAGE, "text/html; charset=utf-8")
|
self._send(200, PAGE, "text/html; charset=utf-8")
|
||||||
return
|
return
|
||||||
self._send(404, "not found", "text/plain")
|
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()
|
paths.ensure_dirs()
|
||||||
|
|
||||||
sampler = Sampler(interval=interval)
|
sampler = Sampler(interval=interval)
|
||||||
sampler.start()
|
sampler.start()
|
||||||
|
|
||||||
|
local_only = host in ("127.0.0.1", "localhost", "::1")
|
||||||
|
|
||||||
Handler.sampler = sampler
|
Handler.sampler = sampler
|
||||||
|
Handler.allow_edit = local_only or allow_remote_edit
|
||||||
|
|
||||||
server = ThreadingHTTPServer((host, port), Handler)
|
server = ThreadingHTTPServer((host, port), Handler)
|
||||||
return server, sampler
|
return server, sampler
|
||||||
Reference in new issue
Block a user