diff --git a/oci/catalog/apps/frigate.json b/oci/catalog/apps/frigate.json index ad80bf52..56ebc6c9 100644 --- a/oci/catalog/apps/frigate.json +++ b/oci/catalog/apps/frigate.json @@ -923,6 +923,31 @@ "device_inventory": "host-sysfs-and-stat", "application_acceleration": "requires-workload-test", "tone_mapping": "not-implied-by-device-access" + }, + "object_detector": { + "prompt": "Object detector for Frigate", + "devices": [ + { + "id": "intel-npu", + "label": "Intel NPU", + "host_glob": "/dev/accel/accel*", + "path_pattern": "/dev/accel/accel[0-9]+", + "sysfs_class": "accel", + "sysfs_vendor": "0x8086", + "completion_notes": [ + "Frigate uses the Intel NPU once /config/config.yaml defines a detector with type: openvino and device: NPU." + ] + }, + { + "id": "coral-pcie", + "label": "Coral PCIe/M.2", + "host_glob": "/dev/apex_*", + "path_pattern": "/dev/apex_[0-9]+", + "completion_notes": [ + "Frigate uses the Coral once /config/config.yaml defines a detector with type: edgetpu and device: pci." + ] + } + ] } }, "image_channel_policy": { diff --git a/oci/catalog/curated/frigate.json b/oci/catalog/curated/frigate.json index 4e61e278..a937bbe1 100644 --- a/oci/catalog/curated/frigate.json +++ b/oci/catalog/curated/frigate.json @@ -923,6 +923,31 @@ "device_inventory": "host-sysfs-and-stat", "application_acceleration": "requires-workload-test", "tone_mapping": "not-implied-by-device-access" + }, + "object_detector": { + "prompt": "Object detector for Frigate", + "devices": [ + { + "id": "intel-npu", + "label": "Intel NPU", + "host_glob": "/dev/accel/accel*", + "path_pattern": "/dev/accel/accel[0-9]+", + "sysfs_class": "accel", + "sysfs_vendor": "0x8086", + "completion_notes": [ + "Frigate uses the Intel NPU once /config/config.yaml defines a detector with type: openvino and device: NPU." + ] + }, + { + "id": "coral-pcie", + "label": "Coral PCIe/M.2", + "host_glob": "/dev/apex_*", + "path_pattern": "/dev/apex_[0-9]+", + "completion_notes": [ + "Frigate uses the Coral once /config/config.yaml defines a detector with type: edgetpu and device: pci." + ] + } + ] } }, "image_channel_policy": { diff --git a/oci/remote/install_oci.sh b/oci/remote/install_oci.sh index 7eda3725..da3d61d2 100755 --- a/oci/remote/install_oci.sh +++ b/oci/remote/install_oci.sh @@ -2154,7 +2154,10 @@ if [[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] && ! oci_quiet python3 "${SCRIPT_DIR} --archive "$ARCHIVE_PATH" --digest "$DIGEST"; then msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")" fi -COMPLETION_NOTES=$(jq -c '.proxmox.installer_profile.completion_notes // []' "$TEMPLATE_FILE") +# The template's notes, then those of this installation's choices, such as the +# detector configuration for a device the user attached. +COMPLETION_NOTES=$(jq -cs '(.[0].proxmox.installer_profile.completion_notes // []) + + (.[1].completion_notes // [])' "$TEMPLATE_FILE" "$DEPLOYMENT_FILE") RESULT=$(jq -cn \ --arg app_id "$APP_ID" \ --arg image "$IMAGE_REF" \ diff --git a/oci/remote/oci_gpu_devices.py b/oci/remote/oci_gpu_devices.py index 2b0d46aa..e4502470 100644 --- a/oci/remote/oci_gpu_devices.py +++ b/oci/remote/oci_gpu_devices.py @@ -15,8 +15,9 @@ def gpu_path(path): def peripheral_path(path): + """Coral, NPU (/dev/accel), USB and serial nodes, identified by their sysfs path.""" return isinstance(path, str) and re.fullmatch( - r'/dev/(apex_[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path) is not None + r'/dev/(apex_[0-9]+|accel/accel[0-9]+|ttyUSB[0-9]+|ttyACM[0-9]+|bus/usb/[0-9]{3}/[0-9]{3})', path) is not None def system_path(path): diff --git a/oci/remote/oci_instance_reconcile.py b/oci/remote/oci_instance_reconcile.py index 471702b8..07291125 100644 --- a/oci/remote/oci_instance_reconcile.py +++ b/oci/remote/oci_instance_reconcile.py @@ -64,7 +64,7 @@ def _device(key, value): raise ValueError(f'{key}: {translate("Unsupported device options")}') path = fields.get('path') if not (gpu_devices.gpu_path(path) or gpu_devices.peripheral_path(path)): - raise ValueError(f'{key}: {translate("Only Intel/AMD DRM, Coral and USB nodes can be adopted automatically")}') + raise ValueError(f'{key}: {translate("Only Intel/AMD DRM, Coral, NPU and USB nodes can be adopted automatically")}') snapshot = gpu_devices.snapshot(path) mode = fields.get('mode', '0660') if not re.fullmatch(r'0?[0-7]{3}', mode): @@ -120,7 +120,7 @@ def propose(record, config): else: device = _device(key, current[key]) deployment.setdefault('devices', []).append(device) - kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral' + kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral/NPU' details.append(f"{key}: {device['host_path']} ({kind})") filtered = b'\n'.join(line for line in config.splitlines() if not any(line.startswith(key.encode() + b': ') for key in new_keys)) + b'\n' diff --git a/oci/src/proxmenux_oci/installer.py b/oci/src/proxmenux_oci/installer.py index 9dcfd8bc..8d7aaa8a 100644 --- a/oci/src/proxmenux_oci/installer.py +++ b/oci/src/proxmenux_oci/installer.py @@ -420,6 +420,7 @@ def build_deployment( devices, selected_hardware_profile, post_start_configurations, environment = configure_acceleration( installer_profile, environment, unprivileged, ui, mode) + devices, completion_notes = configure_detector(installer_profile, devices, ui) if advanced: from .extra_devices import ask_extra_devices @@ -506,6 +507,7 @@ def build_deployment( "mounts": mounts, "tmpfs_mounts": tmpfs_mounts, "devices": devices, + **({"completion_notes": completion_notes} if completion_notes else {}), "hardware_profile": selected_hardware_profile, "device_permissions": (device_permissions(template['container_contract']['image']['reference'], devices, installer_profile.get('device_permissions')) @@ -1417,6 +1419,54 @@ def _stream_process(command: list[str], standard_input: bytes | None = None) -> raise InstallError(f"{translate('The installation ended with exit code')} {return_code}") return result +def detected_detector_devices(installer_profile, root=Path("/")): + """Object detection devices the template accepts and this host has. + + A device is offered only when its node exists and, when the template names + a vendor, the device behind it is from that vendor: an AMD NPU is also + /dev/accel/accel0, and Frigate runs on Intel's only. + """ + found = [] + for item in (installer_profile.get("object_detector") or {}).get("devices", []): + pattern = re.compile(item["path_pattern"]) + for node in sorted((root / "dev").glob(item["host_glob"].removeprefix("/dev/"))): + path = "/" + str(node.relative_to(root)) + if not pattern.fullmatch(path) or not node.is_char_device(): + continue + vendor = item.get("sysfs_vendor") + if vendor: + sysfs = root / "sys/class" / item["sysfs_class"] / node.name / "device/vendor" + try: + if sysfs.read_text().strip() != vendor: + continue + except OSError: + continue + found.append({**item, "host_path": path}) + return found + + +def configure_detector(installer_profile, devices, ui, root=Path("/")): + """Offer the object detection devices found on the host; ask nothing when + there are none. Returns the devices and the note that tells how to use it.""" + attached = {item.get("host_path") for item in devices} + candidates = [item for item in detected_detector_devices(installer_profile, root) + if item["host_path"] not in attached] + if not candidates or any(item.get("id", "").startswith("detector-") for item in devices): + return devices, [] + options = [("none", translate("No detector device (CPU)"))] + [ + (item["host_path"], f"{translate(item['label'])} ({item['host_path']})") for item in candidates] + selected = ui.choose(translate(installer_profile["object_detector"].get("prompt", "Object detector")), options, "none") + if selected is None: + raise UserCancelled(translate("Device configuration cancelled")) + chosen = next((item for item in candidates if item["host_path"] == selected), None) + if chosen is None: + return devices, [] + device = {"id": f"detector-{chosen['id']}", "kind": "character-device", + "host_path": chosen["host_path"], "container_path": chosen["host_path"], + "mode": "0660", "deny_write": False, "gid_strategy": "host-device-gid"} + return [*devices, device], list(chosen.get("completion_notes", [])) + + def configure_acceleration(installer_profile, environment, unprivileged, ui, mode=ADVANCED_MODE): advanced = mode != DEFAULT_MODE devices: list[dict[str, Any]] = [] diff --git a/oci/src/proxmenux_oci/recreation.py b/oci/src/proxmenux_oci/recreation.py index 89f93ad4..c61ad56b 100644 --- a/oci/src/proxmenux_oci/recreation.py +++ b/oci/src/proxmenux_oci/recreation.py @@ -117,6 +117,11 @@ def edit_recreation(record, ui): if ui.confirm(translate('Change the access network?'), False): edit_network(deployment, ui) edit_acceleration(candidate, ui) + from .installer import configure_detector + installer_profile = candidate.get('template', {}).get('proxmox', {}).get('installer_profile', {}) + deployment['devices'], notes = configure_detector(installer_profile, deployment.get('devices', []), ui) + if notes: + deployment['completion_notes'] = notes from .extra_devices import ask_extra_devices reference = candidate.get('template', {}).get('container_contract', {}).get('image', {}).get('reference', '') repository = reference.split('@')[0].rsplit(':', 1)[0] diff --git a/oci/tests/test_object_detector.py b/oci/tests/test_object_detector.py new file mode 100644 index 00000000..fd8b8b46 --- /dev/null +++ b/oci/tests/test_object_detector.py @@ -0,0 +1,87 @@ +"""Frigate offers the object detection devices found on the host, and asks nothing without them.""" + +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "remote")) + +from proxmenux_oci.installer import configure_detector, detected_detector_devices +import oci_gpu_devices + +PROFILE = json.loads((ROOT / "catalog/curated/frigate.json").read_text())["proxmox"]["installer_profile"] + + +class ObjectDetectorTests(unittest.TestCase): + def host(self, nodes, vendors=None): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + for node in nodes: + (root / node.lstrip("/")).parent.mkdir(parents=True, exist_ok=True) + (root / node.lstrip("/")).touch() + for sysfs, vendor in (vendors or {}).items(): + (root / sysfs).parent.mkdir(parents=True, exist_ok=True) + (root / sysfs).write_text(vendor + "\n") + # Regular files stand in for the character devices of a real host. + patcher = patch.object(Path, "is_char_device", lambda self: self.is_file()) + patcher.start() + self.addCleanup(patcher.stop) + return root + + def test_intel_npu_and_coral_are_found_amd_npu_is_not(self): + root = self.host(["/dev/accel/accel0", "/dev/accel/accel1", "/dev/apex_0"], + {"sys/class/accel/accel0/device/vendor": "0x8086", + "sys/class/accel/accel1/device/vendor": "0x1022"}) + found = detected_detector_devices(PROFILE, root) + self.assertEqual([(item["id"], item["host_path"]) for item in found], + [("intel-npu", "/dev/accel/accel0"), ("coral-pcie", "/dev/apex_0")]) + + def test_nothing_found_asks_nothing(self): + root = self.host([]) + ui = Mock() + self.assertEqual(configure_detector(PROFILE, [], ui, root), ([], [])) + ui.choose.assert_not_called() + + def test_selected_npu_is_attached_with_the_host_gid_and_a_note(self): + root = self.host(["/dev/accel/accel0"], {"sys/class/accel/accel0/device/vendor": "0x8086"}) + ui = Mock() + ui.choose.return_value = "/dev/accel/accel0" + devices, notes = configure_detector(PROFILE, [{"id": "gpu-render", "host_path": "/dev/dri/renderD128"}], ui, root) + self.assertEqual(devices[-1], {"id": "detector-intel-npu", "kind": "character-device", + "host_path": "/dev/accel/accel0", "container_path": "/dev/accel/accel0", + "mode": "0660", "deny_write": False, "gid_strategy": "host-device-gid"}) + self.assertIn("device: NPU", notes[0]) + self.assertEqual([tag for tag, _label in ui.choose.call_args.args[1]], ["none", "/dev/accel/accel0"]) + + def test_no_detector_keeps_the_devices(self): + root = self.host(["/dev/apex_0"]) + ui = Mock() + ui.choose.return_value = "none" + self.assertEqual(configure_detector(PROFILE, [], ui, root), ([], [])) + + def test_an_attached_detector_is_not_offered_again(self): + root = self.host(["/dev/apex_0"]) + ui = Mock() + devices = [{"id": "detector-coral-pcie", "host_path": "/dev/apex_0"}] + self.assertEqual(configure_detector(PROFILE, devices, ui, root), (devices, [])) + ui.choose.assert_not_called() + + def test_template_without_detectors_asks_nothing(self): + ui = Mock() + self.assertEqual(configure_detector({}, [], ui, self.host(["/dev/apex_0"])), ([], [])) + ui.choose.assert_not_called() + + def test_npu_node_is_a_supported_device_for_updates_and_adoption(self): + self.assertTrue(oci_gpu_devices.peripheral_path("/dev/accel/accel0")) + self.assertFalse(oci_gpu_devices.peripheral_path("/dev/accel/accel")) + self.assertFalse(oci_gpu_devices.peripheral_path("/dev/accel/../kvm")) + + +if __name__ == "__main__": + unittest.main()