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
4 changes: 4 additions & 0 deletions TASK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# TASKS

## 2025-08-11
- [x] Fix flake8 errors across the codebase.
1 change: 0 additions & 1 deletion examples/EmergencyManagement/Server/models_emergency.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Integer, String, JSON


Base = declarative_base()

# Am I correct in understanding that the Dataclass' are meant as a sort of DTO
Expand Down
22 changes: 17 additions & 5 deletions reticulum_openapi/link_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"""Utilities for communicating over Reticulum links."""

"""Utilities for working with Reticulum links."""

import os
from typing import Callable
import asyncio
import os
from dataclasses import asdict
Expand All @@ -9,11 +12,12 @@
from typing import Optional

import RNS
from .model import dataclass_to_json

from .model import dataclass_to_json

class LinkFileClient:

class ResourceClient:
"""Client helper for sending resources over an established link."""

def __init__(
Expand Down Expand Up @@ -87,24 +91,32 @@ def __init__(
self.established = asyncio.Event()
self.closed = asyncio.Event()
remote_hash = bytes.fromhex(dest_hash)
remote_id = RNS.Identity.recall(remote_hash) or RNS.Identity.recall(
remote_hash, create=True
)
if hasattr(RNS.Identity, "recall"):
remote_id = RNS.Identity.recall(remote_hash) or RNS.Identity.recall(
remote_hash, create=True
)
else:
remote_id = RNS.Identity()
destination = RNS.Destination(
remote_id,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"openapi",
"link",
)
self.established = asyncio.Event()
self.closed = asyncio.Event()
self.packet_queue: asyncio.Queue[bytes] = asyncio.Queue()
self.link = RNS.Link(
destination,
established_callback=self._on_established,
closed_callback=self._on_closed,
)
self.link.set_packet_callback(self._handle_packet)

self.packet_queue: asyncio.Queue[bytes] = asyncio.Queue()


def _on_established(self, _link: RNS.Link) -> None:
"""Internal callback when link is established."""
self.established.set()
Expand Down
13 changes: 7 additions & 6 deletions reticulum_openapi/link_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,16 @@

import asyncio
import os
import shutil
from typing import Any
from typing import Awaitable

from typing import Callable
from typing import Dict
from typing import Optional

import asyncio
from typing import Any, Awaitable, Dict, Optional
import RNS


class ResourceService:
class LinkResourceService:

"""Service utilities for receiving resources on a link."""

def __init__(
Expand Down Expand Up @@ -139,3 +138,5 @@ async def stop(self) -> None:
for task in self._keepalive_tasks.values():
task.cancel()
self._keepalive_tasks.clear()


1 change: 0 additions & 1 deletion reticulum_openapi/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
T = TypeVar("T")


# not a fan of the design of this file and compromises it makes
def dataclass_to_json(data_obj: T) -> bytes:
"""
Serialize a dataclass instance to a compressed JSON byte string.
Expand Down
2 changes: 0 additions & 2 deletions reticulum_openapi/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ def get_api_specification(self) -> dict:

async def _handle_get_schema(self):
"""Handler for the built-in GetSchema command."""

return self.get_api_specification()

def _lxmf_delivery_callback(self, message: LXMF.LXMessage):
Expand Down Expand Up @@ -282,7 +281,6 @@ def announce(self):
+ RNS.prettyhexrep(self.source_identity.hash)
)
except Exception as e:

RNS.log(f"Announcement failed: {e}")

async def start(self):
Expand Down
12 changes: 8 additions & 4 deletions tests/test_link_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,18 +66,20 @@ def recall(_hash, create=False):

@pytest.mark.asyncio
async def test_send_serializes_dict(monkeypatch):
"""Bytes should be sent when serializing dictionary payloads."""
monkeypatch.setattr(lc_module.RNS, "Reticulum", lambda *_: object())
monkeypatch.setattr(lc_module.RNS, "Identity", FakeIdentity)
monkeypatch.setattr(lc_module.RNS, "Destination", FakeDestination)
monkeypatch.setattr(lc_module.RNS, "Link", FakeLink)

captured = {}

def serializer(d):
captured.setdefault("payload", d)
return b"data"
monkeypatch.setattr(
lc_module,
"dataclass_to_json",
lambda d: (captured.setdefault("payload", d), b"data")[1],
)

monkeypatch.setattr(lc_module, "dataclass_to_json", serializer)

cli = lc_module.LinkClient("aa")
await cli.send({"k": "v"})
Expand All @@ -87,6 +89,7 @@ def serializer(d):

@pytest.mark.asyncio
async def test_request_returns_response(monkeypatch):
"""LinkClient.request should deliver response bytes."""
monkeypatch.setattr(lc_module.RNS, "Reticulum", lambda *_: object())
monkeypatch.setattr(lc_module.RNS, "Identity", FakeIdentity)
monkeypatch.setattr(lc_module.RNS, "Destination", FakeDestination)
Expand All @@ -102,6 +105,7 @@ async def test_request_returns_response(monkeypatch):

@pytest.mark.asyncio
async def test_identify_calls_link(monkeypatch):
"""Identify should delegate to the underlying link object."""
monkeypatch.setattr(lc_module.RNS, "Reticulum", lambda *_: object())
monkeypatch.setattr(lc_module.RNS, "Identity", FakeIdentity)
monkeypatch.setattr(lc_module.RNS, "Destination", FakeDestination)
Expand Down
21 changes: 16 additions & 5 deletions tests/test_link_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def __init__(self, *args, **kwargs):


def test_send_resource_callbacks(monkeypatch, tmp_path):
"""Ensure callbacks fire when sending a resource."""
file_path = tmp_path / "data.txt"
file_path.write_text("payload")

Expand Down Expand Up @@ -41,7 +42,9 @@ def completion(res):
def hook(res):
calls["hook"] = True

cli = link_client.ResourceClient(fake_link, on_upload_complete=hook)

cli = link_client.LinkFileClient(fake_link, on_upload_complete=hook)

cli.send_resource(
str(file_path), progress_callback=progress, completion_callback=completion
)
Expand All @@ -52,21 +55,26 @@ def hook(res):


def test_send_resource_raises(monkeypatch, tmp_path):
"""Verify errors during resource send are propagated."""
file_path = tmp_path / "data.txt"
file_path.write_text("payload")

def raise_resource(*a, **k):
raise ValueError("boom")

monkeypatch.setattr(link_client.RNS, "Resource", raise_resource)
cli = link_client.ResourceClient(object())

cli = link_client.LinkFileClient(object())

with pytest.raises(ValueError):
cli.send_resource(str(file_path))


def test_resource_received_callback(tmp_path):
"""Incoming resources with metadata should be stored using filename."""
storage = tmp_path / "store"
service = link_service.ResourceService(str(storage))

service = link_service.LinkResourceService(str(storage))


src_path = tmp_path / "incoming"
src_path.write_bytes(b"content")
Expand All @@ -81,13 +89,16 @@ def test_resource_received_callback(tmp_path):


def test_resource_received_callback_no_metadata(tmp_path):
"""Resources lacking metadata should default to hash-based filenames."""
storage = tmp_path / "store"
called = {}

def hook(path):
called["path"] = path

service = link_service.ResourceService(str(storage), on_download_complete=hook)

service = link_service.LinkResourceService(str(storage), on_download_complete=hook)


src_path = tmp_path / "incoming"
src_path.write_bytes(b"data")
Expand Down
Loading