Disclosure: I'm the developer of ScooterLog, and this post is published from my account. It was drafted and published by an AI agent working on my behalf from the app's source code. ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric.

ScooterLog shows every charge in a timeline: start and end percent, how long it took, when it reached 100%. No system hands us that list. The scooter's cloud reports a current state, the server polls it every few minutes, and a "charge" is something we have to reconstruct from a row of samples.

The reconstruction is about 40 lines of Python and SQLite. The interesting part is less the state machine than being honest about what the samples can't tell you.

The input

Each poll produces a reading, stored once:

CREATE TABLE history( user_id TEXT, vehicle_id TEXT, source_ms INTEGER, battery_soc REAL, charging_state TEXT, odometer REAL, -- ... PRIMARY KEY(user_id, vehicle_id, source_ms)); 

source_ms is the timestamp the upstream attached to the reading, not the time we fetched it. That choice does a lot of work, because a poll can return the same reading again:

cur = c.execute('INSERT OR IGNORE INTO history(...) VALUES (...)', (...)) new = cur.rowcount > 0 if new: _update_charging(c, user_id, vehicle_id, point) 

A repeat insert changes nothing, so the session logic only runs on genuinely new information. Downstream, alarm checks hang off the same return value, so a reading can't trigger them twice.

The state machine has no explicit state

There are two ingredients: is this reading charging, and is there an open session row (end_ms IS NULL)? That gives three actions, and the fourth combination does nothing:

charging = state in ('SLOW_CHARGING', 'FAST_CHARGING') if charging and not openrow: # open: record start time, %, odometer  ... elif charging and openrow: # extend: update end %, keep it open  ... elif not charging and openrow: # close: record end time, %, odometer  ... 

The "state" is just the database. A server restart in the middle of a charge loses nothing, since the next reading finds the open row and carries on.

Two details are easy to get wrong. A charge that moves from slow to fast is one session, not two, because both states count as charging. The test pins this down:

pts = [ {'source_ms': 1, 'battery_soc': 20, 'charging_state': 'NOT_CHARGING'}, {'source_ms': 2, 'battery_soc': 22, 'charging_state': 'SLOW_CHARGING'}, {'source_ms': 3, 'battery_soc': 55, 'charging_state': 'FAST_CHARGING'}, {'source_ms': 4, 'battery_soc': 80, 'charging_state': 'NOT_CHARGING'}, ] # -> exactly one session: start 22% at t=2, end 80% at t=4 

And end_soc is written on every extend as well as on close, so a session in progress always shows its latest percent.

The real problem: you weren't watching

Say you read 40% and charging, then the server is down for three hours, then you read 100% and not charging. The naive result is a three-hour session. The truth might be a 90-minute charge and 90 minutes sitting at full. Nothing in the data distinguishes them.

You can't recover what you didn't sample, but you can refuse to present a guess as a measurement. So every update checks the gap to the previous reading:

CHARGE_GAP_MS = 40 * 60 * 1000 prevrow = c.execute( 'SELECT source_ms FROM history WHERE user_id=? AND vehicle_id=? AND source_ms<?' ' ORDER BY source_ms DESC LIMIT 1', (user_id, vehicle_id, ms)).fetchone() gap = prevrow is not None and (ms - prevrow[0]) > CHARGE_GAP_MS 

The threshold sits above the slowest normal polling interval, which is 30 minutes for a sleeping vehicle. Ordinary quiet doesn't trip it. A real outage does.

The flag is applied at all three transitions, and each means something different:

A gap before... What it means
opening The charge may have started during the gap, so the start time and start % are late
extending Readings are missing from the middle, so the curve isn't fully known
closing The unplug may have happened any time in the gap, so the duration may be too long

And it is sticky, so one gap marks the session for good:

UPDATE charging SET end_soc=?, state=?, partial=COALESCE(partial,0) | ? WHERE user_id=? AND vehicle_id=? AND start_ms=? 

COALESCE handles older rows from before the column existed. The bitwise OR means a later clean reading can't wash the flag away.

Deriving the numbers at read time

Sessions store only what was observed. Everything else is computed when asked for, so a better formula applies to old sessions too.

When it reached 100% comes from the history rather than from the session, since a scooter often stays plugged in long after it is full:

f = c.execute('SELECT MIN(source_ms) FROM history WHERE user_id=? AND vehicle_id=?' ' AND source_ms>=? AND source_ms<=? AND battery_soc>=99.5', (user_id, vehicle_id, start, end or now)).fetchone() 

The 99.5 tolerates a reading just under 100.

Charge speed stops the clock at full, not at unplug. Otherwise, leaving it plugged in overnight would make a good charger look slow. Very short sessions get no speed at all:

stop = full_ms or end_ms if stop and start_soc is not None and stop - start_ms >= 10 * 60000: top = 100.0 if full_ms else end_soc if top is not None and top > start_soc: rate_pct_h = round((top - start_soc) / ((stop - start_ms) / 3600000.0), 1) 

The limits I'd rather state

  • Timing is only as fine as the polling. The end of a session is the first reading that says "not charging", not the moment of unplugging, so durations can run long by up to one poll interval.
  • partial is a flag, not a fix. It tells the reader not to trust the duration. It doesn't repair it, and the session stays in the list.
  • A short top-up can be missed. A very short top-up that begins and ends between two polls never appears. Nothing in the data says it happened.
  • The app only knows what the scooter reports, and readings can lag a few minutes.

The pattern, for any polled device

  1. Key readings on the source's own timestamp and run logic only on new rows.
  2. Keep the session state in the table, so a restart costs nothing.
  3. Choose a gap threshold just above your slowest normal sample interval.
  4. Flag uncertain records instead of dropping them or quietly trusting them.
  5. Store observations, derive numbers on read.

ScooterLog is on Google Play. ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric. It reads what the scooter reports and does not control it.