4"""Control a Tapo smart plug on the HIL rig.
6This tooling drives three independently switched plugs:
8 board -- powers the EK-RA8D2 target board.
9 pi -- powers the Raspberry Pi HIL host itself. Driven directly from the
10 developer workstation so the Pi can be power-cycled when wedged.
11 relay -- a third auxiliary bench plug, NOT board or host power. Driven
12 directly from the workstation, like pi; reads TAPO_RELAY_IP /
13 TAPO_RELAY_MAC (address from .env only; see hil_secrets.py).
15Recent TP15 firmware (>= 1.4) speaks TP-Link's TPAP protocol, which is not
16UDP-discoverable; the connection parameters are therefore pinned explicitly
17and discovery is skipped, so a plug is reached by address alone. That is why
18this module reaches into python-kasa internals -- the library exposes no
19public seam for either -- and why it carries the tree's only SLF001 per-file
22Credentials and per-plug addresses are resolved by hil_secrets.populate_env(),
23which prefers a self-hosted OpenBao vault and falls back to <repo-root>/.env
24(or ~/.tapo.env) so the plug stays controllable even when OpenBao or the k3s
25cluster is down. The resolved values land in the environment as:
27 TAPO_USER, TAPO_PASS -- shared Tapo account
28 TAPO_BOARD_IP, TAPO_BOARD_MAC -- board plug
29 TAPO_PI_IP, TAPO_PI_MAC -- Pi plug
30 TAPO_RELAY_IP, TAPO_RELAY_MAC -- relay plug (.env only; see hil_secrets.py)
32OpenBao consumer credentials (BAO_ADDR, ROLE_ID, SECRET_ID) live outside the
33repo in ~/.config/hil/openbao.env; see scripts/hil/hil_secrets.py for details.
36 python3 scripts/hil/tapo_control.py <board|pi|relay> [status|on|off|cycle]
38cycle powers the outlet off, waits 5 seconds, then powers it back on.
41from __future__
import annotations
47from pathlib
import Path
50from kasa
import Credentials
51from kasa.deviceconfig
import (
53 DeviceConnectionParameters,
57from kasa.exceptions
import KasaException
58from kasa.protocols.smartprotocol
import SmartProtocol
59from kasa.smart.smartdevice
import SmartDevice
60from kasa.transports.tpaptransport
import TpapTransport
62_REPO_ROOT = Path(__file__).resolve().parents[2]
63_ENV_FILE = _REPO_ROOT /
".env"
64_FALLBACK_ENV = Path.home() /
".tapo.env"
68_SECRET_SOURCE = hil_secrets.populate_env(_ENV_FILE, _FALLBACK_ENV)
69print(f
"tapo_control: secrets source = {_SECRET_SOURCE}", file=sys.stderr)
71_TARGETS = (
"board",
"pi",
"relay")
72_COMMANDS = (
"status",
"on",
"off",
"cycle")
81 "usage: tapo_control.py <board|pi|relay> [status|on|off|cycle]",
87def _require(name: str) -> str:
88 val = os.environ.get(name,
"").strip()
91 f
"tapo_control: missing {name} -- set it in .env "
92 f
"(copy .env.example and fill in values).",
99async def _get_device(ip: str, mac: str, creds: Credentials) -> SmartDevice:
102 conn = DeviceConnectionParameters(
103 DeviceFamily.SmartTapoPlug,
104 DeviceEncryptionType.Tpap,
107 http_port=_HTTP_PORT,
109 cfg = DeviceConfig(ip, credentials=creds, connection_type=conn)
110 t = TpapTransport(config=cfg)
111 t._known_tpap_tls = 0
112 t._known_tpap_port = _HTTP_PORT
113 t._known_tpap_dac =
False
114 t._known_tpap_pake = [2]
115 t._known_tpap_user_hash_type = 0
116 t._known_device_mac = mac
117 es = t._encryption_session
119 es._tpap_port = _HTTP_PORT
122 es._tpap_user_hash_type = 0
124 async def _noop() -> None:
128 return SmartDevice(ip, config=cfg, protocol=SmartProtocol(transport=t))
131async def main() -> None:
132 """Run one plug command: status (the default), on, off, or cycle.
134 Both the target and the command are validated against fixed sets before
135 any network call, so a typo prints usage instead of reaching a plug --
136 which matters when the targets include "the machine running the HIL suite"
137 and "an auxiliary bench plug", where the wrong one cuts power to something you
138 did not mean to touch.
140 Every required credential is resolved through ``_require``, so a missing
141 environment variable fails immediately and by name rather than surfacing
142 later as an authentication error against the plug.
144 if len(sys.argv) <= _ARGV_TARGET
or sys.argv[_ARGV_TARGET]
not in _TARGETS:
146 target = sys.argv[_ARGV_TARGET]
147 cmd = sys.argv[_ARGV_CMD]
if len(sys.argv) > _ARGV_CMD
else "status"
148 if cmd
not in _COMMANDS:
151 ip = _require(f
"TAPO_{target.upper()}_IP")
152 mac = _require(f
"TAPO_{target.upper()}_MAC")
153 creds = Credentials(_require(
"TAPO_USER"), _require(
"TAPO_PASS"))
155 dev = await _get_device(ip, mac, creds)
160 print(f
"{target} ({dev.alias}): turned ON")
163 print(f
"{target} ({dev.alias}): turned OFF")
166 print(f
"{target} ({dev.alias}): OFF -- waiting {_OFF_SECONDS} s...")
167 await asyncio.sleep(_OFF_SECONDS)
169 print(f
"{target} ({dev.alias}): ON")
171 state =
"ON" if dev.is_on
else "OFF"
172 print(f
"{target} ({dev.alias}, {dev.model}): {state}")
173 except (TimeoutError, KasaException, OSError)
as exc:
177 f
"tapo_control: could not control the {target} plug at {ip} "
178 f
"({type(exc).__name__}: {exc}). Check the plug is powered, on a "
179 f
"network this machine can reach, and TAPO_USER/TAPO_PASS are correct.",
182 raise SystemExit(2)
from exc
184 with contextlib.suppress(Exception):
185 await dev.protocol._transport._http_client.client.close()
void main(void)
The application entry point Reset_Handler hands control to.