From b078166f26a85ed52340f84bd1060373c1d9e830 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 28 Jul 2026 00:14:03 -0700 Subject: [PATCH 1/3] light: Use the LM36011's full 7-bit torch range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit set_value mapped 0-1 onto 0-20 and passed that to set_torch_current, which expects mA and does int(current * 0.34). The two conversions compounded and collapsed the 128-level torch register to 7 usable levels (registers 0-6, ~20 mA max). Three neighbouring slider values wrote the same register. Map 0-1 straight onto the register instead, via new set_torch_value / get_torch_value helpers that clamp to 7 bits and mask the reserved bit. Also fixes get_value, which divided the raw register by 20 and rounded to an int — so status/light reported 0 for every reachable register. Note: this changes what a stored 0-1 value means (0.4 was register 2, now register 50), so existing calibrations must be re-measured. Refs fairscope/PlanktoScope3#309 --- controller/light/LM36011.py | 43 +++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/controller/light/LM36011.py b/controller/light/LM36011.py index 3216e0eba..6bf665e9e 100644 --- a/controller/light/LM36011.py +++ b/controller/light/LM36011.py @@ -22,6 +22,9 @@ class Register(enum.IntEnum): DEVICE_ADDRESS = 0x64 # This constant defines the current (mA) sent to the LED, 10 allows the use of the full ISO scale and results in a voltage of 2.77v DEFAULT_CURRENT = 10 + # The torch brightness register is 7 bits wide (bit 7 is reserved), so the + # usable range is 0-127: ~2.4 mA at 0 up to ~376 mA at 127. + TORCH_MAX = 127 def __init__(self): with open("/home/pi/PlanktoScope/hardware.json", "r") as file: @@ -90,6 +93,26 @@ def set_torch_current(self, current): value = int(current * 0.34) self._write_byte(self.Register.torch, value) + def set_torch_value(self, value: int) -> None: + """Write the torch brightness register directly, in native 0-127 units. + + Prefer this over `set_torch_current` when the caller wants brightness + rather than a specific current: going through mA quantises the range + twice (mA * 0.34 collapses 0-20 down to just 7 distinct register + values), which is what made lightness 230 unreachable on v2.6. + """ + self._write_byte(self.Register.torch, self._clamp_torch(value)) + + def get_torch_value(self) -> int: + # Mask off the reserved bit so a stray high bit can't report as brightness. + return self._read_byte(self.Register.torch) & self.TORCH_MAX + + @classmethod + def _clamp_torch(cls, value) -> int: + """Clamp to the 7-bit register range; anything higher would spill into + the reserved bit rather than getting brighter.""" + return max(0, min(int(value), cls.TORCH_MAX)) + def get_torch_current(self): return self._read_byte(self.Register.torch) @@ -152,9 +175,25 @@ def deinit() -> None: def get_value() -> float: - return int(round(led.get_torch_current() / 20)) + # A 0-1 fraction, matching what `set_value` accepts and what the v3 MCP4725 + # driver reports. This used to divide the raw register by 20 and round to an + # int, so `status/light` reported 0 for every register below 10. + return led.get_torch_value() / i2c_led.TORCH_MAX def set_value(value: float) -> None: - led.set_torch_current(int(round(value * 20))) + # `value` is a 0-1 fraction of full brightness, mapped straight onto the + # 7-bit torch register for the full 128 levels the LM36011 offers. + # + # This used to route through set_torch_current(round(value * 20)), which + # quantised twice: 0-1 became 0-20, then mA * 0.34 collapsed that to 7 + # distinct register values (0-6). Three neighbouring slider positions all + # produced the same current, and one step past the boundary jumped a whole + # level — which is why lightness 230 sat unreachable between 219 and 242 + # (fairscope/PlanktoScope3#309). + # + # NOTE: this changes what a stored setting means. The same 0-1 value now + # produces a much higher current than before (0.4 was register 2, it is now + # register 50), so existing calibrations must be re-measured. + led.set_torch_value(round(max(0.0, min(float(value), 1.0)) * i2c_led.TORCH_MAX)) return From dfa4b4d38b20658d45cd007211c157c88b5337a2 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 28 Jul 2026 00:24:49 -0700 Subject: [PATCH 2/3] imager: Lower default ISO to 75 on v2.6 so the LED can hit lightness 230 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On v2.6 the LED is an LM36011 driven by a 7-bit torch register, ~2.94 mA per step. At ISO 150 the lightness-230 target lands at register ~5.5, and one step there moves lightness ~12.8 units against the calibration loop's +/-5 acceptance band — so 230 is unreachable even with the full register range restored. Halving the ISO doubles the current the LED must supply for the same image, moving the operating point to register ~14 where a step is ~6.2 units and lands in-band. Simulating the dashboard's update rule, ISO 75 plus the restored range converges in 3 iterations; either change alone still fails. Scoped to pre-v3 HATs. v3 drives the LED through a 4096-level MCP4725 DAC and already resolves the target far finer than the band needs, so it would gain nothing while needing ~2x the LED output to hold the same target -- with no measurement confirming it has that headroom -- and would shift its acquisition brightness and force every v3 device to re-calibrate. hat_version is already on the configuration dict; an absent or unreadable value keeps ISO 150. Changes acq_camera_iso on v2.6; those devices must re-calibrate. Refs fairscope/PlanktoScope3#309 --- controller/imager/camera/mqtt.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/controller/imager/camera/mqtt.py b/controller/imager/camera/mqtt.py index 77d7b2829..b2c755dcc 100644 --- a/controller/imager/camera/mqtt.py +++ b/controller/imager/camera/mqtt.py @@ -54,6 +54,13 @@ def __init__( ) settings = settings.overlay(hardware.config_to_settings_values(configuration)) + # Whether the light controller will drive the LED through the LM36011, whose + # coarse torch register decides the default ISO below. 3.2 is the same + # threshold LM36011.py uses to detect pre-v3 HATs; an absent or unreadable + # hat_version falls through to the v3 default rather than guessing. + hat_version = float(configuration.get("hat_version") or 0) + self._coarse_led = 0 < hat_version < 3.2 + # I/O self._camera: typing.Optional[hardware.PiCamera] = hardware.PiCamera( initial_settings=settings @@ -76,7 +83,23 @@ def run(self) -> None: return self._camera_checked.set() - default_iso = 150 + # The lightness calibration loop's only knob is the LED, so the ISO decides + # how finely it can land on its target. + # + # On v2.6 the LED is an LM36011 driven by a 7-bit torch register, ~2.94 mA + # per step. At ISO 150 the lightness-230 target falls at register ~5.5, and + # one step there moves lightness ~12.8 units against the loop's +/-5 + # acceptance band — so 230 is unreachable. Halving the ISO doubles the + # current the LED must supply for the same image, moving the operating point + # up to register ~14 where a step is ~6.2 units and lands in-band. + # + # v3 drives the LED through a 4096-level MCP4725 DAC, which already resolves + # the target far more finely than the band requires. It gains nothing here, + # so leave it on 150 rather than shift its acquisition brightness and force + # every v3 device to re-calibrate. + # + # See fairscope/PlanktoScope3#309. + default_iso = 75 if self._coarse_led else 150 loguru.logger.debug(f"Setting camera image gain for default ISO value of {default_iso}...") changes = hardware.SettingsValues(image_gain=default_iso / hardware.ISO_CALIBRATION) try: From 0e95bbf23493631c955c6f1093010bf247f52728 Mon Sep 17 00:00:00 2001 From: Adam Date: Tue, 28 Jul 2026 00:50:36 -0700 Subject: [PATCH 3/3] light: Satisfy mypy in the new torch helpers get_torch_value returned `_read_byte(...) & TORCH_MAX`, which is Any because _read_byte is unannotated, tripping warn_return_any. _clamp_torch annotated its return but not its parameter, tripping disallow_incomplete_defs. --- controller/light/LM36011.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/controller/light/LM36011.py b/controller/light/LM36011.py index 6bf665e9e..617a8870d 100644 --- a/controller/light/LM36011.py +++ b/controller/light/LM36011.py @@ -105,10 +105,11 @@ def set_torch_value(self, value: int) -> None: def get_torch_value(self) -> int: # Mask off the reserved bit so a stray high bit can't report as brightness. - return self._read_byte(self.Register.torch) & self.TORCH_MAX + # `_read_byte` is untyped, so narrow it explicitly rather than leaking Any. + return int(self._read_byte(self.Register.torch) & self.TORCH_MAX) @classmethod - def _clamp_torch(cls, value) -> int: + def _clamp_torch(cls, value: float) -> int: """Clamp to the 7-bit register range; anything higher would spill into the reserved bit rather than getting brighter.""" return max(0, min(int(value), cls.TORCH_MAX))