GLaDOS Vol 7 · The Body
ch 66 / 99
Chapter 66

LED Ring Control

Twenty four lights and one pin left

Behind the lens in her eye socket sits a ring of twenty four small lights. They have to glow amber while she waits, dim and swell while she thinks, and go hard red the moment she decides you have said something stupid. The obvious way to build that is one GPIO pin per light, which needs twenty four pins for a plain on and off and seventy two if each light gets its own red, green and blue. A Raspberry Pi header has twenty six usable pins in total, and chapter 65 already spent two of them on the eye servos.

Addressable pixels solve it by putting the electronics inside every light. A WS2812B is a package holding three LED dice and a controller chip, and the chip is the interesting part: it reads the first twenty four bits that arrive on its data input, keeps them as its own colour, and forwards everything after that out of its data output to the next pixel in the chain. Wire the output of each to the input of the next and one wire addresses all twenty four. It also addresses ninety six, or three hundred, with the same line of code.

The price is timing. There is no clock line in that cable, so a pixel has to tell a one from a zero by how long the line stays high, and the two lengths differ by four tenths of a microsecond. Nothing running under Linux can hold that. The rule this chapter is built on comes out of the same fact: the timing belongs to hardware you will never touch, so your only job is producing three integers per pixel that each fit in a byte, and every colour in the program earns those integers at one clamped door.

◆ Note — the bench for this chapter, and the pin the ring gets

A Raspberry Pi, a twenty four pixel WS2812B ring seated behind the eye lens, data in on GPIO 10 through a 330 ohm series resistor, and 5 V from the same external rail the servos use with its ground tied to the Pi's. A 1000 µF capacitor across the ring's power and ground absorbs the current step when every pixel changes at once. GPIO 10 is not a free choice. The rpi_ws281x library can clock its waveform out of three peripherals: PWM on GPIO 18, PCM on GPIO 21, or SPI on GPIO 10. On this build the first two are spoken for, because her amplifier reads its samples off the I2S pins and those are GPIO 18, 19 and 21. SPI is the route left, so SPI is the route the ring takes.

Two practical differences come with it. Turn the SPI controller on before anything works: dtoverlay=spi0-0cs in /boot/firmware/config.txt, reboot, and confirm /dev/spidev0.0 exists. That overlay is the one this build wants instead of the plainer dtparam=spi=on, because the ring is not a chip that gets selected: it listens to the data line and nothing else. The overlay sets the bus up with no chip select at all, and the two pins those selects would have taken, GPIO 7 and 8, stay unclaimed for something else. Then note that the SPI route writes through the kernel's driver instead of mapping DMA registers by hand, so every demo in this chapter runs as your ordinary user with no sudo in front of it, provided that user is in the spi group. One frame has to fit in one SPI transfer, and the driver's default transfer is 4096 bytes; stage 1 works out how many pixels that holds and when you would have to raise it with spidev.bufsiz= on the kernel command line.

A bit is a length of time

Every bit occupies the same 1.25 microseconds. What differs is how that time is split. A zero is high for about 0.40 µs and low for the rest; a one is high for about 0.80 µs and low for the rest. The pixel times the high part against its own internal oscillator and compares it to a threshold near the middle, so the whole protocol rides on 0.2 µs of margin either side. Twenty four bits arrive per pixel, in green, red, blue order, which is not the order anyone writes them in and one more reason to let a library pack the word. After the last pixel's bits, the line goes quiet for more than 50 µs, and that silence is the instruction to display what everyone is holding.

A bit encoded as a pulse length, and the chain that eats bits as they pass Top: two waveforms over the same 1.25 microsecond bit period. The zero bit is high for 0.40 microseconds then low; the one bit is high for 0.80 microseconds then low. A dashed marker at 0.60 microseconds shows where the pixel decides between them. Bottom: the Pi's data pin feeds the first pixel, which keeps the first twenty four bits and passes the remaining 552 downstream; the second keeps the next twenty four and passes 528, and so on to the twenty fourth, after which a gap of more than fifty microseconds latches the frame. ONE BIT PERIOD = 1.25 µs · THE HIGH TIME IS THE VALUE bit 0 high 0.40 µs bit 1 high 0.80 µs decides here, 0.60 µs 576 BITS GO IN · EACH PIXEL KEEPS 24 AND FORWARDS THE REST GPIO 10 576 px 0 552 px 1 528 · · · 24 px 23 720 µs of data, then 50 µs of quiet line: every pixel lights at once
Figure 66.1 — Nothing in the cable says where one pixel's colour ends. Each chip counts twenty four bits, stops listening, and becomes a wire for the rest of the frame. Miss a pulse width and the miscount travels down the whole chain.
▣ Build · stage 1 — what a frame costs, and what a hiccup costs
# labs/ws_timing.py
BIT_PERIOD_US = 1.25     # every bit takes the same time, whatever its value
T0_HIGH_US = 0.40        # a zero: short pulse, long tail
T1_HIGH_US = 0.80        # a one: long pulse, short tail
TOLERANCE_US = 0.15      # how far off the part is specified to still read correctly
RESET_US = 50.0          # quiet line for at least this long means "display now"
BITS_PER_PIXEL = 24      # eight bits each of green, red, blue
SPI_BITS_PER_BIT = 3     # the SPI route spends three clocked bits on each protocol bit
SPI_BUF_BYTES = 4096     # the kernel SPI driver's default single-transfer limit


def frame_time_us(pixels: int) -> float:
    """How long one show() spends on the wire for a chain of this length."""
    return pixels * BITS_PER_PIXEL * BIT_PERIOD_US + RESET_US


def spi_frame_bytes(pixels: int) -> int:
    """The buffer the SPI route hands the kernel for one frame."""
    return pixels * BITS_PER_PIXEL * SPI_BITS_PER_BIT // 8


if __name__ == "__main__":
    pixels = 24
    bits = pixels * BITS_PER_PIXEL
    threshold = (T0_HIGH_US + T1_HIGH_US) / 2
    slip_us = 200.0
    print(f"{pixels} pixels = {bits} bits = {bits * BIT_PERIOD_US:.1f} us of data"
          f" + {RESET_US:.1f} us latch = {frame_time_us(pixels):.1f} us per frame")
    print(f"a 0 holds the line high {T0_HIGH_US:.2f} us, a 1 holds it {T1_HIGH_US:.2f} us,"
          f" the pixel decides at {threshold:.2f} us")
    print(f"margin either side of that: {threshold - T0_HIGH_US:.2f} us,"
          f" and the part is specified to {TOLERANCE_US:.2f} us of error")
    print(f"a {slip_us:.0f} us scheduler pause = {slip_us / BIT_PERIOD_US:.0f} bits"
          f" = {slip_us / BIT_PERIOD_US / BITS_PER_PIXEL:.1f} pixels of garbage")
    print(f"on the SPI route that frame is {spi_frame_bytes(pixels)} bytes;"
          f" one {SPI_BUF_BYTES}-byte transfer holds"
          f" {SPI_BUF_BYTES * 8 // (BITS_PER_PIXEL * SPI_BITS_PER_BIT)} pixels")
$ uv run python -m labs.ws_timing
24 pixels = 576 bits = 720.0 us of data + 50.0 us latch = 770.0 us per frame
a 0 holds the line high 0.40 us, a 1 holds it 0.80 us, the pixel decides at 0.60 us
margin either side of that: 0.20 us, and the part is specified to 0.15 us of error
a 200 us scheduler pause = 160 bits = 6.7 pixels of garbage
on the SPI route that frame is 216 bytes; one 4096-byte transfer holds 455 pixels

The last line is the argument against writing this yourself. A Python loop that sets a pin high, waits, and sets it low is at the mercy of the kernel scheduler, and a Pi that is also transcribing audio and answering a language model will take the CPU away for far longer than 200 microseconds. There is no recovery: the chain has no framing, so a pause partway through a frame leaves some pixels holding half of one colour and half of the next. The rpi_ws281x library sidesteps the CPU entirely. It spells each protocol bit as three SPI bits, long-high for a one and short-high for a zero, builds all 216 bytes of that in memory, and hands the buffer to the SPI peripheral, which shifts it out on its own 2.4 MHz clock. No instruction of yours runs during the 770 microseconds that matter, and that is the only reason the light is stable. The 455-pixel figure on the last line is the ceiling that transfer size puts on the chain: this ring uses about a twentieth of it.

Colour is three bytes; brightness is a fraction of them

▣ Build · stage 2 — the palette she already has, in the units a pixel wants
# labs/led_ring.py
def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    """Turn a palette entry like '#ffcc00' into the three bytes a pixel expects."""
    h = hex_color.lstrip("#")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))


def scale_color(r: int, g: int, b: int, brightness: float) -> tuple[int, int, int]:
    """Dim a colour by a 0.0-1.0 fraction. The clamp is the entire point of this function."""
    s = max(0.0, min(1.0, brightness))
    return (int(r * s), int(g * s), int(b * s))


def pack_color(r: int, g: int, b: int) -> int:
    """The 24-bit word the library builds: one byte per channel, no room for a fourth."""
    return (r << 16) | (g << 8) | b


if __name__ == "__main__":
    from labs.mood_state import EMOTION_COLORS

    for mood, hex_color in EMOTION_COLORS.items():
        print(f"{mood:11} {hex_color} -> {hex_to_rgb(hex_color)}")
    print()
    amber = hex_to_rgb(EMOTION_COLORS["curious"])
    for label, brightness in (("full", 1.0), ("half", 0.5), ("over", 1.5), ("off ", 0.0)):
        print(f"{label}: {scale_color(*amber, brightness)}")
$ uv run python -m labs.led_ring
neutral     #4a9eff -> (74, 158, 255)
satisfied   #39ff14 -> (57, 255, 20)
curious     #ffcc00 -> (255, 204, 0)
hostile     #ff4444 -> (255, 68, 68)
melancholy  #9966cc -> (153, 102, 204)

full: (255, 204, 0)
half: (127, 102, 0)
over: (255, 204, 0)
off : (0, 0, 0)

Nothing here is new work. Chapter 47 gave her a mood that is a name from a fixed set, an intensity between 0.0 and 1.0, and a colour looked up from that same set, and those three pieces map onto this ring without translation: the name picks the hue, the intensity is the fraction, and the hex string was already three bytes wearing a hash. The one line that carries weight is the clamp. Note what over printed: asking for 1.5 gave back exactly what 1.0 gives, not something brighter, and the next section shows what the un-clamped version would have produced instead.

▣ Build · stage 3 — the current the ring will actually pull
# labs/led_ring.py — continued
LED_COUNT = 24
LED_BRIGHTNESS = 80      # global cap out of 255, applied by the library to every channel
MA_PER_CHANNEL = 20.0    # one colour die at full, from the data sheet
MA_IDLE = 1.0            # the controller chip alone, all three dice dark


def led_current_ma(r: int, g: int, b: int) -> float:
    """Rough draw of one pixel showing this colour."""
    return MA_IDLE + (r + g + b) / 255.0 * MA_PER_CHANNEL


def ring_current_ma(rgb: tuple[int, int, int], brightness: float = 1.0,
                    cap: int = LED_BRIGHTNESS, count: int = LED_COUNT) -> float:
    """Both dimming steps in series: your fraction, then the library's global cap."""
    r, g, b = scale_color(*rgb, brightness * (cap / 255.0))
    return led_current_ma(r, g, b) * count
# labs/led_budget.py
from labs.led_ring import hex_to_rgb, ring_current_ma
from labs.mood_state import EMOTION_COLORS

if __name__ == "__main__":
    rows = [("white", (255, 255, 255)),
            ("curious #ffcc00", hex_to_rgb(EMOTION_COLORS["curious"])),
            ("hostile #ff4444", hex_to_rgb(EMOTION_COLORS["hostile"])),
            ("all pixels off", (0, 0, 0))]
    caps = (255, 128, 80, 40)
    print(f"{'':18}" + "".join(f"  cap {c:3d}" for c in caps))
    for name, rgb in rows:
        print(f"{name:18}" + "".join(f"  {ring_current_ma(rgb, 1.0, c) / 1000:6.3f} A" for c in caps))
$ uv run python -m labs.led_budget
                    cap 255  cap 128  cap  80  cap  40
white                1.464 A   0.747 A   0.476 A   0.250 A
curious #ffcc00      0.888 A   0.457 A   0.295 A   0.160 A
hostile #ff4444      0.760 A   0.393 A   0.254 A   0.137 A
all pixels off       0.024 A   0.024 A   0.024 A   0.024 A

Read the top left number first. Twenty four pixels at full white ask for about an amp and a half, which is more than a Pi's 5 V header pin will give you and more than most of the phone chargers people reach for. Read the bottom row second: a ring told to show black still draws 24 mA, because twenty four controller chips are awake and waiting for the next frame. Between them sits the design decision. The global cap of 80 is not timidity, it is a budget: the eye behind a diffuser looks right at roughly a third power, and the ring then stays under a third of an amp in every mood she has, which leaves the rail's headroom for six servos that all want to move at the same moment. The cap and the per-call fraction multiply, so a mood at intensity 0.7 under a cap of 80 is lit at about 22 percent of full. Both numbers are fractions of one, so neither can ever push a channel up.

The ring behind the lens

▣ Build · stage 4 — one class, one door, one flush per frame
# labs/led_ring.py — imports belong at the top of the file, the class at the bottom
import math
import time

from labs.mood_state import EMOTION_COLORS, MoodState

LED_PIN = 10             # BCM 10: SPI0 MOSI, the route left once audio owns PWM and PCM
LED_FREQ_HZ = 800_000    # the protocol's bit rate, not a knob
LED_DMA = 10             # the constructor still takes it; the SPI route ignores it
LED_INVERT = False
LED_CHANNEL = 0


def breath_level(step: int, steps: int = 100) -> float:
    """0.0 up to 1.0 and back down again across one full cycle."""
    t = step / steps
    return (math.sin(t * math.pi * 2 - math.pi / 2) + 1) / 2


class EyeRing:
    """Every colour that reaches a pixel goes through _fill, and _fill clamps."""

    def __init__(self, strip: object | None = None) -> None:
        if strip is None:
            from rpi_ws281x import PixelStrip
            strip = PixelStrip(LED_COUNT, LED_PIN, LED_FREQ_HZ,
                               LED_DMA, LED_INVERT, LED_BRIGHTNESS, LED_CHANNEL)
        self._strip = strip
        self._strip.begin()

    def _fill(self, rgb: tuple[int, int, int],
              brightness: float = 1.0) -> tuple[int, int, int]:
        """The only method in the program that writes to a pixel."""
        channels = scale_color(*rgb, brightness)
        word = pack_color(*channels)
        for i in range(self._strip.numPixels()):
            self._strip.setPixelColor(i, word)
        self._strip.show()
        return channels

    def show_mood(self, state: MoodState) -> tuple[int, int, int]:
        """Her current mood, held steady: hue from the name, level from the dial."""
        return self._fill(hex_to_rgb(state.display_color()), state.intensity)

    def breathe(self, state: MoodState, cycles: int = 2,
                steps: int = 100, speed: float = 0.02) -> None:
        """The waiting animation: the mood's colour, swelling and fading."""
        rgb = hex_to_rgb(state.display_color())
        for _ in range(cycles):
            for step in range(steps):
                self._fill(rgb, breath_level(step, steps) * state.intensity)
                time.sleep(speed)

    def alert(self, flashes: int = 6, on_time: float = 0.08) -> None:
        """Hostile, and in a hurry about it."""
        red = hex_to_rgb(EMOTION_COLORS["hostile"])
        for _ in range(flashes):
            self._fill(red)
            time.sleep(on_time)
            self._fill((0, 0, 0))
            time.sleep(on_time * 0.6)

    def off(self) -> None:
        self._fill((0, 0, 0))

Two decisions in there are worth more than the animations. The first: the import of PixelStrip sits inside __init__, and only on the branch that builds one. A module that imports a Pi-only C extension at the top cannot be imported on a laptop, cannot be imported by a test, and cannot be imported by the dry run below, so all the arithmetic above it would become untestable for the sake of one line. The second: breathe multiplies the breath by the intensity before handing the result to _fill. A mood at 0.4 breathes in a narrower band than a mood at 1.0, so the animation carries the dial instead of overriding it, and because both factors are fractions the product cannot exceed one either.

▣ Build · stage 5 — the whole class, on a laptop, with no library installed
# labs/led_dry_run.py
from labs.led_ring import LED_COUNT, EyeRing
from labs.mood_state import MoodState


class RecordingStrip:
    """Stands in for PixelStrip: same four methods, keeps what it was told."""

    def __init__(self, count: int = LED_COUNT) -> None:
        self._pixels = [0] * count
        self.frames = 0

    def begin(self) -> None:
        pass

    def numPixels(self) -> int:
        return len(self._pixels)

    def setPixelColor(self, index: int, word: int) -> None:
        self._pixels[index] = word

    def show(self) -> None:
        self.frames += 1

    def peek(self, index: int = 0) -> int:
        """Not part of the strip API: how the dry run reads a frame back."""
        return self._pixels[index]


if __name__ == "__main__":
    strip = RecordingStrip()
    ring = EyeRing(strip=strip)
    for mood, intensity in (("curious", 0.7), ("hostile", 1.0), ("hostile", 1.4)):
        channels = ring.show_mood(MoodState(mood=mood, intensity=intensity))
        note = "   (clamped)" if intensity > 1.0 else ""
        print(f"{mood:10} @ {intensity:.2f}  channels {str(channels):16}"
              f" packed 0x{strip.peek():06x}  frames {strip.frames}{note}")
    ring.breathe(MoodState(mood="curious", intensity=0.7), cycles=2, speed=0.0)
    print(f"breathe(curious, cycles=2, steps=100)"
          f"                                     frames {strip.frames}")
$ uv run python -m labs.led_dry_run
curious    @ 0.70  channels (178, 142, 0)    packed 0xb28e00  frames 1
hostile    @ 1.00  channels (255, 68, 68)    packed 0xff4444  frames 2
hostile    @ 1.40  channels (255, 68, 68)    packed 0xff4444  frames 3   (clamped)
breathe(curious, cycles=2, steps=100)                                     frames 203

The stand-in exists because PixelStrip is four methods wide. Nothing in EyeRing knows or cares whether those methods talk to an SPI device or to a list, so the entire class runs on a laptop and its behaviour is visible in numbers. Three facts fall straight out of that output. A steady mood costs exactly one frame, proving _fill flushes once instead of once per pixel: two hundred and three frames for three fills plus two hundred breath steps, not four thousand nine hundred and twenty. An intensity of 1.4, which a directly constructed dataclass will happily hold even though the guarded transition would have refused it, produces the identical word to 1.0. And the packed value for a clamped full-brightness hostile is 0xff4444, the palette string from chapter 47 with the hash removed, which is what a correct byte packing always looks like.

▣ Build · stage 6 — the same code, on the Pi, driving real pixels
$ uv add rpi_ws281x            # builds a C extension: apt install python3-dev gcc first
$ ls /dev/spidev0.0            # empty means dtoverlay=spi0-0cs has not taken effect yet
$ uv run python -m labs.led_show
# labs/led_show.py
import time

from labs.led_ring import EyeRing, hex_to_rgb, ring_current_ma
from labs.mood_state import MoodState

MOODS = [("neutral", 0.5), ("curious", 0.7), ("hostile", 1.0)]


if __name__ == "__main__":
    ring = EyeRing()                      # no strip argument: the real one, on GPIO 10
    try:
        for mood, intensity in MOODS:
            state = MoodState(mood=mood, intensity=intensity)
            channels = ring.show_mood(state)
            draw = ring_current_ma(hex_to_rgb(state.display_color()), intensity)
            print(f"{mood:10} @ {intensity:.2f}  ->  {str(channels):16} est. {draw / 1000:.3f} A")
            time.sleep(1.5)
        ring.breathe(MoodState(mood="curious", intensity=0.7), cycles=2)
        ring.alert(flashes=6)
        print("6 alert flashes")
    finally:
        ring.off()
        print("ring off")
$ uv run python -m labs.led_show   # measured on the bench — yours will vary
neutral    @ 0.50  ->  (37, 79, 127)    est. 0.165 A
curious    @ 0.70  ->  (178, 142, 0)    est. 0.210 A
hostile    @ 1.00  ->  (255, 68, 68)    est. 0.254 A
6 alert flashes
ring off

The bench supply agreed with the estimates to about a hundredth of an amp on every line, which is as close as a table of data sheet typicals deserves to get. The number to distrust is the one the meter shows during alert: the display cannot follow a colour changing every eighty milliseconds and settles somewhere in the middle, while the actual draw is a square wave between the hostile row and the 24 mA idle row. That is precisely the current step the 1000 µF capacitor is there to absorb. Without it, the rail dips each time the ring lights, the Pi sees its 5 V sag, and a build that worked all afternoon starts rebooting whenever she gets annoyed.

Why this works: a byte has no room to be polite

A colour channel is eight bits. The library packs three of them into one 24-bit integer, red in the top byte, green in the middle, blue at the bottom, and shifts that integer out onto the wire bit by bit. Packing is the reason the clamp is not a nicety. A value that does not fit in its byte does not get truncated and does not raise: it is shifted into a position where its high bits land inside the neighbouring channel and become part of a different colour. The corruption is silent at every layer, because each layer is doing exactly what it was asked.

This is the general form of a lesson volume 5 taught in software and volume 7 keeps re-teaching in hardware. Validation belongs at a choke point, and a choke point only works if it is the sole route. _fill qualifies: it is the only method that touches the strip, and it calls scale_color before it computes a word. So breathe, alert, show_mood and anything you add next spring are all safe by construction, not by discipline. Compare that with checking brightness at each call site, where the guarantee lasts until the first caller who forgets, and the punishment for forgetting is a wrong colour rather than an exception.

⚠ Worked failure — the eye that was meant to be brighter and came out brown

Amber at full is her waiting colour, and on the bench behind a thick printed diffuser it looked weak. The clamp is right there in scale_color, capping the very knob that would fix it, so the tempting edit is to take it out and pass 1.5 for extra brightness:

# labs/led_overflow.py
def scale_color_unclamped(r: int, g: int, b: int, brightness: float) -> tuple[int, int, int]:
    # BUG: brightness is never pinned to 0.0-1.0
    return (int(r * brightness), int(g * brightness), int(b * brightness))


def pack_color(r: int, g: int, b: int) -> int:
    return (r << 16) | (g << 8) | b


if __name__ == "__main__":
    channels = scale_color_unclamped(255, 204, 0, 1.5)   # "extra bright amber"
    word = pack_color(*channels)
    print("channels:  ", channels)
    print("packed:    ", hex(word))
    print("red byte:  ", hex((word >> 16) & 0xFF))
    print("green byte:", hex((word >> 8) & 0xFF))
    print("above 24:  ", hex(word >> 24))
$ uv run python -m labs.led_overflow
channels:   (382, 306, 0)
packed:     0x17f3200
red byte:   0x7f
green byte: 0x32
above 24:   0x1

On the ring the result is not a brighter amber. It is a dim, muddy orange that also flickers, and the flicker is the clue that this is not a brightness problem at all. Follow the bytes. Green was asked for 306, which needs nine bits, and the shift by eight put that ninth bit at position sixteen, inside the red byte. Red was asked for 382, whose own ninth bit went to position twenty four, off the end of the word entirely. What survived is a red byte of 0x7f, which is 127, and a green byte of 0x32, which is 50: both far below the 255 and 204 the palette asked for. Aiming for 150 percent of a colour produced roughly half of it.

The stray 0x1 above the top byte explains the flicker. The library sends twenty four bits per pixel, so that bit is not sent to pixel zero; depending on how the buffer is filled it either vanishes or lands in the frame as an extra set bit somewhere downstream. Either way the arithmetic was wrong three steps before the wire, and no part of the pipeline had a place to notice. Restoring one line, s = max(0.0, min(1.0, brightness)), makes 1.5 collapse to 1.0, and the packed word becomes 0xffcc00: the palette entry, byte for byte, which is how you know the packing is honest. If full amber still looks weak behind the lens, the fix is a thinner diffuser or a higher global cap, since brightness is a fraction of a colour and never a multiplier past it.

Checkpoint, and the sense she still does not have

✓ Checkpoint — what you can now do
  • I can explain how a pixel tells a one from a zero with no clock line, and how much timing margin that leaves.
  • I can compute how long a 24-pixel frame occupies the wire, how many bytes it becomes on the SPI route, and how many pixels a 200 microsecond scheduler pause would corrupt.
  • I can convert one of her mood colours from a hex string into three bytes and back into the packed word the library sends.
  • Handed a ring that came out muddy after a brightness change, I can trace the wrong colour to a channel that overflowed into its neighbour.
  • I can estimate the ring's current draw for a colour and a global cap, and say why the cap sits at 80 and why an unlit ring still costs 24 mA.
  • I can run and test the whole animation class on a laptop by standing in four methods for the hardware strip.
⚡ Exercises — try first, then reveal
Exercise 1 — make the dim end actually dim. A pixel's output is close to linear in its channel value, but your eye is not, so a breath fading from 255 to 0 spends most of its time looking bright. Add a gamma correction and print what it does to five sample values.

Normalise the channel, raise it to a power, and scale back. 2.8 is the usual figure for these parts:

# labs/led_gamma.py
def gamma(value: int, exponent: float = 2.8) -> int:
    """Bend an 8-bit channel so equal steps look equal to a person."""
    return int((value / 255) ** exponent * 255 + 0.5)


if __name__ == "__main__":
    for v in (0, 64, 128, 192, 255):
        print(f"{v:3d} -> {gamma(v):3d}")
$ uv run python -m labs.led_gamma
  0 ->   0
 64 ->   5
128 ->  37
192 -> 115
255 -> 255

A raw 64 becomes 5, which looks wrong on paper and correct through the lens. Apply it to each channel inside _fill, after the clamp, and run the breath again: the fade now reaches darkness gradually instead of falling off a cliff in the last few steps. The endpoints map to themselves, so nothing else in the chapter changes.

Exercise 2 — find the cap your rail can afford. Given a budget of 0.30 A for the ring, print the highest global cap that keeps every mood in her palette under it at full intensity.
# labs/led_cap.py
from labs.led_ring import hex_to_rgb, ring_current_ma
from labs.mood_state import EMOTION_COLORS

BUDGET_MA = 300.0

for cap in range(255, 0, -1):
    worst = max(ring_current_ma(hex_to_rgb(c), 1.0, cap) for c in EMOTION_COLORS.values())
    if worst <= BUDGET_MA:
        print(f"cap {cap} keeps the worst mood at {worst / 1000:.3f} A")
        break
$ uv run python -m labs.led_cap
cap 77 keeps the worst mood at 0.299 A

The loop walks down from full and stops at the first cap that fits. The mood it stops on is the one with the most total channel value, which is not the one that grabs your attention across a room: neutral at #4a9eff sums to 487 and costs 0.941 A uncapped, while hostile at #ff4444 sums to 391 and costs 0.760 A. Her calmest colour is her most expensive one, because it is the one with a full blue channel in it.

Exercise 3 — light one pixel at a time. Every fill so far sets all twenty four pixels to the same colour, which wastes what addressability is for. Write a rotating arc: one bright head with a fading tail behind it, printed as a row of brightness values so you can watch it move without hardware.
# labs/led_arc.py
from labs.led_ring import LED_COUNT

TAIL = 5


def arc_levels(head: int, count: int = LED_COUNT, tail: int = TAIL) -> list[float]:
    """Brightness per pixel: 1.0 at the head, fading to 0 over `tail` pixels behind it."""
    return [max(0.0, 1.0 - ((head - i) % count) / tail) for i in range(count)]


if __name__ == "__main__":
    for head in (0, 6, 12):
        row = " ".join(f"{v:.1f}" if v > 0 else " . " for v in arc_levels(head))
        print(f"head {head:2d}  {row}")
$ uv run python -m labs.led_arc
head  0  1.0  .   .   .   .   .   .   .   .   .   .   .   .   .   .   .   .   .   .   .  0.2 0.4 0.6 0.8
head  6   .   .  0.2 0.4 0.6 0.8 1.0  .   .   .   .   .   .   .   .   .   .   .   .   .   .   .   .   .
head 12   .   .   .   .   .   .   .   .  0.2 0.4 0.6 0.8 1.0  .   .   .   .   .   .   .   .   .   .   .

The modulo is what makes the tail wrap past pixel zero instead of stopping there, as the first row shows. To drive it, add a method that calls scale_color per pixel and setPixelColor per pixel, then show() once at the end of the frame, and step the head every 40 milliseconds. On the bench this reads as her thinking, and it pairs with the drift the eye gimbal is doing at the same time.

Her eye now moves and glows, and both are driven by state the rest of the program already maintains: an angle from the gaze controller, a mood name and a dial from the emotion state. She looks alive from across the room while hearing nothing, since every word so far has come through a microphone plugged into your laptop. Chapter 67 gives her ears of her own, and the first problem is not audio quality at all: it is that the device index you test with today points at something else entirely after the next reboot.