diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index d0b9295..ad68186 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -79,7 +79,7 @@ jobs: python-version: "3.12" cache: pip - # Mosquitto is the MQTT broker for tests/integration (novaMock -> backend). + # Mosquitto is the MQTT broker for tests/integration (novaSim -> backend). - name: Install run: | sudo apt-get update -q diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aca2707..29d9219 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,16 +27,17 @@ python -m pytest -q --cov=app CI runs the same tests on every PR and prints coverage. -`tests/integration/` starts a real Mosquitto broker and `tools/novaMock.py` +`tests/integration/` starts a real Mosquitto broker and `tools/novaSim.py` and checks that telemetry reaches a websocket client. It needs Mosquitto installed (on PATH, or in `C:\Program Files\mosquitto`) and is skipped without it. CI sets `NOVA_REQUIRE_INTEGRATION=1` so it fails instead. ## Simulator Test (level 2) -- `tools/novaMock.py`, `tools/nova_dummy.py`, `tools/novaSystem_dummy.py`: +- `tools/novaSim.py`, `tools/nova_dummy.py`, `tools/novaSystem_dummy.py`: simulated novaGround and FAS traffic over MQTT -- `tools/sim_control.html`: drive the simulators from a browser +- `tools/sim_control.html`: the control panel `novaSim.py --ui` serves, to + drive the simulators from a browser Then go through the [testing procedure](https://github.com/UTATRocketry/Nova-Collected/blob/main/docs/development/testing-procedure.md) diff --git a/README.md b/README.md index 627f445..16f1f0f 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ On Linux or macOS, start the backend and the simulator separately: ```bash ./scripts/dev_linux.sh --broker localhost -python tools/novaMock.py --broker localhost +python tools/novaSim.py --broker localhost ``` Open `http://localhost:8000/docs` for the live API reference. Run the tests: diff --git a/docs/tools.md b/docs/tools.md index e157662..2da9ff4 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -6,10 +6,10 @@ The programs in `tools/`. All need `paho-mqtt`; the FAS bridge also needs | Tool | What it's for | |---|---| | `fas_bridge.py` | The production serial ↔ MQTT link to the flight computer (FMC) | -| `novaMock.py` | **The main simulator**: stands in for novaGround and the FAS bridge together | +| `novaSim.py` | **The main simulator**: stands in for novaGround and the FAS bridge together | | `nova_dummy.py` | Lighter novaGround simulator | | `serial_probe.py` | See what's on a serial port | -| `sim_control.html` | Browser controls for the simulators | +| `sim_control.html` | The control panel `novaSim.py --ui` serves | | `novaGround_dummy.py`, `novaSystem_dummy.py` | Legacy simulators; `dev_linux.sh --with-dummy` still uses `novaSystem_dummy.py` | ## fas_bridge.py @@ -42,16 +42,25 @@ A missing or unplugged port is retried in the background instead of stopping the bridge. The link state is published on `nova/console` as `console_serial` and in flight telemetry as `data.fas_link`. -## novaMock.py +## novaSim.py ```bash -python tools/novaMock.py --broker localhost --ui +python tools/novaSim.py --broker localhost --ui ``` Publishes GCS and FAS engine packets and the nested flight snapshot, and answers the same commands as novaGround and the FAS bridge, so the backend and frontend can be exercised end to end with no hardware. +`--ui` serves the control panel on `--ui-port` (8765 by default). The page is +`tools/sim_control.html`, read from disk on each request, so edits to it show +on a browser refresh without restarting the simulator. + +The UI's "Launch" button replays a trajectory. Point it at an OpenRocket CSV +export (11 numeric columns) with `--sim-csv`, or set `NOVA_SIM_CSV` so you +don't have to pass it every time; with neither, it synthesizes a generic +flight profile. + ## nova_dummy.py ```bash diff --git a/pyproject.toml b/pyproject.toml index 2855bd4..464c47e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ include = ["app*"] testpaths = ["tests"] addopts = "-q" markers = [ - "integration: starts Mosquitto and novaMock; skipped if Mosquitto isn't installed", + "integration: starts Mosquitto and novaSim; skipped if Mosquitto isn't installed", ] [tool.pylint.MASTER] diff --git a/scripts/dev_windows.ps1 b/scripts/dev_windows.ps1 index 7681784..64f34fb 100644 --- a/scripts/dev_windows.ps1 +++ b/scripts/dev_windows.ps1 @@ -94,11 +94,11 @@ try { $children = @() if ($WithMock) { - $mockArgs = "tools\novaMock.py --broker ${Broker} --port ${brokerPort} --ui" + $mockArgs = "tools\novaSim.py --broker ${Broker} --port ${brokerPort} --ui" $proc = Start-Process -FilePath $python -ArgumentList $mockArgs ` -WorkingDirectory $repoRoot -PassThru $children += $proc - Write-Host "Started novaMock.py (PID $($proc.Id))" -ForegroundColor Cyan + Write-Host "Started novaSim.py (PID $($proc.Id))" -ForegroundColor Cyan } if ($WithFas) { diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 7cc8aee..09c873a 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,4 +1,4 @@ -"""Fixtures that run real processes: a Mosquitto broker and tools/novaMock.py. +"""Fixtures that run real processes: a Mosquitto broker and tools/novaSim.py. Mosquitto must be installed. Without it these tests are skipped, unless NOVA_REQUIRE_INTEGRATION is set (as it is in CI), in which case they fail. @@ -16,7 +16,7 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[2] -NOVA_MOCK = REPO_ROOT / "tools" / "novaMock.py" +NOVA_SIM = REPO_ROOT / "tools" / "novaSim.py" # Where the Windows installer puts it; it isn't on PATH by default. _WINDOWS_MOSQUITTO = Path(r"C:\Program Files\mosquitto\mosquitto.exe") @@ -88,11 +88,11 @@ def mqtt_broker(tmp_path_factory) -> tuple[str, int]: @pytest.fixture -def nova_mock(mqtt_broker) -> subprocess.Popen: - """tools/novaMock.py publishing simulated telemetry to the test broker.""" +def nova_sim(mqtt_broker) -> subprocess.Popen: + """tools/novaSim.py publishing simulated telemetry to the test broker.""" host, port = mqtt_broker proc = subprocess.Popen( - [sys.executable, str(NOVA_MOCK), "--broker", host, "--port", str(port), "--hz", "20"], + [sys.executable, str(NOVA_SIM), "--broker", host, "--port", str(port), "--hz", "20"], cwd=REPO_ROOT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, diff --git a/tests/integration/test_novamock_e2e.py b/tests/integration/test_novasim_e2e.py similarity index 93% rename from tests/integration/test_novamock_e2e.py rename to tests/integration/test_novasim_e2e.py index 58db4a3..76170ab 100644 --- a/tests/integration/test_novamock_e2e.py +++ b/tests/integration/test_novasim_e2e.py @@ -1,7 +1,7 @@ -"""novaMock -> MQTT -> backend -> websocket client, with real processes. +"""novaSim -> MQTT -> backend -> websocket client, with real processes. The backend runs in-process (TestClient), but talks to a real Mosquitto -broker that novaMock publishes to, so the MQTT contract between the +broker that novaSim publishes to, so the MQTT contract between the simulator and the backend is exercised for real. """ from __future__ import annotations @@ -15,7 +15,7 @@ pytestmark = pytest.mark.integration -# One sensor from each side that novaMock simulates: +# One sensor from each side that novaSim simulates: # PGSO: GCS hat 0 / channel 0, raw 0.99-3.99 V, calibrated here to 99-399 psi. # PCC: FAS EPB_0 / channel 1, raw 0.2-0.9, passed through unconverted. TEST_CONFIG = """ @@ -91,7 +91,7 @@ def _by_name(engine_data: dict) -> dict[str, dict]: return {row["name"]: row for row in engine_data["data"]} -def test_websocket_client_receives_calibrated_engine_telemetry(backend_env, nova_mock) -> None: +def test_websocket_client_receives_calibrated_engine_telemetry(backend_env, nova_sim) -> None: app = create_app() with TestClient(app) as client: _wait_for_engine_data(app.state.ctx) diff --git a/tools/fas_bridge.py b/tools/fas_bridge.py index 73a60da..d73e0b6 100644 --- a/tools/fas_bridge.py +++ b/tools/fas_bridge.py @@ -367,7 +367,7 @@ def _fmc_altitude_m(pressure_pa: float) -> float: ENGINE_STALE_S = 3.0 # ── Flight FSM taxonomy ─────────────────────────────────────────────────────── -# Kept in sync with tools/novaMock.py. fas_state is the top-level avionics state; +# Kept in sync with tools/novaSim.py. fas_state is the top-level avionics state; # flight_phase is the finer phase reported alongside it. FLIGHT_EVENTS maps each # event name to (id, default severity); events are published on nova/console as # {"type":"flight_event","event":{id,name,severity}}. diff --git a/tools/novaMock.py b/tools/novaSim.py similarity index 77% rename from tools/novaMock.py rename to tools/novaSim.py index bd4cf0b..e030114 100644 --- a/tools/novaMock.py +++ b/tools/novaSim.py @@ -45,12 +45,12 @@ sensor's waveform (flat / sine / square / triangle / sawtooth / s-curve / ramp / noise), change the flight state, start/stop a fake SD-card fill, and watch every received command with a live format validation. Without - ``--ui`` novaMock runs exactly as before, fully headless. + ``--ui`` novaSim runs exactly as before, fully headless. Usage: - python tools/novaMock.py # headless - python tools/novaMock.py --ui # with control panel on :8765 - python tools/novaMock.py --ui --ui-port 9000 + python tools/novaSim.py # headless + python tools/novaSim.py --ui # with control panel on :8765 + python tools/novaSim.py --ui --ui-port 9000 Environment variables (CLI flags override these): NOVA_MQTT_BROKER MQTT broker host (default: localhost) @@ -83,7 +83,7 @@ CONSOLE_TOPIC = "nova/console" COMMAND_TOPIC = "nova/command" CONTROL_TOPIC = "nova/control" -CLIENT_ID = "novaMock" +CLIENT_ID = "novaSim" # Coarse lock guarding every piece of mutable sim state below, since the UI # thread, the MQTT callback thread and the publish loop all touch it. @@ -1058,7 +1058,7 @@ def _publish_flight_event(client: mqtt.Client, event: dict) -> None: "severity": event["severity"]}, "source": "FAS"} client.publish(CONSOLE_TOPIC, _dumps(payload), qos=0) - print(f"[novaMock] flight_event #{event['id']} {event['name']} ({event['severity']})") + print(f"[novaSim] flight_event #{event['id']} {event['name']} ({event['severity']})") def _handle_console(client: mqtt.Client, cmd: dict) -> None: @@ -1068,7 +1068,7 @@ def _handle_console(client: mqtt.Client, cmd: dict) -> None: if action in {"start", "stop"}: with _lock: _console_active = action == "start" - print(f"[novaMock] console {action}") + print(f"[novaSim] console {action}") _publish_console(client, {"type": "console_status", "active": action == "start"}) elif action == "list_ports": @@ -1079,7 +1079,7 @@ def _handle_console(client: mqtt.Client, cmd: dict) -> None: elif action == "configure": port = str(cmd.get("port", "")).strip() or "/dev/ttySIM0" baud = int(cmd.get("baud", 460800)) - print(f"[novaMock] console configure -> {port} @ {baud}") + print(f"[novaSim] console configure -> {port} @ {baud}") with _lock: _serial_link.update(connected=True, port=port, baud=baud, error=None) state = dict(_serial_link) @@ -1088,7 +1088,7 @@ def _handle_console(client: mqtt.Client, cmd: dict) -> None: _publish_console(client, {"type": "console_serial", **state}) elif action == "disconnect": - print("[novaMock] console disconnect") + print("[novaSim] console disconnect") with _lock: _serial_link.update(connected=False, port="", error=None) state = dict(_serial_link) @@ -1106,11 +1106,11 @@ def _handle_console(client: mqtt.Client, cmd: dict) -> None: with _lock: connected = _serial_link["connected"] if not connected: - print(f"[novaMock] console tx op={op} dropped: no serial port") + print(f"[novaSim] console tx op={op} dropped: no serial port") _publish_console(client, {"type": "console_tx", "ok": False, "error": "no serial port connected"}) return - print(f"[novaMock] console tx op={op} board={cmd.get('board_id')} " + print(f"[novaSim] console tx op={op} board={cmd.get('board_id')} " f"ch={cmd.get('channel')}") _publish_console(client, { "type": "console_tx", "ok": True, @@ -1120,7 +1120,7 @@ def _handle_console(client: mqtt.Client, cmd: dict) -> None: }) else: - print(f"[novaMock] console: unknown action {action!r}") + print(f"[novaSim] console: unknown action {action!r}") # --------------------------------------------------------------------------- @@ -1137,32 +1137,32 @@ def _handle_fas_port(cmd: dict) -> None: if port == "relay": enable = action == "on" _set_actuator(board_id, channel, load_sw_on=int(enable)) - print(f"[novaMock] fas load_sw EPB:{board_id} ch={channel} " + print(f"[novaSim] fas load_sw EPB:{board_id} ch={channel} " f"{'ON' if enable else 'OFF'}") elif port == "servo": if action == "enable": - print(f"[novaMock] fas servo enable (no-op) EPB:{board_id} ch={channel}") + print(f"[novaSim] fas servo enable (no-op) EPB:{board_id} ch={channel}") return pulse_us = 0 if action == "disable" else int(cmd.get("value", 0)) _set_actuator(board_id, channel, pulse_us=pulse_us) - print(f"[novaMock] fas pwm_set EPB:{board_id} ch={channel} pulse={pulse_us}us") + print(f"[novaSim] fas pwm_set EPB:{board_id} ch={channel} pulse={pulse_us}us") elif port == "gpio": act = action.upper() if act == "ARM": with _lock: _imc_armed, _imc_board_id = True, board_id - print(f"[novaMock] fas imc_arm EPB:{board_id}") + print(f"[novaSim] fas imc_arm EPB:{board_id}") elif act == "DISARM": with _lock: _imc_armed, _imc_board_id = False, board_id - print(f"[novaMock] fas imc_disarm EPB:{board_id}") + print(f"[novaSim] fas imc_disarm EPB:{board_id}") else: - print(f"[novaMock] fas gpio: unknown action {action!r}") + print(f"[novaSim] fas gpio: unknown action {action!r}") else: - print(f"[novaMock] fas: unknown port {port!r}") + print(f"[novaSim] fas: unknown port {port!r}") def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: @@ -1174,34 +1174,34 @@ def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: if op == "pwm_set": _set_actuator(board_id, channel, pulse_us=int(cmd.get("pulse_us", 0)), period_us=int(cmd.get("period_us", 20000))) - print(f"[novaMock] fas op=pwm_set EPB:{board_id} ch={channel} " + print(f"[novaSim] fas op=pwm_set EPB:{board_id} ch={channel} " f"pulse={cmd.get('pulse_us')}us") elif op == "load_sw_set": _set_actuator(board_id, channel, load_sw_on=int(bool(cmd.get("enable")))) - print(f"[novaMock] fas op=load_sw_set EPB:{board_id} ch={channel} " + print(f"[novaSim] fas op=load_sw_set EPB:{board_id} ch={channel} " f"enable={cmd.get('enable')}") elif op == "imc_arm": with _lock: _imc_armed, _imc_board_id = True, board_id - print(f"[novaMock] fas op=imc_arm board={board_id}") + print(f"[novaSim] fas op=imc_arm board={board_id}") elif op == "imc_disarm": with _lock: _imc_armed, _imc_board_id = False, board_id - print(f"[novaMock] fas op=imc_disarm board={board_id}") + print(f"[novaSim] fas op=imc_disarm board={board_id}") elif op == "failsafe": with _lock: _epb_actuators.pop(board_id, None) - print(f"[novaMock] fas op=failsafe board={board_id}") + print(f"[novaSim] fas op=failsafe board={board_id}") elif op == "discover": - print("[novaMock] fas op=discover") + print("[novaSim] fas op=discover") elif op == "actuator_query": - print(f"[novaMock] fas op=actuator_query board={board_id} ch={channel}") + print(f"[novaSim] fas op=actuator_query board={board_id} ch={channel}") elif op == "buzzer": notes = cmd.get("notes") if notes is not None: - print(f"[novaMock] fas op=buzzer melody FMC:{board_id} notes={len(notes)}") + print(f"[novaSim] fas op=buzzer melody FMC:{board_id} notes={len(notes)}") else: - print(f"[novaMock] fas op=buzzer FMC:{board_id} action={cmd.get('action')}") + print(f"[novaSim] fas op=buzzer FMC:{board_id} action={cmd.get('action')}") elif op == "sd_cmd": # Reflect SD logger control in the fake fill, like real firmware would. sub = str(cmd.get("action", "")).lower() @@ -1212,7 +1212,7 @@ def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: # Decimation divisor (1..255, 1 = full rate) — echoed back as # rate_div in the SD status, NOT the UI fill speed (_sd["rate"]). _sd["rate_div"] = max(1, int(cmd.get("divisor", cmd.get("arg", 1)))) - print(f"[novaMock] fas op=sd_cmd FMC:{board_id} action={sub}") + print(f"[novaSim] fas op=sd_cmd FMC:{board_id} action={sub}") elif op in ("rab_arm", "rab_disarm"): # RAB recovery arming, addressed by board_id (0 = A, 1 = B). The readback # follows the command so no mismatch fires (inject one from the UI to test). @@ -1221,7 +1221,7 @@ def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: r = _rab.setdefault(board_id, {"expected": False, "armed": False, "rx": 0}) r["expected"] = arm r["armed"] = arm - print(f"[novaMock] fas op={op} RAB:{board_id} -> {'ARMED' if arm else 'DISARMED'}") + print(f"[novaSim] fas op={op} RAB:{board_id} -> {'ARMED' if arm else 'DISARMED'}") elif op == "aux_power": dev = str(cmd.get("device", "radio")).lower() enable = bool(cmd.get("enable")) @@ -1247,14 +1247,14 @@ def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: _radio_cfg["rf_chain"]["duty_cycle"]) else: _aux["radio"] = enable - print(f"[novaMock] fas op=aux_power {dev} {'ON' if enable else 'OFF'}") + print(f"[novaSim] fas op=aux_power {dev} {'ON' if enable else 'OFF'}") elif op == "runcam_record": enable = bool(cmd.get("enable")) autostop = max(0, min(43200, int(cmd.get("autostop_s", 0) or 0))) with _lock: _aux["runcam_recording"] = enable _aux["runcam_record_s"] = autostop if (enable and autostop) else None - print(f"[novaMock] fas op=runcam_record {'START' if enable else 'STOP'} " + print(f"[novaSim] fas op=runcam_record {'START' if enable else 'STOP'} " f"autostop={autostop or 'none'}") elif op == "radio_config": cfg = cmd.get("cfg") @@ -1266,10 +1266,10 @@ def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: _radio_cfg_txn["generation"] += 1 _radio_cfg_txn["status"] = 2 # applied _radio_cfg_txn["persisted"] = True - print(f"[novaMock] fas op=radio_config applied txn=" + print(f"[novaSim] fas op=radio_config applied txn=" f"{_radio_cfg_txn['transaction_id']}") else: - print("[novaMock] fas op=radio_config ignored (no cfg object)") + print("[novaSim] fas op=radio_config ignored (no cfg object)") elif op == "sound": _handle_sound(cmd) elif op == "sound_upload": @@ -1285,9 +1285,9 @@ def _handle_fas_op(cmd: dict, client: mqtt.Client | None = None) -> None: if v != 0xFF: _charger["v_setting"] = max(0, min(31, v)) en, iset, vset = _charger["enabled"], _charger["i_setting"], _charger["v_setting"] - print(f"[novaMock] fas op=pmb_charger enable={en} i={iset} v={vset}") + print(f"[novaSim] fas op=pmb_charger enable={en} i={iset} v={vset}") else: - print(f"[novaMock] fas: unknown op {op!r}") + print(f"[novaSim] fas: unknown op {op!r}") def _handle_sound_upload(cmd: dict, client: mqtt.Client | None = None) -> None: @@ -1314,12 +1314,12 @@ def ack(ok: bool, stage: str, length: int, error: str | None = None) -> None: with _lock: _sound["clips"].append({"name": name, "length": length, "format": fmt}) n = len(_sound["clips"]) - print(f"[novaMock] fas op=sound_upload {name!r} ({length} B, fmt={fmt}) " + print(f"[novaSim] fas op=sound_upload {name!r} ({length} B, fmt={fmt}) " f"-> {n} clips") else: with _lock: n = len(_sound["clips"]) - print(f"[novaMock] fas op=sound_upload {name!r} FAILED at {stage}: {error}") + print(f"[novaSim] fas op=sound_upload {name!r} FAILED at {stage}: {error}") if client is not None: _publish_console(client, { "type": "sound_upload_result", "upload_id": upload_id, @@ -1372,17 +1372,17 @@ def _handle_sound(cmd: dict) -> None: elif action == "clear": _sound["clips"] = [] _sound["playing"] = None - print(f"[novaMock] fas op=sound action={action}") + print(f"[novaSim] fas op=sound action={action}") def _handle_data_file(cmd: dict) -> None: action = cmd.get("action", "") if action == "start_data_saving": - print(f"[novaMock] Data saving STARTED — file: {cmd.get('filename', 'unknown')}.csv") + print(f"[novaSim] Data saving STARTED — file: {cmd.get('filename', 'unknown')}.csv") elif action == "stop_data_saving": - print("[novaMock] Data saving STOPPED") + print("[novaSim] Data saving STOPPED") else: - print(f"[novaMock] Unknown data_file action: {action}") + print(f"[novaSim] Unknown data_file action: {action}") # --------------------------------------------------------------------------- @@ -1393,14 +1393,14 @@ def on_connect(client: mqtt.Client, _userdata, _flags, reason_code, _properties) if ok: client.subscribe(COMMAND_TOPIC) client.subscribe(CONTROL_TOPIC) - print(f"[novaMock] Connected to {BROKER}:{PORT}") - print(f"[novaMock] Subscribed to {COMMAND_TOPIC} and {CONTROL_TOPIC}") + print(f"[novaSim] Connected to {BROKER}:{PORT}") + print(f"[novaSim] Subscribed to {COMMAND_TOPIC} and {CONTROL_TOPIC}") else: - print(f"[novaMock] Connect failed: {reason_code}") + print(f"[novaSim] Connect failed: {reason_code}") def on_disconnect(_client, _userdata, _flags, reason_code, _properties) -> None: - print(f"[novaMock] Disconnected: {reason_code}") + print(f"[novaSim] Disconnected: {reason_code}") def on_message(client, _userdata, message) -> None: @@ -1408,7 +1408,7 @@ def on_message(client, _userdata, message) -> None: payload = json.loads(message.payload.decode("utf-8")) except Exception as exc: _record_command(message.topic, {"_decode_error": str(exc)}) - print(f"[novaMock] Error decoding message: {exc}") + print(f"[novaSim] Error decoding message: {exc}") return # Capture every inbound message for the UI's validation log. @@ -1422,11 +1422,11 @@ def on_message(client, _userdata, message) -> None: if topic == CONTROL_TOPIC: source = payload.get("source", "") if source.lower() == "novalock": - print(f"[novaMock] Physical lockout: {payload.get('state', '?')}") + print(f"[novaSim] Physical lockout: {payload.get('state', '?')}") elif cmd_type == "data_file": _handle_data_file(cmd) else: - print(f"[novaMock] Control message: {payload}") + print(f"[novaSim] Control message: {payload}") return # nova/command — only act on the backend's own envelopes. @@ -1435,11 +1435,11 @@ def on_message(client, _userdata, message) -> None: # --- GCS device commands (novaGround side) --- if cmd_type == "relay": - print(f"[novaMock] Relay command — id={cmd.get('id')} state={cmd.get('state')}") + print(f"[novaSim] Relay command — id={cmd.get('id')} state={cmd.get('state')}") elif cmd_type == "servo": - print(f"[novaMock] Servo command — id={cmd.get('id')} angle={cmd.get('angle')}") + print(f"[novaSim] Servo command — id={cmd.get('id')} angle={cmd.get('angle')}") elif cmd_type == "gpio": - print(f"[novaMock] GPIO command — id={cmd.get('id')} state={cmd.get('state')}") + print(f"[novaSim] GPIO command — id={cmd.get('id')} state={cmd.get('state')}") # --- FAS device commands (fas_bridge side) --- elif cmd_type == "fas": @@ -1448,7 +1448,7 @@ def on_message(client, _userdata, message) -> None: elif "op" in cmd: _handle_fas_op(cmd, client) else: - print(f"[novaMock] fas: unrecognised shape {cmd}") + print(f"[novaSim] fas: unrecognised shape {cmd}") elif cmd_type == "console": _handle_console(client, cmd) @@ -1457,10 +1457,10 @@ def on_message(client, _userdata, message) -> None: _handle_data_file(cmd) else: - print(f"[novaMock] Unhandled command type '{cmd_type}': {cmd}") + print(f"[novaSim] Unhandled command type '{cmd_type}': {cmd}") except Exception as exc: - print(f"[novaMock] Error processing message: {exc}") + print(f"[novaSim] Error processing message: {exc}") def _maybe_publish_console_frame(client: mqtt.Client) -> None: @@ -1608,7 +1608,7 @@ def _ui_apply_launch(body: dict) -> str: "samples": rows, "times": idx["times"], "apogee_t": idx["apogee_t"], "end_t": idx["end_t"], }) - print(f"[novaMock] LAUNCH — {src} x{speed} (apogee t={idx['apogee_t']:.1f}s, " + print(f"[novaSim] LAUNCH — {src} x{speed} (apogee t={idx['apogee_t']:.1f}s, " f"duration {idx['end_t']:.1f}s)") return src @@ -1732,7 +1732,7 @@ def _send(self, code, body, ctype="application/json"): def do_GET(self): parsed = urlparse(self.path) if parsed.path in ("/", "/index.html"): - self._send(200, _UI_HTML, "text/html; charset=utf-8") + self._send(200, _ui_html(), "text/html; charset=utf-8") elif parsed.path == "/api/state": self._send(200, json.dumps(_ui_state())) elif parsed.path == "/api/commands": @@ -1779,337 +1779,21 @@ def do_POST(self): def start_ui(port: int) -> None: server = ThreadingHTTPServer(("0.0.0.0", port), _make_ui_handler()) threading.Thread(target=server.serve_forever, daemon=True).start() - print(f"[novaMock] Control UI on http://localhost:{port}") - - -_UI_HTML = """ -novaMock control - -
-

novaMock control

- connecting… - -
-
-
-

Sensors

- - - -
namesrcwaveminmaxperiodnoisevalue
-

value is used by the flat wave. period in seconds.

-
-
-

Flight FSM

-
- - - IMC ? -
-
- fas_state: ? - phase: ? -
-
-

Launch (trajectory replay)

-
- - speed × -
-
- CSV -
-

blank uses the default sim CSV, or a generic profile if none. -

-

SD card fill

-
- - rate %/s - -
-
used ?%
-
-

console mode: ?

-
-
-

FAS boards — connect / disconnect

-
-

unchecking a board drops it from the fleet and stops its telemetry.

-
-
-

RAB recovery arming

-
A ? - - -
-
B ? - - -
-
FMC→RAB link:
-
-
-

Battery charger (PMB)

-
- - ? -
-
i - v -
-
-

DAC codes 0..31. Mirrors the /api/fas/charger command effect.

-
-
-

FMC aux / STM32WL radio

-
- - - -
-
- - - PA off -
-
- - -
-
- - -
-
queue dropped
-
PPS is always simulated present. The PA cannot come on - while the modem is not ready — that is the firmware inhibit, not a failure.
-
-
-

Soundboard

-
- add clip - len B - - - - -
-
#namebytes
-

real uploads arrive via the /api/fas/sound/upload backend command and append here too.

-
-
-

Received commands (live validation)

-
-
-
-""" + print(f"[novaSim] Control UI on http://localhost:{port}") + +_UI_HTML_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sim_control.html") -_DEFAULT_SIM_CSV = os.path.join( - os.path.expanduser("~"), - "OneDrive", "Documents", "UTAT", "Discovery", "OpenRocket", "Sim_Data.csv", -) + +def _ui_html() -> str: + """Read the control page from disk, so edits to it show on a refresh.""" + try: + with open(_UI_HTML_PATH, "r", encoding="utf-8") as fh: + return fh.read() + except OSError as exc: + print(f"[novaSim] Cannot read {_UI_HTML_PATH}: {exc}") + return ("novaSim control" + f"

Cannot read {os.path.basename(_UI_HTML_PATH)}: {exc}") def main() -> None: @@ -2122,15 +1806,18 @@ def main() -> None: p.add_argument("--port", type=int, default=PORT, help="MQTT broker port") p.add_argument("--hz", type=float, default=HZ, help="engine-sensor publish rate") p.add_argument("--flight-hz", type=float, default=FLIGHT_HZ, help="flight publish rate") - p.add_argument("--sim-csv", default=_DEFAULT_SIM_CSV, - help="default trajectory CSV used by the UI 'Launch' button " - "(falls back to a synthetic profile if missing)") + p.add_argument("--sim-csv", default=os.environ.get("NOVA_SIM_CSV", ""), + help="trajectory CSV used by the UI 'Launch' button, or set " + "NOVA_SIM_CSV instead (falls back to a synthetic " + "profile when unset or missing)") args = p.parse_args() BROKER, PORT, HZ, FLIGHT_HZ = args.broker, args.port, args.hz, args.flight_hz _sim_csv_default = args.sim_csv if os.path.isfile(args.sim_csv) else "" + if args.sim_csv and not _sim_csv_default: + print(f"[novaSim] --sim-csv not found, ignoring: {args.sim_csv}") - print(f"[novaMock] Starting (broker {BROKER}:{PORT}, engine {HZ} Hz, flight {FLIGHT_HZ} Hz)") + print(f"[novaSim] Starting (broker {BROKER}:{PORT}, engine {HZ} Hz, flight {FLIGHT_HZ} Hz)") if args.ui: start_ui(args.ui_port) @@ -2149,18 +1836,18 @@ def main() -> None: try: client.connect_async(BROKER, PORT, keepalive=60) except Exception as exc: # bad host/port etc. — keep running so the UI lives - print(f"[novaMock] connect_async error (will keep retrying): {exc}") + print(f"[novaSim] connect_async error (will keep retrying): {exc}") client.loop_start() - print(f"[novaMock] Connecting to MQTT in the background. If nothing connects, " + print(f"[novaSim] Connecting to MQTT in the background. If nothing connects, " f"start a broker (e.g. mosquitto) on {BROKER}:{PORT}.") sensor_interval = 1.0 / HZ flight_interval = 1.0 / FLIGHT_HZ next_flight = time.time() - print(f"[novaMock] Publishing GCS + FAS engine sensors to {TELEMETRY_TOPIC} at {HZ} Hz") - print(f"[novaMock] Publishing flight snapshot to {FLIGHT_TOPIC} at {FLIGHT_HZ} Hz") + print(f"[novaSim] Publishing GCS + FAS engine sensors to {TELEMETRY_TOPIC} at {HZ} Hz") + print(f"[novaSim] Publishing flight snapshot to {FLIGHT_TOPIC} at {FLIGHT_HZ} Hz") if not args.ui: - print("[novaMock] (no UI; pass --ui for the control panel)") + print("[novaSim] (no UI; pass --ui for the control panel)") try: while True: @@ -2176,7 +1863,7 @@ def main() -> None: next_flight = now + flight_interval time.sleep(sensor_interval) except KeyboardInterrupt: - print("\n[novaMock] Stopping") + print("\n[novaSim] Stopping") finally: client.loop_stop() client.disconnect() diff --git a/tools/sim_control.html b/tools/sim_control.html index 07aa3b1..7cc7aa2 100644 --- a/tools/sim_control.html +++ b/tools/sim_control.html @@ -1,237 +1,168 @@ - +novaSim control + +

+

novaSim control

+ connecting… + +
+
+
+

Sensors

+ + + +
namesrcwaveminmaxperiodnoisevalue
+

value is used by the flat wave. period in seconds.

+
+
+

Flight FSM

+
+ + + IMC ? +
+
+ fas_state: ? + phase: ? +
+
+

Launch (trajectory replay)

+
+ + speed × +
+
+ CSV +
+

blank uses the default sim CSV, or a generic profile if none. +

+

SD card fill

+
+ + rate %/s + +
+
used ?%
+
+

console mode: ?

+
+
+

FAS boards — connect / disconnect

+
+

unchecking a board drops it from the fleet and stops its telemetry.

+
+
+

RAB recovery arming

+
A ? + + +
+
B ? + + +
+
FMC→RAB link:
+
+
+

Battery charger (PMB)

+
+ + ? +
+
i + v +
+
+

DAC codes 0..31. Mirrors the /api/fas/charger command effect.

+
+
+

FMC aux / STM32WL radio

+
+ + + +
+
+ + + PA off +
+
+ + +
+
+ + +
+
queue dropped
+
PPS is always simulated present. The PA cannot come on + while the modem is not ready — that is the firmware inhibit, not a failure.
+
+
+

Soundboard

+
+ add clip + len B + + + + +
+
#namebytes
+

real uploads arrive via the /api/fas/sound/upload backend command and append here too.

+
+
+

Received commands (live validation)

+
+
+
+ - + tr.querySelector('button').onclick=()=>{ + post('/api/sensor',{name,wave:tr.querySelector('.w').value, + min:num(tr.querySelector('.mn').value),max:num(tr.querySelector('.mx').value), + period:num(tr.querySelector('.pd').value),noise:num(tr.querySelector('.ns').value), + value:num(tr.querySelector('.vl').value)}); + }; + tb.appendChild(tr); + } +} +const SEV_COLOR={DEBUG:'#8b949e',INFO:'#79c0ff',WARNING:'#d29922',ERROR:'#f85149',FATAL:'#ff7b72'}; +function renderFlight(){ + const sel=$('fstate'); + if(sel.options.length!==state.flight_phases.length){ + sel.innerHTML=state.flight_phases.map(s=>``).join(''); + sel.value=state.flight_phase; + } + // Don't clobber the dropdown while the user is choosing or a launch is live. + if(document.activeElement!==sel && !state.launch.active){ + sel.value=state.flight_phase; + } + $('livestate').textContent=state.flight_phase; + $('fasstate').textContent=state.fas_state; + const imc=$('imc'); + imc.textContent='IMC '+(state.imc.armed?'ARMED':'safe')+' (b'+state.imc.board_id+')'; + imc.className='pill '+(state.imc.armed?'on':'off'); + $('console').textContent=state.console_active?'active':'off'; + $('sdpct').textContent=state.sd.pct; + $('sdbar').style.width=state.sd.pct+'%'; + $('sdbar').style.background=state.sd.pct>=80?'#f85149':'#3fb950'; + $('sdtoggle').textContent=state.sd.filling?'stop fill':'start fill'; + // Flight events (most recent first) + $('events').innerHTML=(state.events||[]).slice().reverse().map(e=>{ + const ts=new Date(e.t).toLocaleTimeString(); + const col=SEV_COLOR[e.severity]||'#e6edf3'; + return `
${ts} #${e.id} ${e.name} ${e.severity}
`; + }).join('')||'no events yet'; + // Launch + const L=state.launch; + $('launchbtn').textContent=L.active?'■ Abort':'🚀 Launch'; + if($('lpath').placeholder.indexOf('default')>=0 && L.default_csv){ + $('lpath').placeholder=L.default_csv; + } + $('lstatus').textContent=L.active + ? `— ${L.src}, T+${L.elapsed}s / ${L.duration}s ×${L.speed}` + : (L.src&&L.src!=='none'?`— last: ${L.src}`:''); +} +function renderExtras(){ + // FAS boards online toggles + const bd=$('boards'); + if(bd.children.length!==Object.keys(state.boards||{}).length){ + bd.innerHTML=''; + for(const k of Object.keys(state.boards||{})){ + const lbl=document.createElement('label'); + lbl.innerHTML=` ${k}`; + lbl.querySelector('input').onchange=e=>post('/api/boards',{key:k,online:e.target.checked}); + lbl.dataset.key=k; bd.appendChild(lbl); + } + } + for(const lbl of bd.children){lbl.querySelector('input').checked=!!(state.boards||{})[lbl.dataset.key];} + // RAB A/B + const rab=state.rab||{}; + for(const [id,elid] of [['0','rabA'],['1','rabB']]){ + const r=rab[id]||{}, el=$(elid), mm=r.armed!==r.expected; + el.textContent=(r.armed?'ARMED':'safe')+(mm?' ⚠MISMATCH':''); + el.className='pill '+(mm?'warn2':(r.armed?'on':'off')); + } + $('rablink').textContent=rab.link?'up':'DOWN'; + // Charger + const c=state.charger||{}; + if(document.activeElement!==$('chgI'))$('chgI').value=c.i_setting; + if(document.activeElement!==$('chgV'))$('chgV').value=c.v_setting; + $('chgEn').checked=!!c.enabled; $('chgProt').checked=!!c.protect; + $('chgState').textContent=c.enabled?'CC/CV':'off'; + $('chgState').className='pill '+(c.enabled?'on':'off'); + // Aux / STM32WL radio + const a=state.aux||{}, r=state.radio||{}; + $('auxRadio').checked=!!a.radio; $('auxRuncam').checked=!!a.runcam; + $('auxRec').checked=!!a.runcam_recording; + $('auxPa').checked=!!a.rf_pa_requested; $('auxPaInh').checked=!!a.rf_pa_inhibit; + $('radReady').checked=!!r.ready; $('radFault').checked=!!r.fault; + $('radCfgOk').checked=!!r.config_valid; $('radMatch').checked=!!r.readback_matches; + if(document.activeElement!==$('radQ'))$('radQ').value=r.queue_depth||0; + if(document.activeElement!==$('radDrop'))$('radDrop').value=r.tx_dropped||0; + const pa=$('paState'); + pa.textContent=a.rf_pa_on?(a.rf_pa_cycling?'PA cycling':'PA on') + :(a.rf_pa_requested?'PA inhibited':'PA off'); + pa.className='pill '+(a.rf_pa_on?'on':'off'); + // Soundboard + const s=state.sound||{clips:[]}; + $('sndInfo').textContent=`playing: ${s.playing==null?'—':'#'+s.playing} · vol ${s.volume}`+(s.tone?' · TONE':''); + const tb=$('clips').querySelector('tbody'); tb.innerHTML=''; + (s.clips||[]).forEach((cl,i)=>{ + const tr=document.createElement('tr'); + tr.innerHTML=`${i}${escapeHtml(cl.name)}${cl.length}`+ + ` `; + tb.appendChild(tr); + }); +} +function rabArm(id,a){post('/api/rab',{rab_id:id,armed:a});} +function rabMismatch(id){post('/api/rab',{rab_id:id,mismatch:true});} +function rabLink(){post('/api/rab',{link:!(state.rab&&state.rab.link)});} +function applyCharger(){post('/api/charger',{enable:$('chgEn').checked,i_setting:num($('chgI').value),v_setting:num($('chgV').value),protect:$('chgProt').checked});} +function applyAux(){post('/api/aux',{radio:$('auxRadio').checked,runcam:$('auxRuncam').checked, + runcam_recording:$('auxRec').checked,rf_pa_requested:$('auxPa').checked, + rf_pa_inhibit:$('auxPaInh').checked,ready:$('radReady').checked,fault:$('radFault').checked, + config_valid:$('radCfgOk').checked,readback_matches:$('radMatch').checked, + queue_depth:num($('radQ').value),tx_dropped:num($('radDrop').value)});} +function soundAdd(){post('/api/sound',{action:'add',name:$('clipName').value||'clip',length:num($('clipLen').value)||8000});} +function soundClear(){post('/api/sound',{action:'clear'});} +function soundTone(){post('/api/sound',{action:'tone',ms:400});} +function soundPlay(i){post('/api/sound',{action:'play',idx:i});} +function soundRemove(i){post('/api/sound',{action:'remove',idx:i});} +function setFlight(){post('/api/flight',{phase:$('fstate').value});} +function toggleLaunch(){ + if(state.launch.active){post('/api/launch',{action:'abort'});} + else{post('/api/launch',{action:'launch',speed:num($('lspeed').value)||1,path:$('lpath').value});} +} +function toggleSd(){post('/api/sd',{filling:!state.sd.filling,rate:num($('sdrate').value)});} +function sdReset(){post('/api/sd',{reset:true});} - \ No newline at end of file +async function refresh(){ + try{ + state=await(await fetch('/api/state')).json(); + $('conn').textContent='connected';$('conn').className='muted'; + if(!$('sensors').querySelector('tbody').children.length)renderSensors(); + renderFlight(); + renderExtras(); + }catch(e){$('conn').textContent='disconnected';} +} +async function pollCmds(){ + try{ + const r=await(await fetch('/api/commands?since='+lastSeq)).json(); + const box=$('cmds'); + for(const c of r.commands){ + lastSeq=Math.max(lastSeq,c.seq); + const d=document.createElement('div'); + d.className='cmd '+(c.ok?'ok':'bad'); + const ts=new Date(c.t).toLocaleTimeString(); + d.innerHTML=`
#${c.seq} ${ts} ${c.topic} ${c.ok?'✓':'✗'}
`+ + `
${escapeHtml(JSON.stringify(c.payload))}
`+ + (c.issues.length?`
${c.issues.map(escapeHtml).join('
')}
`:''); + box.insertBefore(d,box.firstChild); + } + while(box.children.length>200)box.removeChild(box.lastChild); + }catch(e){} +} +function escapeHtml(s){return String(s).replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]));} +setInterval(()=>{if($('auto').checked){refresh();pollCmds();}},1000); +refresh();pollCmds(); +