Voor de Fri3dcamp badge 2026. Stuur een bericht naar je groepsgenoten. Werkt zonder internet of relays via ESP-NOW. Kies of je de hele groep oproept of een enkele persoon. Badges die jouw groepswachtwoord hebben melden zich vanzelf aan, dus je kiest de ontvanger uit een lijst. Vul op elke badge hetzelfde groepswachtwoord in; alleen badges met exact dezelfde tekst horen elkaar. Zonder wachtwoord doet de app niets. Alle badges moeten op hetzelfde wifi-kanaal zitten, wat vanzelf goed gaat als ze op hetzelfde netwerk zijn aangemeld. Let op: ESP-NOW-berichten zijn niet versleuteld en worden altijd uitgezonden. Een bericht aan een persoon wordt door andere badges genegeerd, niet gemist. Het wachtwoord scheidt groepen, het is geen geheim.
  • Python 94.1%
  • Shell 5.9%
Find a file
dosch 2f0b7c7cd2 Check Roeper's assumptions against the MicroPythonOS source
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>
2026-08-14 20:59:15 +02:00
nl.dosch.roeper Check Roeper's assumptions against the MicroPythonOS source 2026-08-14 20:59:15 +02:00
tests Check Roeper's assumptions against the MicroPythonOS source 2026-08-14 20:59:15 +02:00
.gitignore Roeper 0.5.0: group broadcast app for the Fri3d Camp 2026 badge 2026-08-14 17:30:33 +02:00
CLAUDE.md Check Roeper's assumptions against the MicroPythonOS source 2026-08-14 20:59:15 +02:00
deploy.sh Swap ESP-NOW for MQTT, and fix two bugs that hid it working 2026-08-14 18:37:50 +02:00
README.md Check Roeper's assumptions against the MicroPythonOS source 2026-08-14 20:59:15 +02:00
roeper_0.8.2.mpk Check Roeper's assumptions against the MicroPythonOS source 2026-08-14 20:59:15 +02:00

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.

  1. Naam — shown on the other badges' alerts. Max 12 characters.
  2. 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.
  3. Server — defaults to 10.70.0.20:1883. Change as adres:poort only when using Roeper elsewhere.
  4. 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

  1. One badge, touch only, no navigator add-on: tap the field, type, send.
  2. One badge, no channel: the app opens on settings and refuses to send.
  3. Set name and channel; the header reaches verbonden.
  4. Two badges, same channel: send from one, the other shows the overlay. Never tested.
  5. Receiving badge in a different app: the overlay appears over it.
  6. With the overlay up, the back swipe and pull-down drawer are dead.
  7. Press OK: back to the previous app, LEDs out.
  8. Two badges, different channels: nothing arrives.
  9. Move the receiving badge to another access point so its WiFi channel changes, then send: it still arrives.
  10. Reboot a badge without opening Roeper, then send: the boot service catches it.
  11. 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-IRA measured 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 RPR1RPR2; 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 RPR2RPR3: 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