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.
Files changed (64) hide show
  1. embedded_lab_control-0.2.0.dist-info/METADATA +855 -0
  2. embedded_lab_control-0.2.0.dist-info/RECORD +64 -0
  3. embedded_lab_control-0.2.0.dist-info/WHEEL +5 -0
  4. embedded_lab_control-0.2.0.dist-info/entry_points.txt +2 -0
  5. embedded_lab_control-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. embedded_lab_control-0.2.0.dist-info/top_level.txt +1 -0
  7. labctl/__init__.py +8 -0
  8. labctl/actuators/__init__.py +56 -0
  9. labctl/actuators/base.py +183 -0
  10. labctl/actuators/lcus1.py +232 -0
  11. labctl/actuators/mock.py +81 -0
  12. labctl/actuators/runtime.py +1178 -0
  13. labctl/cli.py +7485 -0
  14. labctl/core/__init__.py +36 -0
  15. labctl/core/admin_files.py +388 -0
  16. labctl/core/audit.py +269 -0
  17. labctl/core/claim_keepalive.py +342 -0
  18. labctl/core/config.py +1068 -0
  19. labctl/core/database.py +880 -0
  20. labctl/core/images.py +503 -0
  21. labctl/core/manager.py +2815 -0
  22. labctl/core/models.py +815 -0
  23. labctl/core/safe_open.py +109 -0
  24. labctl/extras.py +128 -0
  25. labctl/health/__init__.py +43 -0
  26. labctl/health/alerts.py +275 -0
  27. labctl/health/checks.py +533 -0
  28. labctl/health/daemon.py +397 -0
  29. labctl/mcp_server.py +5254 -0
  30. labctl/power/__init__.py +13 -0
  31. labctl/power/base.py +145 -0
  32. labctl/power/kasa.py +446 -0
  33. labctl/power/shelly.py +89 -0
  34. labctl/power/tasmota.py +95 -0
  35. labctl/sdwire/__init__.py +10 -0
  36. labctl/sdwire/controller.py +1116 -0
  37. labctl/sdwire/power_guard.py +190 -0
  38. labctl/serial/__init__.py +18 -0
  39. labctl/serial/boot_test.py +342 -0
  40. labctl/serial/capture.py +430 -0
  41. labctl/serial/keys.py +89 -0
  42. labctl/serial/proxy.py +802 -0
  43. labctl/serial/ser2net.py +338 -0
  44. labctl/serial/udev.py +273 -0
  45. labctl/services.py +210 -0
  46. labctl/web/__init__.py +9 -0
  47. labctl/web/activity_broadcaster.py +204 -0
  48. labctl/web/api.py +1003 -0
  49. labctl/web/app.py +202 -0
  50. labctl/web/auth.py +97 -0
  51. labctl/web/claims.py +232 -0
  52. labctl/web/static/css/style.css +477 -0
  53. labctl/web/static/js/app.js +76 -0
  54. labctl/web/templates/activity.html +202 -0
  55. labctl/web/templates/base.html +49 -0
  56. labctl/web/templates/claim_refused.html +38 -0
  57. labctl/web/templates/console.html +237 -0
  58. labctl/web/templates/dashboard.html +100 -0
  59. labctl/web/templates/login.html +25 -0
  60. labctl/web/templates/sbc_detail.html +306 -0
  61. labctl/web/templates/settings.html +78 -0
  62. labctl/web/templates/status_history.html +65 -0
  63. labctl/web/views.py +496 -0
  64. labctl/web/websocket.py +208 -0
@@ -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