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
20 changes: 14 additions & 6 deletions 03_implementation/src/hermes3d/api/routes/observe.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,21 @@ def observe_status() -> dict:
def cameras() -> list[dict]:
printers = local_printers(live=False)
plates = {plate["printer_id"]: plate for plate in build_plate_clearance_rows(printers)}
return [
{
result = []
for printer in printers:
configured_state = _configured_camera_state(printer)
# Probe the real camera endpoint so health reflects actual connectivity,
# not just whether a URL is set. This prevents the UI from showing
# "CONNECTING" indefinitely when the camera is actually unreachable.
if configured_state == "configured":
real_health, _http_status, _ms = _probe_camera_timed(printer.get("camera_url"))
else:
real_health = configured_state # "not_configured"
result.append({
"printer_id": printer["id"],
"printer_name": printer["name"],
"camera_url": printer["camera_url"],
"health": _configured_camera_state(printer),
"health": real_health,
"is_locked": _camera_is_locked(printer),
"printer_locked": is_s1_target(printer["id"]),
"camera_kind": _camera_kind(printer),
Expand All @@ -105,9 +114,8 @@ def cameras() -> list[dict]:
"snapshot_url": f"/api/observe/cameras/{printer['id']}/snapshot",
"view_settings": camera_view_settings(str(printer["id"])),
"plate_clearance": plates.get(str(printer["id"])),
}
for printer in printers
]
})
return result


@router.get("/api/observe/build-plate-clearance")
Expand Down
39 changes: 31 additions & 8 deletions 03_implementation/ui/src/components/observe/ObserveConsole.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ const DEFAULT_BRIDGE_PORT = "8765";
const LIVE_BRIDGE_PORT = (import.meta as HermesImportMeta).env.VITE_HERMES3D_BRIDGE_PORT ?? DEFAULT_BRIDGE_PORT;
const LIVE_BASE_URL = `http://127.0.0.1:${LIVE_BRIDGE_PORT}`;

const EVENTS = [
"Layer progress sampled for active print",
"No spaghetti risk detected in last frame",
"S1 camera offline; maintenance flag retained",
"Observe stream is read-only; no printer command path",
];
// No hardcoded fake events — observed events are derived from real camera status data.

async function fetchObserveStatus(): Promise<ObserveStatusResponse | null> {
try {
Expand All @@ -46,6 +41,9 @@ async function fetchObserveStatus(): Promise<ObserveStatusResponse | null> {
}
}

/** Polling interval for /api/observe/status (ms). Must be >= 3000 to avoid lag. */
const STATUS_POLL_INTERVAL_MS = 5_000;

export function ObserveConsole() {
const [statusData, setStatusData] = useState<ObserveStatusResponse | null>(null);
const [loading, setLoading] = useState(true);
Expand All @@ -60,7 +58,7 @@ export function ObserveConsole() {
}
};
void poll();
const timer = window.setInterval(() => void poll(), 5_000);
const timer = window.setInterval(() => void poll(), STATUS_POLL_INTERVAL_MS);
return () => {
mounted = false;
window.clearInterval(timer);
Expand All @@ -71,6 +69,19 @@ export function ObserveConsole() {
const onlineCount = cameras.filter((c) => c.health === "reachable").length;
const totalCount = cameras.length;

// Derive observe events from real camera status — no fake/hardcoded strings.
const observeEvents: string[] = cameras.map((c) => {
const state = c.health === "reachable"
? `online (${c.response_ms != null ? `${c.response_ms}ms` : "connected"}${c.estimated_fps != null ? `, ~${c.estimated_fps} fps` : ""})`
: c.health === "unreachable"
? "unreachable — no feed"
: c.health === "not_configured"
? "not configured"
: c.health;
const readOnlyNote = c.read_only ? " [read-only]" : "";
return `${c.printer_name}: ${state}${readOnlyNote}`;
});

return (
<div className="grid grid-cols-12 gap-2.5 auto-rows-min">
<div className="col-span-12 lg:col-span-8">
Expand Down Expand Up @@ -155,7 +166,19 @@ export function ObserveConsole() {
className="h-[225px]"
>
<ul className="flex h-full flex-col gap-1 overflow-auto text-xs">
{EVENTS.map((event) => (
{loading && (
<li className="flex items-center gap-2 rounded bg-surface2/40 px-2 py-1.5">
<Eye size={13} className="shrink-0 text-muted" />
<span className="truncate text-muted">Fetching camera status...</span>
</li>
)}
{!loading && observeEvents.length === 0 && (
<li className="flex items-center gap-2 rounded bg-surface2/40 px-2 py-1.5">
<Eye size={13} className="shrink-0 text-muted" />
<span className="truncate text-muted">No cameras configured or backend unreachable.</span>
</li>
)}
{observeEvents.map((event) => (
<li key={event} className="flex items-center gap-2 rounded bg-surface2/40 px-2 py-1.5">
<Eye size={13} className="shrink-0 text-accent-cyan" />
<span className="truncate text-fg">{event}</span>
Expand Down
13 changes: 12 additions & 1 deletion 03_implementation/ui/src/tabs/Observe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -777,7 +777,18 @@ function isObserverAvailable(status: string): boolean {
}

function initialFeedState(cameras: CameraFeed[]): Record<string, "loading" | "online" | "error"> {
return Object.fromEntries(cameras.map((camera) => [camera.printer_id, camera.camera_url ? "loading" : "error"]));
return Object.fromEntries(cameras.map((camera) => {
if (!camera.camera_url) {
return [camera.printer_id, "error"];
}
// Use real health from the backend probe — "unreachable" means the camera
// is confirmed offline, so start in error state rather than "loading"
// (which would make the UI appear to be trying to connect to a known-dead feed).
if (camera.health === "unreachable") {
return [camera.printer_id, "error"];
}
return [camera.printer_id, "loading"];
}));
}

async function fetchCameras(): Promise<CameraFeed[]> {
Expand Down