I was recently assigned as the architect and engineer for a Malaysian parking-reminder app, and one requirement turned into a genuinely interesting distributed systems problem: detect, with no manual input and no false positives β the exact moment a user has parked their car.
Two unreliable signals. One source of truth. A system where a wrong state transition means a real person walks back to a parking fine.
I was responsible for the entire architecture which was the data models, the state machine, the detection engines, async task orchestration, and the offline sync layer. This article focuses on one slice of that: the detection-and-confirmation pipeline, and the decisions I made to keep it reliable under concurrency.
The Problem I Had To Solve
The product idea was simple to describe and hard to build: send a user a "remember to pay for parking" notification at exactly the right moment without asking them to open an app or check in manually.
The difficulty was not the notification. The difficulty was actually knowing, with confidence, that a user has parked. I had two signals available to me, and neither was reliable on its own:
- Bluetooth state β does the user have a car Bluetooth device, and did it just disconnect? Unreliable because random Bluetooth devices disconnect near a phone constantly β headphones, a colleague's phone, a speaker, etc.
- GPS geofencing β has the device entered a registered parking zone? Unreliable as well, because someone driving through a car park to exit on the other side looks identical, for the first few seconds, to someone who is about to park.
I needed an architecture that combined both signals, handled the case where neither was available, and produced exactly one outcome per parking episode β never a duplicate, never a false positive, never a silently corrupted state.
Designing Two Independent Detection Paths
I split the problem into two paths based on what hardware signal was available.
Path A β the user has registered a car Bluetooth MAC address. When their phone enters a geofence and that specific Bluetooth device disconnects inside the zone, I treat the disconnection itself as confirmation.
Path B β no car Bluetooth registered. I wait 30 seconds after geofence entry to filter out drive-throughs, then check if the device is still inside. If it is, I send a prompt β "Do you want to park here?" β and wait up to 10 minutes for a response.
Both paths converge on the same underlying object: a ParkingSession record, controlled by a state machine I built specifically because I did not trust myself, or any future contributor to mutate session state safely by hand across a dozen call sites.
Why I Refused To Let Anything Write session.state Directly
Before touching detection logic, I had to solve a more fundamental problem: this system runs multiple Celery workers concurrently. A Bluetooth disconnect event and a geofence timeout could theoretically fire within milliseconds of each other for the same session. If both paths could write to session.state directly, I had no way to guarantee consistency.
So I built an engine β StateMachineEngine β and made it the only code in the entire project allowed to touch session.state.
class StateMachineEngine:
ALLOWED_TRANSITIONS = { # the only allowed transitions from one state
ParkingSession.StateMachine.DETECTING: [
ParkingSession.StateMachine.CONFIRMED,
ParkingSession.StateMachine.CANCELLED
],
ParkingSession.StateMachine.CONFIRMED: [
ParkingSession.StateMachine.NOTIFIED,
ParkingSession.StateMachine.CANCELLED
],
ParkingSession.StateMachine.NOTIFIED: [
ParkingSession.StateMachine.NOTIFIED,
ParkingSession.StateMachine.ACTIVE,
ParkingSession.StateMachine.CANCELLED
],
ParkingSession.StateMachine.ACTIVE: [
ParkingSession.StateMachine.COMPLETED,
ParkingSession.StateMachine.CANCELLED
],
ParkingSession.StateMachine.COMPLETED: [],
ParkingSession.StateMachine.CANCELLED: [],
}
@staticmethod
def transition(session: ParkingSession, new_state: str, **kwargs) -> None:
if not StateMachineEngine.is_transition_allowed(session, new_state):
raise InvalidTransition(
f"Illegal lifecycle transition requested: '{session.state}' -> '{new_state}' "
f"for ParkingSession ID: {session.id}"
)
session.state = new_state
for field, value in kwargs.items():
setattr(session, field, value)
base_update_fields = ['state', 'version']
extended_update_fields = base_update_fields + list(kwargs.keys())
session.save(update_fields=extended_update_fields)
Two decisions here that I stand by:
The exception is never swallowed. If anything attempts an illegal transition β COMPLETED back to ACTIVE, for instance β it fails loudly and immediately. In a parking-reminder app, a silently corrupted session state means a user either gets spammed with notifications after they have already left, or gets no reminder at all. I would rather see a stack trace in my logs than a support ticket from a user who got fined.
Optimistic locking on every write. The model, ParkingSession, comes with a version field, and its save() method checks it before allowing any update through:
def save(self, *args, **kwargs):
try:
with transaction.atomic():
if self.pk:
updated = type(self).objects.filter(
pk=self.pk, version=self.version
).update(version=models.F('version') + 1)
if not updated:
raise Exception("Concurrent update detected")
self.version += 1
super().save(*args, **kwargs)
except Exception as e:
logger.error(f"Failed to save parking session: {e}")
raise
Every call inside transition() to session.save(update_fields=...) is protected by this check. If two Celery workers race to transition the same session, exactly one save succeeds β the other hits the version mismatch and raises, instead of silently overwriting the winner.
I use this same pattern on every model in the system that can be written to by more than one process β sessions, snoozes, subscriptions β but ParkingSession is where it matters most, since it's the one record both detection paths are racing to update.
Path A: Letting A Signal Chain Do The Work
For the Bluetooth path, I made a deliberate choice not to call any engine directly from the geofence views. The view's only job is to persist the raw hardware event:
BluetoothEvent.objects.create(
event_type='disconnected',
user=request.user,
device=device,
session=session,
mac_address=data['mac_address'],
triggered_at=data['triggered_at'],
matched_car_bluetooth=False,
triggered_confirmation=False
)
A post_save signal picks it up from there:
@receiver(post_save, sender=BluetoothEvent)
def handle_bluetooth_confirmation_hook(sender, instance, created, **kwargs):
"""
Fires automatically when a Bluetooth network log is written.
"""
if not created:
return
is_disconnect = (instance.event_type == BluetoothEvent.EventType.DISCONNECTED)
is_car_bt = (instance.matched_car_bluetooth is True)
if is_disconnect and is_car_bt:
try:
session = instance.session
if session is None:
logger.warning(f"Bluetooth Guard: Event {instance.id} has no associated session. Skipping.")
return
if session.state == ParkingSession.StateMachine.DETECTING:
event_id = instance.id
transaction.on_commit(lambda: execute_bluetooth_disconnect_engine(event_id))
I want to highlight transaction.on_commit specifically, because I almost shipped this without it. Without deferring execution until after commit, the engine could read a stale or uncommitted version of the session from inside the same transaction that created the event β a subtle bug that would only show up under load, never in a quick manual test.
The actual confirmation logic lives in its own engine, deliberately separate from the signal:
class BluetoothDetectionEngine:
"""
Handles car Bluetooth disconnection as automatic parking confirmation.
No user question asked.
"""
@staticmethod
def handle_bluetooth_disconnect(event) -> None:
"""
Receives an asynchronous BluetoothEvent instance from signal layer.
Evaluates geofence parameters to confirm active parking and disconnection.
"""
session = event.session
if not session:
logger.info(f"Bluetooth disconnect event [{event.id}] dropped: No associated session context found.")
return
from parking.models import ParkingSession
if session.state != ParkingSession.StateMachine.DETECTING:
logger.info(f"Bluetooth disconnect ignored: Session [{session.id}] is already in '{session.state}' state.")
return
if not BluetoothDetectionEngine.is_known_car_bluetooth(event.device, event.mac_address):
logger.info(f"Bluetooth mismatch: MAC [{event.mac_address}] does not pair with Device [{event.device.id}].")
return
# verify device has not already crossed geofence's boundary exit threshold
from geofence.models import GeofenceEvent
if GeofenceEvent.objects.last_exit(event.user, session.location):
logger.info(f"Bluetooth confirmation aborted: User [{event.user.id}] has already cleared location boundary perimeter.")
return
# OPTIMISATION - Combining both tracking flags into a single database update execution
# to preserve performance
event.matched_car_bluetooth = True
event.triggered_confirmation = True
event.save(update_fields=['matched_car_bluetooth', 'triggered_confirmation'])
from parking.engines.state_machine_engines import StateMachineEngine
StateMachineEngine.confirm(session, detection_method=ParkingSession.DetectionMethod.BLUETOOTH_DISCONNECT)
StateMachineEngine.notify(session)
The MAC address check is the part I considered most carefully:
@staticmethod
def is_known_car_bluetooth(device, mac_address: str | None) -> bool:
if not mac_address or not getattr(device, 'car_bluetooth_mac', None):
return False
return mac_address.upper().strip() == device.car_bluetooth_mac.upper().strip()
Without this guard, any Bluetooth device disconnecting near the phone β headphones, a paired laptop β would trigger a false parking confirmation. I built the comparison case-insensitive and whitespace-stripped after noticing real-world MAC address formatting inconsistencies across Android manufacturers during testing.
Path B: Designing Around A 10-Minute Window I Don't Fully Control
The harder design problem was Path B, because I am asking a human to respond within a bounded window, and humans are unreliable.
I schedule the initial check 30 seconds out, store the Celery task ID on the session so I can revoke it later, and check geofence presence before sending anything:
@staticmethod
def schedule_parking_prompt(session) -> None:
"""schedules the 30 second window task"""
from core.tasks import fire_parking_prompt_task
eta = session.detected_at + timedelta(seconds=30)
task = fire_parking_prompt_task.apply_async(
args=[session.id],
eta=eta
)
session.prompt_celery_task_id = task.id
session.save(update_fields=['prompt_celery_task_id', 'date_updated'])
logger.info(f"Scheduled 30s parking prompt check for Session [{session.id}].")
At the 30-second mark, I deliberately do not confirm the session β only the user tapping "YES" can do that:
@staticmethod
def fire_parking_prompt(session_id) -> None:
"""
Core Celery task execution target at +30 seconds.
Checks device is still inside geofence.
Sends PARKING_PROMPT notification.
Session stays in DETECTING β user must tap YES to confirm.
No state transition happens here.
"""
from parking.models import ParkingSession
from parking.engines.state_machine_engines import StateMachineEngine
try:
session = ParkingSession.objects.select_related(
'device',
'location',
'user'
).get(pk=session_id)
except ParkingSession.DoesNotExist:
logger.error(f"Task dropped: Session [{session_id}] not found.")
return
if session.is_terminal:
return
# If session moved out of DETECTING, abort instantly
if session.state != ParkingSession.StateMachine.DETECTING:
logger.info(f"Prompt check skipped: Session [{session.id}] is already in state '{session.state}'.")
return
from django.core.cache import cache
if cache.get(f"prompt_cancelled:{session.id}"):
logger.info(f"Prompt task aborted via cancellation flag for Session [{session.id}].")
return
# Verify device hasn't registered an out-of-order EXIT footprint
if not TimeBasedDetectionEngine.is_device_still_in_geofence(
session.device, session.location, session=session
):
logger.info(f"Prompt check aborted: Device [{session.device.id}] vacated Location [{session.location.id}].")
StateMachineEngine.cancel(session, reason=ParkingSession.CancelledReason.DRIVE_THROUGH)
return
# Device still inside β send the prompt. Do NOT confirm yet.
# Session stays in DETECTING until user taps YES.
now = timezone.now()
session.prompt_sent_at = now
session.prompt_expires_at = now + timedelta(minutes=10)
session.save(update_fields=['prompt_sent_at', 'prompt_expires_at', 'date_updated'])
from notifications.engines import NotificationEngine
NotificationEngine.queue_parking_prompt(session)
from core.tasks import handle_prompt_expiry_task
handle_prompt_expiry_task.apply_async(
args=[session.id],
eta=session.prompt_expires_at
)
logger.info(f"PARKING_PROMPT dispatched for Session [{session.id}]. Awaiting user response.")
I initially wrote this method to confirm the session immediately on this check β it took a careful re-trace of the full flow to catch that this was wrong. Confirmation has to come from the user's explicit response, not from the system assuming intent. That distinction sounds obvious in hindsight, but it is the kind of bug that passes a casual code review and only surfaces when you trace the entire state lifecycle end to end.
The response routing, once the user acts, goes through the same signal-and-engine pattern as Path A:
def route_prompt_to_state_engine(session_id, response_value, is_inside):
"""
Executes the semantic mapping and fires the StateMachineEngine.
Session is in DETECTING state for all response types.
YES + in geofence β confirm(USER_CONFIRMED) β notify() β arrival notification fires.
YES + outside geofence β cancel(USER_DISMISSED).
NO β cancel(USER_DISMISSED).
EXPIRED β cancel(USER_IGNORED).
"""
try:
from parking.engines.state_machine_engines import StateMachineEngine
from parking.models import ParkingSession, ParkingPromptResponse
session = ParkingSession.objects.get(pk=session_id)
if session.is_terminal:
logger.info(f"Prompt Engine: Session [{session_id}] already terminal. Skipping.")
return
if response_value == ParkingPromptResponse.Response.YES:
if is_inside:
StateMachineEngine.confirm(
session,
detection_method=ParkingSession.DetectionMethod.USER_CONFIRMED
)
StateMachineEngine.notify(session)
logger.info(
f"Prompt Engine: Session [{session_id}] confirmed and notified "
f"via user YES response."
)
else:
StateMachineEngine.cancel(session, reason=ParkingSession.CancelledReason.USER_DISMISSED)
logger.warning(f"Prompt Engine: User said YES but geofence verification failed for Session {session_id}. Cancelled.")
elif response_value == ParkingPromptResponse.Response.NO:
StateMachineEngine.cancel(session, reason=ParkingSession.CancelledReason.USER_DISMISSED)
logger.info(f"Prompt Engine: Terminated Session {session_id} automatically due to prompt expiration.")
elif response_value == ParkingPromptResponse.Response.EXPIRED:
StateMachineEngine.cancel(
session,
reason=ParkingSession.CancelledReason.USER_IGNORED
)
logger.info(
f"Prompt Engine: Session [{session_id}] cancelled due to prompt expiration."
)
except ParkingSession.DoesNotExist:
logger.error(f"Prompt Engine Error: Target ParkingSession {session_id} was removed before response processing could run.")
except Exception as e:
logger.error(f"Prompt Engine Error: Failed running state transitions for Session {session_id}. Error: {e}")
I check is_inside again at response time, not just at the 30-second mark, because a user can tap YES on a notification five minutes after leaving the geofence entirely. Trusting only the original geofence check would have let stale confirmations slip through.
Two Edge Cases I Had To Design For Explicitly
Drive-throughs. A user passing through a car park without stopping enters the geofence, triggers the 30-second schedule, then exits before it fires. I handle this in the exit handler by revoking the pending task and cancelling immediately:
elif session.state == SM.DETECTING: # if it was at detecting when driver leaves then...
from geofence.engines.time_based_detection_engine import TimeBasedDetectionEngine
TimeBasedDetectionEngine.cancel_pending_prompt(session)
StateMachineEngine.cancel(session, reason=ParkingSession.CancelledReason.DRIVE_THROUGH)
cancel_pending_prompt sets a Redis flag the Celery task checks before doing any work β cheap, immediate, no wasted notification.
Re-entry within a short window. A user parks, briefly exits the geofence to grab something from the boot, then re-enters. Without a guard, this creates a duplicate session for the same parking episode. I added a re-entry check that looks at the most recent exit event before allowing a new session:
@staticmethod
def is_within_reentry_window(user, location, window_mins: int = 10) -> bool:
"""see if driver is back within the time set -- with 10 mins of reentry"""
last_exit = GeofenceEvent.objects.last_exit(user, location)
if not last_exit or not getattr(last_exit, 'triggered_at', None):
return False
return timezone.now() - last_exit.triggered_at <= timedelta(minutes=window_mins)
What This Project Taught Me About My Own Process
I trust signals less than I used to, and I architect them more carefully because of it. Every signal in this system does exactly one thing β capture an event, defer to an engine via transaction.on_commit. The moment I am tempted to put calculation logic directly in a signal handler, I now treat that as a signal (no pun intended) that I am about to create something untestable.
State machines pay for themselves on the first concurrency bug they prevent. The setup cost was real β designing the transition matrix, deciding where optimistic locking belonged, writing the exception handling. But every bug I caught during testing traced back to the transition layer, never to scattered ad-hoc state writes across views and tasks. One audit point for the most dangerous part of the system was worth the upfront cost.
I learned to distrust my own first-pass logic on user-confirmation flows. The bug I described above β auto-confirming a session before the user had actually responded β was not caught by a unit test. It was caught by re-tracing the entire agreed flow from geofence entry to payment, end to end, before touching any single method again. The habit β trace the whole flow before editing one piece of it β is the single biggest process change this project taught me.
Storing task IDs on the model they relate to is a small decision with outsized payoff. Every scheduled task β the 30-second check, the 10-minute expiry, the timer warning β has its Celery task ID stored on the relevant session record. It made cancellation explicit and traceable instead of guessed.
Closing
This was one piece of a larger backend I architected solo over several weeks β almost 20 models, multiple engines/managers/signal layered architecture, and an extensive test suite covering every state transition, signal chain, and concurrency edge case I could think to write.
I am not the sole founder of this product β I came in as the architect and engineer, responsible for turning a product idea into a system that behaves correctly under real-world conditions: unreliable hardware signals, concurrent workers, and humans who do not always respond to notifications.
If distributed state machines, signal-driven Django architectures, or detection-and-confirmation problems like this one interest you, I would enjoy comparing notes in the comments.
If you're interested in the implementation, you can explore the complete repository here:
sonofrhea
/
parkping-backend
ParkPing Backend
ParkPing Backend
REST API backend for ParkPing β a Malaysian parking reminder app that automatically detects when a user parks and sends timely payment reminders via push notification.
Built with Python, Django, Celery, PostgreSQL, Redis, and Firebase Cloud Messaging.
What It Does
ParkPing solves a specific problem: Malaysian drivers forget to pay for parking and get fined. The backend handles two automatic detection paths:
Bluetooth Detection β when a user's car Bluetooth disconnects inside a registered geofence, the system confirms they have parked and immediately sends a "Remember to pay" notification. No user interaction required.
Time-Based Detection β for users without car Bluetooth, the system waits 30 seconds after geofence entry, checks the device is still inside, and sends a "Did you park here?" prompt with YES/NO buttons. User confirms, system tracks and reminds.
Both paths feed into a strict state machine that controls every session transition from detection toβ¦













