Extending Routario¶
Routario is designed to be extended without touching core files. Protocols, integrations, alert types, reports, and notification channels are all auto-discovered at startup — add a file, implement the interface, and it appears in the UI on the next restart.
Adding a Protocol Decoder¶
Protocol decoders live in app/protocols/. Each decoder handles one device protocol on its own TCP/UDP port.
- Create a new file in
app/protocols/. - Subclass
BaseProtocolDecoderand implementdecode()and optionallyencode_command(). - Decorate the class with
@ProtocolRegistry.register("your_protocol").
Routario registers the decoder automatically. The TCP/UDP listener on the port defined by PORT starts when at least one active device is configured with that protocol, and stops again when no active devices use it. No changes to main.py are required.
from . import BaseProtocolDecoder, ProtocolRegistry
@ProtocolRegistry.register("myprotocol")
class MyProtocolDecoder(BaseProtocolDecoder):
PORT = 5200
PROTOCOL_TYPES = ['tcp']
async def decode(self, data, client_info, known_imei=None):
# Parse the raw bytes and return a NormalizedPosition (or None to discard)
...
async def encode_command(self, command_type, params):
# Return raw bytes to send to the device, or None if unsupported
...
Adding a Cloud Integration¶
Cloud integrations live in app/integrations/. Each integration polls a remote API and feeds positions into the same pipeline as native devices.
- Create a new file in
app/integrations/. - Subclass
BaseIntegrationand implementauthenticate(),fetch_positions(), and optionallylist_remote_devices(). - Decorate with
@IntegrationRegistry.register("provider_id"). - Define
DISPLAY_NAME,POLL_INTERVAL_SECONDS, and theFIELDSlist — this describes the credential form shown in the UI.
from integrations.base import BaseIntegration, AuthContext, IntegrationField
from integrations.registry import IntegrationRegistry
@IntegrationRegistry.register("myprovider")
class MyProviderIntegration(BaseIntegration):
PROVIDER_ID = "myprovider"
DISPLAY_NAME = "My Provider"
POLL_INTERVAL_SECONDS = 30
SUPPORTS_BROWSE = True # set False to hide the Browse button in the UI
FIELDS = [
IntegrationField(key="token", label="API Token",
field_type="password", required=True),
# field_type options: "text" | "password" | "number" | "url"
]
async def authenticate(self, credentials: dict) -> AuthContext:
...
async def fetch_positions(self, auth_ctx, devices):
...
Tip
Raise AuthExpiredError inside fetch_positions() when the remote API rejects your session. Routario evicts the cached AuthContext and re-authenticates on the next poll cycle automatically.
SUPPORTS_BROWSE = False
Set this on integrations that have no remote device list to query (e.g. the built-in GPS Simulator). It hides the Browse button from the credential form so users are not presented with a button that does nothing.
Adding a Report¶
Reports live in app/reports/. Each report module defines both the backend query and the frontend presentation schema.
- Create a new file in
app/reports/. - Subclass
Report. - Define a
ReportDefinition. - Implement
run()and return atable_payload(). - Expose a module-level
reportobject.
The report registry discovers modules automatically. The Reports UI reads report metadata from /api/reports/types, renders backend-defined controls, and displays the payload returned by /api/reports/{report_key}.
from datetime import datetime
from typing import Any, Optional
from reports.base import Report, ReportDefinition
from reports.common import table_payload
class MyReport(Report):
definition = ReportDefinition(
key="my_report",
label="My Report",
description="Shows useful information.",
needs_date_range=True,
supports_vehicle_filter=True,
controls=(
{
"key": "group_by",
"label": "Group By",
"type": "select",
"default": "vehicle",
"options": [
{"value": "vehicle", "label": "Vehicle"},
{"value": "driver", "label": "Driver"},
],
},
),
)
async def run(
self,
session,
current_user: Any,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
device_ids: Optional[list[int]] = None,
user_ids: Optional[list[int]] = None,
driver_ids: Optional[list[int]] = None,
options: Optional[dict[str, Any]] = None,
historical: bool = False,
) -> dict:
rows = [{"name": "Example", "count": 1}]
return table_payload(
self.definition.key,
rows,
[
{"key": "name", "label": "Name", "type": "text"},
{"key": "count", "label": "Count", "type": "integer"},
],
summary=[{"label": "Rows", "value": len(rows)}],
start_date=start_date,
end_date=end_date,
default_sort={"key": "name", "dir": 1},
csv_filename="my_report.csv",
)
report = MyReport()
Report column types¶
The generic frontend renderer supports these common column types:
| Type | Output |
|---|---|
text |
Escaped text, arrays joined with commas. |
integer |
Whole number. |
number |
Decimal number with optional decimals and suffix. |
datetime |
Localized date/time. |
datetime_split |
Date and time on separate lines. |
duration_minutes |
Minutes formatted as 1h 20m. |
bool_on |
On / Off display. |
bool_active |
Active / Missing display. |
read_status |
Read / Unread display. |
severity |
Colored severity label. |
auto |
Generic value rendering, useful for dynamic sensor keys. |
Columns may also define:
detail_key— secondary text shown below the main value.title_key— tooltip text.max_width— truncates long text cells.emptyandempty_tone— custom empty-state display.tone_if_positive— color a positive numeric value.csv: false— omit the column from CSV export.
Report row actions¶
Reports may define a generic row_action. The built-in supported action is trip_map, which makes each row clickable and opens the trip route map using the row's trip fields.
For user-facing report behavior, scheduled reports, and CSV behavior, see Reports.
Adding an Alert Type¶
Alert types live in app/alerts/. Each alert is evaluated against every incoming position for devices that have the alert rule configured.
- Create a new file in
app/alerts/. - Subclass
BaseAlertand implementdefinition()andcheck()(orcheck_many()to return multiple alerts per position).
from typing import Optional
from .base import BaseAlert, AlertDefinition, AlertField
from models.schemas import AlertType, Severity
class MyAlert(BaseAlert):
@classmethod
def definition(cls) -> AlertDefinition:
return AlertDefinition(
key = "my_alert",
alert_type = AlertType.CUSTOM,
label = "My Alert",
description= "Fires when something interesting happens.",
icon = "⚠️",
severity = Severity.WARNING,
state_keys = ["my_state_key"], # persistent state fields this alert uses
fields = [
AlertField(
key = "threshold",
label = "Threshold",
default = 100,
min_value = 0,
max_value = 10000,
required = True,
help_text = "Fire the alert when value exceeds this.",
),
],
)
async def check(self, position, device, state, params: dict) -> Optional[dict]:
threshold = float(params.get("threshold", 100))
value = position.sensors.get("my_sensor", 0)
if value > threshold:
return {
"type": AlertType.CUSTOM,
"severity": Severity.WARNING,
"message": f"Value {value} exceeded threshold {threshold}.",
}
return None
The state object provides persistent per-device storage via state.alert_states (a dict) — use it to implement debounce, cooldown, or edge-detection logic between position updates.
No registration step is needed — the alert type is discovered automatically and appears in the Add Alert Rule dropdown on the next restart.
Adding a Notification Channel¶
Custom notification channels live in app/notifications/. A channel claims URL schemes via matches() and delivers the notification in send().
- Create a new
.pyfile inapp/notifications/. - Subclass
BaseNotificationChannel. - Implement
matches(url)— returnTrueif this class should handle the URL. - Implement
async send(url, title, message)— deliver the notification and returnTrueon success.
from notifications.base import BaseNotificationChannel
class MyChannel(BaseNotificationChannel):
@classmethod
def matches(cls, url: str) -> bool:
return url.startswith("myscheme://")
async def send(self, url: str, title: str, message: str) -> bool:
# Implement delivery logic here
return True
No registration step is needed — the channel is discovered automatically on the next restart.
Info
The built-in AppriseChannel is named with a z_ prefix so it sorts last and only handles URLs that no other channel claimed first. Your custom channel will take priority over Apprise for any scheme it matches.