Files
felhom.eu/scripts/bench/plugswitch.py
T
admin b4dcf4b373
gates / gates (push) Successful in 6m35s
Power-off spike (Part A) on the N100: plugswitch, wall-power figures, the hub's view
plugswitch.py refuses 'off' unless 20 s of readings show the N100 is already off (standby + 1 W,
no ping); 7 tests, each check red-proofed. By hand: guest shutdown 12 s, host off, 0.0 W; the hub
stayed silent and 'ONLINE' for 18 min; plug off/on did not start the N100, its power button did
(dashboard in 42 s). Wall figures into SIZING.md 4 and sizing.json power (8.5 / 20.7 / 0.0 W).
2026-10-13 19:15:08 +02:00

126 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""plugswitch.py — switch the N100 bench's Wi-Fi plug: on, off, status.
~/.venvs/plugmeter/bin/python scripts/bench/plugswitch.py status
~/.venvs/plugmeter/bin/python scripts/bench/plugswitch.py off
~/.venvs/plugmeter/bin/python scripts/bench/plugswitch.py on
Reads ~/.config/felhom-bench/plug.json (the same file plugmeter.py reads). `off` also needs, in that file:
"standby_w": the plug's measured reading with the machine powered off (the A2 figure);
"host_ping": the machine's addresses, e.g. ["192.168.0.162"].
THE RULE (it keeps this tool from being a power-cut button): `off` refuses unless, for the last 20 s, every
plug reading was below standby_w + 1 W AND no address in host_ping answered a ping. It prints why it refused
and exits 2. No standby_w or no host_ping → refuse. A failed plug read counts as „not known to be off" →
refuse. There is no flag to skip the rule.
The key and the device id are never printed. Pinned by scripts/bench/test_plugswitch.py.
"""
import json, os, subprocess, sys, time
CONF = os.path.expanduser('~/.config/felhom-bench/plug.json')
WINDOW_S = 20
STEP_S = 2
MARGIN_W = 1.0
def load():
return json.load(open(CONF))
def device(c):
import tinytuya
d = tinytuya.OutletDevice(c['id'], c['ip'], c['key'], version=float(c['version']))
d.set_socketTimeout(5)
d.set_socketRetryLimit(1)
return d
def read_watts(dev, c):
dp, div = c['dps']['watts']
dev.updatedps([int(dp)])
s = dev.status()
if not isinstance(s, dict) or 'dps' not in s or dp not in s['dps']:
raise RuntimeError(f"plug read failed ({s.get('Error') if isinstance(s, dict) else s})")
return s['dps'][dp] / div, s['dps'].get('1')
def pings(addr):
return subprocess.run(['ping', '-c', '1', '-W', '1', addr], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL).returncode == 0
def off_allowed(samples, standby_w, window_s=WINDOW_S):
"""samples: [(t, watts or None, host_answered bool)], oldest first. Returns (ok, reason)."""
if standby_w is None:
return False, 'no standby_w in plug.json — measure the switched-off reading first'
if not samples:
return False, 'no readings'
span = samples[-1][0] - samples[0][0]
if span < window_s - STEP_S:
return False, f'only {span:.0f} s of readings, need {window_s} s'
limit = standby_w + MARGIN_W
for t, w, up in samples:
if up:
return False, 'the machine answered a ping — it is running'
if w is None:
return False, 'a plug reading failed — cannot show the machine is off'
if w >= limit:
return False, f'the plug read {w:g} W, not below {limit:g} W (standby {standby_w:g} + {MARGIN_W:g}) — the machine draws power'
return True, f'{len(samples)} readings over {span:.0f} s, all below {limit:g} W, no ping answer'
def main(argv):
if len(argv) != 2 or argv[1] not in ('on', 'off', 'status'):
print('usage: plugswitch.py on|off|status', file=sys.stderr)
return 64
cmd = argv[1]
c = load()
dev = device(c)
if cmd == 'status':
w, on = read_watts(dev, c)
print(f'switch={"on" if on else "off"} watts={w:g}')
return 0
if cmd == 'on':
r = dev.turn_on()
w, on = read_watts(dev, c)
print(f'switch={"on" if on else "off"} watts={w:g}' + ('' if on else f' (turn_on answered {r})'))
return 0 if on else 1
hosts = c.get('host_ping') or []
if not hosts:
print('REFUSED: no host_ping in plug.json — cannot check the machine is off', file=sys.stderr)
return 2
samples, start = [], time.monotonic()
# Read until the readings themselves span the window: a slow plug read (up to 8 s) must not shorten it.
while not samples or samples[-1][0] - samples[0][0] < WINDOW_S:
if time.monotonic() - start > 3 * WINDOW_S:
print('REFUSED: the plug answered too slowly to fill the window', file=sys.stderr)
return 2
t0 = time.monotonic()
try:
w, _ = read_watts(dev, c)
except Exception as e:
print(f'plug read failed: {type(e).__name__}', file=sys.stderr)
w = None
up = any(pings(h) for h in hosts)
samples.append((t0 - start, w, up))
print(f' check {t0 - start:4.0f}s watts={w} ping={"answered" if up else "none"}', file=sys.stderr)
ok, why = off_allowed(samples, c.get('standby_w'), window_s=0) # early exit on the first bad sample
if not ok and 'readings' not in why:
print(f'REFUSED: {why}', file=sys.stderr)
return 2
time.sleep(max(0.0, STEP_S - (time.monotonic() - t0)))
ok, why = off_allowed(samples, c.get('standby_w'))
if not ok:
print(f'REFUSED: {why}', file=sys.stderr)
return 2
print(f'allowed: {why}', file=sys.stderr)
dev.turn_off()
w, on = read_watts(dev, c)
print(f'switch={"on" if on else "off"} watts={w:g}')
return 0 if not on else 1
if __name__ == '__main__':
sys.exit(main(sys.argv))