This page covers the supporting services that don’t belong to the drone-control, video, or billing stories: account management (UserService), VPN address allocation (IPService), the mission / waypoint / geofence CRUD trio, recurring calendar scheduling (CalendarService), and the Isaac Sim EC2 fleet (IsaacSimService plus two dormant siblings). These are ordinary SQLAlchemy-backed services with a few sharp edges — a hex-encoded password round-trip and a mostly-inactive Isaac Sim scheduler chief among them. All of these services are constructed as module-global singletons in src/application/app.py and injected into route blueprints. For the wiring model and the get_service() drone factory, see Service Layer & get_service Factory.
ServiceFileConstructed in app.pyConsumed by
UserServicesrc/service/user_service.pyuser_service = UserService(db, Bcrypt(...), email_service)auth_routes, user_routes
IPServicesrc/service/ip_service.pystatic methods (never instantiated)UserService, PhysicalDroneService
MissionServicesrc/service/mission_service.pygeneric_mission_service = MissionService(db)mission_routes, DroneControlService
PointServicesrc/service/point_service.pypoint_service = PointService(db)mission_routes
GeofenceServicesrc/service/geofence_service.pygeofence_service = GeofenceService(db)geofence_routes, DroneControlService
CalendarServicesrc/service/calendar_service.pycalendar_service = CalendarService(db)calendar_routes
IsaacSimServicesrc/service/isaac_sim_service.pyinstantiated in isaac_sim_routes.py, not app.pyisaac_sim_routes

UserService — bcrypt auth, email flows & the hex round-trip

UserService (src/service/user_service.py) owns user creation, authentication material, and the email-driven verification / password-reset flows. It depends on flask_bcrypt.Bcrypt, itsdangerous.URLSafeTimedSerializer, and EmailService.

The password round-trip you must preserve

This is the single most important behavior on the page. Passwords are bcrypt-hashed and then stored hex-encoded in the user.password column. Every getter reverses the encoding before returning the ORM object:
src/service/user_service.py
def get_user_by_email(self, email):
    user = User.query.filter_by(email=email).first()
    if user:
        user.password = binascii.unhexlify(user.password.encode("utf-8").lstrip(b"\\x"))
    return user
So get_user_by_name, get_user_by_id, and get_user_by_email all mutate the in-memory User.password into raw bcrypt bytes — that is what check_password_hash expects. The login route relies on exactly this round-trip:
src/routes/auth_routes.py
user = user_service.get_user_by_email(username)
if user and check_password_hash(user.password, password):
    ...
Any new query path that reads a user for authentication must go through one of the get_user_by_* methods (or replicate the binascii.unhexlify(...lstrip(b"\\x")) decode). A raw User.query returns the hex string, and check_password_hash will silently fail against it. Conversely, do not call these getters and then persist the same object — you would write raw bcrypt bytes back over the hex column.
create() and save(user, new_password=True) are the write side — they run bcrypt.generate_password_hash(user.password) before committing. save() only re-hashes when new_password=True and a password is present, so it doubles as a plain profile-save when called with defaults.

Registration, verification & reset

1

Request verification email

send_verify_email(email, origin) signs {"email", "origin"} with URLSafeTimedSerializer(JWT_SECRET_KEY) under salt=secret_key and emails a link of the form {origin}/reset-password?token=<token>&verify=true.
2

Create the user (token redeemed)

create_user(token, password) calls serializer.loads(token, salt=secret_key, max_age=3600) — a 1-hour expiry. It rejects an already-existing email, then allocates a VPN address via IPService.get_user_ip() and inserts the User inside a 3-attempt session.begin(nested=True) loop that retries on IntegrityError (concurrent IP / email collisions).
3

Password reset (separate flow)

send_reset_password_email(email, origin) builds its link from a Flask-JWT create_access_token(email) (not the itsdangerous serializer), and update_password(email, password) writes the new password through save(user, new_password=True).
Two different token mechanisms coexist here: itsdangerous URLSafeTimedSerializer (email verification, 1h, salted with the JWT secret) and Flask-JWT create_access_token (password-reset links). The auth model and JWT lifecycle are documented in Authentication & JWT Lifecycle.
get_seed_user() returns the first user whose email matches e2e-% — a hook for the end-to-end test suite. Deletes (delete_user_by_id / delete_user) are hard row deletes; account teardown of the user’s drones, missions, geofences, and calendar events is handled by the per-user cleanup helpers on the other services below.

IPService — CIDR allocation for the WireGuard planes

IPService (src/service/ip_service.py) is a static-only class that hands out the next free address from two WireGuard CIDRs. Users live on the user plane, drones on the drone plane; both default to /16 networks and are configurable via environment variables.
MethodPool (env var)Default CIDRUsed by
get_user_ip()USER_NETWORK_CIDR10.70.0.0/16UserService.create_user
get_drone_ip()DRONE_NETWORK_CIDR10.71.0.0/16PhysicalDroneService.save
is_ip_in_user_cidr(ip)USER_NETWORK_CIDR10.70.0.0/16(no callers — defined but unused)
is_ip_in_drone_cidr(ip)DRONE_NETWORK_CIDR10.71.0.0/16(no callers — defined but unused)
Allocation walks ipaddress.ip_network(cidr).hosts() in order and returns the first host address not already taken and not in the reserved set. The network object is memoized with @lru_cache(maxsize=3). The reserved set excludes the gateway address (.0.1) and a .0.255 sentinel in addition to the network/broadcast addresses that hosts() already skips — so the first user address handed out on a fresh 10.70.0.0/16 is 10.70.0.2.
src/service/ip_service.py
taken_ips = User.query.filter(User.ip != None).with_entities(User.ip).order_by(func.cast(User.ip, INET)).all()
taken_ips = {ip[0] for ip in taken_ips}
network = IPService._get_network(USER_NETWORK_CIDR)
user_ip = next((str(ip) for ip in network.hosts() if str(ip) not in taken_ips and str(ip) not in unwanted), None)
get_drone_ip() orders taken IPs with a CASE that substitutes SITL_HOST (DOCKER_HOST_IP, default <office-docker-host>) for any SITL row before the INET cast:
src/service/ip_service.py
.order_by(func.cast(case((Drone.type == "sitl", SITL_HOST), else_=Drone.ip), INET))
This exists because SITL drones store a Docker container name in Drone.ip, which is not castable to Postgres INET. The substitution only affects sort order — the taken_ips set still contains the raw container names, so they never collide with real drone-plane addresses. Any refactor that assumes Drone.ip is always an IP will break allocation for SITL. See SITL Drone Lifecycle for why the container name lives in that column.
Both methods return None when the pool is exhausted; callers are expected to surface that as an error. The broader plane topology (jumphost, per-user isolation, drone-plane iptables) is covered in Network & VPN Topology and VPN IP Authentication & Jumphost Routing.

Missions, Points & Geofences

Three thin CRUD services back the map-editing surface. They are pure data services — the actual upload to a vehicle is done by DroneControlService (see DroneControlService & Rosbridge Dispatch), and the on-wire MAVLink encoding is documented in Mission & Geofence MAVLink Format.
  • MissionServiceMission + MissionPoint CRUD with joinedload(Mission.mission_points). create_mission_point defaults the MAVLink fields (frame=3, command=16, autocontinue=True, param1..4=0.0) so the frontend can send bare lat/lng/altitude/sequence. delete(mission_id, user_id) first NULLs Drone.mission_id for any drone pointing at the mission, then deletes its points, then the mission row — order matters to avoid FK violations.
  • PointService — a thinner MissionPoint-only service used by mission routes: save, delete, update (rebuilds a MissionPoint then filter(...).update(to_dict())), and batch_update (loops per point in one commit).
  • GeofenceServiceGeofence + GeofencePoint CRUD, plus toggle_geofence (flips enabled), update_geofence / update_geofence_point (field-by-field), batch_update_points, and per-user cleanup (delete_geofences_by_user_id, delete_geofence_points_by_user_id). Points cascade-delete with their geofence. Geofence points default frame=3 but leave command unset — DroneControlService.push_geofence derives the MAVLink command from the fence type at push time.
GeofenceService is imported lazily inside DroneControlService.push_geofence / sync_geofences (along with get_fence_command) to avoid an import cycle. Preserve those in-function imports if you refactor the service layer. The HTTP surface for both lives in Missions & Geofences API.
Every method on all three services is scoped by user_id in the WHERE clause, so a mission or geofence can only be read or mutated by its owner — ownership is enforced per-query here, unlike the drone-command telemetry rooms which enforce ownership only at connection creation.

CalendarService — recurring events with occurrence exceptions

CalendarService (src/service/calendar_service.py) schedules flights against a drone + mission and supports iCal recurrence rules. Its data model is two tables:
  • CalendarEventtitle, description, scheduled_time (timezone-aware), status (EventStatus = scheduled / completed / cancelled), drone_id, mission_id, optional execution_id, and recurrence_rule (an iCal RRULE string). is_recurring() is simply bool(recurrence_rule).
  • CalendarEventOccurrence — a sparse exception table keyed by (event_id, occurrence_time). It only stores deviations from the default (a changed status and/or an attached execution_id); unmodified occurrences are never persisted.

Recurrence is expanded at read time

There is no materialized occurrence per date. get_events(user_id, start, end) fetches candidate events (non-recurring in-range, plus any recurring event whose scheduled_time <= end) and, for recurring events, expands them on the fly with dateutil.rrule.rrulestr(...).between(start, end, inc=True), overlaying any stored exceptions:
src/service/calendar_service.py
rule = rrulestr(event.recurrence_rule, dtstart=event.scheduled_time)
exceptions = self._get_occurrence_exceptions(event.id, start, end)
for occurrence_time in rule.between(start, end, inc=True):
    exc = exceptions.get(occurrence_time)
    result.append(self._build_occurrence_dict(event, occurrence_time, exception=exc))
Each expanded occurrence is a shallow copy of the event dict with is_occurrence=True, its own scheduled_time, and the original stored under original_scheduled_time.

Occurrence status updates are upserts

update_occurrence_status(...) and delete_occurrence(...) first validate that the target time is a real occurrence of the rule (_is_valid_occurrence checks rule.between(t, t, inc=True)). Then:
  • Setting an occurrence back to scheduled deletes its exception row (returning it to the default) rather than storing scheduled.
  • Any other status (completed, cancelled) upserts an exception row; delete_occurrence is implemented as an upsert to cancelled.
Changing an event’s recurrence_rule triggers _cleanup_invalid_exceptions: if recurrence is removed entirely, all exception rows are deleted; otherwise any exception whose time is no longer a valid occurrence of the new rule is pruned. Validation guards throughout raise ValueError (missing/invalid datetime, unknown drone/mission/execution, editing a completed non-recurring event), which the routes translate to 400s. The HTTP endpoints (/calendar/events and /calendar/events/{id}/occurrences/..., all JWT-required) are documented in Billing, Calendar, VPN, Video & Isaac Sim API.

Isaac Sim EC2 services

Three services target NVIDIA Isaac Sim GPU instances on EC2. Only one is live; the other two are dormant scaffolding.

IsaacSimService (active)

IsaacSimService (src/service/isaac_sim_service.py) manages one hardcoded instance and is instantiated directly in src/routes/isaac_sim_routes.py (not in app.py). It exposes four JWT-protected endpoints under /api/v1/isaac-sim:
EndpointMethod callPurpose
GET /isaac-sim/statusget_instance_status()State, public/private IP, runtime, and a live cost estimate
POST /isaac-sim/startstart_instance()Start when stopped (guards other states)
POST /isaac-sim/stopstop_instance()Stop when running, reporting session cost
GET /isaac-sim/metadataget_instance_metadata()Static specs (GPU, ports, software versions)
Key hardcoded configuration:
src/service/isaac_sim_service.py
INSTANCE_ID = "i-0d2c57e141afa83ed"
REGION = "eu-central-1"
INSTANCE_TYPE = "g6e.xlarge"
get_instance_status() computes runtime from LaunchTime and estimates cost at a $0.95/hr on-demand rate plus prorated 150 GB gp3 storage. get_instance_metadata() reports the fixed spec: NVIDIA L40S (48 GB), Isaac Sim 4.2.0, streaming port 8211, WebRTC ports 47995-47999. It uses a plain boto3.client("ec2", region_name="eu-central-1") — the caller’s AWS credentials/role must permit ec2:DescribeInstances / StartInstances / StopInstances on that instance.
Isaac Sim itself (Visual SLAM, GPS-denied navigation) runs on the drone, not the Gateway — see Isaac Visual SLAM & Pose Bridge. This service only powers the cloud instance the Dashboard’s Isaac Sim panel starts and stops.

AWSInstanceService & InstanceSchedulerService (dormant)

AWSInstanceService (src/service/aws_instance_service.py) and InstanceSchedulerService (src/service/instance_scheduler_service.py) are not wired into the running application — neither is instantiated in app.py or any route. Treat them as planned/experimental code:
  • AWSInstanceService is a generic launcher for g6e.4xlarge spot instances (per-drone UserData, EBS snapshots on shutdown, spot-price capacity checks). Its instance_type, security group, subnet, and AMI are placeholders set to None / defaults.
  • InstanceSchedulerService is an asyncio loop that would auto-shut-down instances after 2 hours using raw psycopg cursors (not SQLAlchemy) against the isaac_sim_instances, isaac_sim_usage_tracking, and isaac_sim_budget tables. It imports from service.aws_instance_service import AWSInstanceService — note the missing src. prefix — so it would fail to import under the project’s package layout as-is.
The raw-SQL tables these reference are documented separately in Isaac Sim Tables (Raw SQL, no ORM). Do not assume any of this scheduler logic runs today.

Gotchas to preserve

The DB stores \x-prefixed hex; get_user_by_* decode it in place via binascii.unhexlify(pw.encode().lstrip(b"\\x")). Authentication (check_password_hash) depends on this exact decode. Never bypass the getters for an auth read, and never re-persist a decoded User.
get_drone_ip() uses a CASE to swap in SITL_HOST for the INET sort cast because SITL rows store a container name in Drone.ip. The membership set keeps raw values, so container names never collide with drone-plane addresses.
Recurring events are expanded at read time via dateutil.rrule; only status/execution deviations are stored in CalendarEventOccurrence. Setting an occurrence back to scheduled deletes its row. Changing a rule prunes now-invalid exceptions.
Only IsaacSimService (single hardcoded instance) is live. AWSInstanceService / InstanceSchedulerService are uninstantiated, and the scheduler’s import path is broken under the src. package layout.

Service Layer & Factory

How every service is wired as a singleton and the get_service() drone factory.

Authentication & JWT Lifecycle

Login, refresh, verification and reset token mechanics that sit on top of UserService.

Missions & Geofences API

The HTTP surface backed by Mission/Point/Geofence services.

Isaac Sim Tables (Raw SQL)

The non-ORM tables the dormant Isaac Sim scheduler targets.