camview: tell the client when the camera is unavailable
CI / Analyze & Test (push) Successful in 9s
CI / Debug APK (push) Successful in 25s

The server sends "camera unavailable" after 3 s without a frame and
"camera ok" when frames come; the app shows the notice instead of a
stale frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-12 16:11:03 +09:00
parent 85837363ee
commit 00d161e068
2 changed files with 42 additions and 11 deletions
+23 -9
View File
@@ -4,9 +4,12 @@
Streams the USB camera's own MJPEG frames, unmodified, to any client on 7710/tcp:
server → client, per frame: [u32 length][f64 capture time, unix s][u32 seq][JPEG]
per reply: [u32 0][f64 time][u32 text length][UTF-8 text]
per text: [u32 0][f64 time][u32 text length][UTF-8 text]
client → server, lines: "exposure auto" | "exposure <100 µs units>" | "gain <0-240>"
→ one reply, "ok <state>" or "err <reason>"
→ one text reply, "ok <state>" or "err <reason>"
Texts also carry availability: "camera unavailable" when no frame has come for
UNAVAILABLE_S (none yet, or capture stopped), "camera ok" when frames resume.
Latest-wins: each client gets the newest frame when it is ready for one, never a
queue of old ones. Sockets are paced (SO_MAX_PACING_RATE, project-yukart decision
@@ -25,6 +28,7 @@ import time
SO_MAX_PACING_RATE = 47
CONTROLS = {"exposure", "gain"}
UNAVAILABLE_S = 3.0
class Latest:
@@ -39,7 +43,7 @@ class Latest:
self.frame = (seq, t, jpeg)
self.cond.notify_all()
def after(self, seq, timeout=5.0):
def after(self, seq, timeout):
with self.cond:
self.cond.wait_for(lambda: self.frame and self.frame[0] > seq, timeout)
return self.frame
@@ -106,7 +110,13 @@ def serve_client(conn, peer, dev, latest, pace):
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
alive = threading.Event()
alive.set()
send = threading.Lock() # frames and replies share the socket; never interleave them
send = threading.Lock() # frames and texts share the socket; never interleave them
def send_text(text):
# Texts go in-band as a frame with length 0 and the text after the header.
msg = text.encode()
with send:
conn.sendall(struct.pack("!IdI", 0, time.time(), len(msg)) + msg)
def reader():
buf = b""
@@ -120,10 +130,7 @@ def serve_client(conn, peer, dev, latest, pace):
line, buf = buf.split(b"\n", 1)
reply = control(dev, line.decode(errors="replace").strip())
print(f"{peer}: {line.decode(errors='replace').strip()} -> {reply}", flush=True)
# Replies go in-band as a frame with length 0 and the text after the header.
msg = reply.encode()
with send:
conn.sendall(struct.pack("!IdI", 0, time.time(), len(msg)) + msg)
send_text(reply)
except OSError:
pass
alive.clear()
@@ -131,11 +138,18 @@ def serve_client(conn, peer, dev, latest, pace):
threading.Thread(target=reader, daemon=True).start()
seq = 0
sent = 0
available = None # unknown until the first frame or the first timeout
try:
while alive.is_set():
f = latest.after(seq)
f = latest.after(seq, UNAVAILABLE_S)
if not f or f[0] <= seq:
if available is not False:
available = False
send_text("camera unavailable")
continue
if available is not True:
available = True
send_text("camera ok")
seq, t, jpeg = f
with send:
conn.sendall(struct.pack("!IdI", len(jpeg), t, seq) + jpeg)