3 """Utilities for dealing with the smart lights."""
5 from abc import ABC, abstractmethod
13 from typing import Any, Dict, List, Optional, Set
20 import smart_home.device as dev
21 from google_assistant import ask_google, GoogleResponse
22 from decorator_utils import timeout, memoized
24 logger = logging.getLogger(__name__)
26 parser = config.add_commandline_args(
27 f"Smart Lights ({__file__})",
28 "Args related to smart lights.",
31 '--smart_lights_tplink_location',
32 default='/home/scott/bin/tplink.py',
34 help='The location of the tplink.py helper',
35 type=argparse_utils.valid_filename,
40 5.0, use_signals=False, error_message="Timed out waiting for tplink.py"
42 def tplink_light_command(command: str) -> bool:
43 result = os.system(command)
44 signal = result & 0xFF
46 logger.warning(f'{command} died with signal {signal}')
47 logging_utils.hlog("%s died with signal %d" % (command, signal))
50 exit_value = result >> 8
52 logger.warning(f'{command} failed, exited {exit_value}')
53 logging_utils.hlog("%s failed, exit %d" % (command, exit_value))
55 logger.debug(f'{command} succeeded.')
59 class BaseLight(dev.Device):
60 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
61 super().__init__(name.strip(), mac.strip(), keywords)
64 def turn_on(self) -> bool:
68 def turn_off(self) -> bool:
72 def is_on(self) -> bool:
76 def is_off(self) -> bool:
80 def get_dimmer_level(self) -> Optional[int]:
84 def set_dimmer_level(self, level: int) -> bool:
88 def make_color(self, color: str) -> bool:
92 class GoogleLight(BaseLight):
93 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
94 super().__init__(name, mac, keywords)
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
104 def turn_on(self) -> bool:
105 return GoogleLight.parse_google_response(
106 ask_google(f"turn {self.goog_name()} on")
109 def turn_off(self) -> bool:
110 return GoogleLight.parse_google_response(
111 ask_google(f"turn {self.goog_name()} off")
114 def is_on(self) -> bool:
115 r = ask_google(f"is {self.goog_name()} on?")
118 return 'is on' in r.audio_transcription
120 def is_off(self) -> bool:
121 return not self.is_on()
123 def get_dimmer_level(self) -> Optional[int]:
124 if not self.has_keyword("dimmer"):
126 r = ask_google(f'how bright is {self.goog_name()}?')
130 # the bookcase one is set to 40% bright
131 txt = r.audio_transcription
132 m = re.search(r"(\d+)% bright", txt)
134 return int(m.group(1))
139 def set_dimmer_level(self, level: int) -> bool:
140 if not self.has_keyword("dimmer"):
142 if 0 <= level <= 100:
143 was_on = self.is_on()
144 r = ask_google(f"set {self.goog_name()} to {level} percent")
152 def make_color(self, color: str) -> bool:
153 return GoogleLight.parse_google_response(
154 ask_google(f"make {self.goog_name()} {color}")
158 class TuyaLight(BaseLight):
160 '68:C6:3A:DE:1A:94': '8844664268c63ade1a94',
161 '68:C6:3A:DE:27:1A': '8844664268c63ade271a',
162 '68:C6:3A:DE:1D:95': '8844664268c63ade1d95',
163 '68:C6:3A:DE:19:B3': '8844664268c63ade19b3',
164 '80:7D:3A:77:3B:F5': '07445340807d3a773bf5',
165 '80:7D:3A:58:37:02': '07445340807d3a583702',
168 '68:C6:3A:DE:1A:94': '237f19b1b3d49c36',
169 '68:C6:3A:DE:27:1A': '237f19b1b3d49c36',
170 '68:C6:3A:DE:1D:95': '04b90fc5cd7625d8',
171 '68:C6:3A:DE:19:B3': '2d601f2892f1aefd',
172 '80:7D:3A:77:3B:F5': '27ab921fe4633519',
173 '80:7D:3A:58:37:02': '8559b5416bfa0c05',
176 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
177 from subprocess import Popen, PIPE
178 super().__init__(name, mac, keywords)
180 if mac not in TuyaLight.ids_by_mac or mac not in TuyaLight.keys_by_mac:
181 raise Exception(f'{mac} is unknown; add it to ids_by_mac and keys_by_mac')
182 self.devid = TuyaLight.ids_by_mac[mac]
183 self.key = TuyaLight.keys_by_mac[mac]
185 pid = Popen(['maclookup', mac], stdout=PIPE)
186 ip = pid.communicate()[0]
190 self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
192 def turn_on(self) -> bool:
196 def turn_off(self) -> bool:
200 def get_status(self) -> Dict[str, Any]:
201 return self.bulb.status()
203 def is_on(self) -> bool:
204 s = self.get_status()
207 def is_off(self) -> bool:
208 return not self.is_on()
210 def get_dimmer_level(self) -> Optional[int]:
211 s = self.get_status()
214 def set_dimmer_level(self, level: int) -> bool:
215 self.bulb.set_brightness(level)
218 def make_color(self, color: str) -> bool:
219 self.bulb.set_colour(255,0,0)
223 class TPLinkLight(BaseLight):
224 def __init__(self, name: str, mac: str, keywords: str = "") -> None:
225 super().__init__(name, mac, keywords)
226 self.children: List[str] = []
227 self.info: Optional[Dict] = None
228 self.info_ts: Optional[datetime.datetime] = None
229 if "children" in self.keywords:
230 self.info = self.get_info()
231 if self.info is not None:
232 for child in self.info["children"]:
233 self.children.append(child["id"])
236 def get_tplink_name(self) -> Optional[str]:
237 self.info = self.get_info()
238 if self.info is not None:
239 return self.info["alias"]
242 def get_cmdline(self, child: str = None) -> str:
244 f"{config.config['smart_lights_tplink_location']} -m {self.mac} "
245 f"--no_logging_console "
247 if child is not None:
248 cmd += f"-x {child} "
251 def get_children(self) -> List[str]:
255 self, cmd: str, child: str = None, extra_args: str = None
257 cmd = self.get_cmdline(child) + f"-c {cmd}"
258 if extra_args is not None:
259 cmd += f" {extra_args}"
260 logger.debug(f'About to execute {cmd}')
261 return tplink_light_command(cmd)
263 def turn_on(self, child: str = None) -> bool:
264 return self.command("on", child)
266 def turn_off(self, child: str = None) -> bool:
267 return self.command("off", child)
269 def is_on(self) -> bool:
270 return self.get_on_duration_seconds() > 0
272 def is_off(self) -> bool:
273 return not self.is_on()
275 def make_color(self, color: str) -> bool:
276 raise NotImplementedError
279 10.0, use_signals=False, error_message="Timed out waiting for tplink.py"
281 def get_info(self) -> Optional[Dict]:
282 cmd = self.get_cmdline() + "-c info"
283 out = subprocess.getoutput(cmd)
284 out = re.sub("Sent:.*\n", "", out)
285 out = re.sub("Received: *", "", out)
287 self.info = json.loads(out)["system"]["get_sysinfo"]
288 self.info_ts = datetime.datetime.now()
290 except Exception as e:
292 print(out, file=sys.stderr)
297 def get_on_duration_seconds(self, child: str = None) -> int:
298 self.info = self.get_info()
300 if self.info is None:
302 return int(self.info.get("on_time", "0"))
304 if self.info is None:
306 for chi in self.info.get("children", {}):
307 if chi["id"] == child:
308 return int(chi.get("on_time", "0"))
311 def get_on_limit_seconds(self) -> Optional[int]:
313 m = re.search(r"timeout:(\d+)", kw)
315 return int(m.group(1)) * 60
318 def get_dimmer_level(self) -> Optional[int]:
319 if not self.has_keyword("dimmer"):
321 self.info = self.get_info()
322 if self.info is None:
324 return int(self.info.get("brightness", "0"))
326 def set_dimmer_level(self, level: int) -> bool:
327 if not self.has_keyword("dimmer"):
331 + f'-j \'{{"smartlife.iot.dimmer":{{"set_brightness":{{"brightness":{level} }} }} }}\''
333 return tplink_light_command(cmd)
336 # class GoogleLightGroup(GoogleLight):
337 # def __init__(self, name: str, members: List[GoogleLight], keywords: str = "") -> None:
338 # if len(members) < 1:
339 # raise Exception("There must be at least one light in the group.")
340 # self.members = members
341 # mac = GoogleLightGroup.make_up_mac(members)
342 # super().__init__(name, mac, keywords)
345 # def make_up_mac(members: List[GoogleLight]):
346 # mac = members[0].get_mac()
348 # b[5] = int(b[5], 16) + 1
354 # def is_on(self) -> bool:
355 # r = ask_google(f"are {self.goog_name()} on?")
358 # return 'is on' in r.audio_transcription
360 # def get_dimmer_level(self) -> Optional[int]:
361 # if not self.has_keyword("dimmer"):
363 # r = ask_google(f'how bright are {self.goog_name()}?')
367 # # four lights are set to 100% brightness
368 # txt = r.audio_transcription
369 # m = re.search(r"(\d+)% bright", txt)
371 # return int(m.group(1))
372 # if "is off" in txt:
376 # def set_dimmer_level(self, level: int) -> bool:
377 # if not self.has_keyword("dimmer"):
379 # if 0 <= level <= 100:
380 # was_on = self.is_on()
381 # r = ask_google(f"set {self.goog_name()} to {level} percent")
389 # def make_color(self, color: str) -> bool:
390 # return GoogleLight.parse_google_response(
391 # ask_google(f"make {self.goog_name()} {color}")