3 """Utilities for dealing with the smart lights."""
5 from abc import abstractmethod
13 from typing import Any, Dict, List, Optional, Tuple
15 from overrides import overrides
23 import smart_home.device as dev
24 from google_assistant import ask_google, GoogleResponse
25 from decorator_utils import timeout, memoized
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,
43 5.0, use_signals=False, error_message="Timed out waiting for tplink.py"
45 def tplink_light_command(command: str) -> bool:
46 result = os.system(command)
47 signal = result & 0xFF
49 logger.warning(f'{command} died with signal {signal}')
50 logging_utils.hlog("%s died with signal %d" % (command, signal))
53 exit_value = result >> 8
55 logger.warning(f'{command} failed, exited {exit_value}')
56 logging_utils.hlog("%s failed, exit %d" % (command, exit_value))
58 logger.debug(f'{command} succeeded.')
62 class BaseLight(dev.Device):
63 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
64 super().__init__(name.strip(), mac.strip(), keywords)
67 def parse_color_string(color: str) -> Optional[Tuple[int, int, int]]:
69 '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])',
72 if m is not None and len(m.group) == 3:
73 red = int(m.group(0), 16)
74 green = int(m.group(1), 16)
75 blue = int(m.group(2), 16)
76 return (red, green, blue)
78 return ansi.COLOR_NAMES_TO_RGB.get(color, None)
81 def turn_on(self) -> bool:
85 def turn_off(self) -> bool:
89 def is_on(self) -> bool:
93 def is_off(self) -> bool:
97 def get_dimmer_level(self) -> Optional[int]:
101 def set_dimmer_level(self, level: int) -> bool:
105 def make_color(self, color: str) -> bool:
109 class GoogleLight(BaseLight):
110 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
111 super().__init__(name, mac, keywords)
113 def goog_name(self) -> str:
114 name = self.get_name()
115 return name.replace("_", " ")
118 def parse_google_response(response: GoogleResponse) -> bool:
119 return response.success
122 def turn_on(self) -> bool:
123 return GoogleLight.parse_google_response(
124 ask_google(f"turn {self.goog_name()} on")
128 def turn_off(self) -> bool:
129 return GoogleLight.parse_google_response(
130 ask_google(f"turn {self.goog_name()} off")
134 def is_on(self) -> bool:
135 r = ask_google(f"is {self.goog_name()} on?")
138 return 'is on' in r.audio_transcription
141 def is_off(self) -> bool:
142 return not self.is_on()
145 def get_dimmer_level(self) -> Optional[int]:
146 if not self.has_keyword("dimmer"):
148 r = ask_google(f'how bright is {self.goog_name()}?')
152 # the bookcase one is set to 40% bright
153 txt = r.audio_transcription
154 m = re.search(r"(\d+)% bright", txt)
156 return int(m.group(1))
162 def set_dimmer_level(self, level: int) -> bool:
163 if not self.has_keyword("dimmer"):
165 if 0 <= level <= 100:
166 was_on = self.is_on()
167 r = ask_google(f"set {self.goog_name()} to {level} percent")
176 def make_color(self, color: str) -> bool:
177 return GoogleLight.parse_google_response(
178 ask_google(f"make {self.goog_name()} {color}")
182 class TuyaLight(BaseLight):
184 '68:C6:3A:DE:1A:94': '8844664268c63ade1a94',
185 '68:C6:3A:DE:27:1A': '8844664268c63ade271a',
186 '68:C6:3A:DE:1D:95': '8844664268c63ade1d95',
187 '68:C6:3A:DE:19:B3': '8844664268c63ade19b3',
188 '80:7D:3A:77:3B:F5': '07445340807d3a773bf5',
189 '80:7D:3A:58:37:02': '07445340807d3a583702',
192 '68:C6:3A:DE:1A:94': '237f19b1b3d49c36',
193 '68:C6:3A:DE:27:1A': '237f19b1b3d49c36',
194 '68:C6:3A:DE:1D:95': '04b90fc5cd7625d8',
195 '68:C6:3A:DE:19:B3': '2d601f2892f1aefd',
196 '80:7D:3A:77:3B:F5': '27ab921fe4633519',
197 '80:7D:3A:58:37:02': '8559b5416bfa0c05',
200 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
201 super().__init__(name, mac, keywords)
203 if mac not in TuyaLight.ids_by_mac or mac not in TuyaLight.keys_by_mac:
204 raise Exception(f'{mac} is unknown; add it to ids_by_mac and keys_by_mac')
205 self.devid = TuyaLight.ids_by_mac[mac]
206 self.key = TuyaLight.keys_by_mac[mac]
207 self.arper = arper.Arper()
209 self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
211 def get_status(self) -> Dict[str, Any]:
212 return self.bulb.status()
215 def turn_on(self) -> bool:
220 def turn_off(self) -> bool:
225 def is_on(self) -> bool:
226 s = self.get_status()
230 def is_off(self) -> bool:
231 return not self.is_on()
234 def get_dimmer_level(self) -> Optional[int]:
235 s = self.get_status()
239 def set_dimmer_level(self, level: int) -> bool:
240 self.bulb.set_brightness(level)
244 def make_color(self, color: str) -> bool:
245 rgb = BaseLight.parse_color_string(color)
247 self.bulb.set_colour(rgb[0], rgb[1], rgb[2])
252 class TPLinkLight(BaseLight):
253 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
254 super().__init__(name, mac, keywords)
255 self.children: List[str] = []
256 self.info: Optional[Dict] = None
257 self.info_ts: Optional[datetime.datetime] = None
258 if "children" in self.keywords:
259 self.info = self.get_info()
260 if self.info is not None:
261 for child in self.info["children"]:
262 self.children.append(child["id"])
265 def get_tplink_name(self) -> Optional[str]:
266 self.info = self.get_info()
267 if self.info is not None:
268 return self.info["alias"]
271 def get_cmdline(self, child: str = None) -> str:
273 f"{config.config['smart_lights_tplink_location']} -m {self.mac} "
274 f"--no_logging_console "
276 if child is not None:
277 cmd += f"-x {child} "
280 def get_children(self) -> List[str]:
284 self, cmd: str, child: str = None, extra_args: str = None
286 cmd = self.get_cmdline(child) + f"-c {cmd}"
287 if extra_args is not None:
288 cmd += f" {extra_args}"
289 logger.debug(f'About to execute {cmd}')
290 return tplink_light_command(cmd)
293 def turn_on(self, child: str = None) -> bool:
294 return self.command("on", child)
297 def turn_off(self, child: str = None) -> bool:
298 return self.command("off", child)
301 def is_on(self) -> bool:
302 return self.get_on_duration_seconds() > 0
305 def is_off(self) -> bool:
306 return not self.is_on()
309 def make_color(self, color: str) -> bool:
310 raise NotImplementedError
313 10.0, use_signals=False, error_message="Timed out waiting for tplink.py"
315 def get_info(self) -> Optional[Dict]:
316 cmd = self.get_cmdline() + "-c info"
317 out = subprocess.getoutput(cmd)
318 out = re.sub("Sent:.*\n", "", out)
319 out = re.sub("Received: *", "", out)
321 self.info = json.loads(out)["system"]["get_sysinfo"]
322 self.info_ts = datetime.datetime.now()
324 except Exception as e:
326 print(out, file=sys.stderr)
331 def get_on_duration_seconds(self, child: str = None) -> int:
332 self.info = self.get_info()
334 if self.info is None:
336 return int(self.info.get("on_time", "0"))
338 if self.info is None:
340 for chi in self.info.get("children", {}):
341 if chi["id"] == child:
342 return int(chi.get("on_time", "0"))
346 def get_dimmer_level(self) -> Optional[int]:
347 if not self.has_keyword("dimmer"):
349 self.info = self.get_info()
350 if self.info is None:
352 return int(self.info.get("brightness", "0"))
355 def set_dimmer_level(self, level: int) -> bool:
356 if not self.has_keyword("dimmer"):
360 + f'-j \'{{"smartlife.iot.dimmer":{{"set_brightness":{{"brightness":{level} }} }} }}\''
362 return tplink_light_command(cmd)
365 # class GoogleLightGroup(GoogleLight):
366 # def __init__(self, name: str, members: List[GoogleLight], keywords: str = "") -> None:
367 # if len(members) < 1:
368 # raise Exception("There must be at least one light in the group.")
369 # self.members = members
370 # mac = GoogleLightGroup.make_up_mac(members)
371 # super().__init__(name, mac, keywords)
374 # def make_up_mac(members: List[GoogleLight]):
375 # mac = members[0].get_mac()
377 # b[5] = int(b[5], 16) + 1
383 # def is_on(self) -> bool:
384 # r = ask_google(f"are {self.goog_name()} on?")
387 # return 'is on' in r.audio_transcription
389 # def get_dimmer_level(self) -> Optional[int]:
390 # if not self.has_keyword("dimmer"):
392 # r = ask_google(f'how bright are {self.goog_name()}?')
396 # # four lights are set to 100% brightness
397 # txt = r.audio_transcription
398 # m = re.search(r"(\d+)% bright", txt)
400 # return int(m.group(1))
401 # if "is off" in txt:
405 # def set_dimmer_level(self, level: int) -> bool:
406 # if not self.has_keyword("dimmer"):
408 # if 0 <= level <= 100:
409 # was_on = self.is_on()
410 # r = ask_google(f"set {self.goog_name()} to {level} percent")
418 # def make_color(self, color: str) -> bool:
419 # return GoogleLight.parse_google_response(
420 # ask_google(f"make {self.goog_name()} {color}")