3 """Utilities for dealing with the smart lights."""
5 from abc import abstractmethod
13 from typing import Any, Dict, List, Optional, Tuple
16 from overrides import overrides
24 import smart_home.device as dev
25 from google_assistant import ask_google, GoogleResponse
26 from decorator_utils import timeout, memoized
28 logger = logging.getLogger(__name__)
30 args = config.add_commandline_args(
31 f"Smart Lights ({__file__})",
32 "Args related to smart lights.",
35 '--smart_lights_tplink_location',
36 default='/home/scott/bin/tplink.py',
38 help='The location of the tplink.py helper',
39 type=argparse_utils.valid_filename,
44 5.0, use_signals=False, error_message="Timed out waiting for tplink.py"
46 def tplink_light_command(command: str) -> bool:
47 result = os.system(command)
48 signal = result & 0xFF
50 msg = f'{command} died with signal {signal}'
53 logging_utils.hlog(msg)
56 exit_value = result >> 8
58 msg = f'{command} failed, exited {exit_value}'
61 logging_utils.hlog(msg)
63 logger.debug(f'{command} succeeded.')
67 class BaseLight(dev.Device):
68 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
69 super().__init__(name.strip(), mac.strip(), keywords)
72 def parse_color_string(color: str) -> Optional[Tuple[int, int, int]]:
74 'r#?([0-9a-fA-F][0-9a-fA-F])([0-9a-fA-F][0-9a-fA-F])([0-9a-fA-F][0-9a-fA-F])',
77 if m is not None and len(m.group) == 3:
78 red = int(m.group(0), 16)
79 green = int(m.group(1), 16)
80 blue = int(m.group(2), 16)
81 return (red, green, blue)
83 return ansi.COLOR_NAMES_TO_RGB.get(color, None)
86 def status(self) -> str:
90 def turn_on(self) -> bool:
94 def turn_off(self) -> bool:
98 def is_on(self) -> bool:
102 def is_off(self) -> bool:
106 def get_dimmer_level(self) -> Optional[int]:
110 def set_dimmer_level(self, level: int) -> bool:
114 def make_color(self, color: str) -> bool:
118 class GoogleLight(BaseLight):
119 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
120 super().__init__(name, mac, keywords)
122 def goog_name(self) -> str:
123 name = self.get_name()
124 return name.replace("_", " ")
127 def parse_google_response(response: GoogleResponse) -> bool:
128 return response.success
131 def turn_on(self) -> bool:
132 return GoogleLight.parse_google_response(
133 ask_google(f"turn {self.goog_name()} on")
137 def turn_off(self) -> bool:
138 return GoogleLight.parse_google_response(
139 ask_google(f"turn {self.goog_name()} off")
143 def status(self) -> str:
149 def is_on(self) -> bool:
150 r = ask_google(f"is {self.goog_name()} on?")
153 return 'is on' in r.audio_transcription
156 def is_off(self) -> bool:
157 return not self.is_on()
160 def get_dimmer_level(self) -> Optional[int]:
161 if not self.has_keyword("dimmer"):
163 r = ask_google(f'how bright is {self.goog_name()}?')
167 # the bookcase one is set to 40% bright
168 txt = r.audio_transcription
169 m = re.search(r"(\d+)% bright", txt)
171 return int(m.group(1))
177 def set_dimmer_level(self, level: int) -> bool:
178 if not self.has_keyword("dimmer"):
180 if 0 <= level <= 100:
181 was_on = self.is_on()
182 r = ask_google(f"set {self.goog_name()} to {level} percent")
191 def make_color(self, color: str) -> bool:
192 return GoogleLight.parse_google_response(
193 ask_google(f"make {self.goog_name()} {color}")
197 class TuyaLight(BaseLight):
199 '68:C6:3A:DE:1A:94': '8844664268c63ade1a94',
200 '68:C6:3A:DE:27:1A': '8844664268c63ade271a',
201 '68:C6:3A:DE:1D:95': '8844664268c63ade1d95',
202 '68:C6:3A:DE:19:B3': '8844664268c63ade19b3',
203 '80:7D:3A:77:3B:F5': '07445340807d3a773bf5',
204 '80:7D:3A:58:37:02': '07445340807d3a583702',
207 '68:C6:3A:DE:1A:94': '237f19b1b3d49c36',
208 '68:C6:3A:DE:27:1A': '237f19b1b3d49c36',
209 '68:C6:3A:DE:1D:95': '04b90fc5cd7625d8',
210 '68:C6:3A:DE:19:B3': '2d601f2892f1aefd',
211 '80:7D:3A:77:3B:F5': '27ab921fe4633519',
212 '80:7D:3A:58:37:02': '8559b5416bfa0c05',
215 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
216 super().__init__(name, mac, keywords)
218 if mac not in TuyaLight.ids_by_mac or mac not in TuyaLight.keys_by_mac:
219 raise Exception(f'{mac} is unknown; add it to ids_by_mac and keys_by_mac')
220 self.devid = TuyaLight.ids_by_mac[mac]
221 self.key = TuyaLight.keys_by_mac[mac]
222 self.arper = arper.Arper()
224 self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
226 def get_status(self) -> Dict[str, Any]:
227 return self.bulb.status()
230 def status(self) -> str:
232 for k, v in self.bulb.status().items():
233 ret += f'{k} = {v}\n'
237 def turn_on(self) -> bool:
242 def turn_off(self) -> bool:
247 def is_on(self) -> bool:
248 s = self.get_status()
252 def is_off(self) -> bool:
253 return not self.is_on()
256 def get_dimmer_level(self) -> Optional[int]:
257 s = self.get_status()
261 def set_dimmer_level(self, level: int) -> bool:
262 logger.debug(f'Setting brightness to {level}')
263 self.bulb.set_brightness(level)
267 def make_color(self, color: str) -> bool:
268 rgb = BaseLight.parse_color_string(color)
269 logger.debug(f'Light color: {color} -> {rgb}')
271 self.bulb.set_colour(rgb[0], rgb[1], rgb[2])
276 class TPLinkLight(BaseLight):
277 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
278 super().__init__(name, mac, keywords)
279 self.children: List[str] = []
280 self.info: Optional[Dict] = None
281 self.info_ts: Optional[datetime.datetime] = None
282 if "children" in self.keywords:
283 self.info = self.get_info()
284 if self.info is not None:
285 for child in self.info["children"]:
286 self.children.append(child["id"])
289 def get_tplink_name(self) -> Optional[str]:
290 self.info = self.get_info()
291 if self.info is not None:
292 return self.info["alias"]
295 def get_cmdline(self, child: str = None) -> str:
297 f"{config.config['smart_lights_tplink_location']} -m {self.mac} "
298 f"--no_logging_console "
300 if child is not None:
301 cmd += f"-x {child} "
304 def get_children(self) -> List[str]:
308 self, cmd: str, child: str = None, extra_args: str = None
310 cmd = self.get_cmdline(child) + f"-c {cmd}"
311 if extra_args is not None:
312 cmd += f" {extra_args}"
313 logger.debug(f'About to execute {cmd}')
314 return tplink_light_command(cmd)
317 def turn_on(self, child: str = None) -> bool:
318 return self.command("on", child)
321 def turn_off(self, child: str = None) -> bool:
322 return self.command("off", child)
325 def is_on(self) -> bool:
326 self.info = self.get_info()
327 if self.info is None:
328 raise Exception('Unable to get info?')
329 return self.info.get("relay_state", "0") == "1"
332 def is_off(self) -> bool:
333 return not self.is_on()
336 def make_color(self, color: str) -> bool:
337 raise NotImplementedError
340 10.0, use_signals=False, error_message="Timed out waiting for tplink.py"
342 def get_info(self) -> Optional[Dict]:
343 cmd = self.get_cmdline() + "-c info"
344 out = subprocess.getoutput(cmd)
345 logger.debug(f'RAW OUT> {out}')
346 out = re.sub("Sent:.*\n", "", out)
347 out = re.sub("Received: *", "", out)
349 self.info = json.loads(out)["system"]["get_sysinfo"]
350 self.info_ts = datetime.datetime.now()
352 except Exception as e:
354 print(out, file=sys.stderr)
360 def status(self) -> str:
362 for k, v in self.get_info().items():
363 ret += f'{k} = {v}\n'
366 def get_on_duration_seconds(self, child: str = None) -> int:
367 self.info = self.get_info()
369 if self.info is None:
371 return int(self.info.get("on_time", "0"))
373 if self.info is None:
375 for chi in self.info.get("children", {}):
376 if chi["id"] == child:
377 return int(chi.get("on_time", "0"))
381 def get_dimmer_level(self) -> Optional[int]:
382 if not self.has_keyword("dimmer"):
384 self.info = self.get_info()
385 if self.info is None:
387 return int(self.info.get("brightness", "0"))
390 def set_dimmer_level(self, level: int) -> bool:
391 if not self.has_keyword("dimmer"):
395 + f'-j \'{{"smartlife.iot.dimmer":{{"set_brightness":{{"brightness":{level} }} }} }}\''
397 return tplink_light_command(cmd)
400 # class GoogleLightGroup(GoogleLight):
401 # def __init__(self, name: str, members: List[GoogleLight], keywords: str = "") -> None:
402 # if len(members) < 1:
403 # raise Exception("There must be at least one light in the group.")
404 # self.members = members
405 # mac = GoogleLightGroup.make_up_mac(members)
406 # super().__init__(name, mac, keywords)
409 # def make_up_mac(members: List[GoogleLight]):
410 # mac = members[0].get_mac()
412 # b[5] = int(b[5], 16) + 1
418 # def is_on(self) -> bool:
419 # r = ask_google(f"are {self.goog_name()} on?")
422 # return 'is on' in r.audio_transcription
424 # def get_dimmer_level(self) -> Optional[int]:
425 # if not self.has_keyword("dimmer"):
427 # r = ask_google(f'how bright are {self.goog_name()}?')
431 # # four lights are set to 100% brightness
432 # txt = r.audio_transcription
433 # m = re.search(r"(\d+)% bright", txt)
435 # return int(m.group(1))
436 # if "is off" in txt:
440 # def set_dimmer_level(self, level: int) -> bool:
441 # if not self.has_keyword("dimmer"):
443 # if 0 <= level <= 100:
444 # was_on = self.is_on()
445 # r = ask_google(f"set {self.goog_name()} to {level} percent")
453 # def make_color(self, color: str) -> bool:
454 # return GoogleLight.parse_google_response(
455 # ask_google(f"make {self.goog_name()} {color}")