remla 0.3.2__tar.gz → 0.3.3.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {remla-0.3.2 → remla-0.3.3.dev1}/PKG-INFO +1 -1
  2. {remla-0.3.2 → remla-0.3.3.dev1}/pyproject.toml +1 -1
  3. {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/Controllers.py +276 -0
  4. {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/__init__.py +1 -0
  5. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/mediamtx.yml +1 -1
  6. {remla-0.3.2 → remla-0.3.3.dev1}/remla/systemHelpers.py +2 -3
  7. {remla-0.3.2 → remla-0.3.3.dev1}/README.md +0 -0
  8. {remla-0.3.2 → remla-0.3.3.dev1}/remla/__init__.py +0 -0
  9. {remla-0.3.2 → remla-0.3.3.dev1}/remla/customvalidators.py +0 -0
  10. {remla-0.3.2 → remla-0.3.3.dev1}/remla/i2ccmd.py +0 -0
  11. {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/Experiment.py +0 -0
  12. {remla-0.3.2 → remla-0.3.3.dev1}/remla/labcontrol/test.json +0 -0
  13. {remla-0.3.2 → remla-0.3.3.dev1}/remla/main.py +0 -0
  14. {remla-0.3.2 → remla-0.3.3.dev1}/remla/settings.py +0 -0
  15. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/finalInfoTemplate.md +0 -0
  16. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/hello.txt +0 -0
  17. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/i2c-output.png +0 -0
  18. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/index.html +0 -0
  19. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/localhost.conf +0 -0
  20. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/mediaMTXGetFeed.js +0 -0
  21. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/mediamtx.service +0 -0
  22. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/reader.js +0 -0
  23. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket.js +0 -0
  24. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_BACKUP_5176.js +0 -0
  25. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_BASE_5176.js +0 -0
  26. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_LOCAL_5176.js +0 -0
  27. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setup/remlaSocket_REMOTE_5176.js +0 -0
  28. {remla-0.3.2 → remla-0.3.3.dev1}/remla/setupcmd.py +0 -0
  29. {remla-0.3.2 → remla-0.3.3.dev1}/remla/typerHelpers.py +0 -0
  30. {remla-0.3.2 → remla-0.3.3.dev1}/remla/yaml.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remla
3
- Version: 0.3.2
3
+ Version: 0.3.3.dev1
4
4
  Summary:
5
5
  Author: Zak Espley
6
6
  Author-email: zespley@gmail.com
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "remla"
3
- version = "0.3.2"
3
+ version = "0.3.3dev1"
4
4
  description = ""
5
5
  authors = ["Zak Espley <zespley@gmail.com>"]
6
6
  readme = "README.md"
@@ -3,6 +3,7 @@ import os
3
3
  import subprocess
4
4
  import sys
5
5
  import time
6
+ import shutil
6
7
  from abc import ABC, ABCMeta, abstractmethod
7
8
  from warnings import warn
8
9
 
@@ -1504,6 +1505,281 @@ class ArduCamMultiCamera(BaseController):
1504
1505
  time.sleep(0.1)
1505
1506
 
1506
1507
 
1508
+ class PiCamera2MultiCam(BaseController):
1509
+ deviceType = "measurement"
1510
+
1511
+ CONTROL_MAPPINGS = {
1512
+ "brightness": "Brightness",
1513
+ "contrast": "Contrast",
1514
+ "saturation": "Saturation",
1515
+ "sharpness": "Sharpness",
1516
+ "analoguegain": "AnalogueGain",
1517
+ "gain": "AnalogueGain",
1518
+ "exposuretime": "ExposureTime",
1519
+ "exposure_time": "ExposureTime",
1520
+ "exposure": "ExposureTime",
1521
+ "awb": "AwbEnable",
1522
+ "awbenable": "AwbEnable",
1523
+ "colourgains": "ColourGains",
1524
+ "awb_gains": "ColourGains",
1525
+ "aeenable": "AeEnable",
1526
+ "autoexposure": "AeEnable",
1527
+ "lensposition": "LensPosition",
1528
+ }
1529
+
1530
+ def __init__(
1531
+ self,
1532
+ name,
1533
+ numCameras,
1534
+ videoNumber=0,
1535
+ defaultSettings=None,
1536
+ i2cbus=11,
1537
+ initialCamera="a",
1538
+ controlPins=[4, 17, 18],
1539
+ cameraNamesDict=None,
1540
+ streamPath="cam",
1541
+ streamUrl=None,
1542
+ width=1920,
1543
+ height=1080,
1544
+ fps=30,
1545
+ bitrate=5000000,
1546
+ hflip=False,
1547
+ vflip=False,
1548
+ ):
1549
+ super().__init__(name)
1550
+ self.videoNumber = videoNumber
1551
+ self.numCameras = numCameras
1552
+ self.defaultSettings = defaultSettings or {}
1553
+ self.i2cbus = i2cbus
1554
+ self.cameraNames = cameraNamesDict or {}
1555
+ self.initialCamera = initialCamera
1556
+ self.streamPath = streamPath
1557
+ self.streamUrl = streamUrl or f"rtsp://127.0.0.1:8554/{streamPath}"
1558
+ self.width = width
1559
+ self.height = height
1560
+ self.fps = fps
1561
+ self.bitrate = bitrate
1562
+ self.hflip = hflip
1563
+ self.vflip = vflip
1564
+ self.selection, self.enable1, self.enable2 = controlPins
1565
+ self.channels = controlPins
1566
+ gpio.setup(self.channels, gpio.OUT)
1567
+ from remla.systemHelpers import get_camera_logger
1568
+
1569
+ self.logger = get_camera_logger()
1570
+
1571
+ self.cameraDict = {
1572
+ "a": (gpio.LOW, gpio.LOW, gpio.HIGH),
1573
+ "b": (gpio.HIGH, gpio.LOW, gpio.HIGH),
1574
+ "c": (gpio.LOW, gpio.HIGH, gpio.LOW),
1575
+ "d": (gpio.HIGH, gpio.HIGH, gpio.LOW),
1576
+ "off": (gpio.LOW, gpio.HIGH, gpio.HIGH),
1577
+ }
1578
+ self.slot_order = ["a", "b", "c", "d"]
1579
+ self.active_slot = None
1580
+ self.picam2 = None
1581
+ self.encoder = None
1582
+ self.output = None
1583
+
1584
+ self._runtime = None
1585
+ self._ensure_runtime()
1586
+ self._start_camera(self._resolve_camera_param(initialCamera), apply_defaults=True)
1587
+ self.state["camera"] = self.active_slot
1588
+
1589
+ def _ensure_runtime(self):
1590
+ if self._runtime is not None:
1591
+ return self._runtime
1592
+ if shutil.which("ffmpeg") is None:
1593
+ raise RuntimeError("ffmpeg is required to publish Picamera2 output to MediaMTX")
1594
+
1595
+ try:
1596
+ from libcamera import Transform
1597
+ from picamera2 import Picamera2
1598
+ from picamera2.encoders import H264Encoder
1599
+ from picamera2.outputs import FfmpegOutput
1600
+ except Exception as exc:
1601
+ raise RuntimeError(
1602
+ "PiCamera2MultiCam requires Picamera2/libcamera packages on the Raspberry Pi runtime"
1603
+ ) from exc
1604
+
1605
+ self._runtime = {
1606
+ "Picamera2": Picamera2,
1607
+ "H264Encoder": H264Encoder,
1608
+ "FfmpegOutput": FfmpegOutput,
1609
+ "Transform": Transform,
1610
+ }
1611
+ return self._runtime
1612
+
1613
+ def _coerce_bool(self, value):
1614
+ if isinstance(value, bool):
1615
+ return value
1616
+ return str(value).strip().lower() in {"1", "true", "yes", "on"}
1617
+
1618
+ def _coerce_control_value(self, control_name, value):
1619
+ if control_name in {"AwbEnable", "AeEnable"}:
1620
+ return self._coerce_bool(value)
1621
+ if control_name == "ColourGains":
1622
+ if isinstance(value, (list, tuple)) and len(value) == 2:
1623
+ return (float(value[0]), float(value[1]))
1624
+ pieces = [part.strip() for part in str(value).split(",")]
1625
+ if len(pieces) != 2:
1626
+ raise ValueError("ColourGains expects two comma-separated values")
1627
+ return (float(pieces[0]), float(pieces[1]))
1628
+ try:
1629
+ if "." in str(value):
1630
+ return float(value)
1631
+ return int(value)
1632
+ except ValueError:
1633
+ return value
1634
+
1635
+ def _normalize_control_name(self, control_name):
1636
+ key = str(control_name).strip()
1637
+ normalized = self.CONTROL_MAPPINGS.get(key.lower())
1638
+ if normalized is not None:
1639
+ return normalized
1640
+ if key and key[0].isupper():
1641
+ return key
1642
+ raise ValueError(f"Unsupported camera control '{control_name}'")
1643
+
1644
+ def _resolve_camera_param(self, param):
1645
+ lowered = str(param).lower()
1646
+ if lowered in self.slot_order:
1647
+ return lowered
1648
+ if lowered == "off":
1649
+ return "off"
1650
+ if lowered in self.cameraNames:
1651
+ return self.cameraNames[lowered]
1652
+ raise ValueError(f"Unknown camera selection '{param}'")
1653
+
1654
+ def _select_slot(self, slot):
1655
+ if slot == "off":
1656
+ gpio.output(self.channels, self.cameraDict["off"])
1657
+ self.active_slot = "off"
1658
+ return
1659
+
1660
+ try:
1661
+ index = self.slot_order.index(slot)
1662
+ except ValueError as exc:
1663
+ raise ValueError(f"Unknown camera slot '{slot}'") from exc
1664
+
1665
+ from remla.systemHelpers import select_arducam_channel_index
1666
+
1667
+ ok = select_arducam_channel_index(
1668
+ index,
1669
+ bus=self.i2cbus,
1670
+ control_pins=list(self.channels),
1671
+ )
1672
+ if not ok:
1673
+ raise RuntimeError(f"Failed to select ArduCam channel '{slot}'")
1674
+ gpio.output(self.channels, self.cameraDict[slot])
1675
+ self.active_slot = slot
1676
+
1677
+ def _build_video_config(self):
1678
+ runtime = self._ensure_runtime()
1679
+ controls = {"FrameRate": self.fps}
1680
+ return self.picam2.create_video_configuration(
1681
+ main={"size": (self.width, self.height)},
1682
+ controls=controls,
1683
+ transform=runtime["Transform"](hflip=self.hflip, vflip=self.vflip),
1684
+ )
1685
+
1686
+ def _release_camera(self):
1687
+ if self.picam2 is None:
1688
+ return
1689
+ try:
1690
+ self.picam2.stop_recording()
1691
+ except Exception:
1692
+ self.logger.debug("Picamera2 stop_recording skipped", exc_info=True)
1693
+ try:
1694
+ self.picam2.close()
1695
+ except Exception:
1696
+ self.logger.debug("Picamera2 close skipped", exc_info=True)
1697
+ self.picam2 = None
1698
+ self.encoder = None
1699
+ self.output = None
1700
+
1701
+ def _start_camera(self, slot, apply_defaults=False):
1702
+ runtime = self._ensure_runtime()
1703
+ self._release_camera()
1704
+
1705
+ if slot == "off":
1706
+ self._select_slot("off")
1707
+ self.state["camera"] = self.active_slot
1708
+ return
1709
+
1710
+ self._select_slot(slot)
1711
+ time.sleep(0.2)
1712
+
1713
+ picam_cls = runtime["Picamera2"]
1714
+ self.picam2 = picam_cls(self.videoNumber) if self.videoNumber else picam_cls()
1715
+ self.picam2.configure(self._build_video_config())
1716
+ self.encoder = runtime["H264Encoder"](bitrate=self.bitrate)
1717
+ output_args = f"-f rtsp -rtsp_transport tcp {self.streamUrl}"
1718
+ self.output = runtime["FfmpegOutput"](output_args)
1719
+ self.picam2.start_recording(self.encoder, self.output)
1720
+ if apply_defaults and self.defaultSettings:
1721
+ self._apply_controls(self.defaultSettings)
1722
+ self.state["camera"] = self.active_slot
1723
+
1724
+ def _apply_controls(self, controls):
1725
+ if self.picam2 is None:
1726
+ raise RuntimeError("Camera is not active")
1727
+ translated = {}
1728
+ for control_name, value in controls.items():
1729
+ normalized_name = self._normalize_control_name(control_name)
1730
+ translated[normalized_name] = self._coerce_control_value(normalized_name, value)
1731
+ self.picam2.set_controls(translated)
1732
+ self.state["controls"] = {**self.state.get("controls", {}), **translated}
1733
+
1734
+ def camera(self, param):
1735
+ slot = self._resolve_camera_param(param)
1736
+ print("Switching to camera " + slot)
1737
+ self._start_camera(slot, apply_defaults=True)
1738
+ self.state["camera"] = slot
1739
+
1740
+ def camera_parser(self, params):
1741
+ if len(params) != 1:
1742
+ raise ArgumentNumberError(len(params), 1, "camera")
1743
+ param = params[0].lower()
1744
+ if param not in self.cameraDict:
1745
+ raise ArgumentError(self.name, "camera", param, ["a", "b", "c", "d", "off"])
1746
+ return param
1747
+
1748
+ def cameraName(self, param):
1749
+ key = str(param).lower()
1750
+ if key not in self.cameraNames:
1751
+ raise ArgumentError(self.name, "cameraName", param, self.cameraNames)
1752
+ slot = self.cameraNames[key]
1753
+ print("Switching to camera {0}, slot {1}".format(key, slot))
1754
+ self._start_camera(slot, apply_defaults=True)
1755
+ self.state["camera"] = slot
1756
+
1757
+ def cameraName_parser(self, params):
1758
+ if len(params) != 1:
1759
+ raise ArgumentNumberError(len(params), 1, "cameraName")
1760
+ param = params[0].lower()
1761
+ if param not in self.cameraNames:
1762
+ raise ArgumentError(self.name, "cameraName", param, self.cameraNames)
1763
+ return param
1764
+
1765
+ def imageMod(self, params):
1766
+ control_name = self._normalize_control_name(params[0])
1767
+ control_value = self._coerce_control_value(control_name, params[1])
1768
+ self._apply_controls({control_name: control_value})
1769
+
1770
+ def imageMod_parser(self, params):
1771
+ if len(params) != 2:
1772
+ raise ArgumentNumberError(len(params), 2, "imageMod")
1773
+ return params
1774
+
1775
+ def reset(self):
1776
+ self._start_camera(self._resolve_camera_param(self.initialCamera), apply_defaults=True)
1777
+ self.state["camera"] = self.active_slot
1778
+
1779
+ def close(self):
1780
+ self._release_camera()
1781
+
1782
+
1507
1783
  class ElectronicScreen(BaseController):
1508
1784
  deviceType = "controller"
1509
1785
 
@@ -16,6 +16,7 @@ from .Controllers import (
16
16
  PololuStepperMotor,
17
17
  PushButton,
18
18
  PWMChannel,
19
+ PiCamera2MultiCam,
19
20
  S42CStepperMotor,
20
21
  SingleGPIO,
21
22
  StepperI2C,
@@ -621,4 +621,4 @@ pathDefaults:
621
621
  paths:
622
622
  # example:
623
623
  cam:
624
- source: rpiCamera
624
+ source: publisher
@@ -415,11 +415,11 @@ def cycle_initialize_cameras(timeout_per_camera: int = 4) -> None:
415
415
  device_settings = lab_settings.get("devices", {})
416
416
  camera_cfg = None
417
417
  for device in device_settings.values():
418
- if device.get("type") == "ArduCamMultiCamera":
418
+ if device.get("type") in {"ArduCamMultiCamera", "PiCamera2MultiCam"}:
419
419
  camera_cfg = device
420
420
  break
421
421
  if camera_cfg is None:
422
- logger.info("No ArduCamMultiCamera device configured in lab settings; skipping camera cycling.")
422
+ logger.info("No supported multi-camera device configured in lab settings; skipping camera cycling.")
423
423
  return
424
424
 
425
425
  numCameras = camera_cfg.get("numCameras", 0)
@@ -477,4 +477,3 @@ def get_boot_status() -> bool:
477
477
  runMarker.write_text(str(current_boot))
478
478
  logger.error("Boot record file missing; assuming reboot.")
479
479
  return True
480
-
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes