Cloud-side server for the Librescoot uplink system. Handles bidirectional communication with scooters, stores their telemetry history, and provides a web UI and REST API for monitoring and control.
Part of the Librescoot open-source platform.
- WebSocket-based persistent connections with per-message compression
- Authentication — a shared API key and username/password login (session tokens)
- State synchronization — full snapshots, incremental changes, sparse deltas (with field removals) and batched offline replay
- Command dispatch with response tracking and offline queuing (safe commands are delivered when the scooter reconnects)
- Durable persistence (SQLite) — queryable telemetry history, events, and command history/queue; survives restarts
- Runtime scooter management — register/remove scooters from the web UI or API (no CLI edit required)
- Modern web UI — flat, responsive, light/dark, with live updates, grouped state, command groups, and historical charts
- REST API for integration and automation
- Wire-level byte tracking — monitors actual network bandwidth (post-compression)
- First-run auto-provisioning — generates a config with a random API key and admin password if none exists
- Self-contained binary — the web UI is embedded; no external files needed at runtime
- Go 1.25 or higher (the pure-Go SQLite driver requires it)
- No CGO — builds fully static (
CGO_ENABLED=0) - Optional: Docker, for containerized deployment
make deps # download dependencies
make build # build ./bin/uplink-server
# Cross-compile
make server-linux-amd64
make server-linux-armOr run directly:
go build -o bin/uplink-server ./cmd/uplink-serverOn first run, if the config file does not exist, the server creates one with a
random API key and a random admin password, prints them once, and starts:
./bin/uplink-server -config config.yml================ uplink-server credentials ================
Config: config.yml
API key: 6533428e…c88b2e
Username: admin
Password: 696f6277b581305c1df8d404
==========================================================
Save those — the password is stored in the config but only shown here once. You
can still pre-generate a config explicitly with ./bin/uplink-server init -config config.yml.
Visit http://localhost:8080, click the key icon, and either log in with the
username/password or paste the API key.
Use the + button in the web UI (enter an identifier, get a ready-to-paste client config with a one-time token), or the CLI:
./bin/uplink-server add-client -config config.yml -identifier WUNU2S3B7MZ000147docker compose up --build
# the API key + admin password are printed in the container logs on first runThe image is self-contained (the UI is embedded). Use a named volume for
/app/data (the provided docker-compose.yml does) so the config, database, and
state persist and remain writable by the non-root container user.
See configs/config.example.yml for the full reference.
Key sections:
server.ws_port— port for scooter and web UI connections (default: 8080)server.enable_web_ui—trueserves the web UI;falseruns API only (no/,/ws/web)server.keepalive_interval— e.g."5m"auth.api_key— API key for the web UI and REST APIauth.tokens— map of scooter identifier → auth token (managed via the UI/CLI)auth.users— map of web-UI username → password (omit to disable password login)logging.stats_interval— statistics logging frequency (e.g."30s")
Persistent data lives under ./data (SQLite uplink.db, plus state.json and
events.jsonl).
Note:
auth.api_keyandauth.userspasswords are stored in plaintext in the config file — protect it accordingly.
Two ways to authenticate the web UI and REST API, both presented as
X-API-Key (or ?api_key= for the web WebSocket):
- API key — the shared
auth.api_key. - Username/password —
POST /api/loginwith{username, password}returns a session token (24h) that is accepted anywhere the API key is.POST /api/logoutinvalidates it.
curl -X POST http://localhost:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"…"}'
# => {"token":"…","expires_in":86400,"username":"admin"}A modern, self-contained interface embedded in the binary (enable_web_ui: true).
- Live scooter status and state updates over
/ws/web - Grouped, collapsible state panels (Vehicle, Batteries, Location, Powertrain, Connectivity, System) instead of a flat table
- Grouped command buttons (Access, Lights, Alarm, Power, Diagnostics) with response feedback
- Manage Scooters dialog — add (with a one-time client config to copy) and remove scooters
- History dialog — per-scooter charts (speed, battery charge) over selectable ranges
- Username/password login or API-key entry
- Automatic light/dark theme (follows the OS)
- Connection stats with wire-level bandwidth
Set enable_web_ui: false to run headless (API + scooter WebSocket only).
All endpoints require authentication (API key or session token via X-API-Key),
except POST /api/login.
GET /api/scooters # list connected scooters
POST /api/scooters # register a scooter → returns a token
DELETE /api/scooters/{identifier} # remove a registered scooter
GET /api/registry # list all registered scooters (+ online flag)
GET /api/scooters/{id} # connection details
GET /api/scooters/{id}/state # latest state snapshot
GET /api/scooters/{id}/history?from=&to=&limit= # persisted telemetry time-series
GET /api/scooters/{id}/events # recent events
DELETE /api/scooters/{id}/events # clear events
DELETE /api/scooters/{id}/events/{eventID}
GET /api/scooters/{id}/commands # command history
POST /api/scooters/{id}/config # push dotted-path config deltas to the scooterRegister a scooter:
POST /api/scooters
{ "identifier": "WUNU2S3B7MZ000147", "name": "Front Desk" }
# => 201 { "identifier": "...", "name": "...", "token": "…" }POST /api/commands
{
"scooter_id": "WUNU2S3B7MZ000147",
"command": "lock",
"params": {},
"queue": true, # optional: queue if the scooter is offline
"ttl": "1h" # optional: how long a queued command stays deliverable
}Responses:
201 { "request_id": "…", "status": "sent" }— delivered to an online scooter202 { "request_id": "…", "status": "queued" }— scooter offline, queued for reconnect404— scooter not connected (andqueuenot requested)409— command may not be queued offline (safety: e.g.unlock,open_seatbox)
The request_id (YYYYMMDD-HHMMSS.microseconds) tracks the command.
GET /api/commands/{request_id}
# pending:
{ "request_id": "…", "status": "pending", "message": "Response not yet received" }
# completed:
{ "request_id": "…", "scooter_id": "…", "status": "success",
"command": "lock", "result": {…}, "received_at": "2026-01-23T15:45:31Z" }Recent command responses are cached in-memory for 1 hour (poll the endpoint); full command metadata and status also persist in the database.
POST /api/login # { username, password } → { token, expires_in, username }
POST /api/logout # invalidates the presented session tokenThe server uses an embedded SQLite database (data/uplink.db, pure-Go
modernc.org/sqlite, no CGO):
- telemetry_history — every snapshot (full or post-delta), with extracted
lat/lng/speed/statecolumns for querying. Exposed via/api/scooters/{id}/history. Old rows are pruned (default retention 30 days). - events — event log, durable across restarts.
- commands — command history that doubles as a durable, per-scooter queue:
offline-queued commands survive restarts, are replayed on reconnect, honor a
per-command TTL, and never queue physical-actuation commands
(
unlock,open_seatbox,force_lock).
The latest state per scooter is also mirrored to state.json for the live dashboard.
The server tracks both application-level and wire-level statistics:
- Application bytes — uncompressed message data
- Wire bytes — actual network bandwidth (post-compression), via TCP connection wrappers
- Compression ratio — bandwidth savings from WebSocket compression
- Telemetry / command counts per connection
- auth — authenticate with identifier and token
- state — full state snapshot (nested object structure)
- change — incremental field-level changes (nested)
- telemetry_delta — changed leaves plus a list of removed dotted paths
- telemetry_batch — a batch of buffered offline snapshots (replayed on reconnect)
- event — critical event notification
- keepalive — keepalive ping
- command_response — response to a server command (
status:success/failed/running)
- auth_response — authentication result
- command — execute a command on the scooter
- keepalive — keepalive ping
- config_update — push dotted-path config deltas (optionally requesting a restart)
State and changes use a nested structure keyed by component (Redis hash), preserving semantic grouping:
{
"type": "state",
"timestamp": "2026-01-20T22:45:00Z",
"data": {
"battery:0": { "charge": "64", "voltage": "54214" },
"vehicle": { "state": "stand-by", "handlebar:lock-sensor": "unlocked" },
"engine-ecu":{ "speed": "0", "odometer": "1234567" }
}
}A telemetry_delta additionally carries a removed list of hash.field paths so
the server can prune fields that a merge alone cannot remove:
{
"type": "telemetry_delta",
"timestamp": "2026-01-20T22:45:01Z",
"changes": { "engine-ecu": { "speed": "25" } },
"removed": ["gps.latitude"]
}To connect a scooter:
- Open a WebSocket to
ws://server:8080/ws - Send an
authmessage with identifier + token; wait forstatus: "success" - Send an initial
statesnapshot, thenchange/telemetry_deltaupdates andeventmessages - Handle incoming
commandandconfig_updatemessages; reply withcommand_response - Respond to keepalives; enable per-message-deflate compression for bandwidth savings
Reference client: librescoot/uplink-service — Go client for scooters.
uplink-server/
├── cmd/uplink-server/ # main application + CLI subcommands (init, add-client)
├── internal/
│ ├── auth/ # API-key + scooter authentication
│ ├── session/ # username/password login session tokens
│ ├── registry/ # runtime scooter registration + config persistence
│ ├── handlers/ # HTTP / WebSocket handlers (scooter, web UI, REST API)
│ ├── models/ # config + connection data models
│ ├── protocol/ # wire message protocol
│ ├── storage/ # in-memory connection/state/event stores
│ ├── store/ # SQLite persistence (telemetry history, events, commands)
│ └── webui/ # embedded web UI assets (HTML/CSS/JS)
├── configs/ # example configuration
├── Dockerfile, docker-compose.yml
└── bin/ # built binaries
This project is dual-licensed. The source code is available under the GNU Affero General Public License v3.0. The maintainers reserve the right to grant separate licenses for commercial distribution; please contact the maintainers to discuss commercial licensing.
