Skip to content
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ A local-first community sensor network: Nodes share readings over a local Mesh t
scripts/sandbox.sh # needs uv and pnpm; the first run downloads the Cache Pack and builds the dashboard
```

It opens `http://localhost:8000`. Pick the Bhote Koshi Event in the Sandbox Panel and press Start, or jump straight to "Surge reaches the border".
It opens `http://127.0.0.1:8000`. Pick the Bhote Koshi Event in the Sandbox Panel and press Start, or jump straight to "Surge reaches the border".

## How it's built

Expand Down
1 change: 1 addition & 0 deletions node/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ Area gotchas that are still true. Claude Code loads this file (through `CLAUDE.m
- **Mesh decode and delivery go through `mesh.decode_message` and `deliver_to_listeners`** for both Meshes; add rules there.
- **A Sensor sends an Event's extra `tracks` (made-up Events only) only when `NODE_MEASURES` names the kind**; the default names the flood four, so only the Sandbox (`SANDBOX_MEASURES`) or a Node told so sends `air_temperature`, `humidity`, `wind_speed`.
- **A Camera is a Sensor whose `NODE_MEASURES` names `camera`**: each share bumps its picture number and redraws its one in-memory SVG (`camera.py`, fed only a water depth, so nothing about where it is can leak). The Sandbox Hub reads Sensors' pictures in-process at `/api/sandbox/sensors/<id>/picture`; `MeshedNodes.record_readings()` hears what is on the Mesh.
- **App file locations live in `app_files.py`** (dashboard, Regions, built and downloaded Cache Packs); a test that needs other packs patches `sandbox.BUILT_PACKS_DIR` / `DOWNLOADED_PACKS_DIR`. `storm-shield-sandbox-app` is the no-argument app start: port 8000 or a free one, log and failure page in the temp folder; uvicorn exits with `SystemExit` when it cannot bind, so the app catches that too.

Networking and Pi lessons (firewalls, TCP peers) are in `pi/AGENTS.md`.
1 change: 1 addition & 0 deletions node/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dev = ["pytest", "ruff", "mypy"]
[project.scripts]
storm-shield-node = "storm_shield.main:run"
storm-shield-sandbox = "storm_shield.sandbox:run"
storm-shield-sandbox-app = "storm_shield.sandbox_app:main"

[build-system]
requires = ["hatchling"]
Expand Down
24 changes: 24 additions & 0 deletions node/src/storm_shield/app_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Where the app's own files live: the built dashboard, the Regions and the Cache Packs.

A repo checkout keeps them beside `node/`; a bundled app (PyInstaller) unpacks them under
`sys._MEIPASS`. Everything that needs one of these folders asks here, so the bundle needs no
other change.
"""

import sys
from pathlib import Path


def _app_root() -> Path:
bundle = getattr(sys, "_MEIPASS", None)
if getattr(sys, "frozen", False) and bundle:
return Path(bundle)
return Path(__file__).resolve().parents[3]


APP_ROOT = _app_root()
DASHBOARD_DIR = APP_ROOT / "dashboard" / "dist"
REGIONS_DIR = APP_ROOT / "regions"
# Cache Packs: the one a build command wrote, and the one downloaded from a Release on an earlier run.
BUILT_PACKS_DIR = APP_ROOT / "node" / "build"
DOWNLOADED_PACKS_DIR = APP_ROOT / ".demo" / "pack"
3 changes: 1 addition & 2 deletions node/src/storm_shield/region.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator

# Resolved from this file, not the cwd, so the Node starts from any directory.
REGIONS_DIR = Path(__file__).resolve().parents[3] / "regions"
from storm_shield.app_files import REGIONS_DIR as REGIONS_DIR # noqa: PLC0414 (re-exported: tests and scripts read it here)

Hazard = Literal["flash_flood", "storm_surge"]

Expand Down
55 changes: 33 additions & 22 deletions node/src/storm_shield/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from fastapi.testclient import TestClient
from starlette.types import Receive, Scope, Send

from storm_shield.app_files import APP_ROOT, BUILT_PACKS_DIR, DASHBOARD_DIR, DOWNLOADED_PACKS_DIR
from storm_shield.cache_pack import CachePack, InvalidCachePack, download_source
from storm_shield.community_reading import Position
from storm_shield.main import create_app
Expand All @@ -39,7 +40,7 @@
SuggestedSite,
UnknownSensor,
)
from storm_shield.settings import Settings
from storm_shield.settings import LOOPBACK_HOST, Settings

logger = logging.getLogger(__name__)

Expand All @@ -51,8 +52,7 @@
# it towards the spec's 1 km.
SANDBOX_RADIO_RANGE_M = 40_000.0
DOWNLOAD_TIMEOUT_SECONDS = 120
REPO = Path(__file__).resolve().parents[3]
BUILT_DASHBOARD = REPO / "dashboard" / "dist" / "index.html"
BUILT_DASHBOARD = DASHBOARD_DIR / "index.html"


class NodeSite(NamedTuple):
Expand Down Expand Up @@ -558,10 +558,10 @@ def pack_for(region: str, client: httpx.Client, override: str = "") -> Path:
"""The Region's Cache Pack: the one its build command wrote, else the one fetched from its
Release on an earlier run, else the Release itself. Says which command to run when none is
there."""
built = REPO / "node" / "build" / region / "cache-pack.zip"
built = BUILT_PACKS_DIR / region / "cache-pack.zip"
if built.is_file():
return built
downloaded = REPO / ".demo" / "pack" / region / "cache-pack.zip"
downloaded = DOWNLOADED_PACKS_DIR / region / "cache-pack.zip"
if downloaded.is_file():
return downloaded
build_command = f"scripts/build-cache-pack.sh {region}"
Expand Down Expand Up @@ -594,7 +594,7 @@ def build_dashboard_if_missing() -> None:
if BUILT_DASHBOARD.is_file():
return
logger.info("building the dashboard (once)")
dashboard = REPO / "dashboard"
dashboard = APP_ROOT / "dashboard"
try:
for command in (["pnpm", "install", "--frozen-lockfile"], ["pnpm", "build"]):
subprocess.run(command, cwd=dashboard, check=True, capture_output=True)
Expand Down Expand Up @@ -628,21 +628,32 @@ def run() -> None:
)
arguments = parser.parse_args()
try:
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client:
cache_pack_url = Settings().cache_pack_url

def find_pack(region: str) -> Path:
return pack_for(region, client, cache_pack_url)

pack = arguments.pack or find_pack(arguments.region)
build_dashboard_if_missing()
with Sandbox(pack, arguments.region, find_pack=find_pack) as sandbox:
server = uvicorn.Server(uvicorn.Config(sandbox.hub, port=arguments.port))
url = f"http://localhost:{arguments.port}"
logger.info("Sandbox dashboard: %s (everything in it is SIMULATED)", url)
threading.Thread(
target=_open_browser_when_up, args=(server, url), daemon=True
).start()
server.run()
serve(arguments.port, arguments.region, arguments.pack)
except SandboxError as error:
raise SystemExit(f"storm-shield-sandbox: {error}") from None


def sandbox_server_config(app: Any, port: int) -> uvicorn.Config:
"""Always loopback: the Sandbox is for the one person at this computer, and nothing here or
on the command line widens it."""
# No log_config: uvicorn's own sends its lines to stderr, which the app has no terminal for;
# without it they reach the root logger, and so the app's log file.
return uvicorn.Config(app, host=LOOPBACK_HOST, port=port, log_config=None)


def serve(port: int, region: str, pack: Path | None = None) -> None:
"""Start the Sandbox on `region`, open the browser once it answers, and serve until stopped."""
with httpx.Client(timeout=DOWNLOAD_TIMEOUT_SECONDS, follow_redirects=True) as client:
cache_pack_url = Settings().cache_pack_url

def find_pack(region: str) -> Path:
return pack_for(region, client, cache_pack_url)

pack = pack or find_pack(region)
build_dashboard_if_missing()
with Sandbox(pack, region, find_pack=find_pack) as sandbox:
server = uvicorn.Server(sandbox_server_config(sandbox.hub, port))
url = f"http://{LOOPBACK_HOST}:{port}"
logger.info("Sandbox dashboard: %s (everything in it is SIMULATED)", url)
threading.Thread(target=_open_browser_when_up, args=(server, url), daemon=True).start()
server.run()
113 changes: 113 additions & 0 deletions node/src/storm_shield/sandbox_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""The Sandbox as a double-clickable app: no arguments, and any problem ends up on a page.

Someone who opens it has no terminal, so it picks the port itself, opens the dashboard of a
Sandbox that is already running instead of starting a second one, logs to a file in the temp
folder and, when it cannot start, opens a page that says why.
"""

import html
import logging
import socket
import sys
import tempfile
import traceback
import webbrowser
from pathlib import Path

import httpx

from storm_shield.settings import LOOPBACK_HOST, Settings

logger = logging.getLogger(__name__)

PREFERRED_PORT = 8000
LOG_FILE_NAME = "storm-shield-sandbox.log"
FAILURE_PAGE_NAME = "storm-shield-sandbox-failed.html"


def log_file() -> Path:
return Path(tempfile.gettempdir()) / LOG_FILE_NAME


def sandbox_is_running_on(port: int) -> bool:
try:
state = httpx.get(f"http://{LOOPBACK_HOST}:{port}/api/state", timeout=2).json()
return bool(state["sandbox"])
except (httpx.HTTPError, ValueError, KeyError, TypeError):
return False


def _bindable(port: int) -> bool:
with socket.socket() as probe:
try:
probe.bind((LOOPBACK_HOST, port))
except OSError:
return False
return True


def free_port() -> int:
with socket.socket() as probe:
probe.bind((LOOPBACK_HOST, 0))
return int(probe.getsockname()[1])


class _LastServerError(logging.Handler):
"""Remembers uvicorn's last error line: when it cannot bind it exits with just a number, and
the reason ("address already in use") is only in what it logged."""

def __init__(self) -> None:
super().__init__(level=logging.ERROR)
self.message = ""

def emit(self, record: logging.LogRecord) -> None:
self.message = record.getMessage()


def _reason(error: BaseException, server_error: str) -> str:
if isinstance(error, SystemExit):
return server_error or f"the server stopped while starting (exit code {error.code})"
return str(error) or type(error).__name__


def write_failure_page(reason: str, log: Path) -> Path:
# A fresh folder, so a file planted at a predictable name in the shared temp folder is not followed.
page = Path(tempfile.mkdtemp(prefix="storm-shield-")) / FAILURE_PAGE_NAME
page.write_text(
"<!doctype html><meta charset=utf-8><title>Storm Shield Sandbox could not start</title>"
"<h1>Storm Shield Sandbox could not start</h1>"
f"<p>{html.escape(reason)}</p>"
f"<p>The full log is at <code>{html.escape(str(log))}</code></p>",
encoding="utf-8",
)
return page


def main() -> None:
log = log_file()
logging.basicConfig(level=logging.INFO, filename=log, force=True)
logging.getLogger("httpx").setLevel(logging.WARNING)
last_server_error = _LastServerError()
logging.getLogger("uvicorn.error").addHandler(last_server_error)
port = PREFERRED_PORT
# Whatever went wrong, the person double-clicking has no terminal: it all has to reach the page.
# That includes importing the Sandbox, which reads its Region, and uvicorn exiting with
# SystemExit when it cannot bind.
try:
if sandbox_is_running_on(PREFERRED_PORT):
webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}")
return
from storm_shield.sandbox import serve

port = PREFERRED_PORT if _bindable(PREFERRED_PORT) else free_port()
serve(port, Settings().region)
except (Exception, SystemExit) as error: # noqa: BLE001
logger.error("the Sandbox could not start\n%s", traceback.format_exc())
# Two quick launches both find 8000 free and the slower one loses the bind: the winner is
# the Sandbox to open.
if port == PREFERRED_PORT and sandbox_is_running_on(PREFERRED_PORT):
webbrowser.open(f"http://{LOOPBACK_HOST}:{PREFERRED_PORT}")
return
reason = _reason(error, last_server_error.message)
webbrowser.open(write_failure_page(reason, log).as_uri())
sys.exit(1)
9 changes: 7 additions & 2 deletions node/src/storm_shield/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

from storm_shield.app_files import DASHBOARD_DIR

# The Sandbox listens here and nowhere wider: it is for the one person at this computer.
LOOPBACK_HOST = "127.0.0.1"

Role = Literal["sensor", "hub"]
MeshKind = Literal["reticulum", "memory"]
ReticulumInterface = Literal["auto", "loopback", "tcp"]
Expand All @@ -26,8 +31,8 @@ class Settings(BaseSettings):
# Kilometres down the river from the Lhende-Kyirong confluence (negative above it); blank for a
# Node that is not on the river, which then cannot start a Community Warning.
river_km: float | None = None
# Resolved from this file, not the cwd, so the Node starts from any directory.
dashboard_dir: Path = Path(__file__).resolve().parents[3] / "dashboard" / "dist"
# Resolved from the app's files, not the cwd, so the Node starts from any directory.
dashboard_dir: Path = DASHBOARD_DIR
# "memory" is a Node that hears only itself; tests and the dashboard dev server use it.
mesh: MeshKind = "reticulum"
# "auto" finds Nodes on the local Wi-Fi (the demo). "loopback" joins Nodes on this one machine,
Expand Down
5 changes: 4 additions & 1 deletion node/tests/test_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,10 @@ def test_a_node_outside_the_sandbox_reports_no_sandbox(meshed_nodes: MeshedNodes
@pytest.fixture
def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""An empty checkout, so a test decides which packs are on disk."""
monkeypatch.setattr(sandbox_module, "REPO", tmp_path / "repo")
monkeypatch.setattr(sandbox_module, "BUILT_PACKS_DIR", tmp_path / "repo" / "node" / "build")
monkeypatch.setattr(
sandbox_module, "DOWNLOADED_PACKS_DIR", tmp_path / "repo" / ".demo" / "pack"
)
return tmp_path / "repo"


Expand Down
Loading
Loading