クロック (kurokku) — the Japanese transliteration of clock.
HTTP server for managing ESP32-powered LED display devices. Serves widget instructions to polling クロック devices based on a playlist model with server-owned timing.
Companion firmware: the ESP32 devices that poll this server run led-kurokku-esp. This repo is the brain; that one is the face.
Run the published image from GHCR:
docker compose -f docker-compose.prod.yml up -dPin a specific release by setting KUROKKU_IMAGE_TAG (defaults to latest):
KUROKKU_IMAGE_TAG=v1.2.3 docker compose -f docker-compose.prod.yml up -dThe server will be available at http://localhost:8080.
| Variable | Default | Description |
|---|---|---|
KUROKKU_LISTEN_ADDR |
:8080 |
HTTP listen address |
KUROKKU_REDIS_ADDR |
localhost:6379 |
Redis/Valkey address |
KUROKKU_LOW_PRIORITY_ALERT_CRON |
*/15 * * * * |
When low-priority alerts are allowed to display |
KUROKKU_LOW_PRIORITY_THRESHOLD |
3 |
Alerts with priority >= N are gated by the cron above |
KUROKKU_TM1637_ALERT_SCROLL_SPEED_MS |
150 |
Scroll speed (ms/col) for alert text on tm1637 devices |
KUROKKU_TM1637_ALERT_REPEATS |
3 |
Number of times alert text repeats on tm1637 devices |
All state (device config, playlists, and ephemeral playlist cursors) lives in Redis. Persistence must be enabled or you'll lose device and playlist configuration on restart.
Both compose files run Valkey (Redis-compatible) with these recommended settings:
# AOF persistence — logs every write, replayed on restart
appendonly yes
appendfsync everysec
# RDB snapshots as a secondary safety net
save 900 1
save 300 10
save 60 10000
appendonly yes with appendfsync everysec gives you at most 1 second of data loss on a crash — fine for a home server managing clock playlists. The RDB snapshots provide a fallback if the AOF file is ever corrupted.
If running Redis/Valkey outside of Docker, add these directives to your redis.conf or valkey.conf.
# Build and run locally (requires Go 1.25+ and a running Redis/Valkey instance)
go build -o kurokku-esp-server ./cmd/server
./kurokku-esp-server
# Or with Docker (builds from source, exposes Valkey on :6379)
docker compose -f docker-compose.dev.yml up --buildUsed by ESP32 firmware:
GET /api/v1/devices/{device_id}/instruction?display_type=max7219
GET /api/v1/admin/devices # list all devices
GET /api/v1/admin/devices/{device_id} # get device
PUT /api/v1/admin/devices/{device_id} # create/update device
DELETE /api/v1/admin/devices/{device_id} # delete device
GET /api/v1/admin/playlists # list all playlists
GET /api/v1/admin/playlists/{playlist_id} # get playlist
PUT /api/v1/admin/playlists/{playlist_id} # create/update playlist
DELETE /api/v1/admin/playlists/{playlist_id} # delete playlist
# Create a playlist that cycles: 30s clock → 10s weather alert check → 15s animation
curl -X PUT http://localhost:8080/api/v1/admin/playlists/living-room \
-H 'Content-Type: application/json' \
-d '{
"name": "Living Room",
"entries": [
{"id": "e1", "position": 0, "duration_secs": 30, "widget": {"type": "clock", "format_24h": true}},
{"id": "e2", "position": 1, "duration_secs": 10, "widget": {"type": "alert"}},
{"id": "e3", "position": 2, "duration_secs": 15, "widget": {"type": "animation", "animation": "pong"}}
]
}'
# Register a device using that playlist
curl -X PUT http://localhost:8080/api/v1/admin/devices/esp32-001 \
-H 'Content-Type: application/json' \
-d '{
"name": "Living Room クロック",
"display_type": "max7219",
"location": "living room",
"brightness": 8,
"poll_ms": 5000,
"playlist_id": "living-room"
}'syslog_host and tz on a device are pushed to the firmware in the poll
response's config block, letting you change a deployed device's logging target
or timezone without re-flashing or serial re-provisioning (OTA replaces only the
firmware image and preserves NVS). The firmware persists them to NVS and applies
them live — no reboot. It also deduplicates, so the server returns the same
config on every poll without churning the device's flash.
curl -X PUT http://localhost:8080/api/v1/admin/devices/esp32-001 \
-H 'Content-Type: application/json' \
-d '{
"display_type": "max7219",
"poll_ms": 5000,
"syslog_host": "192.168.1.50:5514",
"tz": "CST6CDT,M3.2.0,M11.1.0"
}'Both fields are also editable from the device form in the admin UI. Omitting a
field (or leaving it blank in the form) leaves the device's current value
untouched. Sending "syslog_host": "" via the API explicitly disables syslog on
the device.
message widgets can render server-side placeholders at poll time while still sending a normal "type": "message" instruction to devices.
Supported placeholders:
| Placeholder | Default output | Notes |
|---|---|---|
{{date}} |
Mon Jan 2 |
Accepts a Go time.Format layout after : and optional timezone after ` |
{{time}} |
3:04 PM |
Accepts a Go time.Format layout after : and optional timezone after ` |
{{datetime}} |
Mon Jan 2 3:04 PM |
Accepts a Go time.Format layout after : and optional timezone after ` |
{{now}} |
RFC3339 timestamp | Accepts a Go time.Format layout after : and optional timezone after ` |
{{redis}} |
Redis key value | Uses the message widget's redis_key |
Timezone handling:
- No timezone suffix uses the server's local timezone.
|UTCrenders in UTC.|America/Chicagoand other IANA timezone names are supported.- Invalid timezone names are left unrendered so configuration mistakes are visible.
Examples:
{
"type": "message",
"text": "Today is {{date:Mon Jan 2}}",
"scroll_speed_ms": 50,
"repeats": 2
}{
"type": "message",
"text": "Temp {{redis}} as of {{time:15:04}}",
"redis_key": "kurokku:temperature"
}{
"type": "message",
"text": "UTC {{time:15:04|UTC}} Chicago {{time:3:04 PM|America/Chicago}}"
}If redis_key is set and the message text contains no placeholders, the existing behavior is preserved: the Redis value replaces the entire message text.
Alerts are stored as individual Redis keys at kurokku:alert:<id>, each containing a JSON object:
{
"id": "tornado-warning",
"message": "TORNADO WARNING - Take shelter",
"priority": 1,
"display_duration": "15s",
"delete_after_display": false
}The server detects alert changes via Redis keyspace notifications and resets all device playlists to their alert widget position so alerts are displayed promptly. When multiple alerts are active, they are sorted by priority (lower = more urgent) and concatenated into a single scrolling message.
# Example: push a weather alert via redis-cli
redis-cli SET kurokku:alert:tornado-warning '{"id":"tornado-warning","message":"TORNADO WARNING - Take shelter","priority":1,"display_duration":"15s","delete_after_display":false}'Alerts with priority < KUROKKU_LOW_PRIORITY_THRESHOLD always display. Alerts at or above the threshold only display when KUROKKU_LOW_PRIORITY_ALERT_CRON matches — so routine advisories (fog, frost, wind chill) don't crowd out more important content.
Both knobs can be overridden per-device via the low_priority_alert_cron and low_priority_threshold fields on Device (admin form or JSON API). Leave blank/null to inherit the server defaults.
The defaults (threshold 3, cron */15 * * * *) are aligned with the priority scale that nalssi assigns when writing to its kurokku Redis backend:
| Priority | Nalssi events (default mapping) | Default behavior |
|---|---|---|
| 0 | tornado, tsunami, hurricane, typhoon, extreme wind, storm surge | always display |
| 1 | flash flood, severe thunderstorm, blizzard, ice storm | always display |
| 2 | flood, winter storm, high wind, excessive heat, fire weather | always display |
| 3 | wind chill, freeze, frost, heat advisory, wind advisory, dense fog | gated by cron |
| 4 | winter weather, special weather | gated by cron |
| 5 | anything else / unknown | gated by cron |
See the nalssi README for the full mapping and how to override it. Alerts pushed from other sources can use any integer priority you like — 0 is most urgent.
The nalssi weather service can push temperature data and weather alerts to kurokku automatically.