Bob streams live telemetry to an InfluxDB Cloud database every 60 seconds — GPS position, battery voltage, solar power, heading, IMU orientation, and more. The database is open for anyone to query with a free read-only token.
PBQmE7mOKmd5U_6krcYOSjBad9_pveomsc8srZME_BfgQpHjaucNQ-4_VYehA7quC7qNLWczAwjFn6OGUMcs3A==
pip install influxdb-client
#!/usr/bin/env python3
"""ProjectBob.xyz — Get Latest GPS Position from InfluxDB"""
from influxdb_client import InfluxDBClient
INFLUXDB_URL = "https://us-east-1-1.aws.cloud2.influxdata.com"
INFLUXDB_TOKEN = "PBQmE7mOKmd5U_6krcYOSjBad9_pveomsc8srZME_BfgQpHjaucNQ-4_VYehA7quC7qNLWczAwjFn6OGUMcs3A=="
INFLUXDB_ORG = "littlebob"
QUERY = '''
from(bucket: "littlebob")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "littlebob")
|> filter(fn: (r) => r._field == "lat" or r._field == "lon")
|> last()
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
'''
def get_latest_position():
with InfluxDBClient(url=INFLUXDB_URL, token=INFLUXDB_TOKEN, org=INFLUXDB_ORG) as client:
tables = client.query_api().query(QUERY)
for table in tables:
for record in table.records:
lat = record.values.get("lat")
lon = record.values.get("lon")
if lat is not None and lon is not None:
return lat, lon, record.get_time()
return None
if __name__ == "__main__":
result = get_latest_position()
if result:
lat, lon, ts = result
print(f"Latest position: {lat:.6f}, {lon:.6f}")
print(f"Timestamp: {ts}")
print(f"Google Maps: https://www.google.com/maps?q={lat},{lon}")
else:
print("No position data found in the last 24 hours.")
Runs right in your browser via the InfluxDB HTTP API. Hit Run to fetch Bob's position, or paste it into JSFiddle.
const INFLUXDB_URL = "https://us-east-1-1.aws.cloud2.influxdata.com";
const TOKEN = "PBQmE7mOKmd5U_6krcYOSjBad9_pveomsc8srZME_BfgQpHjaucNQ-4_VYehA7quC7qNLWczAwjFn6OGUMcs3A==";
const ORG = "littlebob";
const query = `from(bucket: "littlebob")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "littlebob")
|> filter(fn: (r) => r._field == "lat" or r._field == "lon")
|> last()`;
async function getPosition() {
const resp = await fetch(
`${INFLUXDB_URL}/api/v2/query?org=${ORG}`,
{
method: "POST",
headers: {
"Authorization": `Token ${TOKEN}`,
"Content-Type": "application/vnd.flux",
"Accept": "application/csv",
},
body: query,
}
);
const csv = await resp.text();
const rows = csv.trim().split("\n").filter(r => !r.startsWith("#") && r);
const header = rows[0]?.split(",");
const fieldIdx = header?.indexOf("_field");
const valueIdx = header?.indexOf("_value");
const data = {};
for (const row of rows.slice(1)) {
const cols = row.split(",");
if (cols[fieldIdx]) data[cols[fieldIdx]] = parseFloat(cols[valueIdx]);
}
return data;
}
getPosition().then(d => {
console.log(`Position: ${d.lat}, ${d.lon}`);
});
One-liner for the terminal. Returns CSV.
curl -s -XPOST \
"https://us-east-1-1.aws.cloud2.influxdata.com/api/v2/query?org=littlebob" \
-H "Authorization: Token PBQmE7mOKmd5U_6krcYOSjBad9_pveomsc8srZME_BfgQpHjaucNQ-4_VYehA7quC7qNLWczAwjFn6OGUMcs3A==" \
-H "Content-Type: application/vnd.flux" \
-H "Accept: application/csv" \
-d 'from(bucket:"littlebob") |> range(start:-1h) |> filter(fn:(r)=>r._measurement=="littlebob") |> last() |> pivot(rowKey:["_time"],columnKey:["_field"],valueColumn:"_value")'
Edit the Flux query below and hit Run to see live results.
Use these values to connect from any InfluxDB client library (Python, JS, Go, Rust, etc.) or the InfluxDB Cloud UI.
| URL | https://us-east-1-1.aws.cloud2.influxdata.com |
| Organization | 2a6cdd2d0e5960bf |
| Bucket | littlebob |
| Measurement | littlebob |
| Write interval | ~60 seconds |
Every telemetry row is tagged with string (which controller: A or B), device (hardware ID), and master (true/false — whether this controller currently owns the helm).
| Field | Type | Unit | Description |
|---|---|---|---|
lat | float | degrees | GPS latitude (WGS84) |
lon | float | degrees | GPS longitude (WGS84) |
heading | float | degrees | Magnetometer compass heading (tilt-compensated) |
gps_heading | float | degrees | GPS course over ground — only valid when moving |
speed | float | knots | Speed over ground from GPS |
rudder | float | degrees | Current rudder angle (-90 to +90) |
throttle | float | % | Propeller throttle setting (0–100) |
| Field | Type | Unit | Description |
|---|---|---|---|
vbus | float | V | Battery bus voltage — healthy range 12.0–14.4 V |
watts | float | W | Solar panel power input |
| Field | Type | Unit | Description |
|---|---|---|---|
roll | float | degrees | IMU roll (heel angle of the boat) |
pitch | float | degrees | IMU pitch (fore/aft tilt) |
wifi_rssi | int | dBm | WiFi signal — typically -30 (strong) to -90 (weak) |
iridium_signal | int | bars | Iridium satellite signal (0–5) |
| Field | Type | Unit | Description |
|---|---|---|---|
uptime_sec | int | seconds | Time since last reboot |
heap_kb | int | KB | Free heap memory on the ESP32 |
relay_states | int | bitmask | 4-bit relay state: bit 0 = USB1, bit 1 = USB2, bit 2 = Starlink, bit 3 = Relay4 |
Use these with any client or the Flux editor in InfluxDB Cloud.
10-minute averages — overnight drain and daytime solar charge curves.
from(bucket: "littlebob")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "littlebob")
|> filter(fn: (r) => r._field == "vbus")
|> aggregateWindow(every: 10m, fn: mean, createEmpty: false)
Hourly average speed — daily sailing patterns and overnight drift.
from(bucket: "littlebob")
|> range(start: -7d)
|> filter(fn: (r) => r._measurement == "littlebob")
|> filter(fn: (r) => r._field == "speed")
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
Every field in one wide row — good for a status dashboard.
from(bucket: "littlebob")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "littlebob")
|> last()
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
This is a public read-only token. To keep it available for everyone:
aggregateWindow for large time ranges — don't pull millions of raw points