| 155 | [...b].map((x) => x.toString(16).padStart(2, "0")).join(" "); |
| 156 | |
| 157 | export class ViscaCamera implements CameraDriver { |
| 158 | readonly kind = "visca" as const; |
| 159 | |
| 160 | private socket: dgram.Socket | null = null; |
| 161 | private seq = 0; |
| 162 | private inFlight = 0; |
| 163 | private diag: CameraDiagnostics = { |
| 164 | kind: "visca", |
| 165 | connected: false, |
| 166 | lastSeq: 0, |
| 167 | inFlight: 0, |
| 168 | }; |
| 169 | |
| 170 | private pose: CameraPose | null = null; |
| 171 | private lastInquiryReply = 0; |
| 172 | /** When the last PAN/TILT position reply landed (zoom replies excluded). */ |
| 173 | private lastPanTiltReplyAt = 0; |
| 174 | /** Signed commanded rates, deg/s — dead-reckons the pose between replies |
| 175 | * (position inquiries stall outright around drives on this firmware). */ |
| 176 | private cmdPanDps = 0; |
| 177 | private cmdTiltDps = 0; |
| 178 | |
| 179 | private inquiryTimer: ReturnType<typeof setInterval> | null = null; |
| 180 | private commandTimer: ReturnType<typeof setInterval> | null = null; |
| 181 | /** Latest requested absolute pose (latest-wins throttle). */ |
| 182 | private wanted: CameraPose | null = null; |
| 183 | /** Last pan/tilt actually transmitted (zoom tracked separately). */ |
| 184 | private lastSent: { panDeg: number; tiltDeg: number } | null = null; |
| 185 | /** When that absolute was sent + its expected travel time (gate expiry). */ |
| 186 | private lastSentAt = 0; |
| 187 | private lastSentDurMs = 0; |
| 188 | private lastSentZoom: number | null = null; |
| 189 | /** Last velocity-drive command key (dedupe). */ |
| 190 | private lastDrive: string | null = null; |
| 191 | /** Speed bytes for the pending absolute move. */ |
| 192 | private wantedPanVV = 0x18; |
| 193 | private wantedTiltWW = 0x14; |
| 194 | /** Pending absolute move is speed-matched (carrot) — bypass the gate. */ |
| 195 | private wantedMatched = false; |
| 196 | |
| 197 | constructor(private o: ViscaOptions) {} |
| 198 | |
| 199 | start(): void { |
| 200 | if (this.socket) return; |
| 201 | this.openSocket(); |
| 202 | |
| 203 | // 10 Hz: closed-loop pursuit corrects on pose error — at 5 Hz the error |
| 204 | // term was up to 200 ms stale, which reads as hunting. |
| 205 | const inquiryMs = 1000 / (this.o.inquiryHz ?? 10); |
| 206 | this.inquiryTimer = setInterval(() => { |
| 207 | // Reply-stall recovery: the firmware addresses replies to the most |
| 208 | // recent CLIENT and that state machine occasionally wedges (observed |
| 209 | // during calibration sweeps). A brand-new source port forces it to |
| 210 | // re-register us; sequence resets alone do not. |
| 211 | if (this.lastInquiryReply && Date.now() - this.lastInquiryReply > 4000) { |
| 212 | this.diag.lastError = "inquiry stall — rebinding socket"; |
| 213 | this.openSocket(); |
| 214 | this.lastInquiryReply = Date.now(); // back off one stall window |
nothing calls this directly
no outgoing calls
no test coverage detected