5454from framework .core .powerModules .abstractPowerModule import PowerModuleInterface
5555
5656class 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)." )
0 commit comments