Skip to content
This repository was archived by the owner on May 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions examples/EmergencyManagement/Server/controllers_emergency.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
mport asyncio
from reticulum_openapi.controller import Controller, handle_exceptions, APIException
import asyncio
from reticulum_openapi.controller import Controller, handle_exceptions
from examples.EmergencyManagement.Server.models_emergency import (
EmergencyActionMessage, Event, EAMStatus
EmergencyActionMessage,
Event,
EAMStatus,
Detail,
Point,
)


class EmergencyController(Controller):
@handle_exceptions
async def CreateEmergencyActionMessage(self, req: EmergencyActionMessage):
Expand All @@ -15,7 +20,7 @@ async def CreateEmergencyActionMessage(self, req: EmergencyActionMessage):
async def DeleteEmergencyActionMessage(self, callsign: str):
self.logger.info(f"DeleteEAM callsign={callsign}")
await asyncio.sleep(0.1)
return {"status":"deleted","callsign":callsign}
return {"status": "deleted", "callsign": callsign}

@handle_exceptions
async def ListEmergencyActionMessage(self):
Expand All @@ -41,6 +46,7 @@ async def RetrieveEmergencyActionMessage(self, callsign: str):
commsMethod="Radio"
)


class EventController(Controller):
@handle_exceptions
async def CreateEvent(self, req: Event):
Expand All @@ -52,7 +58,7 @@ async def CreateEvent(self, req: Event):
async def DeleteEvent(self, uid: str):
self.logger.info(f"DeleteEvent uid={uid}")
await asyncio.sleep(0.1)
return {"status":"deleted","uid":uid}
return {"status": "deleted", "uid": uid}

@handle_exceptions
async def ListEvent(self):
Expand All @@ -75,5 +81,5 @@ async def RetrieveEvent(self, uid: str):
stale="PT1H", start="PT0S", access="public",
opex=0, qos=1,
detail=Detail(emergencyActionMessage=None),
point=Point(0,0,0,0,0)
point=Point(0, 0, 0, 0, 0)
)
26 changes: 22 additions & 4 deletions examples/EmergencyManagement/Server/models_emergency.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from dataclasses import dataclass
from reticulum_openapi.model import BaseModel


class EAMStatus(str):
Red = "Red"
Yellow = "Yellow"
Green = "Green"


@dataclass
class EmergencyActionMessage(BaseModel):
callsign: str
Expand All @@ -18,16 +20,32 @@ class EmergencyActionMessage(BaseModel):
commsStatus: EAMStatus
commsMethod: str


@dataclass
class Detail(BaseModel):
emergencyActionMessage: EmergencyActionMessage


@dataclass
class Point(BaseModel):
lat: float; lon: float; ce: float; le: float; hae: float
lat: float
lon: float
ce: float
le: float
hae: float


@dataclass
class Event(BaseModel):
uid: int; how: str; version: int; time: int; type: str
stale: str; start: str; access: str; opex: int; qos: int
detail: Detail; point: Point
uid: int
how: str
version: int
time: int
type: str
stale: str
start: str
access: str
opex: int
qos: int
detail: Detail
point: Point
9 changes: 7 additions & 2 deletions examples/EmergencyManagement/Server/server_emergency.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,16 @@
from examples.EmergencyManagement.Server.controllers_emergency import (
EmergencyController, EventController
)
from examples.EmergencyManagement.Server.models_emergency import EmergencyActionMessage, Event
from examples.EmergencyManagement.Server.models_emergency import (
EmergencyActionMessage,
Event,
)


async def main():
svc = LXMFService()
eamc = EmergencyController(); evc = EventController()
eamc = EmergencyController()
evc = EventController()
svc.add_route("CreateEmergencyActionMessage", eamc.CreateEmergencyActionMessage, EmergencyActionMessage)
svc.add_route("DeleteEmergencyActionMessage", eamc.DeleteEmergencyActionMessage)
svc.add_route("ListEmergencyActionMessage", eamc.ListEmergencyActionMessage)
Expand Down
6 changes: 5 additions & 1 deletion examples/EmergencyManagement/client/client_emergency.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import asyncio
from reticulum_openapi.client import LXMFClient
from examples.EmergencyManagement.Server.models_emergency import EmergencyActionMessage, EAMStatus
from examples.EmergencyManagement.Server.models_emergency import (
EmergencyActionMessage,
EAMStatus,
)


async def main():
client = LXMFClient()
Expand Down
1 change: 0 additions & 1 deletion reticulum_openapi/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@

9 changes: 6 additions & 3 deletions reticulum_openapi/client.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import asyncio
import RNS, LXMF
from typing import Optional, Dict, Callable, Type
import RNS
import LXMF
from typing import Optional, Dict
from .model import dataclass_to_json, dataclass_from_json


class LXMFClient:
"""Simple client for sending commands and awaiting responses."""

Expand Down Expand Up @@ -47,7 +49,8 @@ async def send_command(self, dest_hex: str, command: str, payload_obj=None,
else:
data = dataclass_to_json(payload_obj)
if self.auth_token:
import json, zlib
import json
import zlib
obj = dataclass_from_json(type(payload_obj), data)
obj_dict = obj.__dict__
obj_dict['auth_token'] = self.auth_token
Expand Down
27 changes: 19 additions & 8 deletions reticulum_openapi/controller.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
import asyncio
from typing import Callable, Any, Coroutine, TypeVar

# Setup module logger
Expand All @@ -10,31 +9,39 @@
handler.setFormatter(formatter)
logger.addHandler(handler)


class APIException(Exception):
"""Base exception for API errors, carrying a message and HTTP-like status code."""
def __init__(self, message: str, code: int = 500):
super().__init__(message)
self.code = code
self.message = message


F = TypeVar('F', bound=Callable[..., Coroutine[Any, Any, Any]])


def handle_exceptions(func: F) -> F:
"""Decorator to wrap controller methods with logging and exception handling."""
async def wrapper(*args, **kwargs):
logger.info(f"Executing {{func.__name__}} with args={{args[1:]}} kwargs={{kwargs}}")
logger.info(
f"Executing {func.__name__} with args={args[1:]} kwargs={kwargs}"
)
try:
result = await func(*args, **kwargs)
logger.info(f"{{func.__name__}} completed successfully.")
logger.info(f"{func.__name__} completed successfully.")
return result
except APIException as e:
logger.error(f"APIException in {{func.__name__}}: {{e.message}} (code={{e.code}})")
logger.error(
f"APIException in {func.__name__}: {e.message} (code={e.code})"
)
return {"error": e.message, "code": e.code}
except Exception as e:
logger.exception(f"Unhandled exception in {{func.__name__}}: {{e}}")
logger.exception(f"Unhandled exception in {func.__name__}: {e}")
return {"error": "InternalServerError", "code": 500}
return wrapper # type: ignore


class Controller:
"""
Base controller class with built-in logging, exception management,
Expand All @@ -52,11 +59,15 @@ async def run_business_logic(self, logic: Coroutine[Any, Any, Any], *args, **kwa
self.logger.info(f"Running business logic: {logic.__name__}")
try:
result = await logic(*args, **kwargs)
self.logger.info(f"Business logic {{logic.__name__}} succeeded.")
self.logger.info(f"Business logic {logic.__name__} succeeded.")
return result
except APIException as e:
self.logger.error(f"APIException in business logic {{logic.__name__}}: {{e.message}} (code={{e.code}})")
self.logger.error(
f"APIException in business logic {logic.__name__}: {e.message} (code={e.code})"
)
return {"error": e.message, "code": e.code}
except Exception as e:
self.logger.exception(f"Unhandled exception in business logic {{logic.__name__}}: {{e}}")
self.logger.exception(
f"Unhandled exception in business logic {logic.__name__}: {e}"
)
return {"error": "InternalServerError", "code": 500}
6 changes: 5 additions & 1 deletion reticulum_openapi/model.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
# reticulum_openapi/model.py
from dataclasses import dataclass, asdict, is_dataclass
import json, zlib
import json
import zlib
from typing import Type, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

T = TypeVar('T')


def dataclass_to_json(data_obj: T) -> bytes:
"""
Serialize a dataclass instance to a compressed JSON byte string.
Expand All @@ -23,6 +25,7 @@ def dataclass_to_json(data_obj: T) -> bytes:
compressed = zlib.compress(json_bytes)
return compressed


def dataclass_from_json(cls: Type[T], data: bytes) -> T:
"""
Deserialize a dataclass instance from a compressed JSON byte string.
Expand All @@ -37,6 +40,7 @@ def dataclass_from_json(cls: Type[T], data: bytes) -> T:
# Instantiate dataclass by unpacking dict (assumes keys match field names)
return cls(**obj_dict) # type: ignore


@dataclass
class BaseModel:
"""
Expand Down
33 changes: 21 additions & 12 deletions reticulum_openapi/service.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# reticulum_openapi/service.py
import asyncio
import time
import json
import zlib
import RNS, LXMF
import RNS
import LXMF
from typing import Callable, Dict, Optional, Type
from jsonschema import validate, ValidationError
from .model import dataclass_from_json, dataclass_to_json


class LXMFService:
def __init__(self, config_path: str = None, storage_path: str = None,
identity: RNS.Identity = None, display_name: str = "ReticulumOpenAPI",
Expand Down Expand Up @@ -41,9 +42,14 @@ def __init__(self, config_path: str = None, storage_path: str = None,
self.auth_token = auth_token
self.max_payload_size = max_payload_size
RNS.log(f"LXMFService initialized (Identity hash: {RNS.prettyhexrep(self.source_identity.hash)})")

def add_route(self, command: str, handler: Callable, payload_type: Optional[Type] = None,
payload_schema: dict = None):

def add_route(
self,
command: str,
handler: Callable,
payload_type: Optional[Type] = None,
payload_schema: dict = None,
) -> None:
"""
Register a handler for a given command name.
:param command: Command string (should match LXMF message title).
Expand All @@ -52,7 +58,7 @@ def add_route(self, command: str, handler: Callable, payload_type: Optional[Type
"""
self._routes[command] = (handler, payload_type, payload_schema)
RNS.log(f"Route registered: '{command}' -> {handler}")

def _lxmf_delivery_callback(self, message: LXMF.LXMessage):
"""
Internal callback invoked by LXMRouter on message delivery.
Expand Down Expand Up @@ -105,11 +111,13 @@ def _lxmf_delivery_callback(self, message: LXMF.LXMessage):
return
else:
payload_obj = None # No payload content

# Dispatch to handler asynchronously
async def handle_and_reply():
result = None
try:
# Call the handler with the parsed payload. If payload is None, some handlers may not accept a parameter.
# Call the handler with the parsed payload.
# If payload is None, some handlers may not accept a parameter.
if payload_obj is not None:
result = await handler(payload_obj)
else:
Expand All @@ -128,7 +136,8 @@ async def handle_and_reply():
resp_bytes = dataclass_to_json(result)
except Exception as e:
# Fallback: just JSON dump the object (it might not be a dataclass)
resp_bytes = zlib.compress(json.dumps(result).encode('utf-8'))
RNS.log(f"Failed to serialize result dataclass: {e}")
resp_bytes = zlib.compress(json.dumps(result).encode("utf-8"))
else:
# If result is a simple value (str, number, etc.), wrap it in JSON
resp_bytes = zlib.compress(json.dumps(result).encode('utf-8'))
Expand All @@ -146,7 +155,7 @@ async def handle_and_reply():
RNS.log("No source identity to respond to for message.")
# Schedule the handler execution on the asyncio event loop
self._loop.call_soon_threadsafe(lambda: asyncio.create_task(handle_and_reply()))

def _send_lxmf(self, dest_identity: RNS.Identity, title: str, content_bytes: bytes,
propagate: bool = False):
"""
Expand All @@ -164,7 +173,7 @@ def _send_lxmf(self, dest_identity: RNS.Identity, title: str, content_bytes: byt
# For now, let LXMF choose the default (which is typically DIRECT if reachable).
# Dispatch the message via the router
self.router.handle_outbound(lxmessage)

async def send_message(self, dest_hex: str, command: str, payload_obj=None, await_path: bool = True):
"""
Public method to send a command to another LXMF node (by hex hash of its identity).
Expand Down Expand Up @@ -199,15 +208,15 @@ async def send_message(self, dest_hex: str, command: str, payload_obj=None, awai
content_bytes = dataclass_to_json(payload_obj)
# Use internal send helper
self._send_lxmf(dest_identity, command, content_bytes, propagate=False)

def announce(self):
"""Announce this service's identity (make its address known on the network)."""
try:
self.router.announce(self.source_identity.hash)
RNS.log("Service identity announced: " + RNS.prettyhexrep(self.source_identity.hash))
except Exception as e:
RNS.log(f"Announcement failed: {e}")

async def start(self):
"""Run the service until cancelled."""
RNS.log("LXMFService started and listening for messages...")
Expand Down
Loading