Skip to content

Commit 005e68f

Browse files
gh #192 updated the tapo multiple powerswitch things
1 parent 84784d8 commit 005e68f

File tree

2 files changed

+160
-148
lines changed

2 files changed

+160
-148
lines changed

framework/core/powerModules/tapoControl.py

Lines changed: 156 additions & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@
5454
from framework.core.powerModules.abstractPowerModule import PowerModuleInterface
5555

5656
class powerTapo(PowerModuleInterface):
57-
57+
5858
"""Tapo power switch controller supports
5959
"""
60-
60+
6161
def __init__( self, log:logModule, ip:str, outlet:str = None, **kwargs ):
6262
"""
6363
Tapo module based on kasa library.
@@ -71,13 +71,17 @@ def __init__( self, log:logModule, ip:str, outlet:str = None, **kwargs ):
7171
"""
7272
super().__init__(log)
7373
self._is_on = False
74-
self._outlet = None # 0-based index if provided
74+
self._outlet = None
7575
self.ip = ip
7676
self._username = kwargs.get("username", None)
7777
self._password = kwargs.get("password", None)
78-
# Accept 0-based inputs (0,1,2,3...). Keep as string to match original style.
7978
if outlet is not None:
80-
self._outlet = str(outlet).strip().strip("'\"")
79+
cleaned = str(outlet).strip().strip("'\"") # remove accidental quotes
80+
try:
81+
self._outlet = int(cleaned) # keep numeric internally
82+
except ValueError:
83+
self._log.error("Invalid outlet value %r (cleaned=%r)", outlet, cleaned)
84+
8185
self._device_type = None
8286
self._encryption_type = None
8387
self._discover_device()
@@ -106,18 +110,18 @@ def _performCommand(self, command, json = False, append_args:list = []):
106110
if self._password:
107111
command_list.append("--password")
108112
command_list.append(self._password)
109-
# if self._device_type != "UNKNOWN" and self._encryption_type:
110-
# command_list.append("--device-family")
111-
# command_list.append(self._device_type)
112-
# command_list.append("--encrypt-type")
113-
# command_list.append(self._encryption_type)
114-
# else:
115-
# if self._outlet is not None:
116-
# command_list.append("--type")
117-
# command_list.append("smart")
118-
# else:
119-
# command_list.append("--type")
120-
# command_list.append("plug")
113+
if self._device_type != "UNKNOWN" and self._encryption_type:
114+
command_list.append("--device-family")
115+
command_list.append(self._device_type)
116+
command_list.append("--encrypt-type")
117+
command_list.append(self._encryption_type)
118+
else:
119+
if self._outlet:
120+
command_list.append("--type")
121+
command_list.append("strip")
122+
else:
123+
command_list.append("--type")
124+
command_list.append("plug")
121125
command_list.append(command)
122126
for arg in append_args:
123127
command_list.append(arg)
@@ -157,64 +161,91 @@ def powerOn(self):
157161
return True
158162
if self._outlet is not None:
159163
self._performCommand("on", append_args=["--index", str(self._outlet)])
160-
else:
161-
self._performCommand("on")
164+
self._performCommand("on")
162165
self._get_state()
163166
if self.is_on == False:
164-
self._log.error(" Power On Failed")
167+
self._log.error(" Power On Failed")
165168
return self.is_on
166169

167170
def _get_state(self):
168-
"""Get the state of the device."""
169-
result = self._performCommand("state")
171+
"""Get the state of the device (or a specific outlet if configured)."""
170172

171-
if self._outlet is not None:
172-
# 0-based outlet index requested by caller
173+
# If no outlet configured, read the device-level flag and return.
174+
if self._outlet is None:
175+
result = self._performCommand("state")
176+
if ("Device state: True" in result) or ("Device state: ON" in result):
177+
self._is_on = True
178+
self._log.debug("Device state: ON")
179+
elif ("Device state: False" in result) or ("Device state: OFF" in result):
180+
self._is_on = False
181+
self._log.debug("Device state: OFF")
182+
else:
183+
# Conservative fallback if format is unexpected
184+
self._is_on = False
185+
self._log.debug("Device state: UNKNOWN → assuming OFF")
186+
return
187+
188+
# OUTLET CONFIGURED — try JSON first (more reliable if supported).
189+
try:
190+
raw = self._performCommand("state", json=True)
191+
data = json.loads(raw) if raw else None
192+
except Exception:
193+
data = None
194+
195+
if data:
196+
# Common keys for children in different CLI versions
197+
children = data.get("children") or data.get("plugs") or data.get("sockets") or []
173198
try:
174-
idx0 = int(str(self._outlet).strip().strip("'\""))
199+
idx = int(self._outlet)
175200
except Exception:
176-
idx0 = -1
177-
self._log.error("Invalid outlet index %r (must be integer >= 0)", self._outlet)
178-
179-
# Prefer P304M-style lines: "State (state): True/False" (one per child)
180-
child_tf = re.findall(
181-
r"^\s*State\s*\(state\)\s*:\s*(True|False)\s*$",
182-
result,
183-
flags=re.IGNORECASE | re.MULTILINE,
184-
)
185-
states = [s.lower() == "true" for s in child_tf]
186-
187-
# Fallback: "* Socket 'Plug X' state: ON/OFF ..."
188-
if not states:
189-
sockets = re.findall(
190-
r"^\*\s+Socket\s+'.*?'\s+state:\s+(ON|OFF)\b",
191-
result,
192-
flags=re.IGNORECASE | re.MULTILINE,
193-
)
194-
states = [s.upper() == "ON" for s in sockets]
195-
196-
if states and 0 <= idx0 < len(states):
197-
self._is_on = bool(states[idx0])
198-
self._log.debug("Slot state: %s", "ON" if self._is_on else "OFF")
201+
self._log.error(f"Invalid outlet index {self._outlet!r}; defaulting to device state")
202+
idx = -1
203+
204+
if 0 <= idx < len(children):
205+
child = children[idx]
206+
# Normalize child state (could be bool or string)
207+
cstate = child.get("state", child.get("is_on", child.get("on")))
208+
if isinstance(cstate, str):
209+
cstate = cstate.strip().lower() in ("on", "true", "1")
210+
self._is_on = bool(cstate)
211+
self._log.debug(f"Outlet {idx} state (json): {'ON' if self._is_on else 'OFF'}")
199212
return
200-
201-
# Final fallback — device-level indicator
202-
if "Device state: False" in result or "Device state: OFF" in result:
203-
self._is_on = False
204-
self._log.debug("Device state: OFF")
205213
else:
206-
self._is_on = True
207-
self._log.debug("Device state: ON")
208-
214+
self._log.error(f"Outlet index {idx} out of range for JSON children (n={len(children)}). Falling back to text parse.")
215+
216+
# TEXT FALLBACK — matches the P304M output you shared.
217+
result = self._performCommand("state")
218+
219+
# Collect child states in order they appear, e.g. lines like:
220+
# State (state): True
221+
# State (state): False
222+
child_state_matches = re.findall(r"^\s*State\s*\(state\)\s*:\s*(True|False)\s*$",
223+
result, flags=re.IGNORECASE | re.MULTILINE)
224+
child_states = [(s.lower() == "true") for s in child_state_matches]
225+
226+
try:
227+
idx = int(self._outlet)
228+
except Exception:
229+
self._log.error(f"Invalid outlet index {self._outlet!r}; falling back to device state.")
230+
idx = -1
231+
232+
if 0 <= idx < len(child_states):
233+
self._is_on = bool(child_states[idx])
234+
self._log.debug(f"Outlet {idx} state (text): {'ON' if self._is_on else 'OFF'}")
209235
return
210236

211-
# No outlet configured — device-level logic
212-
if "Device state: False" in result or "Device state: OFF" in result:
237+
# Final fallback — device-level indicator if children couldn't be parsed or index OOR.
238+
if ("Device state: True" in result) or ("Device state: ON" in result):
239+
self._is_on = True
240+
elif ("Device state: False" in result) or ("Device state: OFF" in result):
213241
self._is_on = False
214-
self._log.debug("Device state: OFF")
215242
else:
216-
self._is_on = True
217-
self._log.debug("Device state: ON")
243+
self._is_on = False # conservative
244+
self._log.error(
245+
f"Could not parse outlet {idx} from text (children parsed={len(child_states)}). "
246+
f"Falling back to device state: {'ON' if self._is_on else 'OFF'}"
247+
)
248+
218249

219250
def _discover_device(self):
220251
command = ["kasa", "--json", "--target", str(self.ip)]
@@ -261,93 +292,74 @@ def _get_encryption_type(self):
261292

262293
def getPowerLevel(self):
263294
"""
264-
- Single plug: use `emeter --json` (unchanged).
265-
- Strip (outlet set, 0-based): try `feature current_consumption --index <outlet+1>`.
266-
If unsupported/unavailable, raise the same RuntimeError as before to preserve behavior.
295+
Return instantaneous power (Watts) via `feature current_consumption`.
296+
Supports strips via --index <outlet>.
267297
"""
268-
# Per-outlet path (strip)
298+
# --- normalize outlet inline (accept 0; strip accidental quotes) ---
299+
idx_val = None
269300
if self._outlet is not None:
270-
# Normalize 0-based outlet to CLI's 1-based index (defensive parsing)
271-
try:
272-
idx0 = int(str(self._outlet).strip().strip("'\""))
273-
if idx0 < 0:
274-
raise ValueError
275-
cli_index = idx0 + 1
276-
except Exception:
277-
# Keep previous contract: treat as unsupported for bad outlet input
278-
raise RuntimeError("Power monitoring is not yet supported for Tapo strips")
279-
280-
args = ["current_consumption", "--index", str(cli_index)]
281-
282-
# 1) Try JSON first
283-
try:
284-
raw = self._performCommand("feature", json=True, append_args=args)
285-
except Exception:
286-
raw = None
287-
288-
if raw:
289-
try:
290-
data = json.loads(raw)
291-
value = None
292-
if isinstance(data, (int, float, str)):
293-
value = data
294-
elif isinstance(data, dict):
295-
if "current_consumption" in data:
296-
cc = data["current_consumption"]
297-
if isinstance(cc, dict):
298-
value = cc.get("value")
299-
if value is None:
300-
for k in ("w", "power", "power_w"):
301-
v = cc.get(k)
302-
if isinstance(v, (int, float, str)):
303-
value = v
304-
break
305-
else:
306-
value = cc
307-
elif "value" in data:
308-
value = data.get("value")
309-
if value is not None:
310-
return float(value)
311-
except (json.JSONDecodeError, ValueError, TypeError):
312-
pass # fall through to text
313-
314-
# 2) Text fallback
315-
try:
316-
text = self._performCommand("feature", append_args=args)
317-
except Exception:
318-
text = ""
319-
320-
m = re.search(
321-
r"Current\s+consumption\s*\(current_consumption\)\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*W",
322-
text, flags=re.IGNORECASE
323-
)
324-
if m:
325-
return float(m.group(1))
326-
327-
if re.search(r"Current\s+consumption\s*\(current_consumption\)\s*:\s*None\s*W",
328-
text, flags=re.IGNORECASE):
329-
# Explicit "no live reading" — match prior semantics: treat as unsupported
330-
raise RuntimeError("Power monitoring is not yet supported for Tapo strips")
331-
332-
# Preserve original behavior when feature/firmware doesn’t expose per-outlet power
333-
raise RuntimeError("Power monitoring is not yet supported for Tapo strips")
334-
335-
# Device-level (single plug) — original behavior unchanged
336-
result = self._performCommand("emeter", json=True)
301+
cleaned = str(self._outlet).strip().strip("'\"")
302+
if not cleaned.isdigit():
303+
self._log.error(f"Invalid outlet value: {self._outlet!r}; querying device-level power instead.")
304+
else:
305+
idx_val = int(cleaned)
337306

338-
if not result:
339-
raise ValueError("Received empty response from Tapo device for power monitoring")
307+
# Build CLI args: feature current_consumption [--index N]
308+
args = ["current_consumption"]
309+
if idx_val is not None:
310+
idx_token = f"{idx_val}" # plain numeric string, no quotes
311+
args += ["--index", idx_token]
340312

313+
# ---- 1) JSON first ----
314+
raw = None
341315
try:
342-
result = json.loads(result)
343-
except json.JSONDecodeError as e:
344-
raise ValueError(f"Failed to parse JSON from Tapo device response: {e}")
316+
raw = self._performCommand("feature", json=True, append_args=args)
317+
except Exception as e:
318+
self._log.debug(f"feature current_consumption (json) call failed: {e}")
345319

346-
millewatt = result.get('power_mw')
347-
if millewatt:
320+
if raw:
348321
try:
349-
power = int(millewatt) / 1000
350-
return power
351-
except Exception:
352-
raise ValueError(f"Invalid value for power_mw: {millewatt}")
353-
raise KeyError("The dictionary returned by the Tapo device does not contain a valid 'power_mw' value.")
322+
data = json.loads(raw)
323+
value = None
324+
if isinstance(data, (int, float, str)):
325+
value = data
326+
elif isinstance(data, dict):
327+
if "current_consumption" in data:
328+
cc = data["current_consumption"]
329+
if isinstance(cc, dict):
330+
value = cc.get("value")
331+
if value is None:
332+
for k in ("w", "power", "power_w"):
333+
if isinstance(cc.get(k), (int, float, str)):
334+
value = cc.get(k); break
335+
else:
336+
value = cc
337+
elif "value" in data:
338+
value = data.get("value")
339+
if value is not None:
340+
watts = float(value)
341+
self._log.debug(f"current_consumption (json) = {watts} W")
342+
return watts
343+
except (json.JSONDecodeError, TypeError, ValueError):
344+
pass # fall through to text
345+
346+
# ---- 2) Text fallback ----
347+
try:
348+
text = self._performCommand("feature", append_args=args)
349+
except Exception as e:
350+
raise RuntimeError(f"Failed to query current_consumption: {e}")
351+
352+
m = re.search(
353+
r"Current\s+consumption\s*\(current_consumption\)\s*:\s*([0-9]+(?:\.[0-9]+)?)\s*W",
354+
text, flags=re.IGNORECASE
355+
)
356+
if m:
357+
watts = float(m.group(1))
358+
self._log.debug(f"current_consumption (text) = {watts} W")
359+
return watts
360+
361+
if re.search(r"Current\s+consumption\s*\(current_consumption\)\s*:\s*None\s*W",
362+
text, flags=re.IGNORECASE):
363+
raise RuntimeError("Device returned 'None' for current consumption (no live reading).")
364+
365+
raise RuntimeError("current_consumption not available from device (feature missing or unsupported CLI/firmware).")

requirements.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jmespath==1.0.1
3434
marshmallow==3.21.1
3535
multidict==6.0.5
3636
netifaces==0.11.0
37-
numpy==1.26.4
37+
numpy>=2.0.0,<2.3.0
3838
opencv-python==4.9.0.80
3939
outcome==1.3.0.post0
4040
packaging==24.0
@@ -49,7 +49,7 @@ pyserial==3.5
4949
PySocks==1.7.1
5050
pytesseract==0.3.10
5151
python-dateutil==2.9.0.post0
52-
python-kasa==0.6.2.1
52+
python-kasa==0.7.7
5353
PyYAML==6.0.1
5454
requests==2.31.0
5555
requests-toolbelt==1.0.0
@@ -61,9 +61,9 @@ sortedcontainers==2.4.0
6161
soupsieve==2.5
6262
trio==0.25.0
6363
trio-websocket==0.11.1
64-
typing_extensions==4.10.0
64+
typing_extensions==4.12.2
6565
urllib3==1.26.18
6666
wcwidth==0.2.14
6767
wrapt==1.16.0
6868
wsproto==1.2.0
69-
yarl==1.9.4
69+
yarl==1.9.4

0 commit comments

Comments
 (0)