3 """Utilities for dealing with the smart lights."""
12 from abc import abstractmethod
13 from typing import Any, Dict, List, Optional, Tuple
16 from overrides import overrides
23 import smart_home.device as dev
24 from decorator_utils import memoized, timeout
25 from google_assistant import GoogleResponse, ask_google
27 logger = logging.getLogger(__name__)
29 args = config.add_commandline_args(
30 f"Smart Lights ({__file__})",
31 "Args related to smart lights.",
34 '--smart_lights_tplink_location',
35 default='/home/scott/bin/tplink.py',
37 help='The location of the tplink.py helper',
38 type=argparse_utils.valid_filename,
42 @timeout(5.0, use_signals=False, error_message="Timed out waiting for tplink.py")
43 def tplink_light_command(command: str) -> bool:
44 result = os.system(command)
45 signal = result & 0xFF
47 msg = f'{command} died with signal {signal}'
49 logging_utils.hlog(msg)
52 exit_value = result >> 8
54 msg = f'{command} failed, exited {exit_value}'
56 logging_utils.hlog(msg)
58 logger.debug('%s succeeded.', command)
62 class BaseLight(dev.Device):
63 """A base class representing a smart light."""
65 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
66 super().__init__(name.strip(), mac.strip(), keywords)
69 def parse_color_string(color: str) -> Optional[Tuple[int, int, int]]:
71 '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])',
74 if m is not None and len(m.groups()) == 3:
75 red = int(m.group(0), 16)
76 green = int(m.group(1), 16)
77 blue = int(m.group(2), 16)
78 return (red, green, blue)
80 return ansi.COLOR_NAMES_TO_RGB.get(color, None)
83 def status(self) -> str:
87 def turn_on(self) -> bool:
91 def turn_off(self) -> bool:
95 def is_on(self) -> bool:
99 def is_off(self) -> bool:
103 def get_dimmer_level(self) -> Optional[int]:
107 def set_dimmer_level(self, level: int) -> bool:
111 def make_color(self, color: str) -> bool:
115 class GoogleLight(BaseLight):
116 """A smart light controlled by talking to Google."""
118 def goog_name(self) -> str:
119 name = self.get_name()
120 return name.replace("_", " ")
123 def parse_google_response(response: GoogleResponse) -> bool:
124 return response.success
127 def turn_on(self) -> bool:
128 return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} on"))
131 def turn_off(self) -> bool:
132 return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} off"))
135 def status(self) -> str:
141 def is_on(self) -> bool:
142 r = ask_google(f"is {self.goog_name()} on?")
145 if r.audio_transcription is not None:
146 return 'is on' in r.audio_transcription
147 raise Exception("Can't reach Google?!")
150 def is_off(self) -> bool:
151 return not self.is_on()
154 def get_dimmer_level(self) -> Optional[int]:
155 if not self.has_keyword("dimmer"):
157 r = ask_google(f'how bright is {self.goog_name()}?')
161 # the bookcase one is set to 40% bright
162 txt = r.audio_transcription
164 m = re.search(r"(\d+)% bright", txt)
166 return int(m.group(1))
172 def set_dimmer_level(self, level: int) -> bool:
173 if not self.has_keyword("dimmer"):
175 if 0 <= level <= 100:
176 was_on = self.is_on()
177 r = ask_google(f"set {self.goog_name()} to {level} percent")
186 def make_color(self, color: str) -> bool:
187 return GoogleLight.parse_google_response(ask_google(f"make {self.goog_name()} {color}"))
190 class TuyaLight(BaseLight):
191 """A Tuya smart light."""
194 '68:C6:3A:DE:1A:94': '8844664268c63ade1a94',
195 '68:C6:3A:DE:27:1A': '8844664268c63ade271a',
196 '68:C6:3A:DE:1D:95': '8844664268c63ade1d95',
197 '68:C6:3A:DE:19:B3': '8844664268c63ade19b3',
198 '80:7D:3A:77:3B:F5': '07445340807d3a773bf5',
199 '80:7D:3A:58:37:02': '07445340807d3a583702',
202 '68:C6:3A:DE:1A:94': '237f19b1b3d49c36',
203 '68:C6:3A:DE:27:1A': '237f19b1b3d49c36',
204 '68:C6:3A:DE:1D:95': '04b90fc5cd7625d8',
205 '68:C6:3A:DE:19:B3': '2d601f2892f1aefd',
206 '80:7D:3A:77:3B:F5': '27ab921fe4633519',
207 '80:7D:3A:58:37:02': '8559b5416bfa0c05',
210 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
211 super().__init__(name, mac, keywords)
213 if mac not in TuyaLight.ids_by_mac or mac not in TuyaLight.keys_by_mac:
214 raise Exception(f'{mac} is unknown; add it to ids_by_mac and keys_by_mac')
215 self.devid = TuyaLight.ids_by_mac[mac]
216 self.key = TuyaLight.keys_by_mac[mac]
217 self.arper = arper.Arper()
219 self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
221 def get_status(self) -> Dict[str, Any]:
222 return self.bulb.status()
225 def status(self) -> str:
227 for k, v in self.bulb.status().items():
228 ret += f'{k} = {v}\n'
232 def turn_on(self) -> bool:
237 def turn_off(self) -> bool:
242 def is_on(self) -> bool:
243 s = self.get_status()
247 def is_off(self) -> bool:
248 return not self.is_on()
251 def get_dimmer_level(self) -> Optional[int]:
252 s = self.get_status()
256 def set_dimmer_level(self, level: int) -> bool:
257 logger.debug('Setting brightness to %d', level)
258 self.bulb.set_brightness(level)
262 def make_color(self, color: str) -> bool:
263 rgb = BaseLight.parse_color_string(color)
264 logger.debug('Light color: %s -> %s', color, rgb)
266 self.bulb.set_colour(rgb[0], rgb[1], rgb[2])
271 class TPLinkLight(BaseLight):
272 """A TPLink smart light."""
274 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
275 super().__init__(name, mac, keywords)
276 self.children: List[str] = []
277 self.info: Optional[Dict] = None
278 self.info_ts: Optional[datetime.datetime] = None
279 if self.keywords is not None:
280 if "children" in self.keywords:
281 self.info = self.get_info()
282 if self.info is not None:
283 for child in self.info["children"]:
284 self.children.append(child["id"])
287 def get_tplink_name(self) -> Optional[str]:
288 self.info = self.get_info()
289 if self.info is not None:
290 return self.info["alias"]
293 def get_cmdline(self, child: str = None) -> str:
295 f"{config.config['smart_lights_tplink_location']} -m {self.mac} "
296 f"--no_logging_console "
298 if child is not None:
299 cmd += f"-x {child} "
302 def get_children(self) -> List[str]:
305 def command(self, cmd: str, child: str = None, extra_args: str = None) -> bool:
306 cmd = self.get_cmdline(child) + f"-c {cmd}"
307 if extra_args is not None:
308 cmd += f" {extra_args}"
309 logger.debug('About to execute: %s', cmd)
310 return tplink_light_command(cmd)
313 def turn_on(self, child: str = None) -> bool:
314 return self.command("on", child)
317 def turn_off(self, child: str = None) -> bool:
318 return self.command("off", child)
321 def is_on(self) -> bool:
322 self.info = self.get_info()
323 if self.info is None:
324 raise Exception('Unable to get info?')
325 return self.info.get("relay_state", 0) == 1
328 def is_off(self) -> bool:
329 return not self.is_on()
332 def make_color(self, color: str) -> bool:
333 raise NotImplementedError
335 @timeout(10.0, use_signals=False, error_message="Timed out waiting for tplink.py")
336 def get_info(self) -> Optional[Dict]:
337 cmd = self.get_cmdline() + "-c info"
338 logger.debug('Getting status of %s via "%s"...', self.mac, cmd)
339 out = subprocess.getoutput(cmd)
340 logger.debug('RAW OUT> %s', out)
341 out = re.sub("Sent:.*\n", "", out)
342 out = re.sub("Received: *", "", out)
344 self.info = json.loads(out)["system"]["get_sysinfo"]
345 logger.debug("%s", json.dumps(self.info, indent=4, sort_keys=True))
346 self.info_ts = datetime.datetime.now()
348 except Exception as e:
350 print(out, file=sys.stderr)
356 def status(self) -> str:
358 for k, v in self.get_info().items():
359 ret += f'{k} = {v}\n'
362 def get_on_duration_seconds(self, child: str = None) -> int:
363 self.info = self.get_info()
365 if self.info is None:
367 return int(self.info.get("on_time", "0"))
369 if self.info is None:
371 for chi in self.info.get("children", {}):
372 if chi["id"] == child:
373 return int(chi.get("on_time", "0"))
377 def get_dimmer_level(self) -> Optional[int]:
378 if not self.has_keyword("dimmer"):
380 self.info = self.get_info()
381 if self.info is None:
383 return int(self.info.get("brightness", "0"))
386 def set_dimmer_level(self, level: int) -> bool:
387 if not self.has_keyword("dimmer"):
391 + f'-j \'{{"smartlife.iot.dimmer":{{"set_brightness":{{"brightness":{level} }} }} }}\''
393 return tplink_light_command(cmd)
396 # class GoogleLightGroup(GoogleLight):
397 # def __init__(self, name: str, members: List[GoogleLight], keywords: str = "") -> None:
398 # if len(members) < 1:
399 # raise Exception("There must be at least one light in the group.")
400 # self.members = members
401 # mac = GoogleLightGroup.make_up_mac(members)
402 # super().__init__(name, mac, keywords)
405 # def make_up_mac(members: List[GoogleLight]):
406 # mac = members[0].get_mac()
408 # b[5] = int(b[5], 16) + 1
414 # def is_on(self) -> bool:
415 # r = ask_google(f"are {self.goog_name()} on?")
418 # return 'is on' in r.audio_transcription
420 # def get_dimmer_level(self) -> Optional[int]:
421 # if not self.has_keyword("dimmer"):
423 # r = ask_google(f'how bright are {self.goog_name()}?')
427 # # four lights are set to 100% brightness
428 # txt = r.audio_transcription
429 # m = re.search(r"(\d+)% bright", txt)
431 # return int(m.group(1))
432 # if "is off" in txt:
436 # def set_dimmer_level(self, level: int) -> bool:
437 # if not self.has_keyword("dimmer"):
439 # if 0 <= level <= 100:
440 # was_on = self.is_on()
441 # r = ask_google(f"set {self.goog_name()} to {level} percent")
449 # def make_color(self, color: str) -> bool:
450 # return GoogleLight.parse_google_response(
451 # ask_google(f"make {self.goog_name()} {color}")