Author: Mr. Watson Date: 2026-03-29
- Overview
- Architecture
- Quick checks
- MQTT topics
- Database schema
- Ingest script
- Ingest systemd
- API access
- MQTT WebSocket (live data)
- Privacy
- Ops
- Changelog
The G Mobile Lab is a Bluetti AC200M portable power station with sensors mounted on a Raspberry Pi 5 (pibot1). The RPi publishes telemetry to the server's Mosquitto broker via MQTT. A Python ingestor writes 30-second snapshots to TimescaleDB. PostgREST exposes the latest row via API. For real-time IMU/GPS visualization, browsers connect directly to MQTT over WebSocket.
RPi (pibot1)
bluetti-mqtt ── BLE ── AC200M ──┐
pibot-sensors ── I2C sensors ───┤ MQTT (bluetti/state/#)
pibot-sensors ── GPS NEO-6M ────┤─────────────────────────► Mosquitto
pibot-sensors ── IMU MPU-6050 ──┤ │
starlink-watcher ── gRPC ───────┘ │
┌───────────┴──────────┐
│ │
bluetti-ingest WSS :8083
(30s flush) (live to browser)
│
TimescaleDB
(bluetti_stats)
│
PostgREST
(:3010 → Nginx)
│
/api/telemetry/bluetti_latest
Two data paths:
- DB path (30s): MQTT → ingest script →
bluetti_stats→bluetti_latestview → PostgREST → API poll from dashboard. Used for power, environment sensors, Starlink, GPS position. - Live path (2–5 Hz): MQTT → Mosquitto WSS → browser MQTT.js client. Used for IMU attitude (pitch/roll/yaw) and speed/heading — too fast for DB storage.
systemctl status bluetti-ingest postgrest-telemetry
curl -s https://api.beachlab.org/telemetry/public/bluetti_latest | python3 -m json.tool
journalctl -u bluetti-ingest -n 30 --no-pagerAll under bluetti/state/<device_id>/ where device_id is AC200M-2241000242252.
| Topic | Type | Notes |
|---|---|---|
total_battery_percent |
float | Mapped to battery_percent in DB |
dc_input_power |
float | Solar input (W) |
ac_input_power |
float | AC charger input (W) |
ac_output_power |
float | AC output (W) |
dc_output_power |
float | DC output (W) |
power_generation |
float | Total generation (W) |
ac_output_on |
string | ON/OFF → boolean |
dc_output_on |
string | ON/OFF → boolean |
ac_output_mode |
string | AC output mode |
internal_ac_voltage |
float | Internal AC voltage (V) |
internal_dc_input_voltage |
float | Internal DC input voltage (V) |
internal_dc_input_power |
float | Internal DC input power (W) |
auto_sleep_mode |
string | Auto sleep mode |
pack_details1 |
JSON | Battery pack 1 details |
pack_details2 |
JSON | Battery pack 2 details |
| Topic | Type | Notes |
|---|---|---|
co2_ppm |
float | K30 CO2 (ppm) |
temperature_c |
float | BME280 (°C) |
humidity_pct |
float | BME280 (%) |
pressure_hpa |
float | BME280 (hPa) |
| Topic | Type | Notes |
|---|---|---|
gps_lat |
float | Latitude, rounded to 2 decimals (~1 km) |
gps_lon |
float | Longitude, rounded to 2 decimals (~1 km) |
gps_speed_kmh |
float | Speed over ground (km/h) |
gps_altitude_m |
float | GPS altitude MSL (m) |
gps_satellites |
int | Number of satellites |
gps_fix |
int | 0=none, 1=GPS, 2=DGPS |
heading_deg |
float | Course over ground (°), valid when moving |
| Topic | Type | Notes |
|---|---|---|
altitude_m |
float | Fused GPS + barometric altitude (m) |
baro_altitude_m |
float | Barometric altitude from BME280 pressure (m) |
| Topic | Type | Notes |
|---|---|---|
imu_pitch_deg |
float | Pitch angle (°), 100-sample averaged |
imu_roll_deg |
float | Roll angle (°), 100-sample averaged |
imu_yaw_rate_dps |
float | Yaw rate (°/s), 100-sample averaged |
IMU topics publish at 2–5 Hz (fast path for live visualization). The 30s ingest cycle captures one snapshot for DB storage.
IMU reset: publish any message to bluetti/cmd/<device_id>/imu_reset to zero the current pitch/roll as the new flat reference. Calibration saved to /opt/pibot-sensors/imu_calibration.json on the RPi.
| Topic | Type | Notes |
|---|---|---|
starlink_state |
string | CONNECTED, SEARCHING, BOOTING, STOWED, UNREACHABLE, etc. |
starlink_downlink_mbps |
float | Current download (Mbps) |
starlink_uplink_mbps |
float | Current upload (Mbps) |
starlink_ping_ms |
float | Latency (ms) |
starlink_ping_drop_rate |
float | Packet loss (0.0–1.0) |
starlink_uptime_s |
int | Dish uptime (s) |
starlink_obstructed |
bool | Currently obstructed? |
starlink_obstruction_pct |
float | Sky obstruction (%) |
starlink_gps_sats |
int | Dish GPS satellites |
starlink_country |
string | Country code (rough ~10 km) |
starlink_azimuth_deg |
float | Dish boresight azimuth (°) |
starlink_elevation_deg |
float | Dish boresight elevation (°) |
starlink_tilt_deg |
float | Physical tilt (°) |
starlink_alerts |
string | Comma-separated alerts, or none |
environment— co2, temp, humidity, pressure, altitude_m, baro_altitude_mgps— combined GPS JSONimu— combined IMU JSON (includes raw accel/gyro)navigation— lat, lon, speed, heading, altitude, pitch, roll, yaw_ratestarlink— full JSON with all Starlink fields
bluetti/events/<device_id>/starlink— Starlink state changes (published by starlink-watcher, 5s poll)bluetti/events/<device_id>/wifi— WiFi network changes (published by NM dispatcher)
Database: sensors. Table: public.bluetti_stats (TimescaleDB hypertable, 1-day chunks).
43 columns total: 2 keys + 10 power + 4 environment + 12 GPS/IMU + 14 Starlink + 1 extra (starlink_ping_ms_avg).
-- Current schema (2026-03-29)
CREATE TABLE public.bluetti_stats (
time timestamptz NOT NULL,
device_id text NOT NULL,
-- Power
battery_percent double precision,
dc_input_power double precision,
ac_input_power double precision,
ac_output_power double precision,
dc_output_power double precision,
power_generation double precision,
ac_output_on boolean,
dc_output_on boolean,
pack_details1 jsonb,
pack_details2 jsonb,
-- Environment (I2C sensors)
co2_ppm double precision,
temperature_c double precision,
humidity_pct double precision,
pressure_hpa double precision,
-- Starlink
starlink_state text,
starlink_downlink_mbps double precision,
starlink_uplink_mbps double precision,
starlink_ping_ms double precision,
starlink_ping_drop_rate double precision,
starlink_uptime_s integer,
starlink_obstructed boolean,
starlink_obstruction_pct double precision,
starlink_gps_sats integer,
starlink_country text,
starlink_ping_ms_avg double precision,
starlink_azimuth_deg double precision,
starlink_elevation_deg double precision,
starlink_tilt_deg double precision,
starlink_alerts text,
-- GPS
gps_lat double precision,
gps_lon double precision,
gps_speed_kmh real,
gps_altitude_m real,
gps_satellites smallint,
gps_fix smallint,
heading_deg real,
-- Altitude (fused)
altitude_m real,
baro_altitude_m real,
-- IMU
imu_pitch_deg real,
imu_roll_deg real,
imu_yaw_rate_dps real
);
SELECT create_hypertable('bluetti_stats', 'time',
if_not_exists => TRUE, chunk_time_interval => interval '1 day');
ALTER TABLE bluetti_stats
ADD CONSTRAINT bluetti_stats_device_time_unique UNIQUE (device_id, time);
ALTER TABLE bluetti_stats
SET (timescaledb.compress, timescaledb.compress_segmentby = 'device_id');
SELECT add_compression_policy('bluetti_stats', INTERVAL '7 days', if_not_exists => TRUE);View:
CREATE OR REPLACE VIEW public.bluetti_latest AS
SELECT DISTINCT ON (device_id)
time, device_id,
battery_percent, dc_input_power, ac_input_power,
ac_output_power, dc_output_power, power_generation,
ac_output_on, dc_output_on,
pack_details1, pack_details2,
co2_ppm, temperature_c, humidity_pct, pressure_hpa,
starlink_state, starlink_downlink_mbps, starlink_uplink_mbps,
starlink_ping_ms, starlink_ping_drop_rate, starlink_uptime_s,
starlink_obstructed, starlink_obstruction_pct,
starlink_gps_sats, starlink_country,
starlink_ping_ms_avg,
starlink_azimuth_deg, starlink_elevation_deg, starlink_tilt_deg,
starlink_alerts,
gps_lat, gps_lon, gps_speed_kmh, gps_altitude_m,
gps_satellites, gps_fix, heading_deg,
altitude_m, baro_altitude_m,
imu_pitch_deg, imu_roll_deg, imu_yaw_rate_dps
FROM bluetti_stats
ORDER BY device_id, time DESC;
GRANT SELECT ON public.bluetti_latest TO web_anon;GRANT SELECT ON public.bluetti_stats TO web_anon, telemetry_api;
GRANT SELECT ON public.bluetti_latest TO web_anon, telemetry_api;
GRANT INSERT,SELECT ON public.bluetti_stats TO telemetry_ingest;Note:
ON CONFLICT ... DO NOTHINGrequires SELECT in addition to INSERT. Grant both to the ingest role.
Data retention: indefinite (compression after 7 days). As of 2026-03-29: ~12k rows, ~10 MB, oldest row 2026-03-25.
Location: /home/pink/.openclaw/workspace/scripts/bluetti_ingest.py
- Subscribes to
bluetti/state/#(QoS 1) - Parses topic as
bluetti/state/<device_id>/<field> - Stores all fields in
state[device_id]dict (thread-safe with Lock) - Flushes a snapshot row per device every 30 seconds
- Auto-reconnects to DB on connection loss
Key functions:
on_message()— captures any MQTT field into state dictflush()— builds row dict from state, executes INSERT, schedules next flushto_float(),to_int(),to_bool(),to_json()— type coercers (None-safe)
[Unit]
Description=Bluetti MQTT to TimescaleDB ingestor
After=network-online.target postgresql.service mosquitto.service
Wants=network-online.target
[Service]
Type=simple
User=pink
Group=pink
EnvironmentFile=/etc/telemetry-ingest.env
ExecStart=/opt/telemetry-ingest/.venv/bin/python /home/pink/.openclaw/workspace/scripts/bluetti_ingest.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetUses the same env file and venv as telemetry-ingest.service.
PostgREST exposes bluetti_latest via the existing telemetry API route:
# Latest row (all fields)
curl -s https://api.beachlab.org/telemetry/public/bluetti_latest
# Select specific fields
curl -s 'https://api.beachlab.org/telemetry/public/bluetti_latest?select=time,battery_percent,gps_lat,gps_lon,imu_pitch_deg,imu_roll_deg'
# History (last 24h)
curl -s 'https://api.beachlab.org/telemetry/public/bluetti_stats?time=gte.2026-03-28T00:00:00Z&order=time.asc&limit=500'Nginx route (same as server telemetry):
location /api/telemetry/ {
proxy_pass http://127.0.0.1:3010/;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
}For real-time IMU/GPS visualization, browsers connect directly to Mosquitto over WSS.
- Endpoint:
wss://mosquitto.beachlab.org:8083/ - Auth: anonymous (no credentials needed)
- ACL: read-only access to
bluetti/# - Port 8083: open in firewall, TLS with Let's Encrypt cert
- Client library: MQTT.js (browser build)
File: /etc/mosquitto/aclfile
# Bluetti / Home Assistant (vehicle pibot1)
user door
topic readwrite bluetti/#
topic readwrite homeassistant/#
# Anonymous read for Bluetti telemetry
topic read bluetti/#
Fast topics (2–5 Hz): imu_pitch_deg, imu_roll_deg, imu_yaw_rate_dps, heading_deg, gps_speed_kmh, altitude_m.
All other topics publish at 30s intervals and are better consumed via API polling.
GPS coordinates are rounded at the sensor level (RPi) to 2 decimal places before MQTT publish. This gives ~1.1 km precision — enough for a map dot without revealing exact location. Full-precision coordinates never leave the RPi.
The public API (bluetti_latest) only ever contains the rounded values. The D3 wireframe map on the dashboard further obscures location due to its low-detail coastline rendering.
# Service status
systemctl status bluetti-ingest
# Recent ingest logs
journalctl -u bluetti-ingest -n 30 --no-pager
# Restart after script changes
sudo systemctl restart bluetti-ingest
# Reload PostgREST schema (after view/table changes)
sudo -u postgres psql -d sensors -c "NOTIFY pgrst, 'reload schema';"
# Note: PostgREST does not support config reload — always use restart after config changes:
# sudo systemctl restart postgrest-telemetry
# Row count and size
sudo -u postgres psql -d sensors -c "SELECT pg_size_pretty(hypertable_size('bluetti_stats')) AS size, count(*) AS rows FROM bluetti_stats;"
# IMU calibration reset
mosquitto_pub -h 127.0.0.1 -t 'bluetti/cmd/AC200M-2241000242252/imu_reset' -m 'reset'
# Quick MQTT check (10 messages, 5s timeout)
mosquitto_sub -t 'bluetti/state/#' -C 10 -W 5
# Check MQTT topics live (continuous)
mosquitto_sub -h 127.0.0.1 -t 'bluetti/state/#' -vWhen new sensors are added to pibot1:
ALTER TABLE bluetti_stats ADD COLUMN <name> <type>;- Add column to INSERT_SQL and flush() row dict in
bluetti_ingest.py DROP VIEW bluetti_latest; CREATE VIEW ...with the new columnGRANT SELECT ON public.bluetti_latest TO web_anon;sudo systemctl restart bluetti-ingestNOTIFY pgrst, 'reload schema';
- Rows stop appearing: check
journalctl -u bluetti-ingest— likely DB reconnect or MQTT disconnect. Service auto-restarts. - API returns old data: check if
bluetti-ingestis running. If view was recreated, runNOTIFY pgrst, 'reload schema'. - New columns return NULL: the RPi may not be publishing the topic yet, or the MQTT field name in the ingest script doesn't match the topic suffix.
- IMU values noisy: verify the RPi is averaging samples (check pibot-sensors logs). If still noisy, increase sample count.
- 2026-03-29: Added 12 GPS/IMU columns (gps_lat, gps_lon, gps_speed_kmh, gps_altitude_m, gps_satellites, gps_fix, heading_deg, altitude_m, baro_altitude_m, imu_pitch_deg, imu_roll_deg, imu_yaw_rate_dps). RPi now publishes IMU at 2–5 Hz with 100-sample averaging, GPS rounded to 2 decimals for privacy, IMU reset via MQTT command.
- 2026-03-28: Added sensor columns (co2_ppm, temperature_c, humidity_pct, pressure_hpa) and 14 Starlink columns. Starlink watcher service + WiFi auto-reconnect on RPi.
- 2026-03-25: Initial pipeline — Bluetti power fields, ingest script, hypertable, PostgREST exposure.