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 .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
run: uv sync --frozen --no-default-groups --group build

- name: Build with PyInstaller
run: uv run --frozen --no-default-groups --group build pyinstaller --onefile --name Topside --add-data "static${{ matrix.data_sep }}static" app.py
run: uv run --frozen --no-default-groups --group build pyinstaller --onefile --name Topside --add-data "static${{ matrix.data_sep }}static" --add-data "data${{ matrix.data_sep }}data" app.py

- name: Stage release contents
shell: bash
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#egendefinerte til dette prosjektet
logs/*
data/data.json
.idea/*
debug_server/server_logs/*

Expand Down
3 changes: 2 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
from lib.net_transport import DEFAULT_ROV_HOST
from lib.ninedof_receiver import init_imu_receiver
from lib.resource_receiver import init_resource_receiver
from lib.runtime_paths import data_dir, data_path
from lib.runtime_paths import data_dir, data_path, ensure_data_dir
from lib.setpoint_override import init_setpoint_override
from lib.system_control_client import SystemControlClient
from routes import register_routes

app = Flask(__name__, static_folder="static", template_folder="static/templates")
ensure_data_dir()

# Start background UDP sender (20 Hz)
app.config["BITMASK"] = init_bitmask(rate_hz=20.0, host=DEFAULT_ROV_HOST, port=12345)
Expand Down
138 changes: 120 additions & 18 deletions data/data.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,126 @@
{
"imu": {
"yaw": -141.52,
"pitch": -3.62,
"roll": 179.58,
"yr": 0.04,
"pr": 0.07,
"rr": 0.05,
"ax": 0.001,
"ay": 0.001,
"az": 0.001
"yaw": 0.0,
"pitch": 0.0,
"roll": 0.0,
"yr": 0.0,
"pr": 0.0,
"rr": 0.0,
"ax": 0.0,
"ay": 0.0,
"az": 0.0
},
"9dof": {
"acceleration": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"gyroscope": {
"x": 0.0,
"y": 0.0,
"z": 0.0
},
"magnetometer": {
"x": 0.0,
"y": 0.0,
"z": 0.0
}
},
"thrusters": {
"U_FWD_P": {
"power": 0,
"temp": 20
},
"U_FWD_S": {
"power": 0,
"temp": 20
},
"U_AFT_P": {
"power": 0,
"temp": 20
},
"U_AFT_S": {
"power": 0,
"temp": 20
},
"L_FWD_P": {
"power": 0,
"temp": 20
},
"L_FWD_S": {
"power": 0,
"temp": 20
},
"L_AFT_P": {
"power": 0,
"temp": 20
},
"L_AFT_S": {
"power": 0,
"temp": 20
}
},
"lights": {
"level": 0,
"Light1": 0,
"Light2": 0,
"Light3": 0,
"Light4": 0
},
"battery": 0,
"depth": {
"dpt": 0.0,
"dptSet": 0.0
},
"resources": {
"sequence": 1073,
"uptime_ms": 1080133,
"cpu_percent": 48,
"heap_used_percent": 2,
"heap_free_kb": 376,
"heap_total_kb": 384,
"thread_count": 14,
"udp_rx_count": 17301,
"sequence": 0,
"uptime_ms": 0,
"cpu_percent": 0,
"heap_used_percent": 0,
"heap_free_kb": 0,
"heap_total_kb": 0,
"thread_count": 0,
"udp_rx_count": 0,
"udp_rx_errors": 0
},
"control_telemetry": {
"sequence": 0,
"timestamp": 0,
"setpoint": {
"surge": 0.0,
"sway": 0.0,
"heave": 0.0,
"roll": 0.0,
"pitch": 0.0,
"yaw": 0.0
},
"output": {
"surge": 0.0,
"sway": 0.0,
"heave": 0.0,
"roll": 0.0,
"pitch": 0.0,
"yaw": 0.0
},
"error": {
"surge": 0.0,
"sway": 0.0,
"heave": 0.0,
"roll": 0.0,
"pitch": 0.0,
"yaw": 0.0
}
},
"Thrust": [
0.0,
0.0,
0.0,
0.0,
0.0,
0.0
],
"Buttons": {
"button_surface": 0
}
}
}
4 changes: 2 additions & 2 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,6 @@ If you have a firewall (Windows Defender or similar), allow inbound UDP on `5002
## Troubleshooting

- **No video / no telemetry** — verify the static IP, then `ping 10.77.0.2`. If the ping fails, the network is the problem, not the app.
- **Settings don't persist between launches** — the config lives at `%LOCALAPPDATA%\Topside\data\config.json`. The installer only writes a starter file if it doesn't exist, so reinstalling won't wipe your settings.
- **Live data doesn't update** — launch from the Start Menu and check the console line that starts with `Using data directory:`. For the installer it should be `%LOCALAPPDATA%\Topside\data`; `data.json` in that folder should update as UDP telemetry arrives.
- **Settings don't persist between launches** — on Windows packaged builds, the config lives at `%LOCALAPPDATA%\Topside\data\config.json`. Source runs use the repository `data` folder. Starter files are copied only if missing, and the packaged `data/data.json` is used to initialize or fill missing AppData fields, so reinstalling won't wipe your settings.
- **Live data doesn't update** — launch from the Start Menu and check the console line that starts with `Using data directory:`. For Windows packaged builds it should be `%LOCALAPPDATA%\Topside\data`; `data.json` in that folder should update as UDP telemetry arrives.
- **App window closes immediately** — launch from the Start Menu (not by double-clicking inside Program Files). If the console flashes and disappears, run `Topside.exe` from `cmd.exe` to see the error.
116 changes: 114 additions & 2 deletions lib/runtime_paths.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import json
import os
import shutil
import sys
from pathlib import Path

APP_NAME = "Topside"
DATA_FILE_NAME = "data.json"


def app_root() -> Path:
Expand All @@ -16,18 +19,127 @@ def app_root() -> Path:
return Path(__file__).resolve().parents[1]


def _is_windows() -> bool:
return sys.platform.startswith("win")


def _is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))


def _windows_app_data_root() -> Path:
base = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA")
if base:
return Path(base).expanduser().resolve() / APP_NAME

home = Path.home()
return home / "AppData" / "Local" / APP_NAME


def writable_app_root() -> Path:
if _is_frozen() and _is_windows() and not os.getenv("TOPSIDE_APP_ROOT"):
return _windows_app_data_root()
return app_root()


def data_dir() -> Path:
override = os.getenv("TOPSIDE_DATA_DIR")
if override:
return Path(override).expanduser().resolve()
return app_root() / "data"
return writable_app_root() / "data"


def logs_dir() -> Path:
override = os.getenv("TOPSIDE_LOG_DIR")
if override:
return Path(override).expanduser().resolve()
return app_root() / "logs"
return writable_app_root() / "logs"


def _starter_data_candidates() -> list[Path]:
candidates = []

bundle_root = getattr(sys, "_MEIPASS", None)
if bundle_root:
candidates.append(Path(bundle_root) / "data")

if _is_frozen():
candidates.append(Path(sys.executable).resolve().parent / "data")

candidates.append(app_root() / "data")
return candidates


def starter_data_dir() -> Path | None:
target = data_dir().resolve()
for candidate in _starter_data_candidates():
candidate = candidate.expanduser().resolve()
if candidate == target:
continue
if candidate.is_dir():
return candidate
return None


def _merge_missing_values(current, template):
if isinstance(current, dict) and isinstance(template, dict):
merged = dict(current)
changed = False
for key, template_value in template.items():
if key not in merged:
merged[key] = template_value
changed = True
continue
merged_value, child_changed = _merge_missing_values(merged[key], template_value)
if child_changed:
merged[key] = merged_value
changed = True
return merged, changed

return current, False


def _fill_missing_json_values(destination: Path, template: Path) -> None:
try:
current_data = json.loads(destination.read_text(encoding="utf-8"))
template_data = json.loads(template.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return

merged, changed = _merge_missing_values(current_data, template_data)
if not changed:
return

temp_file = destination.with_name(f".{destination.name}.tmp")
temp_file.write_text(json.dumps(merged, indent=4) + "\n", encoding="utf-8")
os.replace(temp_file, destination)


def ensure_data_dir() -> Path:
target = data_dir()
target.mkdir(parents=True, exist_ok=True)

starter = starter_data_dir()
template = starter / DATA_FILE_NAME if starter is not None else None

data_file = target / DATA_FILE_NAME
if template is not None and template.is_file():
if data_file.exists():
_fill_missing_json_values(data_file, template)
else:
shutil.copy2(template, data_file)

if starter is not None:
for item in starter.iterdir():
destination = target / item.name
if destination.exists():
continue
if item.is_dir():
shutil.copytree(item, destination)
else:
shutil.copy2(item, destination)

return target


def data_path(*parts: str) -> Path:
Expand Down
Loading
Loading