"""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()