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