Power-off spike (Part A) on the N100: plugswitch, wall-power figures, the hub's view
gates / gates (push) Successful in 6m35s

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).
This commit is contained in:
2026-10-13 19:15:08 +02:00
parent 6a1f715a87
commit b4dcf4b373
32 changed files with 1410 additions and 20 deletions
+11
View File
@@ -1,3 +1,14 @@
## 2026-10-13 (evening) — power-off spike on the N100: plugswitch, wall-power figures
- New `scripts/bench/plugswitch.py` (`on`/`off`/`status`) + `test_plugswitch.py` (7 tests): `off` refuses unless 20 s of
readings sit below the measured standby + 1 W and the N100 answers no ping. Red-proofed: each of the four checks
removed in turn, a test convicted each. Live: refused while the N100 ran; a slow-read bug (window cut to 16 s) found
and fixed live.
- `documentation/audits/poweroff-2026-10-13/`: Part A by hand — guest shutdown 12 s, host off 13 s later, 0.0 W off;
hub silent for 18 min (still „ONLINE"); plug off/on did NOT start the N100, the power button did (dashboard in 42 s).
- `sizing-2026-10-13`: wall figures in `SIZING.md` §4 and `sizing.json` `power` (8.5 W rest, 20.7 W work, 0.0 W off);
the CPU-only RAPL figures kept under `power.cpu_rapl`.
## 2026-10-13 (evening) — plugmeter: the N100's power plug, read from DooPlex
- New `scripts/bench/plugmeter.py` + `scripts/bench/README.md`: reads the Tuya meter plug the N100 runs on and prints
+15
View File
@@ -31,3 +31,18 @@ lifetime total. Use watts × time for energy.
When the plug moves address or is paired again: re-run the tinytuya wizard in `~/.config/felhom-bench/`
(credentials `TUYA_ID`, `TUYA_SECRET`, `TUYA_DEVICE_ID` from `~/.config/credentials`, region `eu`) and
update `plug.json`. Its output prints the key — send it to a file, never to a terminal or a log.
## plugswitch.py — switch the N100's plug (`on`, `off`, `status`)
```
~/.venvs/plugmeter/bin/python scripts/bench/plugswitch.py status
~/.venvs/plugmeter/bin/python scripts/bench/plugswitch.py off # only when the N100 is already off
~/.venvs/plugmeter/bin/python scripts/bench/plugswitch.py on
```
**`off` is not a power-cut button.** It refuses (exit 2, with the reason) unless the plug's readings over at least
20 s are all below `standby_w` + 1 W and the N100 answers no ping. `standby_w` (0.0, measured 2026-10-13) and
`host_ping` live in `plug.json`. Pinned by `test_plugswitch.py`. Never use it on any other plug.
The N100 does **not** start when the plug comes back on after a deliberate power-off (2026-10-13,
`documentation/audits/poweroff-2026-10-13/`): press its power button.
+125
View File
@@ -0,0 +1,125 @@
#!/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))
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Pins plugswitch.py's rule: `off` only when 20 s of readings show the machine is off."""
import os, sys, unittest
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from plugswitch import off_allowed # noqa: E402
OFF = [(t, 0.4, False) for t in range(0, 20, 2)]
class Rule(unittest.TestCase):
def test_off_machine_allowed(self):
self.assertTrue(off_allowed(OFF, 0.3)[0])
def test_running_machine_refused_by_watts(self):
s = OFF[:5] + [(10, 7.2, False)] + OFF[6:]
ok, why = off_allowed(s, 0.3)
self.assertFalse(ok); self.assertIn('draws power', why)
def test_ping_answer_refused(self):
s = OFF[:-1] + [(18, 0.4, True)]
ok, why = off_allowed(s, 0.3)
self.assertFalse(ok); self.assertIn('ping', why)
def test_failed_read_refused(self):
s = OFF[:3] + [(6, None, False)] + OFF[4:]
self.assertFalse(off_allowed(s, 0.3)[0])
def test_short_window_refused(self):
self.assertFalse(off_allowed(OFF[:4], 0.3)[0])
def test_no_standby_refused(self):
self.assertFalse(off_allowed(OFF, None)[0])
def test_edge_exactly_at_limit_refused(self):
s = OFF[:-1] + [(18, 1.3, False)]
self.assertFalse(off_allowed(s, 0.3)[0])
if __name__ == '__main__':
unittest.main()