ovos-PHAL-plugin-dotstar 0.0.3a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 builderjer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: ovos-PHAL-plugin-dotstar
3
+ Version: 0.0.3a1
4
+ Summary: An OVOS PHAL plugin to control DotStar type LEDs
5
+ Author-email: builderjer <builderjer@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/OpenVoiceOS/ovos-PHAL-plugin-dotstar
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Text Processing :: Linguistic
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: ovos-plugin-manager<3.0.0,>=2.1.0
19
+ Requires-Dist: ovos-lingua-franca>=0.4.8a8
20
+ Requires-Dist: ovos-i2c-detection>=0.0.5
21
+ Requires-Dist: adafruit-circuitpython-dotstar; platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"
22
+ Requires-Dist: gpiozero; platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"
23
+ Requires-Dist: rpi-lgpio; platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest; extra == "test"
26
+ Requires-Dist: pytest-cov; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # ovos-PHAL-plugin-dotstar
30
+
31
+ **Compatible with**
32
+ * Respeaker 2/4/6/8 i2c microphone HAT's
33
+ * Adafruit 2 mic VoiceBonnet
34
+
35
+ # Usage
36
+
37
+ There are a few ways for this plugin to get enabled.
38
+
39
+ * Enable manually
40
+
41
+ Add this to your `~/.config/mycroft/mycroft.conf`
42
+
43
+ ```json
44
+ {
45
+ "PHAL": {
46
+ "ovos-PHAL-plugin-dotstar": {
47
+ "enabled": true
48
+ }
49
+ }
50
+ }
51
+ ```
52
+ With this configuration, no other validation checks are made. It is assuming you have a compatible HAT installed.
53
+
54
+ * Automatically with [ovos-i2csound](https://github.com/OpenVoiceOS/ovos-i2csound)
55
+
56
+ When `ovos-i2csound` is installed and running, it creates a file at `/etc/OpenVoiceOS/i2c_platform` with the HAT name it detected. This plugin then checks that file and if a compatible HAT is detected, the plugin is activated.
57
+
58
+ * Automatically with hardware detection
59
+
60
+ If the above two options don't work, the plugin tries to detect a compatible HAT using `i2c-detect`. If a compatible device address is found, the plugin will activate.
61
+
62
+ From this point, if you are using a ReSpeaker i2c microphone, your LED's should give you prompts of what is going on with your OVOS assistant.
63
+
64
+ ### Adafruit 2mic voicebonnet
65
+
66
+ The [Adafruit voicebonnet](https://learn.adafruit.com/adafruit-voice-bonnet/overview) can be used with this plugin, but it requires one more step of manual configuration. While the above detection options will properly enable the plugin, this HAT uses different GPIO pins for it's LED's, therefore cannot be easily distinguished from a ReSpeaker 2mic HAT.
67
+
68
+ Add the following to your `~/.config/mycroft/mycroft.conf` file
69
+
70
+ ```json
71
+ {
72
+ "PHAL": {
73
+ "ovos-PHAL-plugin-dotstar": {
74
+ "dotstar_hat": "ADAFRUIT2MIC"
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ And restart OVOS
81
+
82
+ Your LED's on your Adafruit voicebonnet will now show the same prompts as the ReSpeaker devices.
83
+
84
+ ## Custom Configuration
85
+
86
+ Colors and animations can be configured in `~/.config/mycroft/mycroft.conf`
87
+
88
+ ```json
89
+ {
90
+ "PHAL": {
91
+ "ovos-PHAL-plugin-dotstar": {
92
+ "main_color": "Mycroft blue",
93
+ "background_color": "OVOS red",
94
+ "listen_animation": "breath",
95
+ "talking_animation": "blink"
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ `main_color` -> str:
102
+ ```
103
+ rgb value -> "32, 195, 63"
104
+ name -> "blue"
105
+ hex value -> "#22A7F0"
106
+ ```
107
+
108
+ `background_color` -> str
109
+ ```
110
+ rgb value -> "32, 195, 63"
111
+ name -> "red"
112
+ hex value -> "#FF1A1A"
113
+ ```
114
+
115
+ `listen_animation` -> str:
116
+ ```
117
+ one of:
118
+ 'breathe'
119
+ 'chase'
120
+ 'fill'
121
+ 'refill'
122
+ 'bounce'
123
+ 'blink'
124
+ 'alternating'
125
+ ```
126
+
127
+ `talking_animation` -> str:
128
+ ```
129
+ one of:
130
+ 'breathe'
131
+ 'chase'
132
+ 'fill'
133
+ 'refill'
134
+ 'bounce'
135
+ 'blink'
136
+ 'alternating'
137
+ ```
138
+ ## Conflicts With
139
+
140
+ There are two pre-existing plugins that either need uninstalled with pip, or blacklisted in your `mycroft.conf` file.
141
+
142
+ [ovos-PHAL-plugin-respeaker2mic](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-respeaker-2mic)
143
+ [ovos-PHAL-plugin-respeaker4mic](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-respeaker-4mic)
144
+
145
+ ### TODO
146
+
147
+ - [x] Add more animations
148
+ - [x] User configurable colors
149
+ - [ ] Theme support
150
+ - [x] User configurable animations
151
+ - [ ] Add github tests and automation
152
+
153
+ Please enjoy this plugin and don't be afraid to create an [issue](#) if you run into any problems.
@@ -0,0 +1,125 @@
1
+ # ovos-PHAL-plugin-dotstar
2
+
3
+ **Compatible with**
4
+ * Respeaker 2/4/6/8 i2c microphone HAT's
5
+ * Adafruit 2 mic VoiceBonnet
6
+
7
+ # Usage
8
+
9
+ There are a few ways for this plugin to get enabled.
10
+
11
+ * Enable manually
12
+
13
+ Add this to your `~/.config/mycroft/mycroft.conf`
14
+
15
+ ```json
16
+ {
17
+ "PHAL": {
18
+ "ovos-PHAL-plugin-dotstar": {
19
+ "enabled": true
20
+ }
21
+ }
22
+ }
23
+ ```
24
+ With this configuration, no other validation checks are made. It is assuming you have a compatible HAT installed.
25
+
26
+ * Automatically with [ovos-i2csound](https://github.com/OpenVoiceOS/ovos-i2csound)
27
+
28
+ When `ovos-i2csound` is installed and running, it creates a file at `/etc/OpenVoiceOS/i2c_platform` with the HAT name it detected. This plugin then checks that file and if a compatible HAT is detected, the plugin is activated.
29
+
30
+ * Automatically with hardware detection
31
+
32
+ If the above two options don't work, the plugin tries to detect a compatible HAT using `i2c-detect`. If a compatible device address is found, the plugin will activate.
33
+
34
+ From this point, if you are using a ReSpeaker i2c microphone, your LED's should give you prompts of what is going on with your OVOS assistant.
35
+
36
+ ### Adafruit 2mic voicebonnet
37
+
38
+ The [Adafruit voicebonnet](https://learn.adafruit.com/adafruit-voice-bonnet/overview) can be used with this plugin, but it requires one more step of manual configuration. While the above detection options will properly enable the plugin, this HAT uses different GPIO pins for it's LED's, therefore cannot be easily distinguished from a ReSpeaker 2mic HAT.
39
+
40
+ Add the following to your `~/.config/mycroft/mycroft.conf` file
41
+
42
+ ```json
43
+ {
44
+ "PHAL": {
45
+ "ovos-PHAL-plugin-dotstar": {
46
+ "dotstar_hat": "ADAFRUIT2MIC"
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ And restart OVOS
53
+
54
+ Your LED's on your Adafruit voicebonnet will now show the same prompts as the ReSpeaker devices.
55
+
56
+ ## Custom Configuration
57
+
58
+ Colors and animations can be configured in `~/.config/mycroft/mycroft.conf`
59
+
60
+ ```json
61
+ {
62
+ "PHAL": {
63
+ "ovos-PHAL-plugin-dotstar": {
64
+ "main_color": "Mycroft blue",
65
+ "background_color": "OVOS red",
66
+ "listen_animation": "breath",
67
+ "talking_animation": "blink"
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ `main_color` -> str:
74
+ ```
75
+ rgb value -> "32, 195, 63"
76
+ name -> "blue"
77
+ hex value -> "#22A7F0"
78
+ ```
79
+
80
+ `background_color` -> str
81
+ ```
82
+ rgb value -> "32, 195, 63"
83
+ name -> "red"
84
+ hex value -> "#FF1A1A"
85
+ ```
86
+
87
+ `listen_animation` -> str:
88
+ ```
89
+ one of:
90
+ 'breathe'
91
+ 'chase'
92
+ 'fill'
93
+ 'refill'
94
+ 'bounce'
95
+ 'blink'
96
+ 'alternating'
97
+ ```
98
+
99
+ `talking_animation` -> str:
100
+ ```
101
+ one of:
102
+ 'breathe'
103
+ 'chase'
104
+ 'fill'
105
+ 'refill'
106
+ 'bounce'
107
+ 'blink'
108
+ 'alternating'
109
+ ```
110
+ ## Conflicts With
111
+
112
+ There are two pre-existing plugins that either need uninstalled with pip, or blacklisted in your `mycroft.conf` file.
113
+
114
+ [ovos-PHAL-plugin-respeaker2mic](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-respeaker-2mic)
115
+ [ovos-PHAL-plugin-respeaker4mic](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-respeaker-4mic)
116
+
117
+ ### TODO
118
+
119
+ - [x] Add more animations
120
+ - [x] User configurable colors
121
+ - [ ] Theme support
122
+ - [x] User configurable animations
123
+ - [ ] Add github tests and automation
124
+
125
+ Please enjoy this plugin and don't be afraid to create an [issue](#) if you run into any problems.
@@ -0,0 +1,251 @@
1
+ from threading import Event
2
+
3
+ from os.path import exists, expanduser, join
4
+ from time import sleep
5
+
6
+ from ovos_bus_client.message import Message
7
+ from ovos_plugin_manager.phal import PHALPlugin
8
+ from ovos_plugin_manager.templates.phal import PHALValidator
9
+ from ovos_utils.log import LOG
10
+ from ovos_config.config import Configuration
11
+
12
+ from ovos_i2c_detection import is_wm8960, is_respeaker_4mic, is_respeaker_6mic, is_mark_1
13
+
14
+ from lingua_franca.util.colors import Color
15
+ from lingua_franca.internal import load_language
16
+
17
+ from ovos_PHAL_plugin_dotstar.leds import DotStarLed
18
+ from ovos_PHAL_plugin_dotstar.animations import animations
19
+
20
+ # File defined in ovos-i2csound
21
+ # https://github.com/OpenVoiceOS/ovos-i2csound/blob/dev/ovos-i2csound#L76
22
+ I2C_PLATFORM_FILE = "/etc/OpenVoiceOS/i2c_platform"
23
+
24
+ # Wiring of the supported HATs (clock pin, data pin, number of LEDs). The actual
25
+ # adafruit_dotstar.DotStar objects are built lazily by get_predefined_hat() so
26
+ # the module stays importable on hosts without SPI/LED hardware (e.g. CI).
27
+ PREDEFINED_HAT_PINS = {
28
+ "WM8960": ("D11", "D10", 3),
29
+ "RESPEAKER4": ("D11", "D10", 12),
30
+ "RESPEAKER6": ("D11", "D10", 12),
31
+ "ADAFRUIT2MIC": ("D6", "D5", 3),
32
+ }
33
+
34
+
35
+ def get_predefined_hat(name):
36
+ """
37
+ Instantiate the adafruit_dotstar.DotStar strip for a known HAT.
38
+
39
+ Hardware libraries (adafruit_dotstar, board) are imported here rather than at
40
+ module load so the plugin can be imported and unit-tested without LED hardware.
41
+
42
+ Parameters:
43
+ name (str): One of the keys in PREDEFINED_HAT_PINS.
44
+
45
+ Returns:
46
+ adafruit_dotstar.DotStar: configured LED strip.
47
+ """
48
+ clock, data, num_led = PREDEFINED_HAT_PINS[name]
49
+ from adafruit_dotstar import DotStar
50
+ import board
51
+ return DotStar(getattr(board, clock), getattr(board, data), num_led,
52
+ brightness=0.2)
53
+
54
+
55
+ def check_i2c_platform():
56
+ if exists(I2C_PLATFORM_FILE):
57
+ with open(I2C_PLATFORM_FILE, "r") as f:
58
+ platform = f.readline().strip()
59
+ LOG.debug(f"platform in check_i2c_platform: {platform}")
60
+ if platform in PREDEFINED_HAT_PINS:
61
+ LOG.debug(f"detected {platform} in i2c_platform")
62
+ return platform
63
+ return None
64
+
65
+
66
+ class DotStarLedControlPluginValidator(PHALValidator):
67
+
68
+ @staticmethod
69
+ def validate(config=None):
70
+ # If the user enabled the plugin no need to go further
71
+ """
72
+ Validate the configuration for the DotStar LED control plugin.
73
+
74
+ Determines whether the plugin should be activated based on configuration and hardware detection.
75
+
76
+ Parameters:
77
+ config (dict, optional): Plugin configuration dictionary. Defaults to None.
78
+
79
+ Returns:
80
+ bool: True if the plugin should be enabled, False otherwise.
81
+
82
+ Conditions for validation:
83
+ 1. If plugin is explicitly enabled in configuration
84
+ 2. If specific audio hardware is detected (WM8960, ReSpeaker 4-mic, ReSpeaker 6-mic)
85
+ 3. Excludes Mark 1 hardware configuration
86
+
87
+ Logs debug information about validation process.
88
+ """
89
+ if config.get("enabled"):
90
+ LOG.debug("user enabled")
91
+ return True
92
+ # Try a direct hardware check
93
+ if is_wm8960() or is_respeaker_4mic() or is_respeaker_6mic():
94
+ if is_mark_1():
95
+ LOG.debug("Mark 1 detected. Dotstar is not needed")
96
+ return False
97
+ LOG.debug("direct hardware check")
98
+ return True
99
+ LOG.debug("no validation")
100
+ return False
101
+
102
+
103
+ class DotStarLedControlPlugin(PHALPlugin):
104
+ validator = DotStarLedControlPluginValidator
105
+
106
+ lang = Configuration().get("lang", "en")
107
+ try:
108
+ load_language(lang)
109
+ except Exception as e:
110
+ LOG.error(f"Could not load language model {e}")
111
+
112
+ def __init__(self, bus=None, config=None):
113
+ super().__init__(bus=bus, name="ovos-PHAL-plugin-dotstar", config=config)
114
+ self._enable_pin = None
115
+ self.active_animation = None
116
+ self.ds = None
117
+ # Check and see if there is a configuration for a specific board
118
+ if self.config.get("dotstar_hat"):
119
+ ds = self.config.get("dotstar_hat")
120
+ if ds in PREDEFINED_HAT_PINS:
121
+ LOG.debug(f"loading {ds} from config")
122
+ try:
123
+ self.ds = DotStarLed(get_predefined_hat(ds))
124
+ except Exception as e:
125
+ LOG.error(f"Could not load {ds} from config: {e}")
126
+ elif isinstance(self.config.get("dotstar_hat"), dict):
127
+ try:
128
+ from adafruit_dotstar import DotStar
129
+ self.ds = DotStar(
130
+ ds["clock_pin"], ds["led_pin"], ds["num_led"], brightness=ds.get("brightness", 0.2))
131
+ self._enable_pin = ds.get("enable_pin", None)
132
+ except Exception as e:
133
+ LOG.error(f"Could not create led array: {e}")
134
+ else:
135
+ try:
136
+ self.ds = DotStarLed(get_predefined_hat(check_i2c_platform()))
137
+ except KeyError as e:
138
+ LOG.debug(f"check_i2c_platform failed {e}")
139
+ except Exception as e:
140
+ LOG.error(e)
141
+ # No manual configuration and i2csound is not installed or failed
142
+ if not self.ds:
143
+ # Direct hardware checks
144
+ if is_wm8960():
145
+ self.ds = DotStarLed(get_predefined_hat("WM8960"))
146
+ elif is_respeaker_4mic():
147
+ self.ds = DotStarLed(get_predefined_hat("RESPEAKER4"))
148
+ elif is_respeaker_6mic():
149
+ self.ds = DotStarLed(get_predefined_hat("RESPEAKER6"))
150
+ # All else fails, fall back to respeker 4mic
151
+ else:
152
+ self.ds = DotStarLed(get_predefined_hat("RESPEAKER4"))
153
+
154
+ # Required for ReSpeaker 4/6/8 mic
155
+ if not is_wm8960():
156
+ LOG.debug("enable LED's")
157
+ from gpiozero import LED
158
+ from RPi.GPIO import cleanup
159
+ cleanup(5)
160
+ self._enable_pin = LED(5)
161
+ self._enable_pin.on()
162
+
163
+ self.ds.fill(self.main_color.rgb255)
164
+ sleep(1.0)
165
+ self.on_reset()
166
+
167
+ @property
168
+ def main_color(self):
169
+ color = self.config.get(
170
+ "main_color", Color.from_description("Mycroft blue"))
171
+ if isinstance(color, str):
172
+ try:
173
+ color = eval(color)
174
+ color = Color.from_rgb(color[0], color[1], color[2])
175
+ except Exception as e:
176
+ LOG.debug(f"Exception caught in eval {e}")
177
+ try:
178
+ LOG.debug(color)
179
+ color = Color.from_hex(color)
180
+ LOG.debug(color)
181
+ except Exception as e:
182
+ LOG.debug(f"Exception caught in description {e}")
183
+ try:
184
+ color = Color.from_description(color)
185
+ except Exception as e:
186
+ LOG.warning(f"could not set color to {color}: {e}")
187
+ color = Color.from_description("Mycroft blue")
188
+ return color
189
+
190
+ @property
191
+ def background_color(self):
192
+ color = self.config.get(
193
+ "background_color", Color.from_description("OVOS red"))
194
+ if isinstance(color, str):
195
+ try:
196
+ color = eval(color)
197
+ color = Color.from_rgb(color[0], color[1], color[2])
198
+ except Exception as e:
199
+ LOG.debug(f"Exception caught in eval {e}")
200
+ try:
201
+ color = Color.from_description(color)
202
+ except Exception as e:
203
+ LOG.debug(f"Exception caught in description {e}")
204
+ try:
205
+ color = Color.from_hex(color)
206
+ except Exception as e:
207
+ LOG.warning(f"could not set color to {color}: {e}")
208
+ color = Color.from_description("OVOS red")
209
+ return color
210
+
211
+ @property
212
+ def listen_animation(self):
213
+ return self.config.get("listen_animation", "breathe")
214
+
215
+ @property
216
+ def talking_animation(self):
217
+ return self.config.get("talking_animation", "blink")
218
+
219
+ def on_record_begin(self, message=None):
220
+ self.active_animation = animations[self.listen_animation](
221
+ self.ds, self.main_color)
222
+ self.active_animation.start()
223
+
224
+ def on_record_end(self, message=None):
225
+ self.on_reset()
226
+
227
+ def on_audio_output_start(self, message=None):
228
+ LOG.debug(animations[self.talking_animation])
229
+ self.active_animation = animations[self.talking_animation](
230
+ self.ds, self.main_color, repeat=True)
231
+ LOG.debug(self.active_animation)
232
+ self.active_animation.start()
233
+
234
+ def on_audio_output_end(self, message=None):
235
+ self.on_reset()
236
+
237
+ def on_think(self, message=None):
238
+ self.on_reset()
239
+
240
+ def on_reset(self, message=None):
241
+ if self.active_animation:
242
+ self.active_animation.stop()
243
+ self.active_animation = None
244
+ self.ds.fill(Color.from_description("black").rgb255)
245
+
246
+ def on_system_reset(self, message=None):
247
+ self.on_reset()
248
+
249
+ def shutdown(self):
250
+ self.reset()
251
+ super().shutdown()
@@ -0,0 +1,308 @@
1
+ from abc import abstractmethod
2
+ from threading import Event
3
+
4
+ from ovos_utils.log import LOG
5
+ from time import time
6
+ from typing import Optional
7
+
8
+ from lingua_franca.util.colors import Color
9
+ from lingua_franca.internal import load_language
10
+
11
+ from ovos_config.config import Configuration
12
+
13
+ from ovos_PHAL_plugin_dotstar.leds import DotStarLed
14
+
15
+
16
+ class LedAnimation:
17
+ def __init__(self, leds: DotStarLed, **kwargs):
18
+ self.leds = leds
19
+ self._delay = Event()
20
+ # Needed for lingua_franca
21
+ self._lang = Configuration().get("lang", "en")
22
+ load_language(self._lang)
23
+
24
+ @abstractmethod
25
+ def start(self, timeout: Optional[int] = None, one_shot: bool = False):
26
+ """
27
+ Start the animation.
28
+ :param timeout: Optional timeout in seconds after which animation stops
29
+ :param one_shot: if True, run animation once and return
30
+ """
31
+
32
+ @abstractmethod
33
+ def stop(self):
34
+ """
35
+ Stop the animation and reset LEDs to black.
36
+ """
37
+
38
+
39
+ class BreatheLedAnimation(LedAnimation):
40
+ def __init__(self, leds: DotStarLed, color: Color, **kwargs):
41
+ """
42
+ Breathing effect where all LEDs dim up and down until timing out
43
+ or being stopped. LEDs are turned off after animation.
44
+ @param leds: LED object to interact with
45
+ @param color: Base color of LEDs
46
+ """
47
+ LedAnimation.__init__(self, leds)
48
+ self.color = color
49
+ self.step = 0.1
50
+ self.step_delay = 0.1
51
+ self.stopping = Event()
52
+
53
+ def start(self, timeout=None, one_shot=False):
54
+ self.stopping.clear()
55
+ end_time = time() + timeout if timeout else None
56
+ brightness = 0
57
+ step = self.step
58
+ ending = False
59
+ while not self.stopping.is_set():
60
+ if brightness >= 1: # Going Down
61
+ step = -1 * self.step
62
+ elif brightness <= 0:
63
+ step = self.step
64
+
65
+ brightness += step
66
+ brightness = round(brightness, 1)
67
+ self.leds.fill(self.color.rgb255 + (brightness,))
68
+ self._delay.wait(self.step_delay)
69
+ if one_shot and brightness >= 1:
70
+ ending = True
71
+ elif ending and brightness <= 0:
72
+ self.stopping.set()
73
+ elif end_time and time() > end_time:
74
+ self.stopping.set()
75
+ self.leds.fill(Color.from_description("black".rgb255))
76
+
77
+ def stop(self):
78
+ self.stopping.set()
79
+
80
+
81
+ class ChaseLedAnimation(LedAnimation):
82
+ def __init__(self, leds: DotStarLed, foreground_color: Color,
83
+ background_color: Optional[Color] = None, **kwargs):
84
+ """
85
+ Chase effect where all LEDs are lit individually in order until timing
86
+ out or being stopped. LEDs are turned off after animation.
87
+ @param leds: LED object to interact with
88
+ @param foreground_color: Color of active LED
89
+ @param background_color: Color of inactive LEDs
90
+ """
91
+ LedAnimation.__init__(self, leds)
92
+ self.foreground_color = foreground_color
93
+ self.background_color = background_color or Color.from_description(
94
+ "black")
95
+ self.step = 0.05
96
+ self.step_delay = 0.1
97
+ self.stopping = Event()
98
+
99
+ def start(self, timeout=None, one_shot=False):
100
+ self.stopping.clear()
101
+ end_time = time() + timeout if timeout else None
102
+
103
+ self.leds.fill(self.background_color.rgb255)
104
+ while not self.stopping.is_set():
105
+ for led in range(0, self.leds.num_leds):
106
+ self.leds.set_led(led, self.foreground_color.rgb255)
107
+ self._delay.wait(self.step_delay)
108
+ self.leds.set_led(led, self.background_color.rgb255)
109
+ if one_shot:
110
+ self.stopping.set()
111
+ elif end_time and time() > end_time:
112
+ self.stopping.set()
113
+ self.leds.fill(Color.from_description("black").rgb255)
114
+
115
+ def stop(self):
116
+ self.stopping.set()
117
+
118
+
119
+ class FillLedAnimation(LedAnimation):
120
+ def __init__(self, leds: DotStarLed, fill_color: Color,
121
+ reverse: bool = False, **kwargs):
122
+ """
123
+ Fill effect where LEDs are set to the same color in order. LEDs will
124
+ remain lit after the animation.
125
+ @param leds: LED object to interact with
126
+ @param fill_color: Color to fill LEDs
127
+ @param reverse: If true, fill in reverse order
128
+ """
129
+ LedAnimation.__init__(self, leds)
130
+ self.fill_color = fill_color
131
+ self.reverse = reverse
132
+ self.step_delay = 0.05
133
+
134
+ def start(self, timeout=None, one_shot=True):
135
+ if not one_shot or timeout is not None:
136
+ LOG.warning("Fill animation does not support persistent animation")
137
+ leds = list(range(0, self.leds.num_leds))
138
+ if self.reverse:
139
+ leds.reverse()
140
+ for led in leds:
141
+ self.leds.set_led(led, self.fill_color.rgb255)
142
+ self._delay.wait(self.step_delay)
143
+
144
+ def stop(self):
145
+ pass
146
+
147
+
148
+ class RefillLedAnimation(LedAnimation):
149
+ def __init__(self, leds: DotStarLed, fill_color: Color,
150
+ reverse: bool = False, **kwargs):
151
+ """
152
+ Fill effect in the requested color, followed by fill effect in black.
153
+ Animation repeats until timing out or being stopped. LEDs are turned
154
+ off after animation.
155
+ @param leds: LED object to interact with
156
+ @param fill_color: Color to fill LEDs
157
+ @param reverse: If true, fill in reverse order
158
+ """
159
+ LedAnimation.__init__(self, leds)
160
+ self.stopping = Event()
161
+ self.fill_color = fill_color
162
+ self.fill_animation = FillLedAnimation(leds, fill_color, reverse)
163
+
164
+ def start(self, timeout=None, one_shot=False):
165
+ self.stopping.clear()
166
+ end_time = time() + timeout if timeout else None
167
+
168
+ while not self.stopping.is_set():
169
+ self.fill_animation.start()
170
+ self.fill_animation.fill_color = Color.from_description("black")
171
+ self.fill_animation.start()
172
+ self.fill_animation.fill_color = self.fill_color
173
+ if one_shot:
174
+ self.stopping.set()
175
+ elif end_time and time() > end_time:
176
+ self.stopping.set()
177
+
178
+ def stop(self):
179
+ self.stopping.set()
180
+
181
+
182
+ class BounceLedAnimation(LedAnimation):
183
+ def __init__(self, leds: DotStarLed, fill_color: Color,
184
+ reverse: bool = False, **kwargs):
185
+ """
186
+ Fill effect in the requested color, followed by reversed fill effect
187
+ in black. Animation repeats until timing out or being stopped.
188
+ LEDs are turned off after animation.
189
+ @param leds: LED object to interact with
190
+ @param fill_color: Color to fill LEDs
191
+ @param reverse: If true, fill in reverse order
192
+ """
193
+ LOG.debug("bouncing")
194
+ LedAnimation.__init__(self, leds)
195
+ self.stopping = Event()
196
+ self.fill_color = fill_color
197
+ self.fill_animation = FillLedAnimation(leds, fill_color, reverse)
198
+
199
+ def start(self, timeout=None, one_shot=False):
200
+ self.stopping.clear()
201
+ end_time = time() + timeout if timeout else None
202
+
203
+ while not self.stopping.is_set():
204
+ self.fill_animation.start()
205
+ self.fill_animation.reverse = not self.fill_animation.reverse
206
+ self.fill_animation.fill_color = Color.from_description("black")
207
+ self.fill_animation.start()
208
+ self.fill_animation.reverse = not self.fill_animation.reverse
209
+ self.fill_animation.fill_color = self.fill_color
210
+ if one_shot:
211
+ self.stopping.set()
212
+ elif end_time and time() > end_time:
213
+ self.stopping.set()
214
+
215
+ def stop(self):
216
+ self.stopping.set()
217
+
218
+
219
+ class BlinkLedAnimation(LedAnimation):
220
+ def __init__(self, leds: DotStarLed, color: Color,
221
+ num_blinks: int = 2, repeat: bool = False, **kwargs):
222
+ """
223
+ Blink LEDs in the requested color, for the requested number of blinks.
224
+ If repeating, pause and repeat the effect until timeout or stop event.
225
+ @param leds: LED object to interact with
226
+ @param color: Color to blink LEDs
227
+ @param num_blinks: Number of times to blink LEDs
228
+ @param repeat: If true, repeat animation until timeout or stopped
229
+ """
230
+ LedAnimation.__init__(self, leds)
231
+ self.stopping = Event()
232
+ self.color = color
233
+ self.num_blinks = num_blinks
234
+ self.repeat = repeat
235
+
236
+ def start(self, timeout=None, one_shot=False):
237
+ self.stopping.clear()
238
+ end_time = time() + timeout if timeout else None
239
+
240
+ self.leds.fill(Color.from_description("black").rgb255)
241
+ self._delay.wait(0.25)
242
+ while not self.stopping.is_set():
243
+ for i in range(self.num_blinks):
244
+ self.leds.fill(self.color.rgb255)
245
+ self._delay.wait(0.1)
246
+ self.leds.fill(Color.from_description("black").rgb255)
247
+ self._delay.wait(0.1)
248
+ if one_shot:
249
+ self.stopping.set()
250
+ elif self.repeat:
251
+ self._delay.wait(.2)
252
+ else:
253
+ self.stopping.set()
254
+ if end_time and time() > end_time:
255
+ self.stopping.set()
256
+
257
+ def stop(self):
258
+ self.stopping.set()
259
+
260
+
261
+ class AlternatingLedAnimation(LedAnimation):
262
+ def __init__(self, leds: DotStarLed, color: Color, **kwargs):
263
+ """
264
+ Show alternating even/odd LEDs
265
+ @param leds: LED object to interact with
266
+ @param color: Color to blink LEDs
267
+ """
268
+ LedAnimation.__init__(self, leds)
269
+ self.stopping = Event()
270
+ self.color = color
271
+ self.delay = 0.5
272
+
273
+ def start(self, timeout: Optional[int] = None, one_shot: bool = False):
274
+ evens = True
275
+ self.leds.fill(Color.from_description("black").rgb255)
276
+ self.stopping.clear()
277
+ end_time = time() + timeout if timeout else None
278
+ while not self.stopping.is_set():
279
+ for led in range(self.leds.num_leds):
280
+ if evens and led % 2 == 0:
281
+ self.leds.set_led(led, self.color.rgb255, False)
282
+ elif not evens and led % 2 == 1:
283
+ self.leds.set_led(led, self.color.rgb255, False)
284
+ else:
285
+ self.leds.set_led(
286
+ led, Color.from_description("black").rgb255, False)
287
+ # self.leds.show()
288
+ self._delay.wait(self.delay)
289
+ evens = not evens
290
+ if one_shot and evens: # We did one animation
291
+ self.stopping.set()
292
+ elif end_time and time() > end_time:
293
+ self.stopping.set()
294
+ self.leds.fill(Color.from_description("black").rgb255)
295
+
296
+ def stop(self):
297
+ self.stopping.set()
298
+
299
+
300
+ animations = {
301
+ 'breathe': BreatheLedAnimation,
302
+ 'chase': ChaseLedAnimation,
303
+ 'fill': FillLedAnimation,
304
+ 'refill': RefillLedAnimation,
305
+ 'bounce': BounceLedAnimation,
306
+ 'blink': BlinkLedAnimation,
307
+ 'alternating': AlternatingLedAnimation
308
+ }
@@ -0,0 +1,40 @@
1
+ from abc import abstractmethod
2
+
3
+ from ovos_plugin_manager.hardware.led import AbstractLed
4
+
5
+
6
+ class DotStarLed(AbstractLed):
7
+ def __init__(self, dotstar_led_strip):
8
+ # dotstar_led_strip is an adafruit_dotstar.DotStar instance; the type is
9
+ # not imported here so the module is importable without LED hardware.
10
+ self.dotstar = dotstar_led_strip
11
+
12
+ @property
13
+ def num_leds(self):
14
+ return self.dotstar.n
15
+
16
+ @property
17
+ def capabilities(self):
18
+ pass
19
+
20
+ def set_led(self, led_idx: int, color: tuple, immediate: bool = True):
21
+ self.dotstar[led_idx] = color
22
+
23
+ def fill(self, color: tuple):
24
+ self.dotstar.fill(color)
25
+
26
+ def show(self):
27
+ self.dotstar.show()
28
+
29
+ def shutdown(self):
30
+ # TODO: something
31
+ pass
32
+
33
+ def scale_brightness(color_val: int, bright_val: float) -> float:
34
+ """
35
+ Scale an individual color value by a specified brightness.
36
+ :param color_val: 0-255 R, G, or B value
37
+ :param bright_val: 0.0-1.0 brightness scalar value
38
+ :returns: Float modified color value to account for brightness
39
+ """
40
+ return min(255.0, round(color_val * bright_val))
@@ -0,0 +1,153 @@
1
+ Metadata-Version: 2.4
2
+ Name: ovos-PHAL-plugin-dotstar
3
+ Version: 0.0.3a1
4
+ Summary: An OVOS PHAL plugin to control DotStar type LEDs
5
+ Author-email: builderjer <builderjer@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/OpenVoiceOS/ovos-PHAL-plugin-dotstar
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Topic :: Text Processing :: Linguistic
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: ovos-plugin-manager<3.0.0,>=2.1.0
19
+ Requires-Dist: ovos-lingua-franca>=0.4.8a8
20
+ Requires-Dist: ovos-i2c-detection>=0.0.5
21
+ Requires-Dist: adafruit-circuitpython-dotstar; platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"
22
+ Requires-Dist: gpiozero; platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"
23
+ Requires-Dist: rpi-lgpio; platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"
24
+ Provides-Extra: test
25
+ Requires-Dist: pytest; extra == "test"
26
+ Requires-Dist: pytest-cov; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # ovos-PHAL-plugin-dotstar
30
+
31
+ **Compatible with**
32
+ * Respeaker 2/4/6/8 i2c microphone HAT's
33
+ * Adafruit 2 mic VoiceBonnet
34
+
35
+ # Usage
36
+
37
+ There are a few ways for this plugin to get enabled.
38
+
39
+ * Enable manually
40
+
41
+ Add this to your `~/.config/mycroft/mycroft.conf`
42
+
43
+ ```json
44
+ {
45
+ "PHAL": {
46
+ "ovos-PHAL-plugin-dotstar": {
47
+ "enabled": true
48
+ }
49
+ }
50
+ }
51
+ ```
52
+ With this configuration, no other validation checks are made. It is assuming you have a compatible HAT installed.
53
+
54
+ * Automatically with [ovos-i2csound](https://github.com/OpenVoiceOS/ovos-i2csound)
55
+
56
+ When `ovos-i2csound` is installed and running, it creates a file at `/etc/OpenVoiceOS/i2c_platform` with the HAT name it detected. This plugin then checks that file and if a compatible HAT is detected, the plugin is activated.
57
+
58
+ * Automatically with hardware detection
59
+
60
+ If the above two options don't work, the plugin tries to detect a compatible HAT using `i2c-detect`. If a compatible device address is found, the plugin will activate.
61
+
62
+ From this point, if you are using a ReSpeaker i2c microphone, your LED's should give you prompts of what is going on with your OVOS assistant.
63
+
64
+ ### Adafruit 2mic voicebonnet
65
+
66
+ The [Adafruit voicebonnet](https://learn.adafruit.com/adafruit-voice-bonnet/overview) can be used with this plugin, but it requires one more step of manual configuration. While the above detection options will properly enable the plugin, this HAT uses different GPIO pins for it's LED's, therefore cannot be easily distinguished from a ReSpeaker 2mic HAT.
67
+
68
+ Add the following to your `~/.config/mycroft/mycroft.conf` file
69
+
70
+ ```json
71
+ {
72
+ "PHAL": {
73
+ "ovos-PHAL-plugin-dotstar": {
74
+ "dotstar_hat": "ADAFRUIT2MIC"
75
+ }
76
+ }
77
+ }
78
+ ```
79
+
80
+ And restart OVOS
81
+
82
+ Your LED's on your Adafruit voicebonnet will now show the same prompts as the ReSpeaker devices.
83
+
84
+ ## Custom Configuration
85
+
86
+ Colors and animations can be configured in `~/.config/mycroft/mycroft.conf`
87
+
88
+ ```json
89
+ {
90
+ "PHAL": {
91
+ "ovos-PHAL-plugin-dotstar": {
92
+ "main_color": "Mycroft blue",
93
+ "background_color": "OVOS red",
94
+ "listen_animation": "breath",
95
+ "talking_animation": "blink"
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ `main_color` -> str:
102
+ ```
103
+ rgb value -> "32, 195, 63"
104
+ name -> "blue"
105
+ hex value -> "#22A7F0"
106
+ ```
107
+
108
+ `background_color` -> str
109
+ ```
110
+ rgb value -> "32, 195, 63"
111
+ name -> "red"
112
+ hex value -> "#FF1A1A"
113
+ ```
114
+
115
+ `listen_animation` -> str:
116
+ ```
117
+ one of:
118
+ 'breathe'
119
+ 'chase'
120
+ 'fill'
121
+ 'refill'
122
+ 'bounce'
123
+ 'blink'
124
+ 'alternating'
125
+ ```
126
+
127
+ `talking_animation` -> str:
128
+ ```
129
+ one of:
130
+ 'breathe'
131
+ 'chase'
132
+ 'fill'
133
+ 'refill'
134
+ 'bounce'
135
+ 'blink'
136
+ 'alternating'
137
+ ```
138
+ ## Conflicts With
139
+
140
+ There are two pre-existing plugins that either need uninstalled with pip, or blacklisted in your `mycroft.conf` file.
141
+
142
+ [ovos-PHAL-plugin-respeaker2mic](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-respeaker-2mic)
143
+ [ovos-PHAL-plugin-respeaker4mic](https://github.com/OpenVoiceOS/ovos-PHAL-plugin-respeaker-4mic)
144
+
145
+ ### TODO
146
+
147
+ - [x] Add more animations
148
+ - [x] User configurable colors
149
+ - [ ] Theme support
150
+ - [x] User configurable animations
151
+ - [ ] Add github tests and automation
152
+
153
+ Please enjoy this plugin and don't be afraid to create an [issue](#) if you run into any problems.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ version.py
5
+ ovos_PHAL_plugin_dotstar/__init__.py
6
+ ovos_PHAL_plugin_dotstar/animations.py
7
+ ovos_PHAL_plugin_dotstar/leds.py
8
+ ovos_PHAL_plugin_dotstar.egg-info/PKG-INFO
9
+ ovos_PHAL_plugin_dotstar.egg-info/SOURCES.txt
10
+ ovos_PHAL_plugin_dotstar.egg-info/dependency_links.txt
11
+ ovos_PHAL_plugin_dotstar.egg-info/entry_points.txt
12
+ ovos_PHAL_plugin_dotstar.egg-info/requires.txt
13
+ ovos_PHAL_plugin_dotstar.egg-info/top_level.txt
14
+ test/test_unit.py
@@ -0,0 +1,2 @@
1
+ [opm.phal]
2
+ ovos-PHAL-plugin-dotstar = ovos_PHAL_plugin_dotstar:DotStarLedControlPlugin
@@ -0,0 +1,12 @@
1
+ ovos-plugin-manager<3.0.0,>=2.1.0
2
+ ovos-lingua-franca>=0.4.8a8
3
+ ovos-i2c-detection>=0.0.5
4
+
5
+ [:platform_machine == "aarch64" or platform_machine == "armv7l" or platform_machine == "armv6l"]
6
+ adafruit-circuitpython-dotstar
7
+ gpiozero
8
+ rpi-lgpio
9
+
10
+ [test]
11
+ pytest
12
+ pytest-cov
@@ -0,0 +1,2 @@
1
+ ovos_PHAL_plugin_dotstar
2
+ version
@@ -0,0 +1,56 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ovos-PHAL-plugin-dotstar"
7
+ dynamic = ["version"]
8
+ description = "An OVOS PHAL plugin to control DotStar type LEDs"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [
12
+ {name = "builderjer", email = "builderjer@gmail.com"}
13
+ ]
14
+ requires-python = ">=3.9"
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Text Processing :: Linguistic",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11"
23
+ ]
24
+ dependencies = [
25
+ "ovos-plugin-manager>=2.1.0,<3.0.0",
26
+ "ovos-lingua-franca>=0.4.8a8",
27
+ "ovos-i2c-detection>=0.0.5",
28
+ # Raspberry Pi LED/GPIO stack. These only build/run on the Pi and pull C
29
+ # extensions (lgpio) that cannot compile on generic CI runners, so they are
30
+ # gated to ARM Linux. The plugin imports them lazily at LED init time.
31
+ "adafruit-circuitpython-dotstar ; platform_machine == 'aarch64' or platform_machine == 'armv7l' or platform_machine == 'armv6l'",
32
+ "gpiozero ; platform_machine == 'aarch64' or platform_machine == 'armv7l' or platform_machine == 'armv6l'",
33
+ "rpi-lgpio ; platform_machine == 'aarch64' or platform_machine == 'armv7l' or platform_machine == 'armv6l'",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ test = ["pytest", "pytest-cov"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/OpenVoiceOS/ovos-PHAL-plugin-dotstar"
41
+
42
+ [project.entry-points."opm.phal"]
43
+ "ovos-PHAL-plugin-dotstar" = "ovos_PHAL_plugin_dotstar:DotStarLedControlPlugin"
44
+
45
+ [tool.setuptools.dynamic]
46
+ version = {attr = "version.__version__"}
47
+
48
+ [tool.setuptools]
49
+ py-modules = ["version"]
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["."]
53
+ include = ["ovos_PHAL_plugin_dotstar*"]
54
+
55
+ [tool.setuptools.package-data]
56
+ ovos_PHAL_plugin_dotstar = ["*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,167 @@
1
+ """Unit tests for ovos-PHAL-plugin-dotstar.
2
+
3
+ These exercise the pure-python logic of the plugin (HAT pin tables, i2c platform
4
+ detection, the DotStarLed wrapper and the LED animations) without requiring any
5
+ real LED/GPIO hardware. The Raspberry Pi hardware libraries (adafruit_dotstar,
6
+ board, gpiozero, RPi.GPIO) are imported lazily inside the plugin, so the package
7
+ imports cleanly here and the LED strip is replaced by a simple in-memory fake.
8
+ """
9
+ import unittest
10
+ from unittest.mock import patch
11
+
12
+ import ovos_PHAL_plugin_dotstar as dotstar
13
+ from ovos_PHAL_plugin_dotstar import (
14
+ PREDEFINED_HAT_PINS,
15
+ check_i2c_platform,
16
+ get_predefined_hat,
17
+ )
18
+ from ovos_PHAL_plugin_dotstar.leds import DotStarLed
19
+ from ovos_PHAL_plugin_dotstar.animations import (
20
+ animations,
21
+ BlinkLedAnimation,
22
+ FillLedAnimation,
23
+ )
24
+
25
+
26
+ class FakeStrip:
27
+ """Minimal stand-in for an adafruit_dotstar.DotStar strip."""
28
+
29
+ def __init__(self, n=4):
30
+ self.n = n
31
+ self.pixels = [(0, 0, 0)] * n
32
+ self.filled = None
33
+ self.shown = 0
34
+
35
+ def __setitem__(self, idx, color):
36
+ self.pixels[idx] = color
37
+
38
+ def fill(self, color):
39
+ self.filled = color
40
+
41
+ def show(self):
42
+ self.shown += 1
43
+
44
+
45
+ class TestModuleImport(unittest.TestCase):
46
+ def test_imports_without_hardware(self):
47
+ # Importing the plugin must not require LED/GPIO hardware libraries.
48
+ self.assertTrue(hasattr(dotstar, "DotStarLedControlPlugin"))
49
+ self.assertTrue(hasattr(dotstar, "DotStarLedControlPluginValidator"))
50
+
51
+ def test_predefined_hat_pins_known(self):
52
+ self.assertIn("WM8960", PREDEFINED_HAT_PINS)
53
+ self.assertIn("RESPEAKER4", PREDEFINED_HAT_PINS)
54
+ self.assertIn("RESPEAKER6", PREDEFINED_HAT_PINS)
55
+ self.assertIn("ADAFRUIT2MIC", PREDEFINED_HAT_PINS)
56
+ for clock, data, num in PREDEFINED_HAT_PINS.values():
57
+ self.assertIsInstance(clock, str)
58
+ self.assertIsInstance(data, str)
59
+ self.assertIsInstance(num, int)
60
+
61
+
62
+ class TestGetPredefinedHat(unittest.TestCase):
63
+ def test_builds_strip_with_board_pins(self):
64
+ captured = {}
65
+
66
+ class FakeBoard:
67
+ D11 = "PIN_D11"
68
+ D10 = "PIN_D10"
69
+
70
+ def fake_dotstar(clock, data, num_led, brightness=0.2):
71
+ captured.update(clock=clock, data=data, num_led=num_led,
72
+ brightness=brightness)
73
+ return FakeStrip(num_led)
74
+
75
+ import sys
76
+ import types
77
+ fake_mod = types.ModuleType("adafruit_dotstar")
78
+ fake_mod.DotStar = fake_dotstar
79
+ with patch.dict(sys.modules, {"adafruit_dotstar": fake_mod,
80
+ "board": FakeBoard()}):
81
+ strip = get_predefined_hat("WM8960")
82
+ self.assertEqual(captured["clock"], "PIN_D11")
83
+ self.assertEqual(captured["data"], "PIN_D10")
84
+ self.assertEqual(captured["num_led"], 3)
85
+ self.assertIsInstance(strip, FakeStrip)
86
+
87
+ def test_unknown_hat_raises(self):
88
+ with self.assertRaises(KeyError):
89
+ get_predefined_hat("NOPE")
90
+
91
+
92
+ class TestCheckI2cPlatform(unittest.TestCase):
93
+ def test_missing_file_returns_none(self):
94
+ with patch("ovos_PHAL_plugin_dotstar.exists", return_value=False):
95
+ self.assertIsNone(check_i2c_platform())
96
+
97
+ def test_known_platform_returned(self):
98
+ from unittest.mock import mock_open
99
+ m = mock_open(read_data="WM8960\n")
100
+ with patch("ovos_PHAL_plugin_dotstar.exists", return_value=True), \
101
+ patch("builtins.open", m):
102
+ self.assertEqual(check_i2c_platform(), "WM8960")
103
+
104
+ def test_unknown_platform_returns_none(self):
105
+ from unittest.mock import mock_open
106
+ m = mock_open(read_data="SOMETHING_ELSE\n")
107
+ with patch("ovos_PHAL_plugin_dotstar.exists", return_value=True), \
108
+ patch("builtins.open", m):
109
+ self.assertIsNone(check_i2c_platform())
110
+
111
+
112
+ class TestDotStarLed(unittest.TestCase):
113
+ def setUp(self):
114
+ self.strip = FakeStrip(n=4)
115
+ self.led = DotStarLed(self.strip)
116
+
117
+ def test_num_leds(self):
118
+ self.assertEqual(self.led.num_leds, 4)
119
+
120
+ def test_set_led(self):
121
+ self.led.set_led(2, (1, 2, 3))
122
+ self.assertEqual(self.strip.pixels[2], (1, 2, 3))
123
+
124
+ def test_fill(self):
125
+ self.led.fill((9, 9, 9))
126
+ self.assertEqual(self.strip.filled, (9, 9, 9))
127
+
128
+ def test_show(self):
129
+ self.led.show()
130
+ self.assertEqual(self.strip.shown, 1)
131
+
132
+
133
+ class TestAnimationsRegistry(unittest.TestCase):
134
+ def test_registry_keys(self):
135
+ for key in ("breathe", "chase", "fill", "refill", "bounce", "blink",
136
+ "alternating"):
137
+ self.assertIn(key, animations)
138
+
139
+
140
+ class TestFillAnimation(unittest.TestCase):
141
+ def test_fill_sets_each_led(self):
142
+ from lingua_franca.util.colors import Color
143
+ strip = FakeStrip(n=3)
144
+ led = DotStarLed(strip)
145
+ anim = FillLedAnimation(led, Color.from_rgb(10, 20, 30))
146
+ # patch the internal delay so the test does not sleep
147
+ anim._delay.set()
148
+ anim.start()
149
+ # all three pixels should have been written
150
+ for px in strip.pixels:
151
+ self.assertEqual(px, (10, 20, 30))
152
+
153
+
154
+ class TestBlinkAnimationOneShot(unittest.TestCase):
155
+ def test_blink_one_shot_terminates(self):
156
+ from lingua_franca.util.colors import Color
157
+ strip = FakeStrip(n=2)
158
+ led = DotStarLed(strip)
159
+ anim = BlinkLedAnimation(led, Color.from_rgb(255, 0, 0), num_blinks=1)
160
+ anim._delay.set()
161
+ # one_shot must terminate the loop without an explicit stop()
162
+ anim.start(one_shot=True)
163
+ self.assertTrue(anim.stopping.is_set())
164
+
165
+
166
+ if __name__ == "__main__":
167
+ unittest.main()
@@ -0,0 +1,9 @@
1
+ # The following lines are replaced during the release process.
2
+ # START_VERSION_BLOCK
3
+ VERSION_MAJOR = 0
4
+ VERSION_MINOR = 0
5
+ VERSION_BUILD = 3
6
+ VERSION_ALPHA = 1
7
+ # END_VERSION_BLOCK
8
+
9
+ __version__ = f"{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_BUILD}" + (f"a{VERSION_ALPHA}" if VERSION_ALPHA else "")