Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
288a561
feat(types): add live_preview capability and pending_aggregation status
styu12 Apr 14, 2026
3b199e6
feat(adapters): scaffold insta360_go3s package
styu12 Apr 14, 2026
03d3e17
feat(go3s/ble): port FFFrame protocol from recorder
styu12 Apr 14, 2026
10a7ed9
feat(go3s/ble): port async BLE camera helper
styu12 Apr 14, 2026
98c3774
feat(go3s/wifi): add OSC HTTP client with atomic download
styu12 Apr 14, 2026
4b3bce1
feat(go3s/wifi): add cross-platform WiFi switcher
styu12 Apr 14, 2026
90a4516
feat(go3s/aggregation): add job + progress types
styu12 Apr 14, 2026
676cc07
feat(go3s/aggregation): add background queue worker with retry
styu12 Apr 14, 2026
14225a6
feat(go3s/aggregation): production downloader with WiFi + OSC
styu12 Apr 14, 2026
2f338ce
feat(go3s): add Go3SStream with deferred aggregation
styu12 Apr 14, 2026
96186de
feat(adapters): re-export Go3SStream and register for discovery
styu12 Apr 14, 2026
ce4fb76
feat(orchestrator): downgrade Go3S aggregation to on_demand for multi…
styu12 Apr 14, 2026
36a6803
feat(viewer): expose aggregation state in WS snapshot
styu12 Apr 14, 2026
c537cfd
feat(viewer): add aggregation control commands
styu12 Apr 14, 2026
c442378
feat(viewer): add StandaloneRecorderPanel for Go3S streams
styu12 Apr 14, 2026
9146e1c
feat(viewer): add aggregation status bar and episode badges
styu12 Apr 14, 2026
366467e
feat(viewer): recognize Go3S in discovery modal
styu12 Apr 14, 2026
cb76e47
test(go3s): integration test for full record-then-aggregate
styu12 Apr 14, 2026
653cc41
test(go3s): aggregation does not block subsequent recordings
styu12 Apr 14, 2026
cf20fe8
test(go3s): atomic failure preserves originals; retry succeeds
styu12 Apr 14, 2026
9d28e74
docs(examples): add Insta360 Go3S example
styu12 Apr 14, 2026
1abbdc6
build: add aiohttp to camera optional extra
styu12 Apr 14, 2026
4598ca7
fix(go3s): reject empty file_path from BLE STOP response
styu12 Apr 14, 2026
ae101b5
fix(go3s): wire recover_from_disk into singleton init for crash recovery
styu12 Apr 14, 2026
c12941d
fix(go3s): replace deprecated asyncio.get_event_loop() with get_runni…
styu12 Apr 14, 2026
c9cba3d
fix(viewer): scope _recent_agg_jobs to ViewerServer instance + lock
styu12 Apr 14, 2026
1b171c6
fix(viewer): include full StreamCapabilities in WS snapshot
styu12 Apr 14, 2026
e4a65b0
docs(spec): remove between_sessions policy from v1; document as futur…
styu12 Apr 14, 2026
13fd3c2
Merge remote-tracking branch 'origin/main' into feat/insta360-go3s-ad…
styu12 Apr 14, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ Go3SStream(
stream_id: str,
ble_address: str, # BLE MAC or CoreBluetooth UUID
output_dir: Path, # episode dir provided by orchestrator
aggregation_policy: Literal["eager", "on_demand", "between_sessions"] = "eager",
aggregation_policy: Literal["eager", "on_demand"] = "eager",
video_mode: Literal["video"] = "video",
)
```
Expand Down Expand Up @@ -221,7 +221,8 @@ PENDING ──► RUNNING ──► COMPLETED
`aggregation_policy`:
- `eager` (default single-host): on `stop_recording()`, auto-enqueue. Worker starts immediately.
- `on_demand` (auto-forced in multihost leader/follower): do not enqueue; viewer "Aggregate now" button triggers enqueue.
- `between_sessions`: enqueue on `stop_recording()` but worker only runs when orchestrator state == `IDLE`. If a new recording starts while the worker is mid-download, the worker finishes the in-flight file (BLE trigger for the new recording is independent — no interference), then pauses before the next file until the orchestrator returns to `IDLE`.

v1 supports `eager` (default) and `on_demand`. Future work: `between_sessions` policy that defers aggregation until the orchestrator returns to IDLE state — would require orchestrator-level coordination.

**Multihost autodetection**: on `session.add(Go3SStream(...))`, if session's role is `LeaderRole` or `FollowerRole`, orchestrator downgrades `eager` → `on_demand` with a health event explaining why.

Expand Down Expand Up @@ -300,7 +301,7 @@ New commands:
- `test_osc_client.py` — mocked aiohttp responses for `/osc/info`, `listFiles`, downloads including partial/truncated streams.
- `test_wifi_switcher.py` — each platform impl unit tested with `subprocess.run` mocked; factory selection test per `sys.platform`.
- `test_aggregation_queue.py` — job lifecycle, retry, crash recovery from `aggregation.json`, listener notifications.
- `test_go3s_stream.py` — lifecycle with fake BLE client, policy resolution (eager / on_demand / between_sessions), multihost auto-downgrade.
- `test_go3s_stream.py` — lifecycle with fake BLE client, policy resolution (eager / on_demand), multihost auto-downgrade.

### Integration tests
- `tests/integration/test_go3s_session_e2e.py` — full session with mocked BLE + mocked OSC server + temp WiFi switcher; verifies episode dir contents, manifest entries, aggregation.json, and finalization reports.
Expand Down
51 changes: 51 additions & 0 deletions examples/insta360_go3s/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Insta360 Go3S example

Records via BLE trigger; downloads files in a background WiFi aggregation
job after the session ends.

## One-time setup

1. **Pair the Go3S** with the laptop using its BLE name (e.g. via the system
Bluetooth pane). After pairing, the BLE address persists.
2. **Discover the BLE address**:
```
uv run python -c "import asyncio; from bleak import BleakScanner; \
print(asyncio.run(BleakScanner.discover()))"
```
3. **macOS only**: the first WiFi switch will request Location permission
(required by `networksetup`). Grant once.

## Run

```
uv run python examples/insta360_go3s/record.py \
--address AA:BB:CC:DD:EE:FF \
--output ./go3s_output \
--duration 10
```

After `stop`, the SDK reports `pending_aggregation` and a background worker
switches the host WiFi to the camera AP, downloads the video file, and
restores the previous network. Episode dir contents:

```
go3s_output/
├── overhead.mp4 ← downloaded
├── aggregation.json ← per-episode atomic state
├── manifest.json ← session metadata
└── ...
```

## Multihost note

If you use a `LeaderRole` or `FollowerRole`, the adapter automatically
downgrades the policy to `on_demand` so aggregation does not break lab
WiFi (mDNS) during the session. Trigger aggregation explicitly from the
viewer's "Aggregate now" button after recording wraps.

## Limitations (v1)

- No live preview (the camera does not expose one over the BLE/OSC path).
- No Windows WiFi switching (`NotImplementedError`); BLE-only flows still work.
- Per-camera resolution/fps uses the camera's own UI setting.
- Aggregation across multiple Go3S devices is sequential per episode.
45 changes: 45 additions & 0 deletions examples/insta360_go3s/record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Single-host recording with one Insta360 Go3S camera.

Usage:
uv run python examples/insta360_go3s/record.py \\
--address AA:BB:CC:DD:EE:FF \\
--output ./go3s_output \\
--duration 10
"""
from __future__ import annotations

import argparse
import time
from pathlib import Path

import syncfield as sf
from syncfield.adapters.insta360_go3s import Go3SStream


def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--address", required=True, help="Go3S BLE address (MAC or CB UUID)")
parser.add_argument("--output", type=Path, default=Path("./go3s_output"))
parser.add_argument("--duration", type=float, default=10.0)
args = parser.parse_args()

args.output.mkdir(parents=True, exist_ok=True)

session = sf.SessionOrchestrator(host_id="local", output_dir=args.output)
session.add(Go3SStream(
stream_id="overhead",
ble_address=args.address,
output_dir=args.output,
))

print(f"[record] starting session, duration={args.duration}s")
session.start_recording()
time.sleep(args.duration)
report = session.stop_recording()
print(f"[record] stopped; per-stream reports: {report}")
print("[record] aggregation runs in the background; check the viewer or look in",
args.output)


if __name__ == "__main__":
main()
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,18 @@ multihost = [
"uvicorn[standard]>=0.24.0",
"httpx>=0.25.0",
]
# Insta360 Go3S adapter: BLE trigger via bleak + WiFi/OSC HTTP download via aiohttp.
camera = [
"bleak>=0.21",
"aiohttp>=3.9",
]
all = [
"sounddevice>=0.4.6",
"numpy>=1.21",
"av>=12.0.0",
"Pillow>=10.0.0",
"bleak>=0.21",
"aiohttp>=3.9",
"depthai>=3.0.0",
"fastapi>=0.104.0",
"uvicorn[standard]>=0.24.0",
Expand All @@ -81,6 +87,7 @@ packages = ["src/syncfield"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
asyncio_mode = "auto"
markers = [
"hardware: tests that require physical hardware (cameras, BLE devices)",
"slow: integration tests that touch real IO (mDNS sockets, filesystem, etc.)",
Expand All @@ -90,6 +97,8 @@ markers = [
[dependency-groups]
dev = [
"pytest>=8.4.2",
"pytest-aiohttp>=1.1.0",
"pytest-asyncio>=1.2.0",
"pytest-mock>=3.12.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.6.0",
Expand Down
8 changes: 8 additions & 0 deletions src/syncfield/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
``BLEImuGenericStream`` ``bleak`` ``syncfield[ble]``
``OgloTactileStream`` ``bleak`` ``syncfield[ble]``
``OakCameraStream`` ``depthai`` + ``av`` ``syncfield[oak]``
``Go3SStream`` ``bleak`` + ``aiohttp`` ``syncfield[camera]``
========================= ===================================== =============================

Users who need a specific optional adapter can always import it directly
Expand Down Expand Up @@ -105,3 +106,10 @@ def _safe_register(cls) -> None:
_safe_register(OakCameraStream)
except ImportError:
pass

try:
from syncfield.adapters.insta360_go3s import Go3SStream # noqa: F401
__all__.append("Go3SStream")
_safe_register(Go3SStream)
except ImportError:
pass
5 changes: 5 additions & 0 deletions src/syncfield/adapters/insta360_go3s/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Insta360 Go3S adapter (BLE trigger + WiFi aggregation)."""

from .stream import Go3SStream

__all__ = ["Go3SStream"]
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Background aggregation queue for Insta360 Go3S episodes."""
Loading
Loading