blinkenhub 0.1.0__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) 2026 Systemix Software, Inc.
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,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: blinkenhub
3
+ Version: 0.1.0
4
+ Summary: Python library and command-line tool to control the Blinkenhub Mac mini stand
5
+ Author-email: Brian Cuthie <brian@systemix.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://blinkenhub.com
8
+ Project-URL: Source, https://github.com/blinkenhub/python
9
+ Project-URL: Issues, https://github.com/blinkenhub/python/issues
10
+ Keywords: blinkenhub,led,mac mini,serial,rgb
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Hardware
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: click
22
+ Requires-Dist: crc
23
+ Requires-Dist: pyserial
24
+ Dynamic: license-file
25
+
26
+ # blinkenhub
27
+
28
+ Python library and command-line tool to control the [Blinkenhub](https://blinkenhub.com)
29
+ Mac mini stand, a USB-connected ring of 17 RGB LEDs.
30
+
31
+ ## Installation
32
+
33
+ ```sh
34
+ pip install blinkenhub
35
+ ```
36
+
37
+ This installs the `blinkenhub` module and the `blinkenhub` command.
38
+
39
+ ## Command-line usage
40
+
41
+ The device is found automatically. To use a specific serial port, pass
42
+ `--port` or set the `BLINKENHUB_PORT` environment variable.
43
+
44
+ ```sh
45
+ blinkenhub rainbow --value 0.3 --rate 2 # spinning rainbow
46
+ blinkenhub disco # random colors until Ctrl-C
47
+ blinkenhub set-pixels -p 0 1 .5 -d 240 1 .2
48
+ blinkenhub animate-value --oscillate --rate 0.5 --count -1
49
+ blinkenhub stop # stop all animations
50
+ ```
51
+
52
+ Run `blinkenhub --help`, or `blinkenhub <command> --help`, for every command
53
+ and option.
54
+
55
+ `blinkenhub run` shows random colors while the Mac's displays are awake and a
56
+ red sweep while they sleep. It works on macOS only; the other commands work on
57
+ any platform.
58
+
59
+ ## Library usage
60
+
61
+ Colors are HSV: hue in degrees (0–360), saturation and value (brightness)
62
+ from 0 to 1.
63
+
64
+ ```python
65
+ from blinkenhub import Blinkenhub, HSV_Color, SetPixels, Rotate, ValueAnimation, Animation
66
+
67
+ bh = Blinkenhub() # or Blinkenhub("/dev/cu.usbmodem1101")
68
+
69
+ # Set all 17 pixels to dim blue, fading over one second
70
+ bh.send(SetPixels([HSV_Color(240, 1, 0.2)] * 17, 1.0))
71
+
72
+ # Pulse the brightness forever
73
+ bh.send(ValueAnimation(Animation.ANIMATE_UPDOWN, rate=0.5, top=0.5, bottom=0.05, count=-1))
74
+
75
+ # Spin the pattern clockwise at 3 pixels per second
76
+ bh.send(Rotate(Rotate.CW, 3.0, -1))
77
+ ```
78
+
79
+ `send()` returns `True` when the device accepts the command, and raises
80
+ `TimeoutException` if it doesn't answer.
81
+
82
+ ## License
83
+
84
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,59 @@
1
+ # blinkenhub
2
+
3
+ Python library and command-line tool to control the [Blinkenhub](https://blinkenhub.com)
4
+ Mac mini stand, a USB-connected ring of 17 RGB LEDs.
5
+
6
+ ## Installation
7
+
8
+ ```sh
9
+ pip install blinkenhub
10
+ ```
11
+
12
+ This installs the `blinkenhub` module and the `blinkenhub` command.
13
+
14
+ ## Command-line usage
15
+
16
+ The device is found automatically. To use a specific serial port, pass
17
+ `--port` or set the `BLINKENHUB_PORT` environment variable.
18
+
19
+ ```sh
20
+ blinkenhub rainbow --value 0.3 --rate 2 # spinning rainbow
21
+ blinkenhub disco # random colors until Ctrl-C
22
+ blinkenhub set-pixels -p 0 1 .5 -d 240 1 .2
23
+ blinkenhub animate-value --oscillate --rate 0.5 --count -1
24
+ blinkenhub stop # stop all animations
25
+ ```
26
+
27
+ Run `blinkenhub --help`, or `blinkenhub <command> --help`, for every command
28
+ and option.
29
+
30
+ `blinkenhub run` shows random colors while the Mac's displays are awake and a
31
+ red sweep while they sleep. It works on macOS only; the other commands work on
32
+ any platform.
33
+
34
+ ## Library usage
35
+
36
+ Colors are HSV: hue in degrees (0–360), saturation and value (brightness)
37
+ from 0 to 1.
38
+
39
+ ```python
40
+ from blinkenhub import Blinkenhub, HSV_Color, SetPixels, Rotate, ValueAnimation, Animation
41
+
42
+ bh = Blinkenhub() # or Blinkenhub("/dev/cu.usbmodem1101")
43
+
44
+ # Set all 17 pixels to dim blue, fading over one second
45
+ bh.send(SetPixels([HSV_Color(240, 1, 0.2)] * 17, 1.0))
46
+
47
+ # Pulse the brightness forever
48
+ bh.send(ValueAnimation(Animation.ANIMATE_UPDOWN, rate=0.5, top=0.5, bottom=0.05, count=-1))
49
+
50
+ # Spin the pattern clockwise at 3 pixels per second
51
+ bh.send(Rotate(Rotate.CW, 3.0, -1))
52
+ ```
53
+
54
+ `send()` returns `True` when the device accepts the command, and raises
55
+ `TimeoutException` if it doesn't answer.
56
+
57
+ ## License
58
+
59
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.4
2
+ Name: blinkenhub
3
+ Version: 0.1.0
4
+ Summary: Python library and command-line tool to control the Blinkenhub Mac mini stand
5
+ Author-email: Brian Cuthie <brian@systemix.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://blinkenhub.com
8
+ Project-URL: Source, https://github.com/blinkenhub/python
9
+ Project-URL: Issues, https://github.com/blinkenhub/python/issues
10
+ Keywords: blinkenhub,led,mac mini,serial,rgb
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Hardware
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: click
22
+ Requires-Dist: crc
23
+ Requires-Dist: pyserial
24
+ Dynamic: license-file
25
+
26
+ # blinkenhub
27
+
28
+ Python library and command-line tool to control the [Blinkenhub](https://blinkenhub.com)
29
+ Mac mini stand, a USB-connected ring of 17 RGB LEDs.
30
+
31
+ ## Installation
32
+
33
+ ```sh
34
+ pip install blinkenhub
35
+ ```
36
+
37
+ This installs the `blinkenhub` module and the `blinkenhub` command.
38
+
39
+ ## Command-line usage
40
+
41
+ The device is found automatically. To use a specific serial port, pass
42
+ `--port` or set the `BLINKENHUB_PORT` environment variable.
43
+
44
+ ```sh
45
+ blinkenhub rainbow --value 0.3 --rate 2 # spinning rainbow
46
+ blinkenhub disco # random colors until Ctrl-C
47
+ blinkenhub set-pixels -p 0 1 .5 -d 240 1 .2
48
+ blinkenhub animate-value --oscillate --rate 0.5 --count -1
49
+ blinkenhub stop # stop all animations
50
+ ```
51
+
52
+ Run `blinkenhub --help`, or `blinkenhub <command> --help`, for every command
53
+ and option.
54
+
55
+ `blinkenhub run` shows random colors while the Mac's displays are awake and a
56
+ red sweep while they sleep. It works on macOS only; the other commands work on
57
+ any platform.
58
+
59
+ ## Library usage
60
+
61
+ Colors are HSV: hue in degrees (0–360), saturation and value (brightness)
62
+ from 0 to 1.
63
+
64
+ ```python
65
+ from blinkenhub import Blinkenhub, HSV_Color, SetPixels, Rotate, ValueAnimation, Animation
66
+
67
+ bh = Blinkenhub() # or Blinkenhub("/dev/cu.usbmodem1101")
68
+
69
+ # Set all 17 pixels to dim blue, fading over one second
70
+ bh.send(SetPixels([HSV_Color(240, 1, 0.2)] * 17, 1.0))
71
+
72
+ # Pulse the brightness forever
73
+ bh.send(ValueAnimation(Animation.ANIMATE_UPDOWN, rate=0.5, top=0.5, bottom=0.05, count=-1))
74
+
75
+ # Spin the pattern clockwise at 3 pixels per second
76
+ bh.send(Rotate(Rotate.CW, 3.0, -1))
77
+ ```
78
+
79
+ `send()` returns `True` when the device accepts the command, and raises
80
+ `TimeoutException` if it doesn't answer.
81
+
82
+ ## License
83
+
84
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ blinkenhub.py
4
+ pyproject.toml
5
+ blinkenhub.egg-info/PKG-INFO
6
+ blinkenhub.egg-info/SOURCES.txt
7
+ blinkenhub.egg-info/dependency_links.txt
8
+ blinkenhub.egg-info/entry_points.txt
9
+ blinkenhub.egg-info/requires.txt
10
+ blinkenhub.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ blinkenhub = blinkenhub:main
@@ -0,0 +1,3 @@
1
+ click
2
+ crc
3
+ pyserial
@@ -0,0 +1 @@
1
+ blinkenhub
@@ -0,0 +1,660 @@
1
+ # SPDX-License-Identifier: MIT
2
+ #
3
+ # Copyright (c) 2026 Systemix Software, Inc.
4
+ #
5
+ # See the LICENSE file for the full license text.
6
+
7
+ """Control library and command-line interface for the Blinkenhub Mac mini stand.
8
+
9
+ Blinkenhub is a USB-attached ring of 17 RGB LEDs. This module talks to it over
10
+ a serial port using a simple framed protocol: each command is serialized to
11
+ little-endian binary and followed by a CRC-8 (CCITT) checksum. ``ESC`` and
12
+ ``ETX`` bytes are escaped, and the frame ends with ``ETX``. The device answers
13
+ each frame with a single ``A`` (ACK) or ``N`` (NACK) byte.
14
+
15
+ Colors are expressed in HSV: hue in degrees ``[0.0, 360.0]``, and saturation
16
+ and value (brightness) in ``[0.0, 1.0]``.
17
+
18
+ Example:
19
+ Show a dim rainbow and spin it clockwise::
20
+
21
+ from blinkenhub import Blinkenhub, SetPixels, Rotate, mkrainbow
22
+
23
+ bh = Blinkenhub() # auto-detect the device
24
+ bh.send(SetPixels(mkrainbow(0.25), 0))
25
+ bh.send(Rotate(Rotate.CW, 2.0, -1))
26
+
27
+ The ``blinkenhub`` console script (see :func:`main`) exposes the same
28
+ functionality from the shell; run ``blinkenhub --help`` for details.
29
+ """
30
+
31
+ import struct
32
+ from crc import Calculator, Crc8
33
+ import serial
34
+ import serial.tools.list_ports
35
+ from random import random
36
+ from time import sleep
37
+ import ctypes
38
+
39
+
40
+ #: Serial port used by :func:`stop_animations` and the CLI commands. ``None``
41
+ #: means auto-detect. The CLI sets it from ``--port`` / ``BLINKENHUB_PORT``.
42
+ device = None
43
+
44
+
45
+ class TimeoutException(Exception):
46
+ """Raised when the Blinkenhub does not reply to a command before the serial timeout."""
47
+ pass
48
+
49
+
50
+ class HSV_Color:
51
+ """A single pixel color in the HSV color space.
52
+
53
+ Attributes:
54
+ h (float): Hue in degrees, ``[0.0, 360.0]``.
55
+ s (float): Saturation, ``[0.0, 1.0]``.
56
+ v (float): Value (brightness), ``[0.0, 1.0]``.
57
+ """
58
+
59
+ def __init__(self, hue, saturation, value):
60
+ """Create a color.
61
+
62
+ Args:
63
+ hue (float): Hue in degrees, ``[0.0, 360.0]``.
64
+ saturation (float): Saturation, ``[0.0, 1.0]``.
65
+ value (float): Value (brightness), ``[0.0, 1.0]``.
66
+ """
67
+ self.h = hue
68
+ self.s = saturation
69
+ self.v = value
70
+
71
+ def __bytes__(self):
72
+ """Encode the color for transmission to the device.
73
+
74
+ Returns:
75
+ bytes: Three little-endian 32-bit floats ``(h, s, v)``, 12 bytes total.
76
+ """
77
+ # encode in format suitable to transmission over the wire
78
+ return struct.pack("<fff", self.h, self.s, self.v)
79
+
80
+
81
+ class Blinkenhub:
82
+ """Serial connection to a Blinkenhub device.
83
+
84
+ Handles device discovery, framing, and the ACK/NACK handshake. Commands are
85
+ built from :class:`BlinkenhubCmd` subclasses and sent with :meth:`send`.
86
+
87
+ Attributes:
88
+ ESC (int): Escape byte; precedes any ``ESC`` or ``ETX`` byte in the payload.
89
+ ACK (str): Reply sent by the device when it accepts a command.
90
+ NACK (str): Reply sent by the device when it rejects a command.
91
+ ETX (int): End-of-frame byte.
92
+ calculator (crc.Calculator): CRC-8 (CCITT) calculator for frame checksums.
93
+ ser (serial.Serial): The open serial port.
94
+ """
95
+
96
+ ESC = 0x1b
97
+ ACK = 'A'
98
+ NACK = 'N'
99
+ ETX = 0x03
100
+
101
+ def __init__(self, port=None):
102
+ """Open a connection to a Blinkenhub.
103
+
104
+ Args:
105
+ port (str, optional): Serial device path, such as
106
+ ``/dev/cu.usbmodem1101``. If omitted, the first USB serial port
107
+ whose manufacturer is ``Systemix`` and product is ``Blinkenhub``
108
+ is used.
109
+
110
+ Raises:
111
+ Exception: If ``port`` is omitted and no Blinkenhub is found.
112
+ serial.SerialException: If the serial port cannot be opened.
113
+ """
114
+ self.calculator = Calculator(Crc8.CCITT)
115
+ if port is None:
116
+ # find blinkenhub
117
+ for p in serial.tools.list_ports.comports():
118
+ if p.manufacturer == 'Systemix' and p.product == 'Blinkenhub':
119
+ port = p.device
120
+ break
121
+ if port is None:
122
+ raise Exception("Blinkenhub not found")
123
+
124
+ self.ser = serial.Serial(port, timeout=.5)
125
+
126
+ def prepare_buf(self, buf):
127
+ """Frame a raw payload for transmission.
128
+
129
+ Appends a CRC-8 checksum, escapes every ``ESC`` and ``ETX`` byte by
130
+ prefixing it with ``ESC``, and terminates the frame with ``ETX``.
131
+
132
+ Args:
133
+ buf (bytes): The serialized command.
134
+
135
+ Returns:
136
+ bytearray: The framed bytes, ready to write to the serial port.
137
+ """
138
+ # add crc
139
+ raw = bytearray()
140
+ raw += buf
141
+ raw.append(self.calculator.checksum(raw))
142
+
143
+ # escape special chars
144
+ escbuf = bytearray()
145
+ for b in raw:
146
+ if b == self.ESC or b == self.ETX:
147
+ escbuf.append(self.ESC)
148
+ escbuf.append(b)
149
+
150
+ # add the record terminator
151
+ escbuf.append(self.ETX)
152
+
153
+ return escbuf
154
+
155
+ def send(self, item, reply=True):
156
+ """Send a command to the device.
157
+
158
+ Args:
159
+ item (BlinkenhubCmd): The command to send. Any object that supports
160
+ ``bytes()`` is accepted.
161
+ reply (bool): If ``True`` (the default), wait for the device's
162
+ ACK/NACK reply.
163
+
164
+ Returns:
165
+ bool: ``True`` if the device acknowledged the command, or if
166
+ ``reply`` is ``False``. ``False`` if the device replied with
167
+ anything other than ACK.
168
+
169
+ Raises:
170
+ TimeoutException: If ``reply`` is ``True`` and no reply arrives
171
+ within the serial timeout (0.5 s).
172
+ """
173
+ self.ser.write(self.prepare_buf(item.__bytes__()))
174
+ if reply:
175
+ resp = self.ser.read()
176
+ if len(resp) < 1:
177
+ raise TimeoutException()
178
+ elif resp == b'A':
179
+ return True
180
+ else:
181
+ return False
182
+ else:
183
+ return True
184
+
185
+
186
+ class BlinkenhubCmd:
187
+ """Base class for all device commands.
188
+
189
+ Each command is sent as a 4-byte header (command ID, protocol version, and
190
+ two padding bytes) followed by a command-specific payload. Subclasses must
191
+ set ``self.buf`` to that payload.
192
+
193
+ Attributes:
194
+ VERSION (int): Protocol version sent in every command header.
195
+ CMD_SET_PIXELS (int): Command ID for :class:`SetPixels`.
196
+ CMD_ANIMATE_HUE (int): Command ID for :class:`HueAnimation`.
197
+ CMD_ANIMATE_SATURATION (int): Command ID for :class:`SaturationAnimation`.
198
+ CMD_ANIMATE_VALUE (int): Command ID for :class:`ValueAnimation`.
199
+ CMD_ROTATE (int): Command ID for :class:`Rotate`.
200
+ CMD_SET_GAMMA (int): Command ID for :class:`SetGamma`.
201
+ cmd (int): This command's ID.
202
+ buf (bytes): This command's payload, set by the subclass.
203
+ """
204
+
205
+ VERSION = 1
206
+
207
+ CMD_SET_PIXELS = 0
208
+ CMD_ANIMATE_HUE = 1
209
+ CMD_ANIMATE_SATURATION = 2
210
+ CMD_ANIMATE_VALUE = 3
211
+ CMD_ROTATE = 4
212
+ CMD_SET_GAMMA = 5
213
+
214
+ def __init__(self, cmd):
215
+ """Initialize the command header.
216
+
217
+ Args:
218
+ cmd (int): One of the ``CMD_*`` command IDs.
219
+ """
220
+ self.cmd = cmd
221
+
222
+ def __bytes__(self):
223
+ """Serialize the command.
224
+
225
+ Returns:
226
+ bytearray: The 4-byte header followed by the payload in ``self.buf``.
227
+ """
228
+ b = bytearray()
229
+ b += struct.pack("<BBxx", self.cmd, self.VERSION)
230
+ b += self.buf
231
+ return b
232
+
233
+
234
+ class Animation(BlinkenhubCmd):
235
+ """Base class for commands that animate one HSV channel of every pixel.
236
+
237
+ Use one of the subclasses: :class:`HueAnimation`,
238
+ :class:`SaturationAnimation`, or :class:`ValueAnimation`.
239
+
240
+ Attributes:
241
+ ANIMATE_NONE (int): No animation.
242
+ ANIMATE_UP (int): Ramp from ``bottom`` to ``top``.
243
+ ANIMATE_DOWN (int): Ramp from ``top`` to ``bottom``.
244
+ ANIMATE_UPDOWN (int): Oscillate between ``bottom`` and ``top``.
245
+ """
246
+
247
+ ANIMATE_NONE = 0
248
+ ANIMATE_UP = 1
249
+ ANIMATE_DOWN = 2
250
+ ANIMATE_UPDOWN = 3
251
+
252
+ def __init__(self, cmd, typ, rate, top, bottom, count):
253
+ """Build an animation command.
254
+
255
+ Args:
256
+ cmd (int): The ``CMD_ANIMATE_*`` command ID.
257
+ typ (int): One of the ``ANIMATE_*`` constants.
258
+ rate (float): Speed in cycles per second.
259
+ top (float): Upper limit of the animated channel.
260
+ bottom (float): Lower limit of the animated channel.
261
+ count (int): Number of cycles to run; ``-1`` runs forever.
262
+ """
263
+ BlinkenhubCmd.__init__(self, cmd)
264
+ self.buf = struct.pack("<Bxxxfffi", typ, rate, top, bottom, count)
265
+
266
+
267
+ class SetGamma(BlinkenhubCmd):
268
+ """Set the gamma correction the device applies to pixel brightness."""
269
+
270
+ def __init__(self, gamma):
271
+ """Build a set-gamma command.
272
+
273
+ Args:
274
+ gamma (float): Gamma exponent; ``1.0`` means no correction.
275
+ """
276
+ BlinkenhubCmd.__init__(self, BlinkenhubCmd.CMD_SET_GAMMA)
277
+ self.buf = struct.pack("<f", gamma)
278
+
279
+
280
+ class SetPixels(BlinkenhubCmd):
281
+ """Set the color of each pixel, with an optional fade.
282
+
283
+ Attributes:
284
+ LEN_LIMIT (int): Maximum number of pixels (the ring has 17).
285
+ """
286
+
287
+ LEN_LIMIT = 17
288
+ def __init__(self, colors, fade_time):
289
+ """Build a set-pixels command.
290
+
291
+ Args:
292
+ colors (list[HSV_Color]): One color per pixel, starting at pixel 0.
293
+ fade_time (float): Seconds to fade from the current colors to the new
294
+ ones; ``0`` changes them at once.
295
+
296
+ Raises:
297
+ Exception: If ``colors`` has more than :attr:`LEN_LIMIT` entries.
298
+ """
299
+ BlinkenhubCmd.__init__(self, BlinkenhubCmd.CMD_SET_PIXELS)
300
+ if len(colors) > self.LEN_LIMIT:
301
+ raise Exception("color vector too long")
302
+ b = bytearray()
303
+ for c in colors:
304
+ b += c.__bytes__()
305
+ fmt = f'<Bxxxf{len(b)}s'
306
+ self.buf = struct.pack(fmt, len(colors), fade_time, b)
307
+
308
+
309
+ class HueAnimation(Animation):
310
+ """Animate the hue of every pixel."""
311
+
312
+ def __init__(self, typ = Animation.ANIMATE_NONE, rate = .5, top = 1, bottom = .1, count = -1):
313
+ """Build a hue animation command.
314
+
315
+ Args:
316
+ typ (int): One of the ``Animation.ANIMATE_*`` constants.
317
+ rate (float): Speed in cycles per second.
318
+ top (float): Upper hue limit in degrees, ``[0.0, 360.0]``.
319
+ bottom (float): Lower hue limit in degrees, ``[0.0, 360.0]``.
320
+ count (int): Number of cycles to run; ``-1`` runs forever.
321
+ """
322
+ Animation.__init__(self, BlinkenhubCmd.CMD_ANIMATE_HUE, typ, rate, top, bottom, count)
323
+
324
+
325
+ class SaturationAnimation(Animation):
326
+ """Animate the saturation of every pixel."""
327
+
328
+ def __init__(self, typ = Animation.ANIMATE_NONE, rate = .5, top = 1, bottom = .1, count = -1):
329
+ """Build a saturation animation command.
330
+
331
+ Args:
332
+ typ (int): One of the ``Animation.ANIMATE_*`` constants.
333
+ rate (float): Speed in cycles per second.
334
+ top (float): Upper saturation limit, ``[0.0, 1.0]``.
335
+ bottom (float): Lower saturation limit, ``[0.0, 1.0]``.
336
+ count (int): Number of cycles to run; ``-1`` runs forever.
337
+ """
338
+ Animation.__init__(self, BlinkenhubCmd.CMD_ANIMATE_SATURATION, typ, rate, top, bottom, count)
339
+
340
+
341
+ class ValueAnimation(Animation):
342
+ """Animate the value (brightness) of every pixel."""
343
+
344
+ def __init__(self, typ = Animation.ANIMATE_NONE, rate = .5, top = 1, bottom = .1, count = -1):
345
+ """Build a value (brightness) animation command.
346
+
347
+ Args:
348
+ typ (int): One of the ``Animation.ANIMATE_*`` constants.
349
+ rate (float): Speed in cycles per second.
350
+ top (float): Upper brightness limit, ``[0.0, 1.0]``.
351
+ bottom (float): Lower brightness limit, ``[0.0, 1.0]``.
352
+ count (int): Number of cycles to run; ``-1`` runs forever.
353
+ """
354
+ Animation.__init__(self, BlinkenhubCmd.CMD_ANIMATE_VALUE, typ, rate, top, bottom, count)
355
+
356
+
357
+ class Rotate(BlinkenhubCmd):
358
+ """Rotate the current pixel pattern around the ring.
359
+
360
+ Attributes:
361
+ NONE (int): Stop rotating.
362
+ CW (int): Rotate clockwise.
363
+ CCW (int): Rotate counterclockwise.
364
+ CYLON (int): Sweep back and forth over ``limit`` pixels.
365
+ """
366
+
367
+ NONE = 0
368
+ CW = 1
369
+ CCW = 2
370
+ CYLON = 3
371
+
372
+ def __init__(self, typ, rate, limit):
373
+ """Build a rotate command.
374
+
375
+ Args:
376
+ typ (int): One of :attr:`NONE`, :attr:`CW`, :attr:`CCW`, or
377
+ :attr:`CYLON`.
378
+ rate (float): Speed in pixels per second.
379
+ limit (int): In :attr:`CYLON` mode, the number of pixels to sweep
380
+ across. The other modes pass ``-1``.
381
+ """
382
+ BlinkenhubCmd.__init__(self, BlinkenhubCmd.CMD_ROTATE)
383
+ self.buf = struct.pack("<Bxxxfi", typ, rate, limit)
384
+
385
+
386
+ def mkrainbow(value):
387
+ """Build a rainbow that spans the full hue circle across the ring.
388
+
389
+ Args:
390
+ value (float): Brightness of every pixel, ``[0.0, 1.0]``.
391
+
392
+ Returns:
393
+ list[HSV_Color]: 17 fully saturated colors with evenly spaced hues.
394
+ """
395
+ colors = []
396
+ h = 0
397
+ for _ in range(17):
398
+ colors += [HSV_Color(h, 1, value)]
399
+ h += 21.17647
400
+ return colors
401
+
402
+
403
+ def stop_animations():
404
+ """Stop any rotation and channel animations running on the device.
405
+
406
+ Connects to the port in the module-level ``device`` variable, which the CLI
407
+ sets from ``--port``.
408
+ """
409
+ bh = Blinkenhub(device)
410
+ bh.send(Rotate(0,0,0))
411
+ bh.send(HueAnimation(Animation.ANIMATE_UP,100,1,0,1))
412
+ bh.send(SaturationAnimation(Animation.ANIMATE_UP,100,1,0,1))
413
+ bh.send(ValueAnimation(Animation.ANIMATE_UP,100,1,0,1))
414
+
415
+
416
+ def mk_random_colors(value):
417
+ """Build a random pattern in which about half the pixels are lit.
418
+
419
+ Args:
420
+ value (float): Brightness of the lit pixels, ``[0.0, 1.0]``.
421
+
422
+ Returns:
423
+ list[HSV_Color]: 17 colors. Each is either off or a fully saturated
424
+ random hue.
425
+ """
426
+ colors = []
427
+ for _ in range(17):
428
+ if random() > 0.5:
429
+ colors += [HSV_Color(random() * 360, 1, value)]
430
+ else:
431
+ colors +=[HSV_Color(0, 0, 0)]
432
+ return colors
433
+
434
+
435
+ # Inspired by Claude
436
+ def are_we_awake(cg):
437
+ """Report whether any active display on this Mac is awake (macOS only).
438
+
439
+ Args:
440
+ cg (ctypes.CDLL): The loaded CoreGraphics framework, with ``restype``
441
+ set to ``ctypes.c_int`` for ``CGDisplayIsAsleep`` and
442
+ ``CGGetActiveDisplayList``.
443
+
444
+ Returns:
445
+ bool: ``True`` if at least one active display is awake, otherwise ``False``.
446
+ """
447
+ # Get list of active display IDs
448
+ max_displays = 16
449
+ display_ids = (ctypes.c_uint32 * max_displays)()
450
+ display_count = ctypes.c_uint32(0)
451
+ cg.CGGetActiveDisplayList(max_displays, display_ids, ctypes.byref(display_count))
452
+ sleeping = True
453
+ for i in range(display_count.value):
454
+ did = display_ids[i]
455
+ if not bool(cg.CGDisplayIsAsleep(did)):
456
+ sleeping = False
457
+ return not sleeping
458
+
459
+
460
+ ##
461
+ ## Commands for exercising Blinkenhub
462
+ ##
463
+
464
+ def main():
465
+ """Entry point for the ``blinkenhub`` command-line tool.
466
+
467
+ Builds the Click command group and runs it. The serial port can be given
468
+ with ``--port`` or the ``BLINKENHUB_PORT`` environment variable; otherwise
469
+ the device is detected automatically.
470
+ """
471
+
472
+ import click
473
+
474
+ @click.group()
475
+ @click.option('--port', envvar='BLINKENHUB_PORT', help='Serial port to which Blinkenhub is connected')
476
+ def cli(port):
477
+ """Control the Blinkenhub Mac mini stand."""
478
+ global device
479
+ device = port
480
+
481
+ @cli.command()
482
+ def run():
483
+ """Show random colors while the displays are awake, and a red Cylon sweep while they sleep (macOS only)."""
484
+ # Load CoreGraphics
485
+ cg = ctypes.cdll.LoadLibrary(
486
+ "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"
487
+ )
488
+
489
+ # Set return type for CGDisplayIsAsleep (returns boolean int)
490
+ cg.CGDisplayIsAsleep.restype = ctypes.c_int
491
+ cg.CGGetActiveDisplayList.restype = ctypes.c_int
492
+
493
+ bh = Blinkenhub(device)
494
+
495
+ while(True):
496
+ if are_we_awake(cg):
497
+ stop_animations()
498
+ while are_we_awake(cg):
499
+ colors = mk_random_colors(.25)
500
+ bh.send(SetPixels(colors, 0))
501
+ sleep(.25)
502
+ else:
503
+ stop_animations()
504
+ colors = [
505
+ HSV_Color(0, 1, .1),
506
+ HSV_Color(0, 1, .5),
507
+ HSV_Color(0, 1, .1),
508
+ HSV_Color(0, 1, 0),
509
+ HSV_Color(0, 1, 0),
510
+ HSV_Color(0, 1, 0),
511
+ HSV_Color(0, 1, 0),
512
+ HSV_Color(0, 1, 0),
513
+ HSV_Color(0, 1, 0),
514
+ HSV_Color(0, 1, 0),
515
+ HSV_Color(0, 1, 0),
516
+ HSV_Color(0, 1, 0),
517
+ HSV_Color(0, 1, 0),
518
+ HSV_Color(0, 1, 0),
519
+ HSV_Color(0, 1, 0),
520
+ HSV_Color(0, 1, 0),
521
+ HSV_Color(0, 1, 0)
522
+ ]
523
+ bh.send(SetPixels(colors, 0))
524
+ bh.send(Rotate(Rotate.CYLON, 10, 14))
525
+ while not are_we_awake(cg):
526
+ sleep(.5)
527
+
528
+
529
+ @cli.command()
530
+ def stop():
531
+ """Stop all rotation and animations."""
532
+ stop_animations()
533
+
534
+
535
+ @cli.command()
536
+ @click.option('-v', '--value', default=.5, help='The value (brightness)')
537
+ @click.option('-r', '--rate', default=0.0, help='Spin rate')
538
+ def rainbow(value, rate):
539
+ """Show a rainbow, optionally spinning clockwise."""
540
+ stop_animations()
541
+ bh = Blinkenhub(device)
542
+ bh.send(SetPixels(mkrainbow(value), 0))
543
+ if (rate > 0):
544
+ bh.send(Rotate(Rotate.CW, rate, -1))
545
+
546
+
547
+ @cli.command()
548
+ @click.option('-p', '--period', default=0.3, help='Time in seconds of each step')
549
+ @click.option('-v', '--value', default=.5, help='The value (brightness)')
550
+ def disco(period, value):
551
+ """Show a new random pattern every PERIOD seconds until interrupted."""
552
+ stop_animations()
553
+ bh = Blinkenhub(device)
554
+ while(True):
555
+ colors = mk_random_colors(value)
556
+ bh.send(SetPixels(colors, 0))
557
+ sleep(period)
558
+
559
+ @cli.command()
560
+ @click.argument('gamma', default=1.0)
561
+ def set_gamma(gamma):
562
+ """Set the gamma correction (1.0 = none)."""
563
+ if Blinkenhub(device).send(SetGamma(gamma)):
564
+ click.echo('OK')
565
+ else:
566
+ click.echo('Error')
567
+
568
+ @cli.command()
569
+ @click.option('-p', '--pixel', type=(float, float, float), multiple=True)
570
+ @click.option('-f', '--fade', default=0.0, help='Fade time in seconds)')
571
+ @click.option('-d', '--default', type=(float, float, float), help='HSV values for the unspecified pixels')
572
+ def set_pixels(pixel, fade, default):
573
+ """Set pixel colors as H S V triples.
574
+
575
+ Each --pixel sets the next pixel, starting at pixel 0. The remaining
576
+ pixels get the --default color, or the last --pixel color if no default
577
+ is given.
578
+ """
579
+ colors = []
580
+ for p in pixel:
581
+ colors += [HSV_Color(p[0], p[1], p[2])]
582
+ if default is not None:
583
+ d = HSV_Color(default[0], default[1], default[2])
584
+ else:
585
+ pi = len(pixel) - 1
586
+ d = HSV_Color(pixel[pi][0], pixel[pi][1], pixel[pi][2])
587
+ for _ in range(17 - len(pixel)):
588
+ colors += [d]
589
+ if Blinkenhub(device).send(SetPixels(colors, fade)):
590
+ click.echo('OK')
591
+ else:
592
+ click.echo('Error')
593
+
594
+ @cli.command()
595
+ @click.option('-n', '--none', 'typ', flag_value=Animation.ANIMATE_NONE, default=True)
596
+ @click.option('-u', '--up', 'typ', flag_value=Animation.ANIMATE_UP)
597
+ @click.option('-d', '--down', 'typ', flag_value=Animation.ANIMATE_DOWN)
598
+ @click.option('-o', '--oscillate', 'typ', flag_value=Animation.ANIMATE_UPDOWN)
599
+ @click.option('-r', '--rate', default=1.0, help='Rate of change as cycles per second)')
600
+ @click.option('-t', '--top', default=360.0, help='Terminal high value [0.0, 360.0]')
601
+ @click.option('-b', '--bottom', default=0.0, help='Terminal low value [0.0, 360.0]')
602
+ @click.option('-c', '--count', default=1, help='Number of times to perform animation; -1 == forever')
603
+ def animate_hue(typ, rate, top, bottom, count):
604
+ """Animate the hue of every pixel."""
605
+ if Blinkenhub(device).send(HueAnimation(typ, rate, top, bottom, count)):
606
+ click.echo('OK')
607
+ else:
608
+ click.echo('Error')
609
+
610
+ @cli.command()
611
+ @click.option('-n', '--none', 'typ', flag_value=Animation.ANIMATE_NONE, default=True)
612
+ @click.option('-u', '--up', 'typ', flag_value=Animation.ANIMATE_UP)
613
+ @click.option('-d', '--down', 'typ', flag_value=Animation.ANIMATE_DOWN)
614
+ @click.option('-o', '--oscillate', 'typ', flag_value=Animation.ANIMATE_UPDOWN)
615
+ @click.option('-r', '--rate', default=1.0, help='Rate of change as cycles per second)')
616
+ @click.option('-t', '--top', default=1.0, help='Terminal high value')
617
+ @click.option('-b', '--bottom', default=0.0, help='Terminal low value')
618
+ @click.option('-c', '--count', default=1, help='Number of times to perform animation; -1 == forever')
619
+ def animate_saturation(typ, rate, top, bottom, count):
620
+ """Animate the saturation of every pixel."""
621
+ if Blinkenhub(device).send(SaturationAnimation(typ, rate, top, bottom, count)):
622
+ click.echo('OK')
623
+ else:
624
+ click.echo('Error')
625
+
626
+ @cli.command()
627
+ @click.option('-n', '--none', 'typ', flag_value=Animation.ANIMATE_NONE, default=True)
628
+ @click.option('-u', '--up', 'typ', flag_value=Animation.ANIMATE_UP)
629
+ @click.option('-d', '--down', 'typ', flag_value=Animation.ANIMATE_DOWN)
630
+ @click.option('-o', '--oscillate', 'typ', flag_value=Animation.ANIMATE_UPDOWN)
631
+ @click.option('-r', '--rate', default=1.0, help='Rate of change as cycles per second)')
632
+ @click.option('-t', '--top', default=1.0, help='Terminal high value')
633
+ @click.option('-b', '--bottom', default=0.0, help='Terminal low value')
634
+ @click.option('-c', '--count', default=1, help='Number of times to perform animation; -1 == forever')
635
+ def animate_value(typ, rate, top, bottom, count):
636
+ """Animate the value (brightness) of every pixel."""
637
+ if Blinkenhub(device).send(ValueAnimation(typ, rate, top, bottom, count)):
638
+ click.echo('OK')
639
+ else:
640
+ click.echo('Error')
641
+
642
+ @cli.command()
643
+ @click.option('--none', 'typ', flag_value=Rotate.NONE, default=True)
644
+ @click.option('--cw', 'typ', flag_value=Rotate.CW)
645
+ @click.option('--ccw', 'typ', flag_value=Rotate.CCW)
646
+ @click.option('--cylon', 'typ', flag_value=Rotate.CYLON)
647
+ @click.option('-r', '--rate', default=1.0, help='Rate of change as pixels per second)')
648
+ @click.option('-l', '--limit', default=1, help='Number of pixels to rotate in cylon mode')
649
+ def rotate(typ, rate, limit):
650
+ """Rotate the current pattern around the ring."""
651
+ if Blinkenhub(device).send(Rotate(typ, rate, limit)):
652
+ click.echo('OK')
653
+ else:
654
+ click.echo('Error')
655
+
656
+ cli()
657
+
658
+ if __name__ == '__main__':
659
+
660
+ main()
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blinkenhub"
7
+ version = "0.1.0"
8
+ description = "Python library and command-line tool to control the Blinkenhub Mac mini stand"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ authors = [
14
+ {name = "Brian Cuthie", email = "brian@systemix.com"}
15
+ ]
16
+ keywords = ["blinkenhub", "led", "mac mini", "serial", "rgb"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Intended Audience :: End Users/Desktop",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Topic :: System :: Hardware",
25
+ ]
26
+ dependencies = [
27
+ "click",
28
+ "crc",
29
+ "pyserial",
30
+ ]
31
+
32
+ [project.urls]
33
+ Homepage = "https://blinkenhub.com"
34
+ Source = "https://github.com/blinkenhub/python"
35
+ Issues = "https://github.com/blinkenhub/python/issues"
36
+
37
+ [project.scripts]
38
+ blinkenhub = "blinkenhub:main"
39
+
40
+ [tool.setuptools]
41
+ py-modules = ["blinkenhub"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+