ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
tapo_control.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Control a Tapo smart plug on the HIL rig.
5
6This tooling drives three independently switched plugs:
7
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).
14
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
20ignore.
21
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:
26
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)
31
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.
34
35Usage:
36 python3 scripts/hil/tapo_control.py <board|pi|relay> [status|on|off|cycle]
37
38cycle powers the outlet off, waits 5 seconds, then powers it back on.
39"""
40
41from __future__ import annotations
42
43import asyncio
44import contextlib
45import os
46import sys
47from pathlib import Path
48
49import hil_secrets
50from kasa import Credentials
51from kasa.deviceconfig import (
52 DeviceConfig,
53 DeviceConnectionParameters,
54 DeviceEncryptionType,
55 DeviceFamily,
56)
57from kasa.exceptions import KasaException
58from kasa.protocols.smartprotocol import SmartProtocol
59from kasa.smart.smartdevice import SmartDevice
60from kasa.transports.tpaptransport import TpapTransport
61
62_REPO_ROOT = Path(__file__).resolve().parents[2]
63_ENV_FILE = _REPO_ROOT / ".env"
64_FALLBACK_ENV = Path.home() / ".tapo.env"
65
66# Resolve secrets: OpenBao first, then the local .env fallback. The source is
67# reported on stderr so it is clear which path supplied the credentials.
68_SECRET_SOURCE = hil_secrets.populate_env(_ENV_FILE, _FALLBACK_ENV)
69print(f"tapo_control: secrets source = {_SECRET_SOURCE}", file=sys.stderr)
70
71_TARGETS = ("board", "pi", "relay")
72_COMMANDS = ("status", "on", "off", "cycle")
73_OFF_SECONDS = 5
74_HTTP_PORT = 80
75_ARGV_TARGET = 1
76_ARGV_CMD = 2
77
78
79def _usage() -> None:
80 print(
81 "usage: tapo_control.py <board|pi|relay> [status|on|off|cycle]",
82 file=sys.stderr,
83 )
84 raise SystemExit(2)
85
86
87def _require(name: str) -> str:
88 val = os.environ.get(name, "").strip()
89 if not val:
90 print(
91 f"tapo_control: missing {name} -- set it in .env "
92 f"(copy .env.example and fill in values).",
93 file=sys.stderr,
94 )
95 raise SystemExit(2)
96 return val
97
98
99async def _get_device(ip: str, mac: str, creds: Credentials) -> SmartDevice:
100 # TPAP is not discoverable -- pin every connection parameter and stub out
101 # discovery so the device is reached by address alone.
102 conn = DeviceConnectionParameters(
103 DeviceFamily.SmartTapoPlug,
104 DeviceEncryptionType.Tpap,
105 login_version=2,
106 https=False,
107 http_port=_HTTP_PORT,
108 )
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
118 es._tpap_tls = 0
119 es._tpap_port = _HTTP_PORT
120 es._tpap_dac = False
121 es._tpap_pake = [2]
122 es._tpap_user_hash_type = 0
123
124 async def _noop() -> None:
125 pass
126
127 es._discover = _noop
128 return SmartDevice(ip, config=cfg, protocol=SmartProtocol(transport=t))
129
130
131async def main() -> None:
132 """Run one plug command: status (the default), on, off, or cycle.
133
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.
139
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.
143 """
144 if len(sys.argv) <= _ARGV_TARGET or sys.argv[_ARGV_TARGET] not in _TARGETS:
145 _usage()
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:
149 _usage()
150
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"))
154
155 dev = await _get_device(ip, mac, creds)
156 try:
157 await dev.update()
158 if cmd == "on":
159 await dev.turn_on()
160 print(f"{target} ({dev.alias}): turned ON")
161 elif cmd == "off":
162 await dev.turn_off()
163 print(f"{target} ({dev.alias}): turned OFF")
164 elif cmd == "cycle":
165 await dev.turn_off()
166 print(f"{target} ({dev.alias}): OFF -- waiting {_OFF_SECONDS} s...")
167 await asyncio.sleep(_OFF_SECONDS)
168 await dev.turn_on()
169 print(f"{target} ({dev.alias}): ON")
170 else:
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:
174 # Almost always reachability or wrong credentials rather than a logic
175 # bug -- surface a clean one-liner instead of a raw traceback.
176 print(
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.",
180 file=sys.stderr,
181 )
182 raise SystemExit(2) from exc
183 finally:
184 with contextlib.suppress(Exception):
185 await dev.protocol._transport._http_client.client.close()
186
187
188asyncio.run(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298