|
1 | 1 | import random |
2 | 2 | import time |
3 | | -from threading import Thread |
4 | | - |
5 | | -from ovos_utils.intents import IntentBuilder |
| 3 | +from ast import literal_eval as parse_tuple |
| 4 | +from difflib import SequenceMatcher |
| 5 | +from ovos_utils import create_daemon |
6 | 6 | from ovos_workshop.decorators import intent_handler |
| 7 | +from ovos_workshop.intents import IntentBuilder |
7 | 8 | from ovos_workshop.skills import OVOSSkill |
| 9 | +from threading import Thread |
| 10 | + |
| 11 | + |
| 12 | +def _hex_to_rgb(_hex): |
| 13 | + """ Convert hex color code to RGB tuple |
| 14 | + Args: |
| 15 | + hex (str): Hex color string, e.g '#ff12ff' or 'ff12ff' |
| 16 | + Returns: |
| 17 | + (rgb): tuple i.e (123, 200, 155) or None |
| 18 | + """ |
| 19 | + try: |
| 20 | + if '#' in _hex: |
| 21 | + _hex = _hex.replace('#', "").strip() |
| 22 | + if len(_hex) != 6: |
| 23 | + return None |
| 24 | + (r, g, b) = int(_hex[0:2], 16), int(_hex[2:4], 16), int(_hex[4:6], 16) |
| 25 | + return (r, g, b) |
| 26 | + except Exception: |
| 27 | + return None |
| 28 | + |
| 29 | + |
| 30 | +def fuzzy_match_color(color_a, color_dict): |
| 31 | + """ fuzzy match for colors |
| 32 | +
|
| 33 | + Args: |
| 34 | + color_a (str): color as string |
| 35 | + color_dict (dict): dict with colors |
| 36 | + Returns: |
| 37 | + color: color from color_dict |
| 38 | + """ |
| 39 | + highest_ratio = float("-inf") |
| 40 | + _color = None |
| 41 | + for color, value in color_dict.items(): |
| 42 | + s = SequenceMatcher(None, color_a, color) |
| 43 | + if s.ratio() > highest_ratio: |
| 44 | + highest_ratio = s.ratio() |
| 45 | + _color = color |
| 46 | + if highest_ratio > 0.8: |
| 47 | + return _color |
| 48 | + else: |
| 49 | + return None |
8 | 50 |
|
9 | 51 |
|
10 | 52 | class EnclosureControlSkill(OVOSSkill): |
11 | 53 | def initialize(self): |
12 | 54 | self.thread = None |
13 | 55 | self.playing = False |
14 | 56 | self.animations = [] |
| 57 | + self.brightness_dict = self.translate_namedvalues('brightness.levels') |
| 58 | + self.color_dict = self.translate_namedvalues('colors') |
| 59 | + self.add_event('mycroft.eyes.default', self.handle_default_eyes) |
| 60 | + self.add_event('mycroft.ready', self.handle_default_eyes) |
| 61 | + |
| 62 | + # TODO: Add OVOSSkill.register_entity_list() and use the |
| 63 | + # self.color_dict.keys() instead of duplicating data |
| 64 | + self.register_entity_file('color.entity') |
15 | 65 |
|
16 | 66 | @property |
17 | 67 | def crazy_eyes_animation(self): |
@@ -113,27 +163,7 @@ def play_animation(self, animation=None): |
113 | 163 |
|
114 | 164 | # Build the list of animation actions to run |
115 | 165 | self.animations = animation |
116 | | - self.thread = Thread(None, self.run) |
117 | | - self.thread.daemon = True |
118 | | - self.thread.start() |
119 | | - |
120 | | - @intent_handler(IntentBuilder("SystemReboot") |
121 | | - .require("perform").require("system").require("reboot")) |
122 | | - def handle_system_reboot(self, message): |
123 | | - self.speak("rebooting") |
124 | | - self.bus.emit(message.reply("system.reboot", {})) |
125 | | - |
126 | | - @intent_handler(IntentBuilder("SystemUnmute") |
127 | | - .require("system").require("unmute")) |
128 | | - def handle_system_unmute(self, message): |
129 | | - self.enclosure.system_unmute() |
130 | | - self.speak("now that i have a voice, i shall not be silent") |
131 | | - |
132 | | - @intent_handler(IntentBuilder("SystemMute") |
133 | | - .require("system").require("mute")) |
134 | | - def handle_system_mute(self, message): |
135 | | - self.speak("am i that annoying?") |
136 | | - self.enclosure.system_mute() |
| 166 | + self.thread = create_daemon(self.run) |
137 | 167 |
|
138 | 168 | @intent_handler(IntentBuilder("EnclosureLookRight") |
139 | 169 | .require("look").require("right") |
@@ -241,10 +271,206 @@ def handle_enclosure_crazy_eyes(self, message): |
241 | 271 | "stupidity, you don't see this every day") |
242 | 272 | self.play_animation(self.crazy_eyes_animation) |
243 | 273 |
|
| 274 | + ##################################################################### |
| 275 | + # Color interactions |
| 276 | + def set_eye_color(self, color=None, rgb=None, speak=True): |
| 277 | + """ Change the eye color on the faceplate, update saved setting |
| 278 | + """ |
| 279 | + if color is not None: |
| 280 | + color_rgb = self._parse_to_rgb(color) |
| 281 | + if color_rgb is not None: |
| 282 | + (r, g, b) = color_rgb |
| 283 | + elif rgb is not None: |
| 284 | + (r, g, b) = rgb |
| 285 | + else: |
| 286 | + return # no color provided! |
| 287 | + |
| 288 | + try: |
| 289 | + self.enclosure.eyes_color(r, g, b) |
| 290 | + if speak: |
| 291 | + self.speak_dialog('set.color.success') |
| 292 | + # Update saved color |
| 293 | + self.settings['current_eye_color'] = [r, g, b] |
| 294 | + except Exception: |
| 295 | + self.log.debug('Bad color code: ' + str(color)) |
| 296 | + if speak: |
| 297 | + self.speak_dialog('error.set.color') |
| 298 | + |
| 299 | + @intent_handler('custom.eye.color.intent') |
| 300 | + def handle_custom_eye_color(self, message): |
| 301 | + # Conversational interaction to set a custom eye color |
| 302 | + |
| 303 | + def is_byte(utt): |
| 304 | + try: |
| 305 | + return 0 <= int(utt) <= 255 |
| 306 | + except Exception: |
| 307 | + return False |
| 308 | + |
| 309 | + self.speak_dialog('set.custom.color') |
| 310 | + wait_while_speaking() |
| 311 | + r = self.get_response('get.r.value', validator=is_byte, |
| 312 | + on_fail="error.rgbvalue", num_retries=2) |
| 313 | + if not r: |
| 314 | + return # cancelled |
| 315 | + |
| 316 | + g = self.get_response('get.g.value', validator=is_byte, |
| 317 | + on_fail="error.rgbvalue", num_retries=2) |
| 318 | + if not g: |
| 319 | + return # cancelled |
| 320 | + |
| 321 | + b = self.get_response('get.b.value', validator=is_byte, |
| 322 | + on_fail="error.rgbvalue", num_retries=2) |
| 323 | + if not b: |
| 324 | + return # cancelled |
| 325 | + |
| 326 | + custom_rgb = [r, g, b] |
| 327 | + self.set_eye_color(rgb=custom_rgb) |
| 328 | + |
| 329 | + @intent_handler('eye.color.intent') |
| 330 | + def handle_eye_color(self, message): |
| 331 | + """ Callback to set eye color from list |
| 332 | +
|
| 333 | + Args: |
| 334 | + message (dict): messagebus message from intent parser |
| 335 | + """ |
| 336 | + color_str = (message.data.get('color', None) or |
| 337 | + self.get_response('color.need')) |
| 338 | + if color_str: |
| 339 | + match = fuzzy_match_color(normalize(color_str), self.color_dict) |
| 340 | + if match is not None: |
| 341 | + self.set_eye_color(color=match) |
| 342 | + else: |
| 343 | + self.speak_dialog('color.not.exist') |
| 344 | + |
| 345 | + def _parse_to_rgb(self, color): |
| 346 | + """ Convert color descriptor to RGB |
| 347 | +
|
| 348 | + Parse a color name ('dark blue'), hex ('#000088') or rgb tuple |
| 349 | + '(0,0,128)' to an RGB tuple. |
| 350 | +
|
| 351 | + Args: |
| 352 | + color (str): RGB, Hex, or color from color_dict |
| 353 | + Returns: |
| 354 | + (r, g, b) (tuple): Tuple of rgb values (0-255) or None |
| 355 | + """ |
| 356 | + if not color: |
| 357 | + return None |
| 358 | + |
| 359 | + # check if named color in dict |
| 360 | + try: |
| 361 | + if color.lower() in self.color_dict: |
| 362 | + return _hex_to_rgb(self.color_dict[color.lower()]) |
| 363 | + except Exception: |
| 364 | + pass |
| 365 | + |
| 366 | + # check if rgb tuple like '(0,0,128)' |
| 367 | + try: |
| 368 | + (r, g, b) = parse_tuple(color) |
| 369 | + if 0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255: |
| 370 | + return (r, g, b) |
| 371 | + else: |
| 372 | + return None |
| 373 | + except Exception: |
| 374 | + pass |
| 375 | + |
| 376 | + # Finally check if color is hex, like '#0000cc' or '0000cc' |
| 377 | + return _hex_to_rgb(color) |
| 378 | + |
| 379 | + def handle_default_eyes(self, message): |
| 380 | + if self.settings.get('current_eye_color'): |
| 381 | + self.set_eye_color(self.settings['current_eye_color'], speak=False) |
| 382 | + |
| 383 | + ##################################################################### |
| 384 | + # Brightness intent interaction |
| 385 | + |
| 386 | + def percent_to_level(self, percent): |
| 387 | + """ converts the brigtness value from percentage to |
| 388 | + a value arduino can read |
| 389 | +
|
| 390 | + Args: |
| 391 | + percent (int): interger value from 0 to 100 |
| 392 | +
|
| 393 | + return: |
| 394 | + (int): value form 0 to 30 |
| 395 | + """ |
| 396 | + return int(float(percent) / float(100) * 30) |
| 397 | + |
| 398 | + def parse_brightness(self, brightness): |
| 399 | + """ parse text for brightness percentage |
| 400 | +
|
| 401 | + Args: |
| 402 | + brightness (str): string containing brightness level |
| 403 | +
|
| 404 | + return: |
| 405 | + (int): brightness as percentage (0-100) |
| 406 | + """ |
| 407 | + |
| 408 | + try: |
| 409 | + # Handle "full", etc. |
| 410 | + name = normalize(brightness) |
| 411 | + if name in self.brightness_dict: |
| 412 | + return self.brightness_dict[name] |
| 413 | + |
| 414 | + if '%' in brightness: |
| 415 | + brightness = brightness.replace("%", "").strip() |
| 416 | + return int(brightness) |
| 417 | + if 'percent' in brightness: |
| 418 | + brightness = brightness.replace("percent", "").strip() |
| 419 | + return int(brightness) |
| 420 | + |
| 421 | + i = int(brightness) |
| 422 | + if i < 0 or i > 100: |
| 423 | + return None |
| 424 | + |
| 425 | + if i < 30: |
| 426 | + # Assume plain 0-30 is "level" |
| 427 | + return int((i * 100.0) / 30.0) |
| 428 | + |
| 429 | + # Assume plain 31-100 is "percentage" |
| 430 | + return i |
| 431 | + except Exception: |
| 432 | + return None # failed in an int() conversion |
| 433 | + |
| 434 | + def set_eye_brightness(self, level, speak=True): |
| 435 | + """ Actually change hardware eye brightness |
| 436 | +
|
| 437 | + Args: |
| 438 | + level (int): 0-30, brightness level |
| 439 | + speak (bool): when True, speak a confirmation |
| 440 | + """ |
| 441 | + self.enclosure.eyes_brightness(level) |
| 442 | + if speak is True: |
| 443 | + percent = int(float(level) * float(100) / float(30)) |
| 444 | + self.speak_dialog( |
| 445 | + 'brightness.set', data={'val': str(percent) + '%'}) |
| 446 | + |
| 447 | + def _set_brightness(self, brightness): |
| 448 | + # brightness can be a number or word like "full", "half" |
| 449 | + percent = self.parse_brightness(brightness) |
| 450 | + if percent is None: |
| 451 | + self.speak_dialog('brightness.not.found.final') |
| 452 | + elif int(percent) is -1: |
| 453 | + self.handle_auto_brightness(None) |
| 454 | + else: |
| 455 | + self.auto_brightness = False |
| 456 | + self.set_eye_brightness(self.percent_to_level(percent)) |
| 457 | + |
| 458 | + @intent_handler('brightness.intent') |
| 459 | + def handle_brightness(self, message): |
| 460 | + """ Intent Callback to set custom eye colors in rgb |
| 461 | +
|
| 462 | + Args: |
| 463 | + message (dict): messagebus message from intent parser |
| 464 | + """ |
| 465 | + brightness = (message.data.get('brightness', None) or |
| 466 | + self.get_response('brightness.not.found')) |
| 467 | + if brightness: |
| 468 | + self._set_brightness(brightness) |
| 469 | + |
244 | 470 | def stop(self): |
245 | 471 | if self.playing and self.thread is not None: |
246 | 472 | self.playing = False |
247 | | - self.thread.join(1) |
248 | 473 | self.enclosure.activate_mouth_events() |
249 | 474 | self.enclosure.eyes_reset() |
250 | 475 | return True |
| 476 | + return False |
0 commit comments