3 """Utilities for dealing with the smart lights."""
8 from abc import abstractmethod
9 from typing import Any, Dict, List, Optional, Tuple
12 from overrides import overrides
18 import smart_home.device as dev
19 import smart_home.tplink_utils as tplink
20 from decorator_utils import memoized
21 from google_assistant import GoogleResponse, ask_google
23 logger = logging.getLogger(__name__)
25 args = config.add_commandline_args(
26 f"Smart Lights ({__file__})",
27 "Args related to smart lights.",
30 '--smart_lights_tplink_location',
31 default='/home/scott/bin/tplink.py',
33 help='The location of the tplink.py helper',
34 type=argparse_utils.valid_filename,
38 class BaseLight(dev.Device):
39 """A base class representing a smart light."""
41 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
42 super().__init__(name.strip(), mac.strip(), keywords)
45 def parse_color_string(color: str) -> Optional[Tuple[int, int, int]]:
47 '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])',
50 if m is not None and len(m.groups()) == 3:
51 red = int(m.group(0), 16)
52 green = int(m.group(1), 16)
53 blue = int(m.group(2), 16)
54 return (red, green, blue)
56 return ansi.COLOR_NAMES_TO_RGB.get(color, None)
59 def status(self) -> str:
63 def turn_on(self) -> bool:
67 def turn_off(self) -> bool:
71 def is_on(self) -> bool:
75 def is_off(self) -> bool:
79 def get_dimmer_level(self) -> Optional[int]:
83 def set_dimmer_level(self, level: int) -> bool:
87 def make_color(self, color: str) -> bool:
91 class GoogleLight(BaseLight):
92 """A smart light controlled by talking to Google."""
94 def goog_name(self) -> str:
95 name = self.get_name()
96 return name.replace("_", " ")
99 def parse_google_response(response: GoogleResponse) -> bool:
100 return response.success
103 def turn_on(self) -> bool:
104 return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} on"))
107 def turn_off(self) -> bool:
108 return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} off"))
111 def status(self) -> str:
117 def is_on(self) -> bool:
118 r = ask_google(f"is {self.goog_name()} on?")
121 if r.audio_transcription is not None:
122 return 'is on' in r.audio_transcription
123 raise Exception("Can't reach Google?!")
126 def is_off(self) -> bool:
127 return not self.is_on()
130 def get_dimmer_level(self) -> Optional[int]:
131 if not self.has_keyword("dimmer"):
133 r = ask_google(f'how bright is {self.goog_name()}?')
137 # the bookcase one is set to 40% bright
138 txt = r.audio_transcription
140 m = re.search(r"(\d+)% bright", txt)
142 return int(m.group(1))
148 def set_dimmer_level(self, level: int) -> bool:
149 if not self.has_keyword("dimmer"):
151 if 0 <= level <= 100:
152 was_on = self.is_on()
153 r = ask_google(f"set {self.goog_name()} to {level} percent")
162 def make_color(self, color: str) -> bool:
163 return GoogleLight.parse_google_response(ask_google(f"make {self.goog_name()} {color}"))
166 class TuyaLight(BaseLight):
167 """A Tuya smart light."""
170 '68:C6:3A:DE:1A:94': '8844664268c63ade1a94',
171 '68:C6:3A:DE:27:1A': '8844664268c63ade271a',
172 '68:C6:3A:DE:1D:95': '8844664268c63ade1d95',
173 '68:C6:3A:DE:19:B3': '8844664268c63ade19b3',
174 '80:7D:3A:77:3B:F5': '07445340807d3a773bf5',
175 '80:7D:3A:58:37:02': '07445340807d3a583702',
178 '68:C6:3A:DE:1A:94': '237f19b1b3d49c36',
179 '68:C6:3A:DE:27:1A': '237f19b1b3d49c36',
180 '68:C6:3A:DE:1D:95': '04b90fc5cd7625d8',
181 '68:C6:3A:DE:19:B3': '2d601f2892f1aefd',
182 '80:7D:3A:77:3B:F5': '27ab921fe4633519',
183 '80:7D:3A:58:37:02': '8559b5416bfa0c05',
186 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
187 super().__init__(name, mac, keywords)
189 if mac not in TuyaLight.ids_by_mac or mac not in TuyaLight.keys_by_mac:
190 raise Exception(f'{mac} is unknown; add it to ids_by_mac and keys_by_mac')
191 self.devid = TuyaLight.ids_by_mac[mac]
192 self.key = TuyaLight.keys_by_mac[mac]
193 self.arper = arper.Arper()
195 self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
197 def get_status(self) -> Dict[str, Any]:
198 return self.bulb.status()
201 def status(self) -> str:
203 for k, v in self.bulb.status().items():
204 ret += f'{k} = {v}\n'
208 def turn_on(self) -> bool:
213 def turn_off(self) -> bool:
218 def is_on(self) -> bool:
219 s = self.get_status()
223 def is_off(self) -> bool:
224 return not self.is_on()
227 def get_dimmer_level(self) -> Optional[int]:
228 s = self.get_status()
232 def set_dimmer_level(self, level: int) -> bool:
233 logger.debug('Setting brightness to %d', level)
234 self.bulb.set_brightness(level)
238 def make_color(self, color: str) -> bool:
239 rgb = BaseLight.parse_color_string(color)
240 logger.debug('Light color: %s -> %s', color, rgb)
242 self.bulb.set_colour(rgb[0], rgb[1], rgb[2])
247 class TPLinkLight(BaseLight):
248 """A TPLink smart light."""
250 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
251 super().__init__(name, mac, keywords)
252 self.children: List[str] = []
253 self.info: Optional[Dict] = None
254 self.info_ts: Optional[datetime.datetime] = None
255 if self.keywords is not None:
256 if "children" in self.keywords:
257 self.info = self.get_info()
258 if self.info is not None:
259 for child in self.info["children"]:
260 self.children.append(child["id"])
263 def get_tplink_name(self) -> Optional[str]:
264 self.info = self.get_info()
265 if self.info is not None:
266 return self.info["alias"]
269 def get_cmdline(self, child: str = None) -> str:
271 f"{config.config['smart_lights_tplink_location']} -m {self.mac} "
272 f"--no_logging_console "
274 if child is not None:
275 cmd += f"-x {child} "
278 def get_children(self) -> List[str]:
281 def command(self, cmd: str, child: str = None, extra_args: str = None) -> bool:
282 cmd = self.get_cmdline(child) + f"-c {cmd}"
283 if extra_args is not None:
284 cmd += f" {extra_args}"
285 logger.debug('About to execute: %s', cmd)
286 return tplink.tplink_command(cmd)
289 def turn_on(self) -> bool:
290 return self.command("on", None)
293 def turn_off(self) -> bool:
294 return self.command("off", None)
296 def turn_on_child(self, child: str = None) -> bool:
297 return self.command("on", child)
299 def turn_off_child(self, child: str = None) -> bool:
300 return self.command("off", child)
303 def is_on(self) -> bool:
304 self.info = self.get_info()
305 if self.info is None:
306 raise Exception('Unable to get info?')
307 return self.info.get("relay_state", 0) == 1
310 def is_off(self) -> bool:
311 return not self.is_on()
314 def make_color(self, color: str) -> bool:
315 raise NotImplementedError
317 def get_info(self) -> Optional[Dict]:
320 self.info = tplink.tplink_get_info(ip)
321 if self.info is not None:
322 self.info_ts = datetime.datetime.now()
329 def status(self) -> str:
331 info = self.get_info()
334 ret += f'{k} = {v}\n'
337 def get_on_duration_seconds(self, child: str = None) -> int:
338 self.info = self.get_info()
340 if self.info is None:
342 return int(self.info.get("on_time", "0"))
344 if self.info is None:
346 for chi in self.info.get("children", {}):
347 if chi["id"] == child:
348 return int(chi.get("on_time", "0"))
352 def get_dimmer_level(self) -> Optional[int]:
353 if not self.has_keyword("dimmer"):
355 self.info = self.get_info()
356 if self.info is None:
358 return int(self.info.get("brightness", "0"))
361 def set_dimmer_level(self, level: int) -> bool:
362 if not self.has_keyword("dimmer"):
366 + '-j \'{"smartlife.iot.dimmer":{"set_brightness":{"brightness":%d}}}\'' % level
368 return tplink.tplink_command(cmd)
371 # class GoogleLightGroup(GoogleLight):
372 # def __init__(self, name: str, members: List[GoogleLight], keywords: str = "") -> None:
373 # if len(members) < 1:
374 # raise Exception("There must be at least one light in the group.")
375 # self.members = members
376 # mac = GoogleLightGroup.make_up_mac(members)
377 # super().__init__(name, mac, keywords)
380 # def make_up_mac(members: List[GoogleLight]):
381 # mac = members[0].get_mac()
383 # b[5] = int(b[5], 16) + 1
389 # def is_on(self) -> bool:
390 # r = ask_google(f"are {self.goog_name()} on?")
393 # return 'is on' in r.audio_transcription
395 # def get_dimmer_level(self) -> Optional[int]:
396 # if not self.has_keyword("dimmer"):
398 # r = ask_google(f'how bright are {self.goog_name()}?')
402 # # four lights are set to 100% brightness
403 # txt = r.audio_transcription
404 # m = re.search(r"(\d+)% bright", txt)
406 # return int(m.group(1))
407 # if "is off" in txt:
411 # def set_dimmer_level(self, level: int) -> bool:
412 # if not self.has_keyword("dimmer"):
414 # if 0 <= level <= 100:
415 # was_on = self.is_on()
416 # r = ask_google(f"set {self.goog_name()} to {level} percent")
424 # def make_color(self, color: str) -> bool:
425 # return GoogleLight.parse_google_response(
426 # ask_google(f"make {self.goog_name()} {color}")