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
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ dependencies = ["pyyaml>=6", "pythonnet>=3.0.5"]

[project.urls]
Homepage = "https://simantic.dev"
Source = "https://github.com/simantic-dev/simantic-py"
Issues = "https://github.com/simantic-dev/simantic-py/issues"
Source = "https://github.com/simantic-dev/pippy"
Issues = "https://github.com/simantic-dev/pippy/issues"

[project.entry-points.pytest11]
simantic = "simantic.pytest_plugin"
Expand Down
33 changes: 31 additions & 2 deletions src/simantic/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import getpass
import sys

from . import auth, esp_image, install, telemetry
from . import auth, demo, esp_image, install, telemetry
from ._locate import BinaryNotFound, locate
from .mcu import BINARY as SIM_BINARY
from .mcu import ENV_VAR as SIM_ENV
Expand Down Expand Up @@ -116,6 +116,16 @@ def _esp_rom(args) -> int:
return 0


def _demo(args) -> int:
if args.name is None:
print("Demos run without an account. Pick one:\n")
for name, entry in sorted(demo.DEMOS.items()):
print(f" {name:<12} {entry.summary}")
print(f"\nRun one with: simantic demo {sorted(demo.DEMOS)[0]}")
return 0
return demo.run(args.name, force=args.force, extra=args.sim_args)


def main(argv: list[str] | None = None) -> int:
# prog is left to argparse so usage reflects however it was invoked:
# `simantic`, the short `smtc`, or `python -m simantic`.
Expand All @@ -124,6 +134,20 @@ def main(argv: list[str] | None = None) -> int:
)
sub = parser.add_subparsers(dest="command", required=True)

# First, because it is the first thing a new reader should run: it needs
# no account and no prior `install`.
p_demo = sub.add_parser("demo", help="run a packaged scenario; no account needed")
p_demo.add_argument("name", nargs="?", help="demo to run; omit to list them")
p_demo.add_argument(
"--force", action="store_true", help="re-download the demo's assets"
)
p_demo.add_argument(
"sim_args",
nargs=argparse.REMAINDER,
help="arguments passed through to the simulator",
)
p_demo.set_defaults(func=_demo)

p_auth = sub.add_parser("auth", help="store backend credentials in ~/.sim_id")
p_auth.add_argument(
"--token",
Expand Down Expand Up @@ -188,7 +212,12 @@ def main(argv: list[str] | None = None) -> int:
# waiting on a simulation, and the spool is due at most hourly.
telemetry.flush()
return result
except (auth.AuthError, install.InstallError, esp_image.EspImageError) as exc:
except (
auth.AuthError,
install.InstallError,
esp_image.EspImageError,
demo.DemoError,
) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
Expand Down
33 changes: 30 additions & 3 deletions src/simantic/_replx.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,20 @@ def model_replx(mcu: str, *, use_cache: bool = True) -> str:
return replx
try:
credentials = auth.load()
except auth.NotAuthenticated as exc:
raise SimError(f"mcu={mcu!r} needs credentials to fetch the model: {exc}") from None
except auth.NotAuthenticated:
# The first wall a new user hits, so it says what to do rather than
# what went wrong. The path to ~/.sim_id is a detail they did not ask
# about and cannot act on.
raise SimError(
f"{mcu} needs an account to download its model.\n"
f"\n"
f" simantic auth sign in (opens a browser)\n"
f" simantic status check who you are signed in as\n"
f"\n"
f"Or run a demo first — those need no account:\n"
f"\n"
f" simantic demo"
) from None
request = urllib.request.Request(
f"{MCU_DETAILS_URL}?model={urllib.parse.quote(mcu)}",
headers={"Authorization": f"Bearer {credentials.api_key}"},
Expand All @@ -83,7 +95,22 @@ def model_replx(mcu: str, *, use_cache: bool = True) -> str:
with urllib.request.urlopen(request, timeout=30) as response:
details = json.loads(response.read())
except urllib.error.HTTPError as exc:
raise SimError(f"mcu={mcu!r} is not a supported model (HTTP {exc.code})") from None
if exc.code in (401, 403):
# Distinct from "no such model": the name is fine, this account
# cannot have it. Saying "not supported" here sends people off
# hunting for a typo that is not there.
raise SimError(
f"your account does not have access to {mcu}.\n"
f"\n"
f" simantic status check who you are signed in as\n"
f"\n"
f"If that is the wrong account, `simantic auth` again. "
f"Otherwise ask us to enable it."
) from None
raise SimError(
f"{mcu} is not a model we publish (HTTP {exc.code}). "
f"`simantic status` lists what you can run."
) from None
except urllib.error.URLError as exc:
raise SimError(f"cannot reach the model backend: {exc.reason}") from None
replx = details.get("replx")
Expand Down
182 changes: 182 additions & 0 deletions src/simantic/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""`simantic demo`: run a packaged scenario without an account.

The first thing someone does after `pip install simantic` — or after
`uvx simantic demo`, which installs nothing permanent. Nothing here reads
`~/.sim_id` or touches `get-mcu-details`: a demo's platform and firmware come
from the public releases bucket, so it runs before a reader has decided
whether to sign up.

Scenarios are multi-machine, which is the point. A single chip printing to a
UART does not show anything a reader cannot already get elsewhere; two of them
finding each other over a simulated radio does. The run is delegated to the
`sim` binary's `--scenario`, which already wires N machines onto one shared
medium and one virtual timeline, rather than reimplemented against the
single-machine `simantic_rust.Session`.
"""

from __future__ import annotations

import json
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path

from . import install, telemetry
from ._locate import BinaryNotFound, locate
from .mcu import BINARY as SIM_BINARY
from .mcu import ENV_VAR as SIM_ENV


class DemoError(RuntimeError):
"""A demo could not be prepared or run."""


@dataclass(frozen=True)
class Demo:
"""One packaged scenario: its assets, and the scenario file to write."""

name: str
summary: str
#: Asset file name -> path under the demo prefix in the releases bucket.
assets: dict[str, str]
#: `scenario.yaml` contents, less the paths, which are filled from assets.
machines: dict[str, dict[str, str]]
media: list[dict[str, object]] = field(default_factory=list)
quantum: float | None = None
timeout: int = 30


#: BLE's inter-frame spacing is 150 us, so the two nodes have to interleave
#: well inside that or one misses the other's response window entirely.
BLE_QUANTUM_S = 0.00001

DEMOS: dict[str, Demo] = {
"ble-pair": Demo(
name="ble-pair",
summary=(
"two ESP32-C3s running stock ESP-IDF bleprph and blecent, "
"pairing over a simulated BLE medium"
),
assets={
"esp32c3.repl": "ble-pair/esp32c3.repl",
"bleprph.bin": "ble-pair/bleprph-flash.bin",
"blecent.bin": "ble-pair/blecent-flash.bin",
},
machines={
"peripheral": {"repl": "esp32c3.repl", "elf": "bleprph.bin"},
"central": {"repl": "esp32c3.repl", "elf": "blecent.bin"},
},
media=[{"type": "ble", "connect": ["peripheral.radio", "central.radio"]}],
quantum=BLE_QUANTUM_S,
timeout=30,
),
}

#: Demo assets are published beside the release manifests, under their own
#: prefix so a demo can be re-cut without a new engine release.
DEMO_PRODUCT = "demos"


def demo_root() -> Path:
"""Where fetched demo assets live: ~/.simantic/demos/<name>/."""
return install.simantic_home() / "demos"


def _manifest(timeout: float = 30) -> dict:
"""The demo asset manifest: {"assets": {path: {"sha256": ...}}}."""
url = f"{install.releases_url()}/{DEMO_PRODUCT}/manifest.json"
try:
with urllib.request.urlopen(urllib.request.Request(url), timeout=timeout) as response:
return json.loads(response.read())
except urllib.error.HTTPError as exc:
raise DemoError(
f"no demo assets published (HTTP {exc.code} from {url})"
) from None
except urllib.error.URLError as exc:
raise DemoError(f"cannot reach the release server: {exc.reason}") from None
except json.JSONDecodeError as exc:
raise DemoError(f"demo manifest is not valid JSON: {exc}") from None


def fetch_assets(demo: Demo, *, force: bool = False) -> Path:
"""Download the demo's assets into its cache directory, and return it."""
target = demo_root() / demo.name
missing = [n for n in demo.assets if force or not (target / n).exists()]
if not missing:
return target

entries = _manifest().get("assets") or {}
target.mkdir(parents=True, exist_ok=True)
for name in missing:
remote = demo.assets[name]
entry = entries.get(remote)
if entry is None:
raise DemoError(f"{remote!r} is not in the demo manifest")
artifact = install.Artifact(
version=demo.name,
url=f"{install.releases_url()}/{DEMO_PRODUCT}/{remote}",
sha256=entry.get("sha256"),
)
try:
payload = install.download(artifact)
except install.InstallError as exc:
raise DemoError(f"{name}: {exc}") from None
(target / name).write_bytes(payload)
return target


def write_scenario(demo: Demo, directory: Path) -> Path:
"""Write the scenario file `sim --scenario` reads, beside the assets.

Paths stay bare names because `sim` resolves `repl` and `elf` relative to
the scenario file's own directory.
"""
scenario: dict[str, object] = {
"machines": demo.machines,
"timeout": demo.timeout,
}
if demo.media:
scenario["media"] = demo.media
if demo.quantum is not None:
scenario["quantum"] = demo.quantum
path = directory / "scenario.yaml"
# The reader is a serde_yaml Deserialize, and JSON is a subset of YAML, so
# this avoids a PyYAML dump and its tag/anchor surprises.
path.write_text(json.dumps(scenario, indent=2) + "\n")
return path


def run(name: str, *, force: bool = False, extra: list[str] | None = None) -> int:
"""Prepare and run a demo, streaming the simulators' output."""
demo = DEMOS.get(name)
if demo is None:
known = ", ".join(sorted(DEMOS))
raise DemoError(f"unknown demo {name!r}; available: {known}")

try:
binary = locate(SIM_BINARY, SIM_ENV)
except BinaryNotFound:
try:
binary = install.install(SIM_BINARY)
except install.InstallError as exc:
raise DemoError(
f"the {SIM_BINARY!r} binary is needed to run a scenario and "
f"could not be installed: {exc}"
) from None

directory = fetch_assets(demo, force=force)
scenario = write_scenario(demo, directory)
command = [str(binary), "--scenario", str(scenario), *(extra or [])]
started = time.monotonic()
try:
code = subprocess.call(command)
except OSError as exc:
raise DemoError(f"could not run {binary}: {exc}") from None
# Reported here rather than spooled: a demo is usually a one-shot `uvx`
# process, so an hourly upload would never happen. Best-effort, and it
# must not change what the command returns.
telemetry.report_demo(demo.name, ok=code == 0, seconds=time.monotonic() - started)
return code
48 changes: 48 additions & 0 deletions src/simantic/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
#: failure — a call-count report there would corrupt a live metric.
REPORT_URL = "https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-sdk-usage"

#: Demo runs report here instead, unauthenticated. A separate function so an
#: anonymous, internet-facing endpoint can be rate-limited on its own and can
#: never reach the billing path that `report-usage` feeds.
ANON_REPORT_URL = os.environ.get(
"SIMANTIC_DEMO_REPORT_URL",
"https://drjdhqfvrttolueolzif.supabase.co/functions/v1/report-demo-usage",
)

#: The server caps a report at 16 KB. Nothing here approaches it, and the
#: cap is enforced locally so an oversized report is dropped rather than
#: rejected with an error nobody sees.
Expand Down Expand Up @@ -97,6 +105,43 @@ def report(event: str, **fields: object) -> bool:
return False


def report_demo(demo: str, *, ok: bool, seconds: float) -> bool:
"""Report one `simantic demo` run, with no account and no identifier.

Sent immediately rather than spooled. A demo is usually run through
`uvx`, so the process is ephemeral and the reader is unlikely to run a
second command: an hourly spool would never be uploaded, and the one run
that mattered would be the one we never heard about.

Strictly less is sent than for an authenticated run — the same
environment fields, which demo, whether it worked and how long it took.
Nothing here distinguishes two runs by the same person from two people,
and that is deliberate: these are strangers who have not signed up to
anything.
"""
if not enabled():
return False

payload = {
"event": "demo",
"demo": demo,
"ok": ok,
"seconds": round(seconds, 1),
**environment(),
}
request = urllib.request.Request(
ANON_REPORT_URL,
data=json.dumps(payload).encode(),
method="POST",
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(request, timeout=3):
return True
except (urllib.error.URLError, OSError, ValueError):
return False


# --- the call spool ---
#
# Which calls get made, buffered locally and uploaded on an interval rather
Expand Down Expand Up @@ -219,6 +264,9 @@ def describe() -> str:
f"telemetry: enabled when authenticated\n"
f" sends: {fields}, plus test counts and which calls were made\n"
f" never sends: file paths, project or test names, firmware, output\n"
f" `simantic demo` also reports anonymously without an account:\n"
f" which demo, whether it worked, how long it took, and {fields}\n"
f" with no identifier, so runs cannot be linked to each other\n"
f" buffered at: {spool_path()} ({pending} calls pending, "
f"uploaded hourly)\n"
f" disable with: SIMANTIC_TELEMETRY=0 (or DO_NOT_TRACK=1)"
Expand Down
Loading
Loading