embedded-lab-control 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
- embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
- embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
- embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
- embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
- embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
- labctl/__init__.py +8 -0
- labctl/actuators/__init__.py +56 -0
- labctl/actuators/base.py +183 -0
- labctl/actuators/lcus1.py +232 -0
- labctl/actuators/mock.py +81 -0
- labctl/actuators/runtime.py +1178 -0
- labctl/cli.py +7485 -0
- labctl/core/__init__.py +36 -0
- labctl/core/admin_files.py +388 -0
- labctl/core/audit.py +269 -0
- labctl/core/claim_keepalive.py +342 -0
- labctl/core/config.py +1068 -0
- labctl/core/database.py +880 -0
- labctl/core/images.py +503 -0
- labctl/core/manager.py +2815 -0
- labctl/core/models.py +815 -0
- labctl/core/safe_open.py +109 -0
- labctl/extras.py +128 -0
- labctl/health/__init__.py +43 -0
- labctl/health/alerts.py +275 -0
- labctl/health/checks.py +533 -0
- labctl/health/daemon.py +397 -0
- labctl/mcp_server.py +5254 -0
- labctl/power/__init__.py +13 -0
- labctl/power/base.py +145 -0
- labctl/power/kasa.py +446 -0
- labctl/power/shelly.py +89 -0
- labctl/power/tasmota.py +95 -0
- labctl/sdwire/__init__.py +10 -0
- labctl/sdwire/controller.py +1116 -0
- labctl/sdwire/power_guard.py +190 -0
- labctl/serial/__init__.py +18 -0
- labctl/serial/boot_test.py +342 -0
- labctl/serial/capture.py +430 -0
- labctl/serial/keys.py +89 -0
- labctl/serial/proxy.py +802 -0
- labctl/serial/ser2net.py +338 -0
- labctl/serial/udev.py +273 -0
- labctl/services.py +210 -0
- labctl/web/__init__.py +9 -0
- labctl/web/activity_broadcaster.py +204 -0
- labctl/web/api.py +1003 -0
- labctl/web/app.py +202 -0
- labctl/web/auth.py +97 -0
- labctl/web/claims.py +232 -0
- labctl/web/static/css/style.css +477 -0
- labctl/web/static/js/app.js +76 -0
- labctl/web/templates/activity.html +202 -0
- labctl/web/templates/base.html +49 -0
- labctl/web/templates/claim_refused.html +38 -0
- labctl/web/templates/console.html +237 -0
- labctl/web/templates/dashboard.html +100 -0
- labctl/web/templates/login.html +25 -0
- labctl/web/templates/sbc_detail.html +306 -0
- labctl/web/templates/settings.html +78 -0
- labctl/web/templates/status_history.html +65 -0
- labctl/web/views.py +496 -0
- labctl/web/websocket.py +208 -0
labctl/power/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Power control module for lab controller.
|
|
3
|
+
|
|
4
|
+
Provides smart plug control for Tasmota, Kasa, and Shelly devices.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from labctl.power.base import PowerController, PowerState, get_controller
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"PowerController",
|
|
11
|
+
"PowerState",
|
|
12
|
+
"get_controller",
|
|
13
|
+
]
|
labctl/power/base.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for power controllers.
|
|
3
|
+
|
|
4
|
+
Defines abstract interface and factory for smart plug implementations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from enum import Enum
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from labctl.core.models import PlugType, PowerPlug
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PowerState(Enum):
|
|
16
|
+
"""Power state values."""
|
|
17
|
+
|
|
18
|
+
ON = "on"
|
|
19
|
+
OFF = "off"
|
|
20
|
+
UNKNOWN = "unknown"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PowerController(ABC):
|
|
24
|
+
"""Abstract base class for power controllers."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, address: str, plug_index: int = 1, timeout: float = 5.0):
|
|
27
|
+
"""
|
|
28
|
+
Initialize power controller.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
address: IP address or hostname of the device
|
|
32
|
+
plug_index: Outlet index for multi-relay devices (1-based)
|
|
33
|
+
timeout: Request timeout in seconds
|
|
34
|
+
"""
|
|
35
|
+
self.address = address
|
|
36
|
+
self.plug_index = plug_index
|
|
37
|
+
self.timeout = timeout
|
|
38
|
+
|
|
39
|
+
@abstractmethod
|
|
40
|
+
def power_on(self) -> bool:
|
|
41
|
+
"""
|
|
42
|
+
Turn power on.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
True if successful, False otherwise
|
|
46
|
+
"""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
def power_off(self) -> bool:
|
|
51
|
+
"""
|
|
52
|
+
Turn power off.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
True if successful, False otherwise
|
|
56
|
+
"""
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def power_cycle(self, delay: float = 3.0) -> bool:
|
|
60
|
+
"""
|
|
61
|
+
Power cycle (off, wait, on).
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
delay: Seconds to wait between off and on
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
True if successful, False otherwise
|
|
68
|
+
"""
|
|
69
|
+
import time
|
|
70
|
+
|
|
71
|
+
if not self.power_off():
|
|
72
|
+
return False
|
|
73
|
+
time.sleep(delay)
|
|
74
|
+
return self.power_on()
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def get_state(self) -> PowerState:
|
|
78
|
+
"""
|
|
79
|
+
Get current power state.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
PowerState enum value
|
|
83
|
+
"""
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def from_plug(cls, plug: "PowerPlug", timeout: float = 5.0) -> "PowerController":
|
|
88
|
+
"""
|
|
89
|
+
Create controller from PowerPlug model.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
plug: PowerPlug instance from database
|
|
93
|
+
timeout: Request timeout in seconds
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Appropriate PowerController subclass instance
|
|
97
|
+
"""
|
|
98
|
+
return get_controller(
|
|
99
|
+
plug_type=plug.plug_type,
|
|
100
|
+
address=plug.address,
|
|
101
|
+
plug_index=plug.plug_index,
|
|
102
|
+
timeout=timeout,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_controller(
|
|
107
|
+
plug_type: "PlugType",
|
|
108
|
+
address: str,
|
|
109
|
+
plug_index: int = 1,
|
|
110
|
+
timeout: float = 5.0,
|
|
111
|
+
) -> PowerController:
|
|
112
|
+
"""
|
|
113
|
+
Factory function to create appropriate power controller.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
plug_type: Type of smart plug (tasmota, kasa, shelly)
|
|
117
|
+
address: IP address or hostname
|
|
118
|
+
plug_index: Outlet index for multi-relay devices
|
|
119
|
+
timeout: Request timeout in seconds
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
PowerController subclass instance
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: If plug_type is not supported
|
|
126
|
+
"""
|
|
127
|
+
from labctl.core.models import PlugType
|
|
128
|
+
|
|
129
|
+
if plug_type == PlugType.TASMOTA:
|
|
130
|
+
from labctl.power.tasmota import TasmotaController
|
|
131
|
+
|
|
132
|
+
return TasmotaController(address, plug_index, timeout)
|
|
133
|
+
|
|
134
|
+
elif plug_type == PlugType.KASA:
|
|
135
|
+
from labctl.power.kasa import KasaController
|
|
136
|
+
|
|
137
|
+
return KasaController(address, plug_index, timeout)
|
|
138
|
+
|
|
139
|
+
elif plug_type == PlugType.SHELLY:
|
|
140
|
+
from labctl.power.shelly import ShellyController
|
|
141
|
+
|
|
142
|
+
return ShellyController(address, plug_index, timeout)
|
|
143
|
+
|
|
144
|
+
else:
|
|
145
|
+
raise ValueError(f"Unsupported plug type: {plug_type}")
|
labctl/power/kasa.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kasa power controller.
|
|
3
|
+
|
|
4
|
+
Controls TP-Link Kasa smart plugs and power strips via python-kasa library.
|
|
5
|
+
Supports single-outlet plugs and multi-outlet strips (e.g., HS300, KP303, EP40).
|
|
6
|
+
|
|
7
|
+
Per-host state cache
|
|
8
|
+
====================
|
|
9
|
+
|
|
10
|
+
Each multi-outlet strip exposes one KLAP endpoint regardless of how
|
|
11
|
+
many outlets it has. Naively probing each outlet independently fans
|
|
12
|
+
out into N parallel KLAP handshakes against the same physical device,
|
|
13
|
+
which the strip rate-limits — observed in production as bursts of
|
|
14
|
+
``AuthenticationError`` against a single IP across all outlets in a
|
|
15
|
+
~3 ms window.
|
|
16
|
+
|
|
17
|
+
This module caches the *state* (channel → is_on) per host with a
|
|
18
|
+
short TTL and serializes refreshes through a per-host lock. The first
|
|
19
|
+
caller in a cycle pays the KLAP cost; concurrent callers on the same
|
|
20
|
+
host wait briefly and read from the cache. Writes invalidate.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import logging
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from functools import lru_cache
|
|
28
|
+
|
|
29
|
+
from labctl.core.config import load_config
|
|
30
|
+
from labctl.extras import BrokenExtraError, MissingExtraError, is_missing
|
|
31
|
+
from labctl.power.base import PowerController, PowerState
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Per-host state cache
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
# Default TTL for cached states. Tuned to span a single monitor
|
|
41
|
+
# `check_all` parallel batch (typically completes in <1 s) without
|
|
42
|
+
# letting external state changes go unnoticed for long. CLI/MCP fresh
|
|
43
|
+
# processes start with empty caches, so this only matters within the
|
|
44
|
+
# daemon and within long-running web requests.
|
|
45
|
+
KASA_STATE_TTL_SECONDS = 5.0
|
|
46
|
+
|
|
47
|
+
_kasa_state_cache: dict[str, tuple[float, dict[int, bool]]] = {}
|
|
48
|
+
_kasa_host_locks: dict[str, threading.Lock] = {}
|
|
49
|
+
_kasa_cache_meta_lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _get_host_lock(host: str) -> threading.Lock:
|
|
53
|
+
"""Return (lazily creating) the per-host serialization lock."""
|
|
54
|
+
with _kasa_cache_meta_lock:
|
|
55
|
+
lock = _kasa_host_locks.get(host)
|
|
56
|
+
if lock is None:
|
|
57
|
+
lock = threading.Lock()
|
|
58
|
+
_kasa_host_locks[host] = lock
|
|
59
|
+
return lock
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _read_cached_state(host: str, channel: int) -> bool | None:
|
|
63
|
+
"""Return cached is_on for (host, channel), or None if missing/stale."""
|
|
64
|
+
with _kasa_cache_meta_lock:
|
|
65
|
+
entry = _kasa_state_cache.get(host)
|
|
66
|
+
if entry is None:
|
|
67
|
+
return None
|
|
68
|
+
cached_at, states = entry
|
|
69
|
+
if time.monotonic() - cached_at > KASA_STATE_TTL_SECONDS:
|
|
70
|
+
return None
|
|
71
|
+
return states.get(channel)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _store_host_state(host: str, states: dict[int, bool]) -> None:
|
|
75
|
+
with _kasa_cache_meta_lock:
|
|
76
|
+
_kasa_state_cache[host] = (time.monotonic(), dict(states))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _invalidate_host(host: str) -> None:
|
|
80
|
+
"""Drop the cache entry for a host so the next read refreshes."""
|
|
81
|
+
with _kasa_cache_meta_lock:
|
|
82
|
+
_kasa_state_cache.pop(host, None)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _clear_kasa_caches() -> None:
|
|
86
|
+
"""Reset all per-host caches. Test-only entry point."""
|
|
87
|
+
with _kasa_cache_meta_lock:
|
|
88
|
+
_kasa_state_cache.clear()
|
|
89
|
+
_kasa_host_locks.clear()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# First python-kasa release that uses KLAP v2 for IOT devices whose
|
|
93
|
+
# discovery reports login version 2 (e.g. HS300 firmware 1.1.2); older
|
|
94
|
+
# releases always use KLAP v1 and fail those devices' handshake.
|
|
95
|
+
KASA_KLAP_V2_VERSION = (0, 12)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _klap_v2_hint() -> str:
|
|
99
|
+
"""Hint appended to an AuthenticationError from python-kasa < 0.12."""
|
|
100
|
+
try:
|
|
101
|
+
from importlib.metadata import version
|
|
102
|
+
|
|
103
|
+
installed = version("python-kasa")
|
|
104
|
+
major_minor = tuple(int(p) for p in installed.split(".")[:2])
|
|
105
|
+
except Exception:
|
|
106
|
+
return ""
|
|
107
|
+
if major_minor >= KASA_KLAP_V2_VERSION:
|
|
108
|
+
return ""
|
|
109
|
+
return (
|
|
110
|
+
f" (python-kasa {installed} can't authenticate to devices on KLAP "
|
|
111
|
+
"login version 2, e.g. HS300 firmware 1.1.2; if the credentials are "
|
|
112
|
+
"correct, upgrade to python-kasa >= 0.12, which needs Python >= 3.11)"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@lru_cache(maxsize=1)
|
|
117
|
+
def _get_cached_kasa_credentials():
|
|
118
|
+
"""Load Kasa credentials once per process.
|
|
119
|
+
|
|
120
|
+
A missing python-kasa raises ModuleNotFoundError instead of returning
|
|
121
|
+
None: lru_cache doesn't cache exceptions, so the outcome isn't fixed
|
|
122
|
+
for the life of the process.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
from kasa import Credentials
|
|
126
|
+
|
|
127
|
+
config = load_config()
|
|
128
|
+
if config.kasa.username and config.kasa.password:
|
|
129
|
+
logger.debug("Loaded Kasa credentials for user: %s", config.kasa.username)
|
|
130
|
+
return Credentials(config.kasa.username, config.kasa.password)
|
|
131
|
+
logger.debug("No Kasa credentials configured")
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if isinstance(e, ImportError) and is_missing(e, "kasa"):
|
|
134
|
+
raise
|
|
135
|
+
logger.warning("Failed to load Kasa credentials: %s", e)
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class KasaController(PowerController):
|
|
140
|
+
"""
|
|
141
|
+
Power controller for TP-Link Kasa devices.
|
|
142
|
+
|
|
143
|
+
Requires python-kasa (the [kasa] extra).
|
|
144
|
+
|
|
145
|
+
Supports both single plugs (HS103, EP10, etc.) and multi-outlet
|
|
146
|
+
power strips (HS300, KP303, EP40, etc.). For strips, use plug_index
|
|
147
|
+
to select the specific outlet (1-based).
|
|
148
|
+
|
|
149
|
+
Newer Kasa devices using the KLAP protocol require TP-Link cloud
|
|
150
|
+
account credentials. Configure these in config.yaml under the
|
|
151
|
+
'kasa' section.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def _load_credentials(self):
|
|
155
|
+
"""Load Kasa credentials from config, returning Credentials or None."""
|
|
156
|
+
try:
|
|
157
|
+
return _get_cached_kasa_credentials()
|
|
158
|
+
except ModuleNotFoundError:
|
|
159
|
+
# python-kasa is missing; _run_async reports the [kasa] extra.
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
async def _discover_and_update(self):
|
|
163
|
+
"""Discover the device at self.address and fetch its state.
|
|
164
|
+
|
|
165
|
+
Discovery over KLAP (UDP 20002) types an IOT device by its family
|
|
166
|
+
alone: ``IOT.SMARTPLUGSWITCH`` becomes ``IotPlug``, so a strip
|
|
167
|
+
that no longer answers legacy discovery (HS300 firmware 1.1.2
|
|
168
|
+
closed port 9999) comes back as a plug without children. For IOT
|
|
169
|
+
devices this does what python-kasa's own ``connect`` does: query
|
|
170
|
+
the sysinfo, let ``get_device_class_from_sys_info`` pick the
|
|
171
|
+
class, and rebuild the device as that class on the same protocol
|
|
172
|
+
(same session, no new handshake) when discovery picked another.
|
|
173
|
+
|
|
174
|
+
The caller must disconnect the returned device; on failure
|
|
175
|
+
(including cancellation) it is disconnected here.
|
|
176
|
+
"""
|
|
177
|
+
from kasa import Discover
|
|
178
|
+
from kasa.device_factory import (
|
|
179
|
+
GET_SYSINFO_QUERY,
|
|
180
|
+
get_device_class_from_sys_info,
|
|
181
|
+
)
|
|
182
|
+
from kasa.iot import IotDevice
|
|
183
|
+
|
|
184
|
+
credentials = self._load_credentials()
|
|
185
|
+
kwargs = {"host": self.address}
|
|
186
|
+
if credentials:
|
|
187
|
+
kwargs["credentials"] = credentials
|
|
188
|
+
|
|
189
|
+
logger.debug("Discovering Kasa device at %s", self.address)
|
|
190
|
+
device = await Discover.discover_single(**kwargs)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Legacy discovery (port 9999) already returns the sysinfo and
|
|
194
|
+
# picks the class from it; only KLAP discovery needs the query.
|
|
195
|
+
discovery_info = getattr(device, "_discovery_info", None) or {}
|
|
196
|
+
from_sysinfo = bool(discovery_info.get("system", {}).get("get_sysinfo"))
|
|
197
|
+
if isinstance(device, IotDevice) and not from_sysinfo:
|
|
198
|
+
info = await device.protocol.query(GET_SYSINFO_QUERY)
|
|
199
|
+
device_class = get_device_class_from_sys_info(info)
|
|
200
|
+
if type(device) is not device_class:
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Kasa device at %s discovered as %s; using %s",
|
|
203
|
+
self.address,
|
|
204
|
+
type(device).__name__,
|
|
205
|
+
device_class.__name__,
|
|
206
|
+
)
|
|
207
|
+
device = device_class(self.address, protocol=device.protocol)
|
|
208
|
+
device.update_from_discover_info(info)
|
|
209
|
+
logger.debug(
|
|
210
|
+
"Updating device state for %s (%s)", self.address, device.alias
|
|
211
|
+
)
|
|
212
|
+
await device.update()
|
|
213
|
+
except BaseException:
|
|
214
|
+
await device.disconnect()
|
|
215
|
+
raise
|
|
216
|
+
return device
|
|
217
|
+
|
|
218
|
+
async def _get_device(self):
|
|
219
|
+
"""
|
|
220
|
+
Connect to device and return (root_device, target) tuple.
|
|
221
|
+
|
|
222
|
+
root_device is the top-level device (needed for disconnect cleanup).
|
|
223
|
+
target is the specific outlet to control — either the root device
|
|
224
|
+
itself (for single plugs) or a child device (for strip outlets).
|
|
225
|
+
|
|
226
|
+
On failure, ensures the device is disconnected before raising.
|
|
227
|
+
"""
|
|
228
|
+
device = await self._discover_and_update()
|
|
229
|
+
|
|
230
|
+
if device.children:
|
|
231
|
+
idx = self.plug_index - 1 # Convert 1-based to 0-based
|
|
232
|
+
if idx < 0 or idx >= len(device.children):
|
|
233
|
+
await device.disconnect()
|
|
234
|
+
raise RuntimeError(
|
|
235
|
+
f"Outlet index {self.plug_index} out of range "
|
|
236
|
+
f"(device has {len(device.children)} outlets)"
|
|
237
|
+
)
|
|
238
|
+
target = device.children[idx]
|
|
239
|
+
logger.debug(
|
|
240
|
+
"Selected outlet %d (%s) on strip %s",
|
|
241
|
+
self.plug_index,
|
|
242
|
+
target.alias,
|
|
243
|
+
device.alias,
|
|
244
|
+
)
|
|
245
|
+
return device, target
|
|
246
|
+
|
|
247
|
+
# No outlets: only index 1 (or the legacy 0) means the device
|
|
248
|
+
# itself. Anything else is a misconfiguration or a strip that
|
|
249
|
+
# wasn't recognised; switching the root would switch every outlet.
|
|
250
|
+
if self.plug_index > 1:
|
|
251
|
+
await device.disconnect()
|
|
252
|
+
raise RuntimeError(
|
|
253
|
+
f"Outlet index {self.plug_index} given, but the Kasa device "
|
|
254
|
+
f"at {self.address} reports no outlets"
|
|
255
|
+
)
|
|
256
|
+
return device, device
|
|
257
|
+
|
|
258
|
+
def _run_async(self, coro_factory, action: str, retries: int = 2):
|
|
259
|
+
"""
|
|
260
|
+
Execute an async coro factory with retry/logging.
|
|
261
|
+
|
|
262
|
+
The *factory* pattern (rather than passing the coro directly)
|
|
263
|
+
ensures each retry attempt creates a fresh awaitable — async
|
|
264
|
+
coros are single-shot.
|
|
265
|
+
|
|
266
|
+
Retries on authentication / transient errors (e.g. a strip
|
|
267
|
+
refusing a burst of concurrent handshakes). An authentication
|
|
268
|
+
error that persists with python-kasa < 0.12 is usually the
|
|
269
|
+
device needing KLAP v2, which no retry fixes; the final error
|
|
270
|
+
says so (see _klap_v2_hint).
|
|
271
|
+
|
|
272
|
+
Logging policy:
|
|
273
|
+
- Retry attempts log at WARNING so they show in default
|
|
274
|
+
(INFO) logs without enabling debug.
|
|
275
|
+
- A successful retry logs at INFO so the recovery is visible.
|
|
276
|
+
- Final failure logs at ERROR (in addition to raising), so
|
|
277
|
+
the full attempt history is captured even when the caller
|
|
278
|
+
only surfaces the exception text.
|
|
279
|
+
"""
|
|
280
|
+
last_error = None
|
|
281
|
+
total_attempts = 1 + retries
|
|
282
|
+
for attempt in range(total_attempts):
|
|
283
|
+
try:
|
|
284
|
+
# If already inside an async event loop (e.g., MCP server),
|
|
285
|
+
# run in a new thread to avoid "cannot call asyncio.run()
|
|
286
|
+
# from a running event loop" error.
|
|
287
|
+
try:
|
|
288
|
+
asyncio.get_running_loop()
|
|
289
|
+
in_loop = True
|
|
290
|
+
except RuntimeError:
|
|
291
|
+
in_loop = False
|
|
292
|
+
if in_loop:
|
|
293
|
+
import concurrent.futures
|
|
294
|
+
|
|
295
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
296
|
+
future = pool.submit(asyncio.run, coro_factory())
|
|
297
|
+
result = future.result(timeout=self.timeout + 10)
|
|
298
|
+
else:
|
|
299
|
+
result = asyncio.run(coro_factory())
|
|
300
|
+
|
|
301
|
+
if attempt > 0:
|
|
302
|
+
logger.info(
|
|
303
|
+
"Kasa %s for %s[%d] succeeded on attempt %d/%d",
|
|
304
|
+
action,
|
|
305
|
+
self.address,
|
|
306
|
+
self.plug_index,
|
|
307
|
+
attempt + 1,
|
|
308
|
+
total_attempts,
|
|
309
|
+
)
|
|
310
|
+
return result
|
|
311
|
+
except ImportError as e:
|
|
312
|
+
if not is_missing(e, "kasa"):
|
|
313
|
+
raise BrokenExtraError("kasa", e) from e
|
|
314
|
+
raise MissingExtraError("kasa") from e
|
|
315
|
+
except RuntimeError:
|
|
316
|
+
raise
|
|
317
|
+
except Exception as e:
|
|
318
|
+
last_error = e
|
|
319
|
+
if attempt < retries:
|
|
320
|
+
logger.warning(
|
|
321
|
+
"Kasa %s attempt %d/%d failed for %s[%d]: "
|
|
322
|
+
"%s: %s — retrying in 2s",
|
|
323
|
+
action,
|
|
324
|
+
attempt + 1,
|
|
325
|
+
total_attempts,
|
|
326
|
+
self.address,
|
|
327
|
+
self.plug_index,
|
|
328
|
+
type(e).__name__,
|
|
329
|
+
e,
|
|
330
|
+
)
|
|
331
|
+
time.sleep(2)
|
|
332
|
+
continue
|
|
333
|
+
break
|
|
334
|
+
|
|
335
|
+
error_type = type(last_error).__name__
|
|
336
|
+
msg = (
|
|
337
|
+
f"Kasa {action} failed for {self.address} "
|
|
338
|
+
f"[{self.plug_index}] after {total_attempts} attempts: "
|
|
339
|
+
f"{error_type}: {last_error}"
|
|
340
|
+
)
|
|
341
|
+
if error_type == "AuthenticationError" and self._load_credentials():
|
|
342
|
+
msg += _klap_v2_hint()
|
|
343
|
+
logger.error(msg)
|
|
344
|
+
raise RuntimeError(msg) from last_error
|
|
345
|
+
|
|
346
|
+
def _run(self, coro_func, action: str, retries: int = 2):
|
|
347
|
+
"""Per-outlet flow used by writes — discovers, runs, disconnects."""
|
|
348
|
+
|
|
349
|
+
def factory():
|
|
350
|
+
async def go():
|
|
351
|
+
device, target = await self._get_device()
|
|
352
|
+
try:
|
|
353
|
+
return await coro_func(device, target)
|
|
354
|
+
finally:
|
|
355
|
+
await device.disconnect()
|
|
356
|
+
|
|
357
|
+
return go()
|
|
358
|
+
|
|
359
|
+
return self._run_async(factory, action, retries)
|
|
360
|
+
|
|
361
|
+
def _fetch_all_outlet_states(self) -> dict[int, bool]:
|
|
362
|
+
"""Discover device, update once, return is_on for every outlet.
|
|
363
|
+
|
|
364
|
+
One KLAP handshake covers every child outlet — caller stores
|
|
365
|
+
the result in the host cache so subsequent get_state() calls
|
|
366
|
+
on sibling outlets hit the cache instead of re-handshaking.
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
def factory():
|
|
370
|
+
async def go():
|
|
371
|
+
device = await self._discover_and_update()
|
|
372
|
+
try:
|
|
373
|
+
states: dict[int, bool] = {}
|
|
374
|
+
if device.children:
|
|
375
|
+
for i, child in enumerate(device.children, start=1):
|
|
376
|
+
states[i] = bool(child.is_on)
|
|
377
|
+
else:
|
|
378
|
+
states[1] = bool(device.is_on)
|
|
379
|
+
return states
|
|
380
|
+
finally:
|
|
381
|
+
await device.disconnect()
|
|
382
|
+
|
|
383
|
+
return go()
|
|
384
|
+
|
|
385
|
+
return self._run_async(factory, "get_state", retries=2)
|
|
386
|
+
|
|
387
|
+
def power_on(self) -> bool:
|
|
388
|
+
"""Turn power on."""
|
|
389
|
+
logger.debug("Kasa power_on: %s[%d]", self.address, self.plug_index)
|
|
390
|
+
|
|
391
|
+
async def _on(device, target):
|
|
392
|
+
await target.turn_on()
|
|
393
|
+
logger.debug("Power ON sent to %s[%d]", self.address, self.plug_index)
|
|
394
|
+
return True
|
|
395
|
+
|
|
396
|
+
# Serialize with concurrent reads on the same host and invalidate
|
|
397
|
+
# so the next get_state() reflects the change.
|
|
398
|
+
with _get_host_lock(self.address):
|
|
399
|
+
try:
|
|
400
|
+
return self._run(_on, "power_on")
|
|
401
|
+
finally:
|
|
402
|
+
_invalidate_host(self.address)
|
|
403
|
+
|
|
404
|
+
def power_off(self) -> bool:
|
|
405
|
+
"""Turn power off."""
|
|
406
|
+
logger.debug("Kasa power_off: %s[%d]", self.address, self.plug_index)
|
|
407
|
+
|
|
408
|
+
async def _off(device, target):
|
|
409
|
+
await target.turn_off()
|
|
410
|
+
logger.debug("Power OFF sent to %s[%d]", self.address, self.plug_index)
|
|
411
|
+
return True
|
|
412
|
+
|
|
413
|
+
with _get_host_lock(self.address):
|
|
414
|
+
try:
|
|
415
|
+
return self._run(_off, "power_off")
|
|
416
|
+
finally:
|
|
417
|
+
_invalidate_host(self.address)
|
|
418
|
+
|
|
419
|
+
def get_state(self) -> PowerState:
|
|
420
|
+
"""Get current power state, using the per-host cache when fresh.
|
|
421
|
+
|
|
422
|
+
Concurrent callers on the same host serialize through a per-host
|
|
423
|
+
lock so the strip sees one KLAP handshake per cache window
|
|
424
|
+
instead of one per outlet. This eliminates the parallel-handshake
|
|
425
|
+
contention that the strip rate-limits as auth failures.
|
|
426
|
+
"""
|
|
427
|
+
logger.debug("Kasa get_state: %s[%d]", self.address, self.plug_index)
|
|
428
|
+
|
|
429
|
+
cached = _read_cached_state(self.address, self.plug_index)
|
|
430
|
+
if cached is not None:
|
|
431
|
+
return PowerState.ON if cached else PowerState.OFF
|
|
432
|
+
|
|
433
|
+
with _get_host_lock(self.address):
|
|
434
|
+
# Double-check after acquiring the lock — another caller may
|
|
435
|
+
# have just populated the cache while we were waiting.
|
|
436
|
+
cached = _read_cached_state(self.address, self.plug_index)
|
|
437
|
+
if cached is not None:
|
|
438
|
+
return PowerState.ON if cached else PowerState.OFF
|
|
439
|
+
|
|
440
|
+
states = self._fetch_all_outlet_states()
|
|
441
|
+
_store_host_state(self.address, states)
|
|
442
|
+
|
|
443
|
+
is_on = states.get(self.plug_index)
|
|
444
|
+
if is_on is None:
|
|
445
|
+
return PowerState.UNKNOWN
|
|
446
|
+
return PowerState.ON if is_on else PowerState.OFF
|
labctl/power/shelly.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shelly power controller.
|
|
3
|
+
|
|
4
|
+
Controls Shelly smart plugs via HTTP API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from labctl.power.base import PowerController, PowerState
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShellyController(PowerController):
|
|
18
|
+
"""
|
|
19
|
+
Power controller for Shelly devices.
|
|
20
|
+
|
|
21
|
+
Uses Shelly HTTP API (Gen1):
|
|
22
|
+
- Power On: http://<ip>/relay/<index>?turn=on
|
|
23
|
+
- Power Off: http://<ip>/relay/<index>?turn=off
|
|
24
|
+
- Status: http://<ip>/relay/<index>
|
|
25
|
+
|
|
26
|
+
Note: plug_index is 0-based for Shelly API (converted from 1-based input)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def _relay_index(self) -> int:
|
|
31
|
+
"""Convert 1-based plug_index to 0-based relay index."""
|
|
32
|
+
return self.plug_index - 1
|
|
33
|
+
|
|
34
|
+
def _request(self, endpoint: str, params: Optional[dict] = None) -> Optional[dict]:
|
|
35
|
+
"""
|
|
36
|
+
Send request to Shelly device.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
endpoint: API endpoint (e.g., "relay/0")
|
|
40
|
+
params: Optional query parameters
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
JSON response dict or None on error
|
|
44
|
+
"""
|
|
45
|
+
url = f"http://{self.address}/{endpoint}"
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
response = requests.get(url, params=params, timeout=self.timeout)
|
|
49
|
+
response.raise_for_status()
|
|
50
|
+
return response.json()
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.warning(
|
|
53
|
+
"Shelly request '%s' failed for %s: %s", endpoint, self.address, e
|
|
54
|
+
)
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def power_on(self) -> bool:
|
|
58
|
+
"""Turn power on."""
|
|
59
|
+
result = self._request(f"relay/{self._relay_index}", params={"turn": "on"})
|
|
60
|
+
|
|
61
|
+
if result is None:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Shelly returns {"ison": true, ...}
|
|
65
|
+
return bool(result.get("ison", False))
|
|
66
|
+
|
|
67
|
+
def power_off(self) -> bool:
|
|
68
|
+
"""Turn power off."""
|
|
69
|
+
result = self._request(f"relay/{self._relay_index}", params={"turn": "off"})
|
|
70
|
+
|
|
71
|
+
if result is None:
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
return not result.get("ison", True)
|
|
75
|
+
|
|
76
|
+
def get_state(self) -> PowerState:
|
|
77
|
+
"""Get current power state."""
|
|
78
|
+
result = self._request(f"relay/{self._relay_index}")
|
|
79
|
+
|
|
80
|
+
if result is None:
|
|
81
|
+
return PowerState.UNKNOWN
|
|
82
|
+
|
|
83
|
+
ison = result.get("ison")
|
|
84
|
+
if ison is True:
|
|
85
|
+
return PowerState.ON
|
|
86
|
+
elif ison is False:
|
|
87
|
+
return PowerState.OFF
|
|
88
|
+
else:
|
|
89
|
+
return PowerState.UNKNOWN
|