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 can tell you when the battery reaches 80% while charging, when a charge is complete, when the battery is low, and in the evening when tonight's level looks short of what your usual day needs. Each of those is one line of logic. The work is in everything around that line: two places that can send the alarm, a reading that may arrive twice, and a phone that may not be there to receive it.
This post walks through how the server side is put together, in about 60 lines of Python.
Rule 1: the decision is a pure function, and there is only one of it
Alarms can be delivered two ways. The server polls the scooter's cloud and can send a web push to the installed web app. The Android app's background worker can raise a local notification itself. If each path had its own copy of "is 80% reached?", they would drift apart the first time someone tweaked a threshold.
So the decision lives in one function that returns what is due and does nothing else:
ALARM_REPEAT_MS = 6 * 3600 * 1000
def due_alarms(user_id, vehicle_id, point, now=None):
"""Return [(kind, body)] for every enabled alarm whose condition the latest
reading meets and whose repeat window has elapsed."""
out = []
soc = point.get('battery_soc')
if soc is None:
return out
state = point.get('charging_state')
now = now or int(time.time() * 1000)
for a in store.get_alerts(user_id, vehicle_id):
if not a['enabled'] or now - (a['last_fired_ms'] or 0) < ALARM_REPEAT_MS:
continue
kind, thr = a['kind'], a['threshold']
body = None
if kind == 'charge_complete' and state == 'NOT_CHARGING' and soc >= 98:
body = 'Charging complete — battery full'
elif kind == 'charge_level' and state in ('SLOW_CHARGING', 'FAST_CHARGING') and thr and soc >= thr:
body = f'Battery reached {int(soc)}%'
elif kind == 'low_battery' and state == 'NOT_CHARGING' and thr and soc <= thr:
body = f'Battery low — {int(soc)}%'
# ... idle drain and the evening reminder follow the same shape
if body:
out.append((kind, body))
return out
No sending, no writing. It reads the alarm settings and returns a list of (kind, body) pairs. Both delivery paths call it, and the tests call it directly with a hand-built reading. That last part matters more than it looks: testing "does the low-battery alarm fire at 12% when the threshold is 15?" needs no push service, no phone and no clock.
A few choices in the conditions are deliberate:
-
"Complete" is
>= 98, not== 100. A reading taken just after the charger stops can report 99. Waiting for exactly 100 would sometimes never fire. -
"Complete" also needs
NOT_CHARGING. A battery sitting at 99% while still charging is not finished yet. - The 80% alarm only fires while charging. Riding past 80% on the way down must not say "Battery reached 80%".
- The low-battery alarm only fires while not charging. Plugging in at 10% should quiet it, not trigger it.
-
No reading, no decision. If
battery_socis missing the function returns nothing rather than guess.
Rule 2: the repeat window is stored, not remembered
A poller sees the same condition many times. At 80% the next reading is 81%, then 82%, and every one of them satisfies soc >= thr. Without a memory of "already told them", one charge would produce a notification every three minutes.
That memory is a column, last_fired_ms, on the alarm's own row. The decision skips any alarm that fired within the last six hours. Because it is in SQLite and not in a Python dict, a server restart in the middle of a charge does not re-arm every alarm.
The window has an honest cost: two separate charges inside six hours get one "reached 80%" between them. For a scooter that is charged once or twice a day, that trade is fine. A product that charges more often would want the window keyed on the charge session instead of the clock.
Rule 3: mark it fired only after it was delivered
The server path looks like this:
def _check_alarms(user_id, vehicle_id, point):
if point.get('charging_state') not in ('SLOW_CHARGING', 'FAST_CHARGING', 'NOT_CHARGING'):
return
now = int(time.time() * 1000)
for kind, body in due_alarms(user_id, vehicle_id, point, now):
if push.notify_user(user_id, vehicle_id, {'title': 'ScooterLog', 'body': body}):
store.mark_alert_fired(user_id, vehicle_id, kind, now)
notify_user returns how many push subscriptions accepted the message, and the alarm is stamped only when that number is above zero. If the user has no subscription yet, or the push service is down, nothing is stamped, and the next reading asks the same question again. Nobody received anything, so there is nothing to repeat.
Two smaller details make this safe:
-
The sender never raises.
push.sendwraps the whole web-push call and returnsTrueorFalse. One expired subscription can't throw out of the loop and skip the user's other devices. - An unknown charging state is not a decision. If the upstream sends a state the code has never seen, the guard at the top returns before anything is evaluated. A new state string should be read and added on purpose, not fall into a branch by accident.
Rule 4: editing a setting must not re-arm it
The settings screen saves an alarm with an upsert. The obvious version, INSERT OR REPLACE ... last_fired_ms = 0, has a quiet bug: moving the threshold from 80 to 85 while the scooter is charging would wipe the "already fired" stamp and fire again on the next reading.
The fix is to carry the old stamp across the replace:
INSERT OR REPLACE INTO alerts(user_id, vehicle_id, kind, threshold, enabled, last_fired_ms)
VALUES (?, ?, ?, ?, ?,
COALESCE((SELECT last_fired_ms FROM alerts
WHERE user_id = ? AND vehicle_id = ? AND kind = ?), 0))
A new alarm starts at 0, so it can fire straight away. An edited one keeps its history.
Rule 5: an alarm that needs history says nothing until it has some
The evening reminder answers "is tonight's level enough for a usual day tomorrow?". It needs this rider's own average daily kilometres and kilometres per 1% of battery, plus a reserve:
def _needed_for_tomorrow(user_id, vehicle_id, reserve):
ins = store.insights(user_id, vehicle_id, days=14)
daily, kmpp = ins.get('avg_daily_km'), (ins.get('forecast') or {}).get('km_per_pct')
if not daily or not kmpp:
return None
return min(100, int(round(daily / kmpp + reserve)))
Until there are two weeks of readings to average, it returns None and the reminder stays silent. A brand-new user gets no reminder rather than one based on a guessed range. It is also only checked between 19:00 and 23:00 IST, which is computed from UTC plus 5.5 hours rather than from the server's local time zone.
The tests
The feature test sets the four basic alarms on a throwaway user, feeds a full non-charging reading and checks that "complete" is in the result. It then feeds a low one and checks for "low battery". Between the two it resets one stamp with mark_alert_fired(..., 0), which is the same function the poller uses, so the test also covers the repeat window.
What I'd take from this for any polling notifier
- Put the decision in one pure function and call it from every delivery path.
- Store the "already told them" stamp next to the rule, so a restart remembers it.
- Stamp it only after something was actually delivered.
- Let an upsert keep the stamp when a setting is edited.
- Stay silent until you have the data a message depends on.
ScooterLog is on Google Play. It only knows what the scooter's cloud reports, and readings can lag by a few minutes. ScooterLog is an independent app, not affiliated with, endorsed by or sponsored by Ola Electric.
Top comments (0)