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
140 changes: 139 additions & 1 deletion README-WORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,142 @@ permukaan. Bernilai **40% jika full-autonomous**, hanya **10% jika dilakukan rem
di panel QR. `jsQR` sudah di-vendor (`public/vendor/jsqr.min.js`) agar jalan offline.
- **Gripper:** tombol **OPEN/CLOSE** sudah ada di halaman Control (juga keyboard **H**/**G**)
dan mengirim command `gripper` — dipakai Tahap 2, 3 & 5.
- **Replay camera & trajectory** (fitur opsional KKI) belum diimplementasikan.
- **Replay camera & trajectory** (fitur **nilai tambah**, di luar komponen wajib
§4.7.3) **sudah diimplementasikan** — lihat §8.

## 7. Joystick manual control (Gamepad → MANUAL_CONTROL)

Kontrol manual ROV memakai joystick fisik yang dicolok ke komputer operator (browser),
melalui jalur existing: **Browser → WebSocket → Node.js server → UDP → Raspberry Pi → Pixhawk**.

**Cara pakai:**
1. Colok joystick ke laptop, buka dashboard, tekan satu tombol joystick agar terdeteksi
(`gamepadconnected`).
2. Di panel **Controller**, pilih tab **Gamepad** (default Keyboard). Badge menampilkan status.
3. Pastikan mode kontrol = **MANUAL** (toggle Manual/Autonomous di header) — joystick **tidak**
berefek saat Autonomous.
4. Halaman **Joystick** menyediakan mapping axis/tombol (disimpan ke `server/config/joystick-profile.json`).

**Mapping axis (GUI persen −100..100 → MANUAL_CONTROL):**

| Axis GUI | Gerak | Field MANUAL_CONTROL | Rentang |
|----------|------------------|----------------------|----------------|
| surge | maju/mundur | `x` | −1000..1000 |
| sway | lateral kiri/kanan | `y` | −1000..1000 |
| yaw | rotasi | `r` | −1000..1000 |
| heave | throttle naik/turun | `z` | 0..1000 (netral **500**) |

- **Deadzone** & remap di browser (`GP_DEADZONE = 0.12`, `public/js/app.js`).
- **Throttle pengiriman ~15 Hz**: axis di-resend berkala walau ditahan konstan, supaya Pi
menerima MANUAL_CONTROL berkelanjutan (tidak masuk fail-safe timeout).
- **Validasi ulang di server** (`server/server.js`): axis di-clamp ke −100..100 sebelum
diteruskan (tidak percaya input klien).
- **Encoding MANUAL_CONTROL** di sisi Pi (`rov_agent.py` + `manual_control.py`, via `pymavlink`
`manual_control_send`). Node server **tidak** meng-encode MAVLink — ia hanya meneruskan JSON,
konsisten dengan pola command lain (arm/light/stop).

**Kenapa MANUAL_CONTROL, bukan RC_CHANNELS_OVERRIDE:** MANUAL_CONTROL adalah cara standar ArduSub
menerima kontrol manual dari ground station (4 sumbu + bitmask tombol). Tidak menimpa channel RC
fisik, jadi aman berdampingan dengan konfigurasi channel/servo di Pixhawk (scope Devanka).

**Safety / fallback:**
- **Joystick disconnect** (`gamepaddisconnected`) → axis dinetralkan (x=y=r=0, z=500).
- **E-Stop / Spasi** → joystick **terkunci** sampai operator ARM ulang; tidak bisa override E-Stop.
- **Mode Autonomous** → joystick otomatis nonaktif (otoritas GUI vs FSM, mirip prinsip gripper).
- **Fail-safe Pi**: jika tak ada axis baru > 0.5 s, Pi mengirim satu perintah netral lalu berhenti
(command terakhir tidak "nyangkut", tidak mengganggu mode autonomous).

**Testing:**
- Unit test mapping (pure function, tanpa hardware): `python3 -m unittest test_manual_control -v`.
- Manual test verifikasi command sampai UDP:
1. `cd server && node server.js --sim` (atau `hydroship` di launch.json).
2. Buka dashboard, pilih Gamepad + mode Manual, gerakkan stick.
3. Amati log server `[CMD] surge = ... -> <RPI>:14550` (nilai sudah ter-clamp −100..100).
4. Di Pi, jalankan `rov_agent.py`; amati log `[MANUAL]` dan MANUAL_CONTROL terkirim ke Pixhawk.

**Tombol joystick:** untuk task ini `buttons` MANUAL_CONTROL masih **placeholder = 0** (TODO).
Aksi tombol (arm/mode/mount/light) sudah ditangani terpisah lewat command GUI existing.

## 8. Replay Camera & Trajectory (nilai tambah, bukan §4.7.3 wajib)

Fitur untuk **merekam** satu run misi lalu **memutar ulang** video 2 kamera + posisi
ROV di scene 3D **secara tersinkron**. Berguna sebagai bukti & bahan analisis pasca-run.
Ini **nilai tambah** (opsional), terpisah penuh dari jalur kontrol live — tidak pernah
bisa mengirim perintah ke ROV.

### Cara pakai
1. Buka halaman **Replay** (sidebar). Pastikan URL kamera BOTTOM/WALL sudah diisi di
**Setup → Camera Stream** bila ingin ikut merekam video (opsional; trajectory tetap
terekam walau tanpa kamera).
2. Tekan **● Start Recording** tepat sebelum/di awal run misi. Badge berubah **REC ●**.
Rekaman berjalan **sepenuhnya di server** — tak masalah halaman mana yang dibuka.
3. Jalankan misi seperti biasa (Manual/Autonomous). Server mencatat tiap sampel
telemetry + command gerak (surge/sway) dan men-tap frame kedua kamera.
4. Tekan **■ Stop Recording** di akhir run. Sesi baru muncul di daftar **SESI TERSIMPAN**.
5. Klik sesi → video + lintasan termuat. Pakai **scrubber/timeline** (play/pause/seek):
video kedua kamera **dan** posisi ROV di scene 3D bergerak bersama sesuai timestamp.

### Command / message baru (tidak mengubah command existing §3)
| Message WS | Arah | Fungsi |
|---|---|---|
| `record_start` | GUI→server | mulai rekam (kirim daftar `cameras:[{role,url}]`) |
| `record_stop` | GUI→server | hentikan rekam |
| `record_status` | server→GUI | status rekam (broadcast + saat connect) |

> Ini **message type tersendiri**, sengaja **bukan** `type:"cmd"`, sehingga **tidak
> pernah** diteruskan ke UDP/ROV. Mode Replay tak punya jalur ke kontrol ROV.

### Playback API (HTTP — konsisten dgn static server & proxy `/cam` existing)
| Endpoint | Fungsi |
|---|---|
| `GET /api/recordings` | daftar sesi (id, tanggal, durasi, ukuran, kamera) |
| `GET /recordings/<id>/meta.json` | metadata sesi (termasuk `session_start_time`) |
| `GET /recordings/<id>/trajectory.jsonl` | log telemetry berstempel waktu |
| `GET /recordings/<id>/commands.jsonl` | log command gerak berstempel waktu |
| `GET /replay/frame?session=<id>&cam=<bottom\|wall>&i=<idx>` | 1 frame JPEG dari mjpeg |

Data replay bersifat historis/akses-acak → HTTP (bukan WebSocket) dipilih agar sejalan
dengan pola server yang sudah meng-serve file & mem-proxy kamera lewat HTTP; WS tetap
khusus push live sehingga live & replay tak bercampur.

### Penyimpanan (`server/recordings/<session_id>/`)
```
meta.json session_start_time (acuan sync), durasi, ukuran, jumlah sampel
trajectory.jsonl {t, heading, depth, roll, pitch} (append sinkron → durable)
commands.jsonl {t, name, value} (hanya surge/sway/yaw/heave/control_mode/set_surface)
bottom.mjpeg / wall.mjpeg frame JPEG mentah disambung
bottom.index.jsonl / wall.index.jsonl {t, off, len} per frame → seek per timestamp
```
Folder `server/recordings/` **tidak di-commit** (ada `.gitignore` di dalamnya).

### Keputusan teknis
- **Video = frame store (JPEG + index), bukan .webm.** Stream kamera aktual = **MJPEG**
(mjpg-streamer `?action=stream`). ffmpeg tidak tersedia di server, jadi encoding webm
butuh dependency berat + kurang presisi sinkron. Frame store: **nol dependency baru**,
sinkron **frame-accurate** (browser tinggal tukar `<img>.src` per waktu scrubber).
- **Posisi x,y direkonstruksi di browser saat replay.** Server hanya punya heading/depth
(x,y adalah dead-reckoning di `mission.js`). Maka server merekam telemetry **+** command
surge/sway; halaman Replay merekonstruksi lintasan dengan integrator **identik**
`mission.js` (`VEL_SCALE`, `DEPTH_SCALE`, konvensi heading) → lintasan replay sepadan live.
- **Sinkronisasi** memakai satu clock server: `session_start_time` (meta.json) + timestamp
tiap sampel/frame. Scrubber menghitung `tc = session_start_time + posisi_slider`, lalu
memilih pose 3D & frame video dengan `t <= tc` (binary search).

### Batas durasi/ukuran
Auto-stop di **`MAX_RECORD_MIN` menit (default 15)**, configurable via env:
`MAX_RECORD_MIN=10 node server.js`. Run KKI ~maks 20 menit (5 siap+10 misi+5 evakuasi),
15 menit cukup untuk fase misi+evakuasi. Saat auto-stop, server memberi warning ke log &
event dashboard.

### Testing
- **Unit test server** (start/stop, tulis trajectory, tap command, listing, guard path,
baca frame): `cd server && npm test` (tanpa framework/dependency tambahan).
- **Manual test sinkron video+trajectory** (sinkronisasi visual sulit di-assert otomatis):
1. `cd server && node server.js --sim` (atau `hydroship` di launch.json).
2. Buka dashboard → halaman **Replay** → **Start Recording**.
3. Di halaman **Control**, gerakkan ROV (W/S/A/D) beberapa detik agar trajectory bergerak.
Untuk video nyata, isi URL kamera dan pastikan feed tampil di halaman Camera.
4. **Stop Recording** → klik sesi di daftar → tekan **play**.
5. **Verifikasi:** garis lintasan & marker ROV bergerak; DENGAN kamera nyata, frame kedua
video maju seiring posisi ROV pada timestamp yang sama (geser scrubber untuk cek titik
tertentu). Bandingkan momen kunci (mis. saat gripper menutup) di video vs posisi 3D.
9 changes: 9 additions & 0 deletions autonomy/PR-AUTONOMY.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ Legenda: ✅ selesai · ⏳ bisa dikerjakan sekarang (tak butuh hardware) ·
**Fase 4 (butuh setup penuh):**
- [ ] Rehearsal 3× run + drill fallback; isi scoresheet di `PERSIAPAN_FASE2-4.md`.

**Joystick manual control (GUI, baru diimplementasikan — 2026-07-14):**
- [x] Capture Gamepad API + deadzone + mapping axis (browser, sudah ada sebelumnya).
- [x] Encoding **MANUAL_CONTROL** di Pi (`rov_agent.py` + `manual_control.py`, `pymavlink`);
Node server hanya forward JSON + clamp axis (−100..100).
- [x] Gating otoritas: joystick nonaktif saat mode Autonomous & terkunci saat E-Stop.
- [x] Fail-safe: netral saat disconnect / idle > 0.5 s. Unit test `test_manual_control.py`.
- [ ] **Butuh keputusan Rasya / uji hardware:** verifikasi tanda & skala sumbu (surge/sway/yaw/heave)
cocok dengan orientasi thruster di kolam; bitmask tombol masih placeholder.

**Peningkatan opsional (nice-to-have):**
- [ ] Resolusi/preprocessing `VisionPipeline` (bukan hanya tool webcam) bila deteksi di Pi kurang.
- [ ] Squaring yaw PBVS (QR-02) bila terbukti stabil.
Expand Down
58 changes: 58 additions & 0 deletions manual_control.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Pemetaan MURNI axis joystick (persen -100..100) -> MANUAL_CONTROL MAVLink.

Dipisah dari rov_agent.py supaya logika mapping bisa di-unit-test tanpa
bergantung pada pymavlink / hardware / Gamepad API.

Kenapa MANUAL_CONTROL (bukan RC_CHANNELS_OVERRIDE)?
MANUAL_CONTROL adalah cara standar ArduSub menerima kontrol manual dari
ground station: empat sumbu (x=maju/mundur, y=lateral, z=throttle, r=yaw)
plus bitmask tombol. Ini tidak menimpa channel RC fisik dan lebih aman
dipakai berdampingan dengan konfigurasi channel/servo di sisi Pixhawk.

Rentang MANUAL_CONTROL:
x, y, r : -1000 .. 1000 (netral 0)
z : 0 .. 1000 (netral 500)
"""


def _clamp(v, lo, hi):
return lo if v < lo else hi if v > hi else v


def _to_float(v):
try:
return float(v)
except (TypeError, ValueError):
return 0.0


def axis_percent_to_manual(p):
"""Sumbu bipolar: persen -100..100 -> -1000..1000 (netral 0)."""
return int(round(_clamp(_to_float(p), -100.0, 100.0) * 10.0))


def throttle_percent_to_manual(p):
"""Throttle: persen -100..100 -> 0..1000 (netral 500)."""
return int(round(_clamp(500.0 + _clamp(_to_float(p), -100.0, 100.0) * 5.0, 0.0, 1000.0)))


def axes_to_manual_control(surge=0, sway=0, yaw=0, heave=0, buttons=0):
"""Terjemahkan axis GUI (persen) ke field MANUAL_CONTROL.

surge -> x (maju/mundur)
sway -> y (lateral kiri/kanan)
heave -> z (throttle naik/turun)
yaw -> r (rotasi)
buttons: bitmask tombol (placeholder/TODO; belum dipetakan ke tombol GUI).
"""
return {
"x": axis_percent_to_manual(surge),
"y": axis_percent_to_manual(sway),
"z": throttle_percent_to_manual(heave),
"r": axis_percent_to_manual(yaw),
"buttons": int(buttons) & 0xFFFF,
}


# Perintah netral / fail-safe: diam di tempat, throttle di tengah.
NEUTRAL = axes_to_manual_control(0, 0, 0, 0)
34 changes: 34 additions & 0 deletions public/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,32 @@ body[data-theme="light"] .theme__icon::after {
gap: 6px;
pointer-events: none;
}
.cam__switch {
position: absolute;
top: 18px;
right: 18px;
z-index: 30;
display: none;
align-items: center;
justify-content: center;
border: 1px solid rgba(20,216,255,.35);
border-radius: 999px;
padding: 8px 12px;
background: rgba(4,23,38,.78);
color: var(--ink);
font-family: "Chakra Petch", sans-serif;
font-size: 11px;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: all .18s ease;
pointer-events: auto;
}
.cam__switch:hover {
background: rgba(20,216,255,.16);
border-color: rgba(20,216,255,.55);
box-shadow: 0 0 16px rgba(20,216,255,.16);
}
.cam__res {
background: rgba(4,23,38,.7);
color: var(--ink);
Expand Down Expand Up @@ -900,6 +926,14 @@ body.pseudo-fs-lock { overflow: hidden; }
.cam.pseudo-fs #ctrlCamPip,
.camcell__view:fullscreen .campip,
.camcell__view.pseudo-fs .campip { display: block; }
/* Tombol switch kamera: hanya saat kamera fullscreen DAN tersedia >1 kamera
(.is-multi diset di app.js). Di luar fullscreen tetap tersembunyi. */
.cam:fullscreen .cam__switch.is-multi,
.cam.pseudo-fs .cam__switch.is-multi { display: inline-flex; }
/* Saat fullscreen, pojok kanan-atas dipesan untuk tombol switch — geser overlay
resolusi/rec ke bawahnya agar tidak bertumpuk. */
.cam:fullscreen .cam__overlay,
.cam.pseudo-fs .cam__overlay { top: 62px; }
/* PiP pilot di kamera Control: kiri-ATAS supaya tidak menutupi tombol
SNAPSHOT/REC/HUD (.cam__bar di kiri-bawah); overlay resolusi ada di kanan-atas. */
#ctrlCamPip { left: 18px; top: 18px; right: auto; bottom: auto; }
Expand Down
13 changes: 11 additions & 2 deletions public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
<a class="sidebar__link" href="#" data-page="joystick" title="Joystick Control">
<span class="sidebar__label">Joystick</span>
</a>
<a class="sidebar__link" href="#" data-page="replay" title="Replay (rekam & putar ulang)">
<span class="sidebar__label">Replay</span>
</a>
</nav>
</aside>

Expand Down Expand Up @@ -210,6 +213,8 @@
<span id="camRecIndicator" class="cam__rec" aria-hidden="true" title="Recording"></span>
</div>

<button class="cam__switch" id="btnCamSwitch" type="button" title="Switch camera">CAM 1</button>

<div class="cam__nosignal" id="camNoSignal">
<span>TIDAK ADA SINYAL</span>
</div>
Expand All @@ -220,9 +225,10 @@
<button class="chip" id="btnHud" aria-pressed="true">HUD</button>
</div>

<!-- PiP pilot/digital-twin (tampil saat kamera fullscreen) -->
<!-- PiP digital twin / ROV Control (tampil saat kamera fullscreen) -->
<div class="pip" id="ctrlCamPip" aria-hidden="true">
<canvas id="ctrlCamPipCanvas"></canvas>
<canvas id="ctrlCamPipCanvas" width="318" height="178"></canvas>
<div class="pip__nosignal" id="ctrlCamPipNo">NO PILOT</div>
<span class="pip__label">PILOT</span>
</div>
</div>
Expand Down Expand Up @@ -261,6 +267,9 @@
<!-- PAGE 6: JOYSTICK (diisi oleh js/pages/joystick.js) -->
<div class="page page--joystick" id="page-joystick" data-page="joystick" style="display:none;"></div>

<!-- PAGE 7: REPLAY (diisi oleh js/pages/replay.js) — rekam & putar ulang -->
<div class="page page--replay" id="page-replay" data-page="replay" style="display:none;"></div>

</div>
</div>
</div>
Expand Down
Loading