Gaëlle Lannuzel commited on
Commit
80f2124
·
1 Parent(s): c67cff6

Button press should also work

Browse files
Files changed (2) hide show
  1. app.py +35 -26
  2. tests/test_start_wait_camera.py +56 -0
app.py CHANGED
@@ -196,11 +196,9 @@ SLAM_QUALITY_TIMEOUT_S = 10.0
196
  # peer overran a 12s lead by ~0.7s (cold boot ≈10.2s, but it only had ~9.5s
197
  # after poll delivery), so 15s gives ~2.3s margin over the observed cold boot.
198
  GROUP_START_LEAD_COLD_S = 15.0 # OAK-D likely asleep → cover poll delivery + cold boot
199
- # Warm: only command delivery to cover, one round-trip on the long-poll (see _enqueue).
200
  GROUP_START_LEAD_WARM_S = 1.0
201
- # Fleet button with a member's depth camera still booting: T0 is fixed once every
202
- # camera reports warm (heartbeat), rather than paying the full cold lead. Past
203
- # this, give up waiting and start with the cold lead anyway.
204
  START_WAIT_CAMERA_MAX_S = 20.0
205
  # Fleet can't see the OAK-D power state directly; it infers "warm" from the time
206
  # since the session's last recording stop. This window MUST stay safely below
@@ -860,10 +858,9 @@ class Session:
860
  start_errors: dict[str, str] = field(default_factory=dict)
861
  # device_id -> epoch of the last depth-camera lease sent (see DEPTH_HOLD_TTL_S).
862
  depth_hold_sent_at: dict[str, float] = field(default_factory=dict)
863
- # Epoch of a fleet-button start waiting for the depth cameras (no T0 yet, see
864
- # START_WAIT_CAMERA_MAX_S). None = no start waiting.
865
  start_waiting_since: Optional[float] = None
866
- # Why a waiting start could not be launched in the end ("" = none).
867
  start_wait_error: str = ""
868
  # Each entry: {"episode_id": str, "roles": {role: device_id}, "started_at": float}
869
  episodes: list[dict[str, Any]] = field(default_factory=list)
@@ -1543,8 +1540,7 @@ async def list_devices(request: Request) -> dict[str, Any]:
1543
  # Why this device refuses to record ("" = healthy). Separate from
1544
  # activity: a device can be both faulted and busy.
1545
  "hardware_error": d.hardware_error,
1546
- # Depth camera warm (None = not reported): drives the session's
1547
- # "camera is initializing" note.
1548
  "depth_warm": d.depth_warm,
1549
  # id of the dataset build this device is working for ("" = none):
1550
  # what the fleet list's Cancel button acts on.
@@ -2574,7 +2570,7 @@ def _get_open_session(owner: str, session_id: str) -> Session:
2574
 
2575
 
2576
  def _start_session_episode(owner: str, s: Session) -> tuple[str, str]:
2577
- """Schedule the fleet-button episode now. Returns (T0 iso, episode_id)."""
2578
  s.start_waiting_since = None
2579
  _task = _tasks_of(owner).get(s.task_id)
2580
  target_iso = _schedule_episode_start(
@@ -2593,8 +2589,7 @@ def _start_session_episode(owner: str, s: Session) -> tuple[str, str]:
2593
 
2594
 
2595
  def _should_wait_for_cameras(owner: str, s: Session) -> bool:
2596
- """Wait only if a camera is cold AND every member will report when it is
2597
- warm (hold capability) — otherwise nothing would ever end the wait."""
2598
  fleet = _fleet_of(owner)
2599
  devs = [fleet.get(d) for d in s.members.values()]
2600
  if any(d is None or DEPTH_HOLD_CAPABILITY not in d.capabilities for d in devs):
@@ -2603,8 +2598,7 @@ def _should_wait_for_cameras(owner: str, s: Session) -> bool:
2603
 
2604
 
2605
  def _launch_waiting_start(owner: str, dev: Device) -> None:
2606
- """Called on every heartbeat: launch dev's session's waiting start once every
2607
- camera is warm, or with the cold lead once the wait has run too long."""
2608
  s = _open_session_for_device(owner, dev.device_id)
2609
  if s is None or s.start_waiting_since is None:
2610
  return
@@ -2615,7 +2609,7 @@ def _launch_waiting_start(owner: str, dev: Device) -> None:
2615
  _raise_if_recording_blocked(owner, s.members.values())
2616
  _start_session_episode(owner, s)
2617
  except HTTPException as e:
2618
- # Went offline / faulted / busy while waiting: drop the start and say so.
2619
  s.start_waiting_since = None
2620
  detail = e.detail if isinstance(e.detail, dict) else {"message": str(e.detail)}
2621
  s.start_wait_error = str(detail.get("message") or "start failed")
@@ -2632,7 +2626,7 @@ async def session_episode_start(session_id: str, request: Request) -> dict[str,
2632
  _raise_if_recording_blocked(owner, s.members.values())
2633
  s.start_wait_error = ""
2634
  if _should_wait_for_cameras(owner, s):
2635
- # Offline / faulted members are refused now, not after the wait.
2636
  _raise_if_start_refused(owner, s.members)
2637
  s.start_waiting_since = time.time()
2638
  return {"status": "waiting_camera", "devices": list(s.members.values())}
@@ -2646,7 +2640,7 @@ async def session_episode_stop(session_id: str, request: Request) -> dict[str, A
2646
  owner = await _operator_loaded(request)
2647
  s = _get_open_session(owner, session_id)
2648
  if s.start_waiting_since is not None and not s.recording:
2649
- # Cancelled before any T0: nothing was dispatched, nothing to stop.
2650
  s.start_waiting_since = None
2651
  return {"status": "ok", "devices": list(s.members.values())}
2652
  ids = _dispatch_episode_stop(owner, s.members)
@@ -3543,9 +3537,17 @@ async def start_slam_check(req: SlamCheckReq, request: Request) -> dict[str, Any
3543
  # device (found by membership). It behaves exactly like the operator starting
3544
  # an episode from the dashboard: same task, same synchronized T0 across the
3545
  # session's members. No open session → solo (the device records locally). ======
 
 
 
 
 
 
3546
  @app.post("/api/devices/{device_id}/sync/start")
3547
- async def device_sync_start(device_id: str, auth: tuple[str, str] = Depends(device_auth)) -> dict[str, Any]:
 
3548
  owner, _token = auth
 
3549
  if device_id not in _fleet_of(owner):
3550
  raise HTTPException(404, "Device not registered")
3551
  s = _open_session_for_device(owner, device_id)
@@ -3558,8 +3560,14 @@ async def device_sync_start(device_id: str, auth: tuple[str, str] = Depends(devi
3558
  # busy peer refuses its own start_capture locally. A 409 travels back as
3559
  # "refused", which the device turns into an error buzz and no recording.
3560
  _raise_if_recording_blocked(owner, s.members.values())
3561
- # The physical button takes over a start still waiting for the cameras: it
3562
- # can't wait itself (it needs T0 in this response), so it pays the cold lead.
 
 
 
 
 
 
3563
  s.start_waiting_since = None
3564
  s.start_wait_error = ""
3565
  task_name = _task_name(owner, s.task_id)
@@ -3589,7 +3597,10 @@ async def device_sync_stop(device_id: str, auth: tuple[str, str] = Depends(devic
3589
  s = _open_session_for_device(owner, device_id)
3590
  if s is None:
3591
  return {"status": "solo"}
3592
- s.start_waiting_since = None
 
 
 
3593
  ids = _dispatch_episode_stop(owner, s.members, exclude_device_id=device_id)
3594
  _begin_episode_stop(s, ids)
3595
  return {"status": "ok", "peers": [d for d in s.members.values() if d != device_id]}
@@ -3980,7 +3991,7 @@ _INDEX = """<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
3980
  /* record circle blocked because a member is busy with a dataset job */
3981
  .rec-toggle.blocked{cursor:not-allowed;opacity:.4;border-color:rgba(255,255,255,.3)}
3982
  .sp-busy-note{margin-top:.6rem;text-align:center;font-size:.8rem;color:#d6c9fb}
3983
- /* a member's depth camera is still booting: the next start pays the cold boot */
3984
  .sp-depth-note{margin-top:.6rem;display:flex;align-items:center;gap:.6rem;
3985
  font-size:.85rem;font-weight:600;color:#fcd34d;line-height:1.4;
3986
  background:rgba(245,158,11,.14);border:1px solid rgba(251,191,36,.45);
@@ -4467,8 +4478,7 @@ function activityBadge(id){
4467
  // calibration, no angle sensors). Shown next to the activity badge rather than
4468
  // instead of it — a faulted grabette can also be uploading, and hiding either
4469
  // fact behind the other is how you go hunting for the wrong problem.
4470
- // Depth camera still booting under the session's hold: the first start pays the
4471
- // cold boot (long lead). Only devices that take the hold report this.
4472
  function deviceDepthCold(id){const d=DEVICES.find(x=>x.device_id===id);
4473
  return !!(d&&d.online&&(d.capabilities||[]).includes('hold_depth_camera')&&d.depth_warm!==true);}
4474
  function sessionDepthCold(s){return Object.values(s.members).some(m=>deviceDepthCold(m.device_id));}
@@ -5398,7 +5408,7 @@ async function deleteSession(id){if(!confirm('Delete this session and its manife
5398
  // stopping is reported by the fleet and ends when the devices actually finish
5399
  // (s.stopping is cleared as each stop_capture reports its result).
5400
  function sessionPhase(s){
5401
- if(s.start_waiting)return 'initializing'; // no T0 yet: waiting for the cameras
5402
  if(s.recording){
5403
  const ep=(s.episodes&&s.episodes.length)?s.episodes[s.episodes.length-1]:null;
5404
  const t0=ep&&ep.start_at_utc?Date.parse(ep.start_at_utc):0;
@@ -5901,7 +5911,6 @@ function renderSessionList(){
5901
  const circTitle=faultBlocked?'A device cannot record — see the fault below'
5902
  :recBlocked?`A device is busy ${busyWhat} — wait for it to finish`:M.title;
5903
  const circle=`<div class="sp-rec"><button class="rec-toggle ${M.btn}${recBlocked?' blocked':''}" ${circDis} title="${circTitle}" onclick='episodeToggle("${s.id}")'><span class="rt-inner"></span></button></div>`;
5904
- // Kept through "initializing": that is exactly when the operator waits on it.
5905
  const depthCold=((p==='idle'&&!recBlocked)||p==='initializing')&&sessionDepthCold(s);
5906
  const depthNote=depthCold?'<div class="sp-depth-note"><span class="sp-spinner"></span><span>Camera is currently initializing. First recording may take longer to start.</span></div>':'';
5907
  const busyNote=(recBlocked&&!faultBlocked)?`<div class="sp-busy-note">${busyMembers.map(deviceName).join(', ')} busy ${busyWhat} — recording paused until it finishes.</div>`:'';
 
196
  # peer overran a 12s lead by ~0.7s (cold boot ≈10.2s, but it only had ~9.5s
197
  # after poll delivery), so 15s gives ~2.3s margin over the observed cold boot.
198
  GROUP_START_LEAD_COLD_S = 15.0 # OAK-D likely asleep → cover poll delivery + cold boot
199
+ # Warm: only covers command delivery (long-poll, see _enqueue).
200
  GROUP_START_LEAD_WARM_S = 1.0
201
+ # Max wait for the depth cameras before starting with the cold lead anyway.
 
 
202
  START_WAIT_CAMERA_MAX_S = 20.0
203
  # Fleet can't see the OAK-D power state directly; it infers "warm" from the time
204
  # since the session's last recording stop. This window MUST stay safely below
 
858
  start_errors: dict[str, str] = field(default_factory=dict)
859
  # device_id -> epoch of the last depth-camera lease sent (see DEPTH_HOLD_TTL_S).
860
  depth_hold_sent_at: dict[str, float] = field(default_factory=dict)
861
+ # Epoch of a start waiting for the depth cameras (no T0 yet).
 
862
  start_waiting_since: Optional[float] = None
863
+ # Why a waiting start was dropped.
864
  start_wait_error: str = ""
865
  # Each entry: {"episode_id": str, "roles": {role: device_id}, "started_at": float}
866
  episodes: list[dict[str, Any]] = field(default_factory=list)
 
1540
  # Why this device refuses to record ("" = healthy). Separate from
1541
  # activity: a device can be both faulted and busy.
1542
  "hardware_error": d.hardware_error,
1543
+ # None = not reported.
 
1544
  "depth_warm": d.depth_warm,
1545
  # id of the dataset build this device is working for ("" = none):
1546
  # what the fleet list's Cancel button acts on.
 
2570
 
2571
 
2572
  def _start_session_episode(owner: str, s: Session) -> tuple[str, str]:
2573
+ """Returns (T0 iso, episode_id)."""
2574
  s.start_waiting_since = None
2575
  _task = _tasks_of(owner).get(s.task_id)
2576
  target_iso = _schedule_episode_start(
 
2589
 
2590
 
2591
  def _should_wait_for_cameras(owner: str, s: Session) -> bool:
2592
+ """Only members with the hold report warm, so only they can end a wait."""
 
2593
  fleet = _fleet_of(owner)
2594
  devs = [fleet.get(d) for d in s.members.values()]
2595
  if any(d is None or DEPTH_HOLD_CAPABILITY not in d.capabilities for d in devs):
 
2598
 
2599
 
2600
  def _launch_waiting_start(owner: str, dev: Device) -> None:
2601
+ """On heartbeat: launch a waiting start once the cameras are warm."""
 
2602
  s = _open_session_for_device(owner, dev.device_id)
2603
  if s is None or s.start_waiting_since is None:
2604
  return
 
2609
  _raise_if_recording_blocked(owner, s.members.values())
2610
  _start_session_episode(owner, s)
2611
  except HTTPException as e:
2612
+ # A member went offline / faulted / busy meanwhile.
2613
  s.start_waiting_since = None
2614
  detail = e.detail if isinstance(e.detail, dict) else {"message": str(e.detail)}
2615
  s.start_wait_error = str(detail.get("message") or "start failed")
 
2626
  _raise_if_recording_blocked(owner, s.members.values())
2627
  s.start_wait_error = ""
2628
  if _should_wait_for_cameras(owner, s):
2629
+ # Refuse now rather than after the wait.
2630
  _raise_if_start_refused(owner, s.members)
2631
  s.start_waiting_since = time.time()
2632
  return {"status": "waiting_camera", "devices": list(s.members.values())}
 
2640
  owner = await _operator_loaded(request)
2641
  s = _get_open_session(owner, session_id)
2642
  if s.start_waiting_since is not None and not s.recording:
2643
+ # No T0 yet: nothing to stop.
2644
  s.start_waiting_since = None
2645
  return {"status": "ok", "devices": list(s.members.values())}
2646
  ids = _dispatch_episode_stop(owner, s.members)
 
3537
  # device (found by membership). It behaves exactly like the operator starting
3538
  # an episode from the dashboard: same task, same synchronized T0 across the
3539
  # session's members. No open session → solo (the device records locally). ======
3540
+ class SyncStartReq(BaseModel):
3541
+ task_name: str = ""
3542
+ # Older firmware would record solo on "waiting_camera".
3543
+ can_wait: bool = False
3544
+
3545
+
3546
  @app.post("/api/devices/{device_id}/sync/start")
3547
+ async def device_sync_start(device_id: str, req: Optional[SyncStartReq] = None,
3548
+ auth: tuple[str, str] = Depends(device_auth)) -> dict[str, Any]:
3549
  owner, _token = auth
3550
+ can_wait = req is not None and req.can_wait
3551
  if device_id not in _fleet_of(owner):
3552
  raise HTTPException(404, "Device not registered")
3553
  s = _open_session_for_device(owner, device_id)
 
3560
  # busy peer refuses its own start_capture locally. A 409 travels back as
3561
  # "refused", which the device turns into an error buzz and no recording.
3562
  _raise_if_recording_blocked(owner, s.members.values())
3563
+ if can_wait and (s.start_waiting_since is not None or _should_wait_for_cameras(owner, s)):
3564
+ # The presser gets its start_capture like a peer.
3565
+ if s.start_waiting_since is None:
3566
+ _raise_if_start_refused(owner, s.members)
3567
+ s.start_waiting_since = time.time()
3568
+ s.start_wait_error = ""
3569
+ return {"status": "waiting_camera"}
3570
+ # Older firmware needs T0 now: cold lead.
3571
  s.start_waiting_since = None
3572
  s.start_wait_error = ""
3573
  task_name = _task_name(owner, s.task_id)
 
3597
  s = _open_session_for_device(owner, device_id)
3598
  if s is None:
3599
  return {"status": "solo"}
3600
+ if s.start_waiting_since is not None and not s.recording:
3601
+ # No T0 yet: nothing to stop.
3602
+ s.start_waiting_since = None
3603
+ return {"status": "ok", "peers": []}
3604
  ids = _dispatch_episode_stop(owner, s.members, exclude_device_id=device_id)
3605
  _begin_episode_stop(s, ids)
3606
  return {"status": "ok", "peers": [d for d in s.members.values() if d != device_id]}
 
3991
  /* record circle blocked because a member is busy with a dataset job */
3992
  .rec-toggle.blocked{cursor:not-allowed;opacity:.4;border-color:rgba(255,255,255,.3)}
3993
  .sp-busy-note{margin-top:.6rem;text-align:center;font-size:.8rem;color:#d6c9fb}
3994
+ /* depth camera still booting */
3995
  .sp-depth-note{margin-top:.6rem;display:flex;align-items:center;gap:.6rem;
3996
  font-size:.85rem;font-weight:600;color:#fcd34d;line-height:1.4;
3997
  background:rgba(245,158,11,.14);border:1px solid rgba(251,191,36,.45);
 
4478
  // calibration, no angle sensors). Shown next to the activity badge rather than
4479
  // instead of it — a faulted grabette can also be uploading, and hiding either
4480
  // fact behind the other is how you go hunting for the wrong problem.
4481
+ // Only devices with the hold report depth_warm.
 
4482
  function deviceDepthCold(id){const d=DEVICES.find(x=>x.device_id===id);
4483
  return !!(d&&d.online&&(d.capabilities||[]).includes('hold_depth_camera')&&d.depth_warm!==true);}
4484
  function sessionDepthCold(s){return Object.values(s.members).some(m=>deviceDepthCold(m.device_id));}
 
5408
  // stopping is reported by the fleet and ends when the devices actually finish
5409
  // (s.stopping is cleared as each stop_capture reports its result).
5410
  function sessionPhase(s){
5411
+ if(s.start_waiting)return 'initializing'; // no T0 yet
5412
  if(s.recording){
5413
  const ep=(s.episodes&&s.episodes.length)?s.episodes[s.episodes.length-1]:null;
5414
  const t0=ep&&ep.start_at_utc?Date.parse(ep.start_at_utc):0;
 
5911
  const circTitle=faultBlocked?'A device cannot record — see the fault below'
5912
  :recBlocked?`A device is busy ${busyWhat} — wait for it to finish`:M.title;
5913
  const circle=`<div class="sp-rec"><button class="rec-toggle ${M.btn}${recBlocked?' blocked':''}" ${circDis} title="${circTitle}" onclick='episodeToggle("${s.id}")'><span class="rt-inner"></span></button></div>`;
 
5914
  const depthCold=((p==='idle'&&!recBlocked)||p==='initializing')&&sessionDepthCold(s);
5915
  const depthNote=depthCold?'<div class="sp-depth-note"><span class="sp-spinner"></span><span>Camera is currently initializing. First recording may take longer to start.</span></div>':'';
5916
  const busyNote=(recBlocked&&!faultBlocked)?`<div class="sp-busy-note">${busyMembers.map(deviceName).join(', ')} busy ${busyWhat} — recording paused until it finishes.</div>`:'';
tests/test_start_wait_camera.py CHANGED
@@ -120,3 +120,59 @@ def test_member_lost_while_waiting_drops_start_with_error():
120
  assert s.start_waiting_since is None and s.recording is False
121
  assert not starts(left)
122
  assert app._session_dict(OWNER, s)["start_wait_error"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  assert s.start_waiting_since is None and s.recording is False
121
  assert not starts(left)
122
  assert app._session_dict(OWNER, s)["start_wait_error"]
123
+
124
+
125
+ def press(device_id, can_wait=True):
126
+ req = app.SyncStartReq(can_wait=can_wait)
127
+ return asyncio.run(app.device_sync_start(device_id, req, auth=(OWNER, "token")))
128
+
129
+
130
+ def press_stop(device_id):
131
+ return asyncio.run(app.device_sync_stop(device_id, auth=(OWNER, "token")))
132
+
133
+
134
+ def test_button_waits_then_starts_every_member_including_presser():
135
+ left, right = fleet()
136
+ s = launch()
137
+ left.depth_warm, right.depth_warm = True, False
138
+
139
+ assert press(LEFT)["status"] == "waiting_camera"
140
+ assert not starts(left) and not starts(right)
141
+
142
+ beat(RIGHT, depth_warm=True)
143
+ assert len(starts(left)) == len(starts(right)) == 1
144
+ assert lead_of(left) <= app.GROUP_START_LEAD_WARM_S
145
+
146
+
147
+ def test_old_firmware_button_keeps_cold_lead():
148
+ left, right = fleet()
149
+ s = launch()
150
+ left.depth_warm, right.depth_warm = True, False
151
+
152
+ res = press(LEFT, can_wait=False)
153
+ assert res["status"] == "scheduled"
154
+ assert not starts(left) # the presser self-schedules from the response
155
+ assert lead_of(right) > app.GROUP_START_LEAD_WARM_S
156
+
157
+
158
+ def test_button_joins_a_waiting_fleet_start():
159
+ left, right = fleet()
160
+ s = launch()
161
+ left.depth_warm, right.depth_warm = True, False
162
+ start(s)
163
+
164
+ assert press(LEFT)["status"] == "waiting_camera"
165
+ assert s.start_waiting_since is not None
166
+
167
+
168
+ def test_button_stop_while_waiting_cancels_without_stops():
169
+ left, right = fleet()
170
+ s = launch()
171
+ left.depth_warm, right.depth_warm = True, False
172
+ press(LEFT)
173
+
174
+ press_stop(LEFT)
175
+ assert s.start_waiting_since is None and s.stopping is False
176
+ beat(RIGHT, depth_warm=True)
177
+ assert not starts(left) and not starts(right)
178
+ assert not [c for c in right.queue if c.type == "stop_capture"]