Since this thing is on the innerwebs I suppose it should have a
[python_utils.git] / smart_home / lights.py
1 #!/usr/bin/env python3
2
3 # © Copyright 2021-2022, Scott Gasch
4
5 """Utilities for dealing with the smart lights."""
6
7 import datetime
8 import logging
9 import re
10 from abc import abstractmethod
11 from typing import Any, Dict, List, Optional, Tuple
12
13 import tinytuya as tt
14 from overrides import overrides
15
16 import ansi
17 import argparse_utils
18 import arper
19 import config
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
24
25 logger = logging.getLogger(__name__)
26
27 args = config.add_commandline_args(
28     f"Smart Lights ({__file__})",
29     "Args related to smart lights.",
30 )
31 args.add_argument(
32     '--smart_lights_tplink_location',
33     default='/home/scott/bin/tplink.py',
34     metavar='FILENAME',
35     help='The location of the tplink.py helper',
36     type=argparse_utils.valid_filename,
37 )
38
39
40 class BaseLight(dev.Device):
41     """A base class representing a smart light."""
42
43     def __init__(self, name: str, mac: str, keywords: str = "") -> None:
44         super().__init__(name.strip(), mac.strip(), keywords)
45
46     @staticmethod
47     def parse_color_string(color: str) -> Optional[Tuple[int, int, int]]:
48         m = re.match(
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])',
50             color,
51         )
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)
57         color = color.lower()
58         return ansi.COLOR_NAMES_TO_RGB.get(color, None)
59
60     @abstractmethod
61     def status(self) -> str:
62         pass
63
64     @abstractmethod
65     def turn_on(self) -> bool:
66         pass
67
68     @abstractmethod
69     def turn_off(self) -> bool:
70         pass
71
72     @abstractmethod
73     def is_on(self) -> bool:
74         pass
75
76     @abstractmethod
77     def is_off(self) -> bool:
78         pass
79
80     @abstractmethod
81     def get_dimmer_level(self) -> Optional[int]:
82         pass
83
84     @abstractmethod
85     def set_dimmer_level(self, level: int) -> bool:
86         pass
87
88     @abstractmethod
89     def make_color(self, color: str) -> bool:
90         pass
91
92
93 class GoogleLight(BaseLight):
94     """A smart light controlled by talking to Google."""
95
96     def goog_name(self) -> str:
97         name = self.get_name()
98         return name.replace("_", " ")
99
100     @staticmethod
101     def parse_google_response(response: GoogleResponse) -> bool:
102         return response.success
103
104     @overrides
105     def turn_on(self) -> bool:
106         return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} on"))
107
108     @overrides
109     def turn_off(self) -> bool:
110         return GoogleLight.parse_google_response(ask_google(f"turn {self.goog_name()} off"))
111
112     @overrides
113     def status(self) -> str:
114         if self.is_on():
115             return 'ON'
116         return 'off'
117
118     @overrides
119     def is_on(self) -> bool:
120         r = ask_google(f"is {self.goog_name()} on?")
121         if not r.success:
122             return False
123         if r.audio_transcription is not None:
124             return 'is on' in r.audio_transcription
125         raise Exception("Can't reach Google?!")
126
127     @overrides
128     def is_off(self) -> bool:
129         return not self.is_on()
130
131     @overrides
132     def get_dimmer_level(self) -> Optional[int]:
133         if not self.has_keyword("dimmer"):
134             return False
135         r = ask_google(f'how bright is {self.goog_name()}?')
136         if not r.success:
137             return None
138
139         # the bookcase one is set to 40% bright
140         txt = r.audio_transcription
141         if txt is not None:
142             m = re.search(r"(\d+)% bright", txt)
143             if m is not None:
144                 return int(m.group(1))
145             if "is off" in txt:
146                 return 0
147         return None
148
149     @overrides
150     def set_dimmer_level(self, level: int) -> bool:
151         if not self.has_keyword("dimmer"):
152             return False
153         if 0 <= level <= 100:
154             was_on = self.is_on()
155             r = ask_google(f"set {self.goog_name()} to {level} percent")
156             if not r.success:
157                 return False
158             if not was_on:
159                 self.turn_off()
160             return True
161         return False
162
163     @overrides
164     def make_color(self, color: str) -> bool:
165         return GoogleLight.parse_google_response(ask_google(f"make {self.goog_name()} {color}"))
166
167
168 class TuyaLight(BaseLight):
169     """A Tuya smart light."""
170
171     ids_by_mac = {
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',
178     }
179     keys_by_mac = {
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',
186     }
187
188     def __init__(self, name: str, mac: str, keywords: str = "") -> None:
189         super().__init__(name, mac, keywords)
190         mac = mac.upper()
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()
196         ip = self.get_ip()
197         self.bulb = tt.BulbDevice(self.devid, ip, local_key=self.key)
198
199     def get_status(self) -> Dict[str, Any]:
200         return self.bulb.status()
201
202     @overrides
203     def status(self) -> str:
204         ret = ''
205         for k, v in self.bulb.status().items():
206             ret += f'{k} = {v}\n'
207         return ret
208
209     @overrides
210     def turn_on(self) -> bool:
211         self.bulb.turn_on()
212         return True
213
214     @overrides
215     def turn_off(self) -> bool:
216         self.bulb.turn_off()
217         return True
218
219     @overrides
220     def is_on(self) -> bool:
221         s = self.get_status()
222         return s['dps']['1']
223
224     @overrides
225     def is_off(self) -> bool:
226         return not self.is_on()
227
228     @overrides
229     def get_dimmer_level(self) -> Optional[int]:
230         s = self.get_status()
231         return s['dps']['3']
232
233     @overrides
234     def set_dimmer_level(self, level: int) -> bool:
235         logger.debug('Setting brightness to %d', level)
236         self.bulb.set_brightness(level)
237         return True
238
239     @overrides
240     def make_color(self, color: str) -> bool:
241         rgb = BaseLight.parse_color_string(color)
242         logger.debug('Light color: %s -> %s', color, rgb)
243         if rgb is not None:
244             self.bulb.set_colour(rgb[0], rgb[1], rgb[2])
245             return True
246         return False
247
248
249 class TPLinkLight(BaseLight):
250     """A TPLink smart light."""
251
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"])
263
264     @memoized
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"]
269         return None
270
271     def get_cmdline(self, child: str = None) -> str:
272         cmd = (
273             f"{config.config['smart_lights_tplink_location']} -m {self.mac} "
274             f"--no_logging_console "
275         )
276         if child is not None:
277             cmd += f"-x {child} "
278         return cmd
279
280     def get_children(self) -> List[str]:
281         return self.children
282
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)
289
290     @overrides
291     def turn_on(self) -> bool:
292         return self.command("on", None)
293
294     @overrides
295     def turn_off(self) -> bool:
296         return self.command("off", None)
297
298     def turn_on_child(self, child: str = None) -> bool:
299         return self.command("on", child)
300
301     def turn_off_child(self, child: str = None) -> bool:
302         return self.command("off", child)
303
304     @overrides
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
310
311     @overrides
312     def is_off(self) -> bool:
313         return not self.is_on()
314
315     @overrides
316     def make_color(self, color: str) -> bool:
317         raise NotImplementedError
318
319     def get_info(self) -> Optional[Dict]:
320         ip = self.get_ip()
321         if ip is not None:
322             self.info = tplink.tplink_get_info(ip)
323             if self.info is not None:
324                 self.info_ts = datetime.datetime.now()
325             else:
326                 self.info_ts = None
327             return self.info
328         return None
329
330     @overrides
331     def status(self) -> str:
332         ret = ''
333         info = self.get_info()
334         if info is not None:
335             for k, v in info:
336                 ret += f'{k} = {v}\n'
337         return ret
338
339     def get_on_duration_seconds(self, child: str = None) -> int:
340         self.info = self.get_info()
341         if child is None:
342             if self.info is None:
343                 return 0
344             return int(self.info.get("on_time", "0"))
345         else:
346             if self.info is None:
347                 return 0
348             for chi in self.info.get("children", {}):
349                 if chi["id"] == child:
350                     return int(chi.get("on_time", "0"))
351         return 0
352
353     @overrides
354     def get_dimmer_level(self) -> Optional[int]:
355         if not self.has_keyword("dimmer"):
356             return False
357         self.info = self.get_info()
358         if self.info is None:
359             return None
360         return int(self.info.get("brightness", "0"))
361
362     @overrides
363     def set_dimmer_level(self, level: int) -> bool:
364         if not self.has_keyword("dimmer"):
365             return False
366         cmd = (
367             self.get_cmdline()
368             + '-j \'{"smartlife.iot.dimmer":{"set_brightness":{"brightness":%d}}}\'' % level
369         )
370         return tplink.tplink_command_wrapper(cmd)
371
372
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)
380
381 #     @staticmethod
382 #     def make_up_mac(members: List[GoogleLight]):
383 #         mac = members[0].get_mac()
384 #         b = mac.split(':')
385 #         b[5] = int(b[5], 16) + 1
386 #         if b[5] > 255:
387 #             b[5] = 0
388 #         b[5] = str(b[5])
389 #         return ":".join(b)
390
391 #     def is_on(self) -> bool:
392 #         r = ask_google(f"are {self.goog_name()} on?")
393 #         if not r.success:
394 #             return False
395 #         return 'is on' in r.audio_transcription
396
397 #     def get_dimmer_level(self) -> Optional[int]:
398 #         if not self.has_keyword("dimmer"):
399 #             return False
400 #         r = ask_google(f'how bright are {self.goog_name()}?')
401 #         if not r.success:
402 #             return None
403
404 #         # four lights are set to 100% brightness
405 #         txt = r.audio_transcription
406 #         m = re.search(r"(\d+)% bright", txt)
407 #         if m is not None:
408 #             return int(m.group(1))
409 #         if "is off" in txt:
410 #             return 0
411 #         return None
412
413 #     def set_dimmer_level(self, level: int) -> bool:
414 #         if not self.has_keyword("dimmer"):
415 #             return False
416 #         if 0 <= level <= 100:
417 #             was_on = self.is_on()
418 #             r = ask_google(f"set {self.goog_name()} to {level} percent")
419 #             if not r.success:
420 #                 return False
421 #             if not was_on:
422 #                 self.turn_off()
423 #             return True
424 #         return False
425
426 #     def make_color(self, color: str) -> bool:
427 #         return GoogleLight.parse_google_response(
428 #             ask_google(f"make {self.goog_name()} {color}")
429 #         )