Touchscreen kiosk for the Raspberry Pi HQ camera (IMX477) used as a microscope: live preview from boot, raw frame recording, full-resolution stills, and automatic capture-SSD mounting. - app.py: GTK3 fullscreen UI over cage — preview, REC/STOP, PHOTO, 90-degree rotate button, tap-to-open exposure/gain/settings sliders, and an "SSD NOT MOUNTED" badge - camera.py: picamera2 wrapper with separate video (ISP preview + packed 12-bit raw) and still (4056x3040) configurations - recorder.py: writer thread turning the raw stream into one Bayer frame per file plus index.jsonl/session.json, with dropped/missed accounting - photos.py: still capture to DNG + JPEG, and a background worker that renders a viewable 16-bit sRGB TIFF from the 12-bit Bayer frame - storage.py: SSD mount state and the date/time/format session layout - tools/unpack_raw.py: desktop-side unpacker/demosaicer - system/ + install.sh: systemd units, udev rule, mount helper, sudoers Note: raw frames are written byte-for-byte as the sensor produced them, so they stay decodeable regardless of how the stills pipeline evolves.
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
"""SSD discovery, mount state and the on-disk session layout.
|
|
|
|
Layout (date, then time, then file format):
|
|
|
|
/mnt/microscope/2026-10-30/21-33-05/raw/frame_000001.raw
|
|
/mnt/microscope/2026-10-30/21-33-05/index.jsonl
|
|
/mnt/microscope/2026-10-30/21-33-05/session.json
|
|
/mnt/microscope/2026-10-30/21-40-12/raw/still_0001.dng
|
|
/mnt/microscope/2026-10-30/21-40-12/tiff/still_0001.tiff
|
|
/mnt/microscope/2026-10-30/21-40-12/jpeg/still_0001.jpg
|
|
"""
|
|
|
|
import datetime as _dt
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger("storage")
|
|
|
|
UUID_BY_PATH = "/dev/disk/by-uuid"
|
|
MOUNT_HELPER = "/usr/local/sbin/microscope-ssd"
|
|
|
|
|
|
class Storage:
|
|
def __init__(self, cfg):
|
|
self.cfg = cfg
|
|
self.mount_point = Path(cfg.get("mount_point", "/mnt/microscope"))
|
|
self.uuid = cfg.get("fs_uuid", "")
|
|
self._last_session_key = None
|
|
self._last_session_dir = None
|
|
|
|
# ------------------------------------------------------------------ state
|
|
@property
|
|
def device_node(self):
|
|
if not self.uuid:
|
|
return None
|
|
node = Path(UUID_BY_PATH) / self.uuid
|
|
return node if node.exists() else None
|
|
|
|
def device_present(self) -> bool:
|
|
"""True when the SSD is plugged in and the partition node exists."""
|
|
return self.device_node is not None
|
|
|
|
def mount_source(self):
|
|
"""Device backing the mount point, or None when it is a plain directory."""
|
|
target = os.fspath(self.mount_point)
|
|
try:
|
|
with open("/proc/self/mounts", "r", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
parts = line.split()
|
|
if len(parts) >= 2 and parts[1] == target:
|
|
return parts[0]
|
|
except OSError:
|
|
log.debug("could not read /proc/self/mounts", exc_info=True)
|
|
return None
|
|
|
|
def is_mounted(self) -> bool:
|
|
"""True only when the SSD is really mounted *and* still present.
|
|
|
|
Yanking a USB drive leaves a stale entry in /proc/mounts that still
|
|
reports as a mount point, so the backing device is checked too.
|
|
"""
|
|
source = self.mount_source()
|
|
if source is None:
|
|
return False
|
|
try:
|
|
os.statvfs(self.mount_point)
|
|
except OSError:
|
|
return False
|
|
if self.uuid:
|
|
return (Path(UUID_BY_PATH) / self.uuid).exists()
|
|
return os.path.exists(source)
|
|
|
|
def free_mb(self) -> int:
|
|
if not self.is_mounted():
|
|
return 0
|
|
try:
|
|
usage = shutil.disk_usage(self.mount_point)
|
|
return int(usage.free // (1024 * 1024))
|
|
except OSError:
|
|
return 0
|
|
|
|
def state(self) -> str:
|
|
"""One of: 'ready', 'unmounted', 'absent'."""
|
|
if self.is_mounted():
|
|
return "ready"
|
|
if self.device_present():
|
|
return "unmounted"
|
|
return "absent"
|
|
|
|
# ------------------------------------------------------------- mount ops
|
|
def _run_helper(self, action: str, timeout: float = 20.0):
|
|
if not os.access(MOUNT_HELPER, os.X_OK):
|
|
return False, f"{MOUNT_HELPER} not installed"
|
|
try:
|
|
proc = subprocess.run(
|
|
["sudo", "-n", MOUNT_HELPER, action],
|
|
capture_output=True, text=True, timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return False, "mount helper timed out"
|
|
except OSError as exc:
|
|
return False, str(exc)
|
|
out = (proc.stdout + proc.stderr).strip()
|
|
return proc.returncode == 0, out
|
|
|
|
def mount(self):
|
|
if self.is_mounted():
|
|
return True, "already mounted"
|
|
return self._run_helper("mount")
|
|
|
|
def unmount(self):
|
|
if not self.is_mounted():
|
|
return True, "not mounted"
|
|
return self._run_helper("umount")
|
|
|
|
# --------------------------------------------------------- session paths
|
|
def new_session(self, kind: str) -> Path:
|
|
"""Create (or reuse) the date/time session directory for a capture.
|
|
|
|
Two captures in the same second share a directory and continue the
|
|
frame/still numbering, which keeps rapid photo bursts tidy.
|
|
"""
|
|
now = _dt.datetime.now()
|
|
date_part = now.strftime("%Y-%m-%d")
|
|
time_part = now.strftime("%H-%M-%S")
|
|
key = f"{date_part}/{time_part}"
|
|
session = self.mount_point / date_part / time_part
|
|
|
|
if key == self._last_session_key and session.is_dir():
|
|
return session
|
|
|
|
session.mkdir(parents=True, exist_ok=True)
|
|
self._last_session_key = key
|
|
self._last_session_dir = session
|
|
log.info("new %s session: %s", kind, session)
|
|
return session
|
|
|
|
@staticmethod
|
|
def format_dir(session: Path, fmt: str) -> Path:
|
|
path = session / fmt
|
|
path.mkdir(exist_ok=True)
|
|
return path
|
|
|
|
@staticmethod
|
|
def next_serial(directory: Path, prefix: str) -> int:
|
|
"""Highest existing ``prefix_NNNN`` number in *directory*, plus one."""
|
|
highest = 0
|
|
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)\.[A-Za-z0-9]+$")
|
|
if directory.is_dir():
|
|
for entry in directory.iterdir():
|
|
match = pattern.match(entry.name)
|
|
if match:
|
|
highest = max(highest, int(match.group(1)))
|
|
return highest + 1
|
|
|
|
def relative(self, path: Path) -> str:
|
|
try:
|
|
return str(Path(path).relative_to(self.mount_point))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
def wait_mounted(self, timeout: float = 15.0) -> bool:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
if self.is_mounted():
|
|
return True
|
|
time.sleep(0.25)
|
|
return self.is_mounted()
|