Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions lib/programkit/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""Reach a board's display, keys and touch from a program that runs without Home.

A program in ``programs/`` runs on any board with the hardware it needs, so it
never names a pin. `Board` finds what the board's ``board_config`` and
``board_peripherals`` offer, `Rows` puts lines of text on whatever display is
there, and `Press` hears a press of any key, touch or button::

from programkit import Board, Press, Rows

board = Board()
rows = Rows(board.display) if board.display else None
press = Press(board)
if board.has("audio_out"):
out = board.get("audio_out")(AudioFormat(44100, 2, 16))

A board with no display has no ``board_config``; `Board` then reads
``board_peripherals`` alone, which is how a headless board describes itself.
"""

from .board import Board
from .press import Press
from .rows import Rows

__all__ = ["Board", "Press", "Rows"]
56 changes: 56 additions & 0 deletions lib/programkit/board.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""What the board offers: its ``board_config`` if it has one, and its lazy roles."""


def _optional(name):
"""Import a module that may be absent, but not one that's broken."""
try:
return __import__(name)
except ImportError as e:
if name in str(e):
return None
raise


class Board:
"""The board this program runs on.

Importing ``board_config`` starts the board's display, so a program that
makes a `Board` gets one. A board without a display has only
``board_peripherals``.

Attributes:
config: the ``board_config`` module, or None.
peripherals: the ``board_peripherals`` module, or None.
display: ``board_config.display_drv``, or None.
"""

def __init__(self):
self.config = _optional("board_config")
self.peripherals = _optional("board_peripherals")
self.display = getattr(self.config, "display_drv", None)
self._roles = getattr(self.peripherals, "PERIPHERALS", frozenset())
self._factories = getattr(self.peripherals, "FACTORY_ROLES", frozenset())

def has(self, role):
"""True when the board lists ``role`` (``"audio_out"``, ``"sdcard"``, ...)."""
return role in self._roles

def get(self, role):
"""The role's object, or its factory for the audio roles; None if absent.

Each call through ``board_peripherals`` alone makes a new object, so
keep what this returns.
"""
if role not in self._roles:
return None
if self.config is not None:
return getattr(self.config, role)
factory = getattr(self.peripherals, role)
return factory if role in self._factories else factory()

def eager(self, name):
"""A ``board_config`` name such as ``touch_read`` or ``keypad_read``, or None."""
return getattr(self.config, name, None)
63 changes: 63 additions & 0 deletions lib/programkit/press.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""A press of any key, touch or button the board has."""

import time


class Press:
"""Hears the board's touch screen, its keys and its Boot button as one button.

Call it as often as the program loops. It reads every source each time,
so a board with none of them simply never reports a press.

Args:
board: a `Board`.

Attributes:
sources: the names of what it listens to, for a program to report.
"""

def __init__(self, board):
self._reads = []
self.sources = []
touch = board.eager("touch_read")
if touch is not None:
self._reads.append(lambda: bool(touch()))
self.sources.append("touch")
keys = board.eager("keypad_read")
if keys is not None:
self._reads.append(lambda: bool(keys()))
self.sources.append("keys")
button = board.get("boot_button")
if button is not None:
self._reads.append(lambda: button.value() == 0)
self.sources.append("Boot button")
self._down_at = None

def down(self):
"""True while anything is pressed."""
for read in self._reads:
try:
if read():
return True
except OSError:
pass # a touch controller that missed one read
return False

def __call__(self):
"""True once for each new press."""
if self.down():
if self._down_at is None:
self._down_at = time.ticks_ms()
return True
return False
self._down_at = None
return False

def held_ms(self):
"""How long the current press has lasted, in ms, or None between presses."""
if self._down_at is None:
return None
return time.ticks_diff(time.ticks_ms(), self._down_at)
149 changes: 149 additions & 0 deletions lib/programkit/rows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
# SPDX-FileCopyrightText: 2026 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""Lines of text on a board's display, sent one row at a time.

A one-bit OLED (SH1106, SSD1305, SSD1306) is drawn with ``framebuf`` straight
into its driver's copy of the panel, and each changed row goes out as one
page: drawing a pixel at a time through ``blit_rect`` costs about 90 ms a
line on a classic ESP32, too long to sit beside audio. That leans on two
driver internals, ``_buffer`` and ``_flush``, at rotation 0. Every other
display gets each row composed in a strip with pygraphics and sent in one
``blit_rect``.
"""

WHITE = 0xFFFF
BLACK = 0


def clean(text):
"""Printable ASCII only: the fonts have nothing else."""
return "".join(c if " " <= c <= "~" else "?" for c in text)


def wrap(text, width):
"""Split text at the last space that fits in width, or at width if none does."""
if len(text) <= width:
return text, ""
cut = text.rfind(" ", 0, width + 1)
if cut <= 0:
return text[:width], text[width:]
return text[:cut], text[cut + 1 :]


def _one_bit(display):
buf = getattr(display, "_buffer", None)
if buf is None or not hasattr(display, "_flush"):
return False
if getattr(display, "rotation", 0) not in (0, None):
return False
return len(buf) == display.width * ((display.height + 7) // 8)


class Rows:
"""Rows of text, each redrawn only when it changes.

`line` changes a row in memory and `send` puts one changed row on the
panel, so a program that must keep audio fed can draw a row a turn.

Args:
display: the board's ``display_drv``.

Attributes:
count: how many rows fit.
cols: characters in a full row.
height: a row's height in pixels.
width: the display's width in pixels.
"""

def __init__(self, display):
self.display = display
self.width = display.width
self._dirty = []
if _one_bit(display):
import framebuf

self._fb = framebuf.FrameBuffer(display._buffer, display.width, display.height, framebuf.MONO_VLSB)
self._mono = True
self.height = 8
self._scale = 1
self._fg = 1
self._fb.fill(0)
display._flush(0, 0, display.width - 1, display.height - 1)
else:
try:
import pygraphics
except ImportError:
raise ImportError("this display needs pygraphics to draw text: mip.install(\"pygraphics\")")
depth = getattr(display, "color_depth", 16) or 16
fmt = {1: pygraphics.MONO_HMSB, 8: pygraphics.GS8, 16: pygraphics.RGB565, 24: pygraphics.RGB888}[depth]
self._mono = False
self._scale = max(1, display.width // 320)
self.height = 16 * self._scale
self._fg = WHITE if depth == 16 else (1 if depth == 1 else 0xFFFFFF if depth == 24 else 0xFF)
self._depth = depth
self._fmt = fmt
self._pg = pygraphics
self._strips = {} # width: (buffer, FrameBuffer), one per row width in use
display.fill_rect(0, 0, display.width, display.height, BLACK)
self.count = display.height // self.height
self.cols = display.width // (8 * self._scale)
self._text = [None] * self.count
self._widths = [self.width] * self.count

def line(self, row, text, width=None):
"""Put text on a row, replacing what was there; nothing happens if it's unchanged.

Args:
row: 0 is the top.
text: cut to fit.
width: pixels from the left edge that the text owns; the rest of
the row is left alone, for a program's own drawing.
"""
if not 0 <= row < self.count:
return
width = self.width if width is None else width
text = clean(text)[: width // (8 * self._scale)]
if self._text[row] == text and self._widths[row] == width:
return
self._text[row] = text
self._widths[row] = width
if row not in self._dirty:
self._dirty.append(row)

def lines(self, first_row, texts):
"""Put each of ``texts`` on its own row, starting at ``first_row``."""
for i, text in enumerate(texts):
self.line(first_row + i, text)

def send(self):
"""Put one changed row on the panel. Returns False when none was waiting."""
if not self._dirty:
return False
row = self._dirty.pop(0)
text = self._text[row]
width = self._widths[row]
y = row * self.height
if self._mono:
self._fb.fill_rect(0, y, width, 8, 0)
self._fb.text(text, 0, y, 1)
self.display._flush(0, y, width - 1, y + 7)
else:
strip = self._strips.get(width)
if strip is None:
buf = bytearray((width * self._depth + 7) // 8 * self.height)
strip = self._strips[width] = (buf, self._pg.FrameBuffer(buf, width, self.height, self._fmt))
buf, fb = strip
fb.fill(BLACK)
fb.text16(text, 0, 0, self._fg, self._scale)
self.display.blit_rect(buf, 0, y, width, self.height)
return True

def send_all(self):
"""Put every changed row on the panel."""
while self.send():
pass

def rect(self, x, y, w, h, on=True):
"""Fill a rectangle in white, or black when ``on`` is False, straight to the panel."""
self.display.fill_rect(x, y, w, h, WHITE if on else BLACK)
3 changes: 3 additions & 0 deletions programs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ runs it from the home screen.

| Program | What it does |
|---|---|
| [Bluetooth speaker](bt-speaker) | A speaker your phone or computer pairs with, on a board with Bluetooth Classic. |
| [Internet radio](internet-radio) | Plays MP3 streams over Wi-Fi; a press changes station. |
| [MIDI module](midi-module) | A General MIDI sound module: MIDI from a DIN socket plays a SoundFont. |
| [Ostinato](ostinato) | A pattern sequencer and song editor, in a browser or a desktop window. |
| [Vamp](vamp) | Plays Ostinato's songs on one MicroPython, with the screens elsewhere. |

Expand Down
40 changes: 40 additions & 0 deletions programs/bt-speaker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Bluetooth speaker

Bluetooth speaker turns a board into a speaker your phone or computer pairs
with and plays through. With a display, it shows the connection, the title and
artist, and on a display tall enough, the sender's name and a left and right
level meter. Holding a key, the touch screen or the Boot button for a second
drops the sender, so another device can find the speaker.

Runs on: a MicroPython board with Bluetooth Classic and audio out (`pcm_out`
in its board package): ESP32 boards, and the Pico W and Pico 2 W family.
Tested on the QT Py ESP32 Pico with the Audio BFF and a 128x64 SH1106 OLED,
and the Pimoroni Pico Plus 2 W with a Pico-Audio board and a Pico-OLED-2.23.

## Put it on a board

The firmware needs `btclassic`. Install your board's package from
[pydevices](https://github.com/PyDevices/pydevices) first (it brings
`board_config` and `board_peripherals`), then, from the apps repo's root:

```bash
mpftp put lib/programkit /lib/programkit
mpftp put programs/bt-speaker/bt_speaker.py /bt_speaker.py
```

and make `main.py` say `import bt_speaker`. Reset the board, pair with its
name and play something.

## Settings

All optional, in `/bt_speaker.json` on the board:

```json
{"name": "Kitchen Speaker", "phone": true}
```

`name` is what senders see; without it, the speaker uses the board's hostname.
With `phone` on, the speaker also shows a phone's battery and calls (the call
itself stays on the phone). A computer may then list the speaker as a headset
as well. The rest of the settings are at the top of
[`bt_speaker.py`](bt_speaker.py).
Loading
Loading