3 # © Copyright 2021-2022, Scott Gasch
5 """Utilities for dealing with the smart lights."""
10 from abc import abstractmethod
11 from typing import Any, Dict, List, Optional, Tuple
14 from overrides import overrides
20 import smart_home.device as dev
21 import smart_home.tplink_utils as tplink
22 from decorator_utils import memoized
23 from google_assistant import GoogleResponse, ask_google
25 logger = logging.getLogger(__name__)
27 args = config.add_commandline_args(
28 f"Smart Lights ({__file__})",
29 "Args related to smart lights.",
32 '--smart_lights_tplink_location',
33 default='/home/scott/bin/tplink.py',
35 help='The location of the tplink.py helper',
36 type=argparse_utils.valid_filename,
40 class BaseLight(dev.Device):
41 """A base class representing a smart light."""
43 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
44 super().__init__(name.strip(), mac.strip(), keywords)
47 def parse_color_string(color: str) -> Optional[Tuple[int, int, int]]:
49 '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])',
52 if m is not None and len(m.groups()) == 3:
53 red = int(m.group(0), 16)
54 green = int(m.group(1), 16)
55 blue = int(m.group(2), 16)
56 return (red, green, blue)
58 return ansi.COLOR_NAMES_TO_RGB.get(color, None)
61 def status(self) -> str:
65 def turn_on(self) -> bool:
69 def turn_off(self) -> bool:
73 def is_on(self) -> bool:
77 def is_off(self) -> bool:
81 def get_dimmer_level(self) -> Optional[int]:
85 def set_dimmer_level(self, level: int) -> bool:
89 def make_color(self, color: str) -> bool:
93 class GoogleLight(BaseLight):
94 """A smart light controlled by talking to Google."""
96 def goog_name(self) -> str:
97 name = self.get_name()
98 return name.replace("_", " ")
101 def parse_google_response(response: GoogleResponse) -> bool:
102 return response.success
105 def turn_on(self) -> bool:
106 return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} on"))
109 def turn_off(self) -> bool:
110 return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} off"))
113 def status(self) -> str:
119 def is_on(self) -> bool:
120 r = ask_google(f"is {self.goog_name()} on?")
123 if r.audio_transcription is not None:
124 return 'is on' in r.audio_transcription
125 raise Exception("Can't reach Google?!")
128 def is_off(self) -> bool:
129 return not self.is_on()
132 def get_dimmer_level(self) -> Optional[int]:
133 if not self.has_keyword("dimmer"):
135 r = ask_google(f'how bright is {self.goog_name()}?')
139 # the bookcase one is set to 40% bright
140 txt = r.audio_transcription
142 m = re.search(r"(\d+)% bright", txt)
144 return int(m.group(1))
150 def set_dimmer_level(self, level: int) -> bool:
151 if not self.has_keyword("dimmer"):
153 if 0 <= level <= 100:
154 was_on = self.is_on()
155 r = ask_google(f"set {self.goog_name()} to {level} percent")
164 def make_color(self, color: str) -> bool:
165 return GoogleLight.parse_google_response(ask_google(f"make {self.goog_name()} {color}"))
168 class TuyaLight(BaseLight):
169 """A Tuya smart light."""
172 '68:C6:3A:DE:1A:94': '8844664268c63ade1a94',
173 '68:C6:3A:DE:27:1A': '8844664268c63ade271a',
174 '68:C6:3A:DE:1D:95': '8844664268c63ade1d95',
175 '68:C6:3A:DE:19:B3': '8844664268c63ade19b3',
176 '80:7D:3A:77:3B:F5': '07445340807d3a773bf5',
177 '80:7D:3A:58:37:02': '07445340807d3a583702',
180 '68:C6:3A:DE:1A:94': '237f19b1b3d49c36',
181 '68:C6:3A:DE:27:1A': '237f19b1b3d49c36',
182 '68:C6:3A:DE:1D:95': '04b90fc5cd7625d8',
183 '68:C6:3A:DE:19:B3': '2d601f2892f1aefd',
184 '80:7D:3A:77:3B:F5': '27ab921fe4633519',
185 '80:7D:3A:58:37:02': '8559b5416bfa0c05',
188 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
189 super().__init__(name, mac, keywords)
191 if mac not in TuyaLight.ids_by_mac or mac not in TuyaLight.keys_by_mac:
192 raise Exception(f'{mac} is unknown; add it to ids_by_mac and keys_by_mac')
193 self.devid = TuyaLight.ids_by_mac[mac]
194 self.key = TuyaLight.keys_by_mac[mac]
195 self.arper = arper.Arper()
197 self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
199 def get_status(self) -> Dict[str, Any]:
200 return self.bulb.status()
203 def status(self) -> str:
205 for k, v in self.bulb.status().items():
206 ret += f'{k} = {v}\n'
210 def turn_on(self) -> bool:
215 def turn_off(self) -> bool:
220 def is_on(self) -> bool:
221 s = self.get_status()
225 def is_off(self) -> bool:
226 return not self.is_on()
229 def get_dimmer_level(self) -> Optional[int]:
230 s = self.get_status()
234 def set_dimmer_level(self, level: int) -> bool:
235 logger.debug('Setting brightness to %d', level)
236 self.bulb.set_brightness(level)
240 def make_color(self, color: str) -> bool:
241 rgb = BaseLight.parse_color_string(color)
242 logger.debug('Light color: %s -> %s', color, rgb)
244 self.bulb.set_colour(rgb[0], rgb[1], rgb[2])
249 class TPLinkLight(BaseLight):
250 """A TPLink smart light."""
252 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
253 super().__init__(name, mac, keywords)
254 self.children: List[str] = []
255 self.info: Optional[Dict] = None
256 self.info_ts: Optional[datetime.datetime] = None
257 if self.keywords is not 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]:
283 def command(self, cmd: str, child: str = None, extra_args: str = None) -> bool:
284 cmd = self.get_cmdline(child) + f"-c {cmd}"
285 if extra_args is not None:
286 cmd += f" {extra_args}"
287 logger.debug('About to execute: %s', cmd)
288 return tplink.tplink_command_wrapper(cmd)
291 def turn_on(self) -> bool:
292 return self.command("on", None)
295 def turn_off(self) -> bool:
296 return self.command("off", None)
298 def turn_on_child(self, child: str = None) -> bool:
299 return self.command("on", child)
301 def turn_off_child(self, child: str = None) -> bool:
302 return self.command("off", child)
305 def is_on(self) -> bool:
306 self.info = self.get_info()
307 if self.info is None:
308 raise Exception('Unable to get info?')
309 return self.info.get("relay_state", 0) == 1
312 def is_off(self) -> bool:
313 return not self.is_on()
316 def make_color(self, color: str) -> bool:
317 raise NotImplementedError
319 def get_info(self) -> Optional[Dict]:
322 self.info = tplink.tplink_get_info(ip)
323 if self.info is not None:
324 self.info_ts = datetime.datetime.now()
331 def status(self) -> str:
333 info = self.get_info()
336 ret += f'{k} = {v}\n'
339 def get_on_duration_seconds(self, child: str = None) -> int:
340 self.info = self.get_info()
342 if self.info is None:
344 return int(self.info.get("on_time", "0"))
346 if self.info is None:
348 for chi in self.info.get("children", {}):
349 if chi["id"] == child:
350 return int(chi.get("on_time", "0"))
354 def get_dimmer_level(self) -> Optional[int]:
355 if not self.has_keyword("dimmer"):
357 self.info = self.get_info()
358 if self.info is None:
360 return int(self.info.get("brightness", "0"))
363 def set_dimmer_level(self, level: int) -> bool:
364 if not self.has_keyword("dimmer"):
368 + '-j \'{"smartlife.iot.dimmer":{"set_brightness":{"brightness":%d}}}\'' % level
370 return tplink.tplink_command_wrapper(cmd)
373 # class GoogleLightGroup(GoogleLight):
374 # def __init__(self, name: str, members: List[GoogleLight], keywords: str = "") -> None:
375 # if len(members) < 1:
376 # raise Exception("There must be at least one light in the group.")
377 # self.members = members
378 # mac = GoogleLightGroup.make_up_mac(members)
379 # super().__init__(name, mac, keywords)
382 # def make_up_mac(members: List[GoogleLight]):
383 # mac = members[0].get_mac()
385 # b[5] = int(b[5], 16) + 1
391 # def is_on(self) -> bool:
392 # r = ask_google(f"are {self.goog_name()} on?")
395 # return 'is on' in r.audio_transcription
397 # def get_dimmer_level(self) -> Optional[int]:
398 # if not self.has_keyword("dimmer"):
400 # r = ask_google(f'how bright are {self.goog_name()}?')
404 # # four lights are set to 100% brightness
405 # txt = r.audio_transcription
406 # m = re.search(r"(\d+)% bright", txt)
408 # return int(m.group(1))
409 # if "is off" in txt:
413 # def set_dimmer_level(self, level: int) -> bool:
414 # if not self.has_keyword("dimmer"):
416 # if 0 <= level <= 100:
417 # was_on = self.is_on()
418 # r = ask_google(f"set {self.goog_name()} to {level} percent")
426 # def make_color(self, color: str) -> bool:
427 # return GoogleLight.parse_google_response(
428 # ask_google(f"make {self.goog_name()} {color}")