Hardware and initial Software Copyright (c) 2026, Theo Borm — licensed under the BSD 3-Clause License.
Our Extended Software GNU GENERAL PUBLIC LICENSE Copyright (C) 2026 PD2EMC
This is a new extended firmware for the 2026 Dutch Hackerspace Map by Einstein — an ESP32-C3 based PCB that polls the SpaceAPI of 18 Dutch hackerspaces and drives WS2812-compatible RGB LEDs to show their open/closed state. It also exposes a web interface for configuration and integrates with Home Assistant via MQTT.
- Hardware
- Building & flashing
- Configuration (config.h)
- Web interface
- REST API
- MQTT / Home Assistant
- OTA updates
- Serial commands
- LED colours
- Code reference
| Part | Description |
|---|---|
| MCU | ESP32-C3 "super-mini" |
| Map LEDs | 18 × 2020 WS2812-compatible (one per hackerspace) |
| Backlight | Optional 20-LED WS2812B strip |
| LED data pin | GPIO 10 |
| Total LED count | 38 (18 map + 20 backlight) |
Important: Leave pin D9 of the ESP32-C3 module disconnected. It is the boot-mode selection pin; connecting it to the DO of the last WS2812 causes the module to boot into upload mode. Cut the trace or remove the pin before soldering. See the hardware README for details.
The project uses PlatformIO.
Dependencies (declared in platformio.ini, installed automatically):
bblanchon/ArduinoJsonknolleary/PubSubClient
First flash (USB serial):
pio run -e esp32-c3 --target uploadMake sure USB CDC on boot is enabled in the board settings. Hold the BOOT button while pressing RESET to enter upload mode if needed.
Subsequent flashes (OTA):
pio run -e esp32-c3-ota --target uploadThe OTA target connects to hackerspace-status.local (password: see OTA_PASS in config.h).
All compile-time defaults live in src/config.h. Runtime settings are saved to NVS (non-volatile storage) via Preferences and survive reboots.
| Define | Default | Description |
|---|---|---|
WIFI_SLOT_COUNT |
6 | Number of saved WiFi credentials |
WIFI_S0_SSID … WIFI_S5_SSID |
— | WiFi SSIDs tried in order on boot |
FALLBACK_AP_SSID |
HackerspaceMap-OTA |
SoftAP name when no WiFi connects |
FALLBACK_AP_PASS |
itoldyoualready |
SoftAP password |
OTA_HOSTNAME |
hackerspace-status |
mDNS hostname for OTA |
OTA_PASS |
itoldyoualready |
OTA password |
WIFI_CONNECT_TIMEOUT_MS |
15000 | Per-slot WiFi connect timeout |
LED_PIN |
10 | GPIO for LED data |
LED_COUNT |
38 | Total LEDs (map + backlight) |
MAP_LED_COUNT |
18 | LEDs used for hackerspace states |
BACKLIGHT_COUNT |
20 | Remaining LEDs used as backlight |
POLL_INTERVAL_MS |
120000 | SpaceAPI poll interval (ms) |
ANIM_TICK_MS |
30 | Animation update interval (ms) |
ANIM_MODE_DEFAULT |
ANIM_MODE_ORIGINAL |
Default animation on first boot |
SPARKLE_BRIGHTNESS |
2 | Sparkle flash intensity (0–10) |
MQTT_BROKER_DEFAULT |
192.168.2.26 |
Default MQTT broker IP |
MQTT_PORT_DEFAULT |
1883 | Default MQTT port |
MQTT_TOPIC_DEFAULT |
hackerspace/status |
Default MQTT state topic |
MQTT_HA_ENABLE_DEFAULT |
true |
Enable Home Assistant integration |
After connecting to WiFi the device is reachable on its IP address (shown on the serial monitor). If no known WiFi is found it starts a SoftAP at 192.168.4.1.
The single-page web UI has the following cards:
| Card | Function |
|---|---|
| Hackerspaces | Live open/closed/unknown state of all 18 spaces, time since last poll |
| MQTT | Configure broker, port, topic, enable/disable Home Assistant integration |
| WiFi | View and edit any of the 6 saved credential slots |
| Poll interval | Set how often SpaceAPI is fetched (1 / 2 / 5 / 10 min presets) |
| Display | Set LED brightness (Off / 25% / 50% / 75% / 100%) and animation mode |
| Hardware | Chip, CPU, flash, free heap, MAC, IP, RSSI, uptime — auto-refreshed every 5 s |
The navbar also has a reboot button and a dark/light theme toggle (preference saved in browser localStorage).
All endpoints are served on port 80.
| Method | Path | Description |
|---|---|---|
GET |
/ |
Web UI (single HTML page) |
GET |
/api/spaces |
JSON: state of all 18 spaces + seconds since last poll |
GET |
/api/hw |
JSON: chip info, heap, MAC, IP, RSSI, uptime |
GET/POST |
/api/brightness |
Get or set LED brightness (0–10). POST param: v |
GET/POST |
/api/anim |
Get or set animation mode (0–2). POST param: mode |
GET/POST |
/api/poll |
Get or set poll interval (ms). POST param: ms |
POST |
/api/poll-now |
Trigger an immediate SpaceAPI poll |
GET |
/api/wifi-slot |
Get credentials for a slot. Param: slot (0–5) |
POST |
/api/save-wifi-slot |
Save credentials for a slot. Params: slot, label, ssid, password |
GET/POST |
/api/mqtt |
Get or save MQTT settings. POST params: broker, port, topic, ha_enable |
POST |
/api/reboot |
Reboot the device |
The ESP publishes a JSON status message to the configured topic (default hackerspace/status) every 60 seconds:
{
"space_states": [0, 1, 2, 1, 0, ...],
"poll_interval": 120000,
"brightness": 8,
"anim_mode": 2,
"hw_chip": "ESP32-C3"
}space_states is an array of 18 values: 0 = closed, 1 = open, 2 = unknown.
On every MQTT connect the device publishes retained discovery config messages. Home Assistant automatically creates a HackerspaceMap device containing:
Binary sensors (one per hackerspace, device_class: opening):
| Entity | Description |
|---|---|
binary_sensor.hsmap_maakplek |
ON = open |
binary_sensor.hsmap_hs_drenthe |
ON = open |
binary_sensor.hsmap_tkkrlab |
ON = open |
binary_sensor.hsmap_hack42 |
ON = open |
binary_sensor.hsmap_hs_nijmegen |
ON = open |
binary_sensor.hsmap_td_venlo |
ON = open |
binary_sensor.hsmap_ackspace |
ON = open |
binary_sensor.hsmap_hackalot |
ON = open |
binary_sensor.hsmap_pi4dec |
ON = open |
binary_sensor.hsmap_pixelbar |
ON = open |
binary_sensor.hsmap_revspace |
ON = open |
binary_sensor.hsmap_space_leiden |
ON = open |
binary_sensor.hsmap_techinc |
ON = open |
binary_sensor.hsmap_awesomespace |
ON = open |
binary_sensor.hsmap_randomdata |
ON = open |
binary_sensor.hsmap_hermithive |
ON = open |
binary_sensor.hsmap_nurdspace |
ON = open |
binary_sensor.hsmap_bitlair |
ON = open |
Controllable select entities (changing them in HA is sent back to the ESP and applied immediately):
| Entity | Options |
|---|---|
select.hsmap_brightness |
Off / 25% / 50% / 75% / 100% |
select.hsmap_anim |
Sparkle / Breathe / Original |
select.hsmap_poll |
1 min / 2 min / 5 min / 10 min |
Commands are received on <topic>/set/brightness, <topic>/set/anim_mode, and <topic>/set/poll_interval. The ESP applies the change, saves it to NVS, and immediately publishes a new status message to confirm the change back to HA.
After the first serial flash, subsequent updates can be done over WiFi:
pio run -e esp32-c3-ota --target uploadOr from the Arduino IDE using the hackerspace-status.local network port. Password is set by OTA_PASS in config.h.
Connect at 115200 baud. Commands are newline-terminated.
| Command | Description |
|---|---|
HELP |
Print command list |
CLEAR |
Turn off all map LEDs |
ALL,OPEN |
Set all spaces to open (green) |
ALL,CLOSED |
Set all spaces to closed (red) |
ALL,UNKNOWN |
Set all spaces to unknown (blue) |
<n>,<state> |
Set space n (1–18) to a state or colour |
Valid states/colours: OPEN, CLOSED, UNKNOWN, RED, GREEN, BLUE, YELLOW, WHITE, PURPLE, CYAN, ORANGE, PINK, MAGENTA, TEAL, AQUA, GOLD, AMBER, VIOLET, INDIGO, BLACK, GRAY
| Colour | Meaning |
|---|---|
| Green | Space is open |
| Red | Space is closed |
| Blue | Unknown / communication error |
| Orange (pulsing) | Space is being polled right now |
| White (startup) | Boot sequence in progress |
| Green (solid, all) | WiFi connected |
| Orange (solid, all) | Running as SoftAP fallback |
All compile-time constants. Edit this file before flashing to set your WiFi credentials, MQTT broker, OTA password, and LED counts.
| Function | Description |
|---|---|
initLeds() |
Initialise SPI2 peripheral for WS2812 data output via DMA |
encodeByte(value) |
Encode one byte into the 4× expanded SPI bit pattern WS2812 expects |
setPixel(index, r, g, b) |
Write one LED into the DMA buffer (brightness-scaled) |
showLeds() |
Transmit the full DMA buffer to the LED strip via SPI |
showLedsLocked() |
Same as showLeds() but acquires the LED mutex first (safe from any task) |
clearAll() |
Set all LEDs to off in the buffer |
fillRange(first, count, r, g, b) |
Fill a range of LEDs with a solid colour |
fillAll(r, g, b) |
Fill every LED with a solid colour |
clearSpaces() |
Turn off all 18 map LEDs |
clearBacklight() |
Turn off all backlight LEDs |
setBacklightColor(r, g, b) |
Set all backlight LEDs to one colour |
| Function | Description |
|---|---|
setSpaceColor(n, r, g, b) |
Set the colour of space n (1-based) and update base colour arrays |
setSpaceState(n, state) |
Set space n to OPEN/CLOSED/UNKNOWN/CUSTOM and update its LED colour and animation parameters |
setAllSpaces(state) |
Apply one state to all 18 spaces |
fetchSpaceState(url) |
HTTP GET the SpaceAPI URL, parse state.open, return OPEN/CLOSED/UNKNOWN |
pollAllSpaces() |
Poll every hackerspace sequentially, updating LEDs and spaceStates[] |
| Function | Description |
|---|---|
initOriginalAnim() |
Randomise per-space phase and speed for the Original animation |
updateAnimation() |
Compute one animation frame for the current animMode (called from the animation task) |
animTaskFunc(void*) |
FreeRTOS task: runs updateAnimation() + showLeds() every ANIM_TICK_MS ms |
Animation modes:
| Mode | Constant | Description |
|---|---|---|
| 0 | ANIM_MODE_SPARKLE |
Random white sparkles on top of space colours |
| 1 | ANIM_MODE_BREATHE |
All spaces and backlight slowly pulse together |
| 2 | ANIM_MODE_ORIGINAL |
Each space breathes independently; backlight cycles a rotating rainbow (by Theo Borm) |
| Function | Description |
|---|---|
loadSettings() |
Load display settings (brightness, animMode, pollInterval) and WiFi slots from NVS, then calls loadMqttSettings() |
saveDisplaySettings() |
Save display settings to NVS, then calls saveMqttSettings() |
saveWifiSlot(slot) |
Save one WiFi credential slot to NVS |
loadMqttSettings() |
Load MQTT broker, port, topic, and HA-enable flag from NVS |
saveMqttSettings() |
Save MQTT settings to NVS |
| Function | Description |
|---|---|
tryConnectSlot(slot) |
Attempt to connect to one saved WiFi slot; returns true on success |
connectWifiOrStartSoftAp() |
Try all WiFi slots in order; fall back to SoftAP if all fail |
setupOta() |
Configure and start Arduino OTA, set hostname and password |
serviceDelay(ms) |
delay() replacement that keeps OTA responsive during long waits |
| Function | Description |
|---|---|
mqttCallback(topic, payload, length) |
Receives commands from HA (set/brightness, set/anim_mode, set/poll_interval), applies and saves the change, then immediately publishes updated status |
mqttReconnect() |
Blocking reconnect loop: connects, subscribes to <topic>/set/#, publishes HA discovery |
publishMqttStatus() |
Publish current space_states, brightness, anim_mode, poll_interval as JSON to the state topic |
publishHADiscovery() |
Publish retained MQTT auto-discovery config for all 18 binary sensors and the 3 controllable select entities |
| Function | Description |
|---|---|
handleSerialCommand(line) |
Parse and execute a serial command (n,STATE or ALL,STATE or CLEAR) |
parseSpaceState(text, state) |
Convert a state/colour name string to a SPACE_* constant |
printSerialHelp() |
Print the serial command reference to Serial |
| Function | Description |
|---|---|
startupTest() |
Cycle all LEDs through red → green → blue → white → backlight warm-white on boot |
setup() |
Arduino entry point: load settings, init LEDs, connect WiFi, start OTA, start animation task, initial poll, start web server |
loop() |
Arduino main loop: MQTT reconnect/publish, OTA handle, serial commands, timed SpaceAPI poll, web server |
| Function | Description |
|---|---|
setupWebServer() |
Register all API routes and start the HTTP server on port 80 |
handleRoot() |
Serve the full single-page web UI |
handleApiSpaces() |
Return space states and poll age as JSON |
handleApiHw() |
Return hardware info (chip, heap, MAC, IP, RSSI, uptime) as JSON |
handleApiBrightness() |
GET: return current brightness. POST ?v=: set brightness and save |
handleApiAnim() |
GET: return current animation mode. POST ?mode=: set mode and save |
handleApiPoll() |
GET: return poll interval. POST ?ms=: set interval and save |
handleApiPollNow() |
Set forcePoll = true to trigger an immediate poll on the next loop iteration |
handleApiGetWifiSlot() |
Return label/SSID/password for a given slot |
handleApiSaveWifiSlot() |
Update and persist a WiFi credential slot |
handleApiMqtt() |
GET: return current MQTT settings as JSON. POST: update broker/port/topic/ha_enable, reconnect client |
handleApiReboot() |
Send response then call ESP.restart() |
sendJson(json) |
Helper: send a JSON response with CORS and no-cache headers |