Power-off spike (Part A) on the N100: plugswitch, wall-power figures, the hub's view
gates / gates (push) Successful in 6m35s
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user