- Python 94.1%
- Shell 5.9%
Reading the OS source rather than inferring from behaviour, after it turned up the keyboard's hidden min-height. Ten mpos imports, the alarm's APIs, the overlay pattern and the Activity contract were checked against github.com/MicroPythonOS/MicroPythonOS. Three things were wrong. Back quit the app. Activity.onBackPressed returns False by default, which finishes the activity, so back on the settings screen closed Roeper instead of returning to the message screen. It is now consumed to hide the keyboard, or to leave settings when there is something to go back to; an unconfigured badge stays put. The buzzer asked for STREAM_NOTIFICATION. This is an alarm and STREAM_ALARM exists. The audio manager stops any session conflicting on the buzzer pin either way, so the alert still interrupts whatever was playing, but the intent is now stated correctly. Enumerating outputs by hand duplicated AudioManager.find_output_by_kind. Confirmed correct and left alone: the msgbox-on-layer_top overlay matches mpos/ui/errordialog.py exactly; set_back_screen_disabled and set_drawer_open_disabled are real exports aliasing InputManager; LightsManager, Service and the AudioManager player signature all match; and execute_script saves and restores only the entrypoint's sys.modules entry, so the VERSION handshake between the entrypoints and roeper_wire is justified rather than paranoid. 131 desktop assertions. Not deployed: the badge is off USB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|---|---|---|
| nl.dosch.roeper | ||
| tests | ||
| .gitignore | ||
| CLAUDE.md | ||
| deploy.sh | ||
| README.md | ||
| roeper_0.8.2.mpk | ||
Roeper
A group broadcast app for the Fri3d Camp 2026 badge.
Send a message to your group. It appears as a blocking overlay on top of whatever app they are running, buzzes, turns the LEDs red, and stays until they press OK.
Badges publish to one MQTT topic named after the channel. Everyone on the same WiFi with the same channel name gets the alert, on any access point. Messages sent while a badge was off are delivered when it comes back.
Status
0.8.2, written against MicroPythonOS main.
Verified on desktop only. 110 assertions across three test files.
Not yet run on hardware. 0.8.0 changes the wire format, the session
handling and both screens, and none of it has been on a badge.
Carried over unchanged from earlier releases and last seen working there: the alert itself (buzzer, LEDs, OK button) and the overlay appearing over another app.
Never tested at any version: two badges reaching each other through the broker. Every delivery test so far used one badge and a second MQTT client.
How it works
| Layer | Choice |
|---|---|
| Transport | MQTT publish/subscribe, topic roeper/<channel> |
| Channel filter | The topic. Nothing else — the topic is the channel |
| Offline delivery | clean_session=False + QoS 1, so the broker queues for an absent badge |
| Dedupe | Sender + 4-hex message id, last 64 remembered |
| Alert | lv.msgbox(lv.layer_top()) + set_back_screen_disabled() + set_drawer_open_disabled() |
| Background | Service on boot_completed, plus a lazy start from the Activity |
The overlay technique is the one MicroPythonOS uses for its crash dialog
(mpos/ui/errordialog.py) and status bar (mpos/ui/topmenu.py): objects
parented to lv.layer_top() float above every app's screen.
Files
| File | Role |
|---|---|
main.py |
RoeperActivity — message screen, settings screen, sending |
roeper_mqtt.py |
Connect, publish, subscribe, reconnect |
roeper_listener.py |
The single receive loop (idempotent start) |
roeper_alarm.py |
The blocking overlay, buzzer and LEDs |
roeper_wire.py |
Frame encode/decode and message age |
roeper_settings.py |
Name, channel and broker in SharedPreferences, cached |
roeper_service.py |
boot_completed service, wrapper over listener |
Wire format
RPR3|<sender>|<msgid>|<sent>|<text>
sent is unix seconds; the badge clock is NTP-synced. A message queued while a
badge was off can arrive much later, and the alert says how late
("12 min geleden") rather than pretending to be current. Max frame 214 bytes.
Text may contain |; decoding uses maxsplit=4.
RPR2 also carried a recipient, a message type and a 4-hex group tag. Nothing
used them: every message goes to the whole channel, presence frames went with
the recipient picker, and the topic already separates channels. Dropping all
three also dropped the SHA-256.
What keeps a message in a channel
The topic, roeper/<channel>. Nothing else.
Anyone who knows the channel name can read and send. On an open broker,
roeper/# subscribes to every channel. The channel name separates channels; it
does not protect them.
Tests
python3 tests/test_wire.py # no stubs
python3 tests/test_settings_listener.py # mpos and lvgl stubbed
python3 tests/test_ui.py # Activity against a fake LVGL
110 assertions, exit non-zero on failure. The wire cases pin the on-air format: if one has to change, badges on the previous release stop understanding this one. Screen, network and second-badge behaviour are not covered.
Install
Users
From the App Store on the badge. Published to
BadgeHub (badge slug fri3d_2026) via
Fri3d-IDE. Upload to the existing
nl.dosch.roeper project; a new project installs as a second app instead of
upgrading. Bump the version on every republish.
Development (USB)
./deploy.sh
Auto-detects the badge, checks free flash, wipes the old install, copies, verifies every file arrived, and resets. Pass a port if more than one badge is attached.
Building the .mpk
An uncompressed ZIP with one top-level directory matching the app fullname:
rm -rf nl.dosch.roeper/__pycache__ && find nl.dosch.roeper -exec touch -t 202501010000.00 {} \; && (find nl.dosch.roeper -type d; find nl.dosch.roeper -type f) | sort | TZ=CET zip -X -r -0 roeper_0.8.2.mpk -@
Why the app id has dots and the modules have a prefix
BadgeHub rejects an app id without a dot. MicroPythonOS loads an app as a Python
package only when the app directory contains __init__.py and the fullname is a
valid Python identifier. nl.dosch.roeper is not, so the flat loading path runs
instead: the OS puts the app directory on sys.path and imports each file as a
bare top-level module.
Flat modules stay in sys.modules under their bare name, and only the entrypoint
is cleaned up. A wire.py would collide with any other app shipping one. Hence
the roeper_ prefix on every module except the two entrypoints.
Setup, per badge
Roeper opens on the settings screen until a name and a channel are set.
- Naam — shown on the other badges' alerts. Max 12 characters.
- Kanaal — the name your group agreed on. No default: until it is set, the app neither sends nor receives. Case and surrounding spaces are ignored.
- Server — defaults to
10.70.0.20:1883. Change asadres:poortonly when using Roeper elsewhere. - Opslaan.
There is no default channel: a shipped default would put everyone who never changed it into one channel.
Sending
Type in the field and press Verstuur. Tapping the field brings up the keyboard, which covers the bottom 40% of the screen; its confirm key puts it away again.
The header shows name, channel and connection state, and is the way back to settings. It refreshes every two seconds, so what it says about the connection is current.
Receiving
The boot service subscribes at startup, so a badge does not need Roeper open to be alerted. Because the session is persistent and messages go at QoS 1, the broker also queues messages for a badge that is off or out of range, and delivers them on reconnect.
That is deliberate, and it has a consequence: a badge switched off for three hours comes back to every message sent in that time, each as its own overlay, one after another. Each says how old it is.
Verification
- One badge, touch only, no navigator add-on: tap the field, type, send.
- One badge, no channel: the app opens on settings and refuses to send.
- Set name and channel; the header reaches
verbonden. - Two badges, same channel: send from one, the other shows the overlay. Never tested.
- Receiving badge in a different app: the overlay appears over it.
- With the overlay up, the back swipe and pull-down drawer are dead.
- Press OK: back to the previous app, LEDs out.
- Two badges, different channels: nothing arrives.
- Move the receiving badge to another access point so its WiFi channel changes, then send: it still arrives.
- Reboot a badge without opening Roeper, then send: the boot service catches it.
- Switch a badge off, send twice, switch it on: both arrive, each labelled with its age.
Known limitations
- UI-blocking, not device-blocking. Reset and USB reflash still work.
- Not private and not authenticated. Frames are plain text; anyone with the channel name can read and send.
- Anyone on the channel can interrupt everyone.
- No WiFi or no broker means no Roeper. ESP-NOW needed no infrastructure but
only reached badges on the same WiFi channel, and the camp's APs use
different ones (
fri3d-IRAmeasured on channel 1 and 9 from two APs; one badge seen on 8, 9, 1, 13 and 5 in an afternoon). - One broker is one point of failure. The address is editable.
- A badge that returns after a long absence gets every queued message as a separate overlay.
- The queue is the broker's. A badge that has never connected with this client id has nothing queued for it, so messages sent before its first run are lost.
- Names are not unique. This affects what the alert says, nothing else.
History
| Version | Change |
|---|---|
0.2.0 |
No default group passphrase; Test alarm removed |
0.3.0 |
Directed messages, presence, roster; wire RPR1 → RPR2; dotted app id, flat modules |
0.3.1 |
VERSION handshake between entrypoints and roeper_wire; error view instead of a traceback |
0.3.2 |
Dedupe keyed on sender + msgid; settings cached off the receive path; byte-prefix reject; alarm builds its dialog before locking navigation |
0.4.0 |
Text entry moved to InputActivity: an lv.textarea cannot be activated by touch |
0.4.1 |
Incomplete installs report themselves instead of raising ImportError; deploy.sh checks free flash and file count |
0.4.2 |
LVGL enum members resolved by name with fallbacks (DOT vs DOTS) |
0.4.3 |
Labels no longer stashed as attributes on LVGL objects |
0.5.0 |
Recipient selector removed; settings moved to their own screen |
0.5.1 |
Whole status bar is the settings button; 48 px tap targets |
0.6.0 |
ESP-NOW replaced by MQTT. Hard cutover: 0.6.x and 0.5.x cannot reach each other |
0.6.1 |
MQTT client id hex-encoded; machine.unique_id() is not valid UTF-8 |
0.6.2 |
Status bar refreshes on a timer instead of once at startup |
0.6.3 |
Listener waits for WiFi before connecting; backoff resets on association |
0.7.0 |
Message typed inline, keyboard bound in code, confirm key sends |
0.7.1 |
Keyboard forced 320x175 and left the field at zero height; explicit sizes |
0.8.0 |
Wire RPR2 → RPR3: recipient, type and group tag dropped, timestamp added. Persistent session + QoS 1, so messages missed while off are delivered. Keyboard only while typing. "Groep" renamed "kanaal" |
0.8.1 |
Keyboard sized to 40% of the screen by clearing its 175px style min-height first, and shown via show_keyboard() so the navigator add-on can reach it |
0.8.2 |
onBackPressed: back closes the keyboard or leaves settings instead of quitting the app. Buzzer uses find_output_by_kind and STREAM_ALARM |
Licence
GPL-3.0