From 6c49b8231ddb069c1cb62532b75c467d25ec7716 Mon Sep 17 00:00:00 2001 From: Naushir Patuck Date: Fri, 12 Jun 2026 12:09:43 +0100 Subject: [PATCH 1/2] ctt: rework the lightbox API around illuminant names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Illuminants get short canonical names (Halogen10, HalogenBF, ...) plus descriptive labels and a typo-safe Illuminant enum; resolution accepts any of them, or a channel number, with case/punctuation folding. The public surface shrinks to set_illuminant()/off() for writes and get_state()/get_default_intensity()/info() for reads — get_state() reads channel and intensity atomically under one driver lock, and info() is now the complete snapshot including the illuminant maps. Drivers implement int-only underscore primitives behind the resolution layer. --- apps/ctt-server/ctt_server/app.py | 20 ++- .../ctt_server/templates/capture.html | 3 +- .../ctt_server/templates/preview.html | 3 +- apps/ctt/ctt/devices/__init__.py | 3 +- apps/ctt/ctt/devices/__main__.py | 22 +-- apps/ctt/ctt/devices/lightbox.py | 134 +++++++++++++----- .../ctt/ctt/devices/lightstudio_s/__init__.py | 4 +- apps/ctt/ctt/devices/lightstudio_s/device.py | 63 +++++--- docs/device-control.md | 40 ++++-- tests/test_ctt_server_lightbox.py | 30 ++-- tests/test_devices_lightbox.py | 59 +++++--- tests/test_devices_lightstudio.py | 16 ++- 12 files changed, 274 insertions(+), 123 deletions(-) diff --git a/apps/ctt-server/ctt_server/app.py b/apps/ctt-server/ctt_server/app.py index 18b2913..b66186f 100644 --- a/apps/ctt-server/ctt_server/app.py +++ b/apps/ctt-server/ctt_server/app.py @@ -184,8 +184,7 @@ def lightbox_status() -> dict: if box is None: return {'present': False} try: - info = box.info() - return {'present': True, 'illuminants': box.illuminants, **info} + return {'present': True, **box.info()} except LightboxError: # device went away mid-session return {'present': False} @@ -354,19 +353,18 @@ def api_set_lightbox(): box = lightbox_or_503() body = request.get_json(force=True) or {} percent = body.get('percent') + if percent is not None: + percent = float(percent) + # set_illuminant accepts names and channel numbers alike, so 'illuminant' + # and 'channel' are interchangeable; a bare 'percent' adjusts the active one. + target = body.get('illuminant') if body.get('illuminant') is not None else body.get('channel') try: if body.get('off'): box.off() - elif body.get('illuminant') is not None: - box.set_illuminant(body['illuminant'], None if percent is None else float(percent)) - elif body.get('channel') is not None: - channel = int(body['channel']) - if percent is None: - box.set_channel(channel) - else: - box.set_intensity(channel, float(percent)) + elif target is not None: + box.set_illuminant(target, percent) elif percent is not None: - box.set_intensity(box.get_channel(), float(percent)) + box.set_illuminant(box.get_state().channel, percent) except (LightboxError, TypeError, ValueError) as err: return jsonify({'error': str(err)}), 400 return jsonify(lightbox_status()) diff --git a/apps/ctt-server/ctt_server/templates/capture.html b/apps/ctt-server/ctt_server/templates/capture.html index 146a31d..3c23cae 100644 --- a/apps/ctt-server/ctt_server/templates/capture.html +++ b/apps/ctt-server/ctt_server/templates/capture.html @@ -116,7 +116,8 @@

Lightbox

diff --git a/apps/ctt-server/ctt_server/templates/preview.html b/apps/ctt-server/ctt_server/templates/preview.html index 53e82e4..bbd6be4 100644 --- a/apps/ctt-server/ctt_server/templates/preview.html +++ b/apps/ctt-server/ctt_server/templates/preview.html @@ -120,7 +120,8 @@

Lightbox

diff --git a/apps/ctt/ctt/devices/__init__.py b/apps/ctt/ctt/devices/__init__.py index 3eabec7..e98b9c9 100644 --- a/apps/ctt/ctt/devices/__init__.py +++ b/apps/ctt/ctt/devices/__init__.py @@ -4,13 +4,14 @@ # # Generic, pluggable device interfaces for CTT (currently: controllable lightboxes). -from .lightbox import Lightbox, LightboxError +from .lightbox import Lightbox, LightboxError, LightboxState from .registry import DRIVERS, get_lightbox, get_shared_lightbox, register_driver __all__ = [ 'DRIVERS', 'Lightbox', 'LightboxError', + 'LightboxState', 'get_lightbox', 'get_shared_lightbox', 'register_driver', diff --git a/apps/ctt/ctt/devices/__main__.py b/apps/ctt/ctt/devices/__main__.py index 7eb1223..4613867 100644 --- a/apps/ctt/ctt/devices/__main__.py +++ b/apps/ctt/ctt/devices/__main__.py @@ -32,15 +32,15 @@ def main(argv: list[str] | None = None) -> int: sub.add_parser('status', help='show current channel + intensity') sub.add_parser('get', help="read the active channel's intensity") - p_set = sub.add_parser('set', help='set a channel to an intensity (0-100 %%)') - p_set.add_argument('channel', type=int, help='channel number') + p_set = sub.add_parser('set', help='set an illuminant to an intensity (0-100 %%)') + p_set.add_argument('illuminant', help='illuminant name (e.g. D65) or channel number') p_set.add_argument('percent', type=float, help='intensity 0-100') - p_chan = sub.add_parser('channel', help='switch to a channel at its default intensity') - p_chan.add_argument('channel', type=int, help='channel number') + p_chan = sub.add_parser('channel', help='switch to an illuminant at its default intensity') + p_chan.add_argument('illuminant', help='illuminant name (e.g. D65) or channel number') p_illum = sub.add_parser('illuminant', help='switch to a named illuminant (e.g. D65)') - p_illum.add_argument('name', help='illuminant name') + p_illum.add_argument('name', help='illuminant name or channel number') p_illum.add_argument('percent', type=float, nargs='?', default=None, help='optional intensity 0-100') sub.add_parser('off', help='turn the active channel off') @@ -62,17 +62,21 @@ def main(argv: list[str] | None = None) -> int: if args.cmd == 'probe': print(f'{box.model} (serial {box.serial or "?"})') print('illuminants:') + labels = box.illuminant_labels for channel, name in box.illuminants.items(): - print(f' {channel}: {name}') + label = labels.get(channel, '') + extra = f' ({label})' if label and label != name else '' + print(f' {channel}: {name}{extra}') elif args.cmd == 'status': _print_status(box) elif args.cmd == 'get': - print(f'{box.get_intensity():.0f}%') + state = box.get_state() + print(f'{state.illuminant or state.channel} at {state.intensity:.0f}%') elif args.cmd == 'set': - box.set_intensity(args.channel, args.percent) + box.set_illuminant(args.illuminant, args.percent) _print_status(box) elif args.cmd == 'channel': - box.set_channel(args.channel) + box.set_illuminant(args.illuminant) _print_status(box) elif args.cmd == 'illuminant': box.set_illuminant(args.name, args.percent) diff --git a/apps/ctt/ctt/devices/lightbox.py b/apps/ctt/ctt/devices/lightbox.py index 3c6b944..4ac8f16 100644 --- a/apps/ctt/ctt/devices/lightbox.py +++ b/apps/ctt/ctt/devices/lightbox.py @@ -16,21 +16,40 @@ import logging from abc import ABC, abstractmethod +from typing import NamedTuple logger = logging.getLogger(__name__) +class LightboxState(NamedTuple): + """The live device state — one channel is active at a time.""" + + channel: int + illuminant: str | None + intensity: float + + class LightboxError(RuntimeError): """Raised when a lightbox is unavailable or a command fails.""" +def _fold(name: str) -> str: + """Normalise an illuminant name for matching: drop case, spacing and punctuation.""" + return ''.join(ch for ch in str(name).lower() if ch.isalnum()) + + class Lightbox(ABC): """A controllable illumination device with one or more illuminant channels. Intensity is always expressed in percent (0–100). Channels are 1-based and map - to named illuminants via `illuminants`. Concrete drivers implement the abstract - primitives plus `probe`; the convenience methods (`set_illuminant`, `off`, - `info`) and context-manager support are built on top of those primitives. + to named illuminants via `illuminants`; every public method accepts either the + illuminant name (case-insensitive, e.g. 'D65') or the channel number. Concrete + drivers implement the abstract per-channel primitives plus `probe`; the public + methods and context-manager support are built on top of those primitives. + + Only one channel is active at a time, and selecting an intensity is what + switches channels — intensities cannot be staged on an inactive channel (the + only stored per-channel value is the device's power-on default). """ # --- identity (drivers may override) ----------------------------------- @@ -45,7 +64,26 @@ def serial(self) -> str | None: @property @abstractmethod def illuminants(self) -> dict[int, str]: - """Map of channel number → illuminant name.""" + """Map of channel number → canonical illuminant name (short, e.g. 'D65').""" + + @property + def illuminant_labels(self) -> dict[int, str]: + """Map of channel number → human-readable description, for UIs. + + Defaults to the canonical names; drivers override where a fuller + description helps (e.g. 'HalogenBF' → 'Halogen + blue filter (400 lux)'). + Labels are also accepted anywhere an illuminant name is. + """ + return self.illuminants + + @property + def illuminant_aliases(self) -> dict[int, set[str]]: + """Map of channel number → extra accepted names (alternate spellings). + + Matching already ignores case, spacing and punctuation; this is for + genuinely different names. Defaults to none. + """ + return {} @property def illuminant_temps(self) -> dict[int, int]: @@ -68,69 +106,97 @@ def probe(cls, serial: str | None = None) -> Lightbox | None: for the merely-absent case, so the registry can try the next driver. """ - # --- control primitives (drivers implement) ---------------------------- + # --- control primitives (drivers implement, channel numbers only) ------- @abstractmethod - def set_intensity(self, channel: int, percent: float) -> None: + def _set_intensity(self, channel: int, percent: float) -> None: """Set `channel` to `percent` (0–100 %); also makes it the active channel.""" @abstractmethod - def get_intensity(self) -> float: - """Return the active channel's intensity (0–100 %).""" - - @abstractmethod - def get_channel(self) -> int: - """Return the active channel number.""" + def _get_state(self) -> tuple[int, float]: + """Return (active channel, its intensity 0–100 %), read atomically.""" @abstractmethod - def set_channel(self, channel: int) -> None: + def _set_channel(self, channel: int) -> None: """Switch to `channel` at its stored default intensity.""" @abstractmethod - def get_default_intensity(self, channel: int) -> float: + def _get_default_intensity(self, channel: int) -> float: """Return `channel`'s power-on default intensity (0–100 %).""" @abstractmethod def close(self) -> None: """Release the device.""" - # --- convenience (built on the primitives) ------------------------------ - def set_illuminant(self, name: str, percent: float | None = None) -> int: - """Select the channel whose illuminant matches `name` (case-insensitive). + # --- public control (illuminant = name or channel number) --------------- + def set_illuminant(self, illuminant: int | str, percent: float | None = None) -> int: + """Switch to an illuminant, the single write entry point. - With `percent` None the channel is switched on at its default intensity; - otherwise it is set to `percent`. Returns the resolved channel number. + `illuminant` is a name (case-insensitive, e.g. 'D65'), a channel number, + or a driver enum member. With `percent` None the illuminant comes on at + its stored default intensity, otherwise at `percent` (0–100 %). Returns + the resolved channel number. + + Intensities cannot be staged on an inactive channel: setting one always + switches to that channel (the only stored per-channel value is the + device's power-on default). """ - channel = self._resolve_illuminant(name) + channel = self._resolve(illuminant) if percent is None: - self.set_channel(channel) + self._set_channel(channel) else: - self.set_intensity(channel, percent) + self._set_intensity(channel, percent) return channel + def get_state(self) -> LightboxState: + """Return the live state: the active channel, its illuminant name and + intensity — read atomically (channel and intensity change together).""" + channel, intensity = self._get_state() + return LightboxState(channel, self.illuminants.get(channel), intensity) + + def get_default_intensity(self, illuminant: int | str) -> float: + """Return an illuminant's power-on default intensity (0–100 %).""" + return self._get_default_intensity(self._resolve(illuminant)) + def off(self) -> None: """Turn the active channel off (0 %).""" - channel = self.get_channel() + channel, _ = self._get_state() if channel in self.illuminants: - self.set_intensity(channel, 0) + self._set_intensity(channel, 0) def info(self) -> dict: - """A small status snapshot for UIs/CLIs.""" - channel = self.get_channel() + """The complete status snapshot for UIs/CLIs: identity, capabilities + (illuminant maps) and live state (active channel + intensity).""" + state = self.get_state() return { 'model': self.model, 'serial': self.serial, - 'channel': channel, - 'illuminant': self.illuminants.get(channel), - 'intensity': self.get_intensity(), + 'channel': state.channel, + 'illuminant': state.illuminant, + 'intensity': state.intensity, + 'illuminants': self.illuminants, + 'illuminant_labels': self.illuminant_labels, 'illuminant_temps': self.illuminant_temps, } - def _resolve_illuminant(self, name: str) -> int: - key = str(name).strip().lower() - for channel, illuminant in self.illuminants.items(): - if illuminant.lower() == key: + def _resolve(self, illuminant: int | str) -> int: + """Resolve an illuminant name or channel number to a channel number. + + Name matching ignores case, spacing and punctuation, and accepts the + canonical names, the descriptive labels and any driver aliases; numeric + strings are treated as channel numbers. + """ + if isinstance(illuminant, int): + if illuminant in self.illuminants: + return illuminant + raise LightboxError(f'invalid channel {illuminant!r}; valid channels are {sorted(self.illuminants)}') + key = _fold(illuminant) + if key.isdigit(): + return self._resolve(int(key)) + for channel, name in self.illuminants.items(): + names = {name, self.illuminant_labels.get(channel, '')} | set(self.illuminant_aliases.get(channel, ())) + if key in {_fold(n) for n in names if n}: return channel - raise LightboxError(f'unknown illuminant {name!r}; known: {sorted(self.illuminants.values())}') + raise LightboxError(f'unknown illuminant {illuminant!r}; known: {sorted(self.illuminants.values())}') # --- context manager --------------------------------------------------- def __enter__(self) -> Lightbox: diff --git a/apps/ctt/ctt/devices/lightstudio_s/__init__.py b/apps/ctt/ctt/devices/lightstudio_s/__init__.py index 7fc5132..39a1647 100644 --- a/apps/ctt/ctt/devices/lightstudio_s/__init__.py +++ b/apps/ctt/ctt/devices/lightstudio_s/__init__.py @@ -4,6 +4,6 @@ # # Image Engineering lightSTUDIO-S driver package. -from .device import CHANNEL_NAMES, CHANNEL_TEMPS, LightStudioS +from .device import CHANNEL_LABELS, CHANNEL_NAMES, CHANNEL_TEMPS, Illuminant, LightStudioS -__all__ = ['CHANNEL_NAMES', 'CHANNEL_TEMPS', 'LightStudioS'] +__all__ = ['CHANNEL_LABELS', 'CHANNEL_NAMES', 'CHANNEL_TEMPS', 'Illuminant', 'LightStudioS'] diff --git a/apps/ctt/ctt/devices/lightstudio_s/device.py b/apps/ctt/ctt/devices/lightstudio_s/device.py index e361218..a804ddf 100644 --- a/apps/ctt/ctt/devices/lightstudio_s/device.py +++ b/apps/ctt/ctt/devices/lightstudio_s/device.py @@ -9,6 +9,7 @@ from __future__ import annotations +import enum import logging import threading @@ -16,8 +17,42 @@ logger = logging.getLogger(__name__) + +class Illuminant(enum.StrEnum): + """The lightSTUDIO-S illuminants, for typo-safe scripting. + + Any API that takes an illuminant also accepts these (they are plain strings), + e.g. box.set_illuminant(Illuminant.D65). + + Members are deliberately named exactly like their string values (rather than + PEP 8's UPPER_CASE) so the two spellings can never diverge. + """ + + F12 = 'F12' + F11 = 'F11' + D50 = 'D50' + D65 = 'D65' + Halogen10 = 'Halogen10' + Halogen100 = 'Halogen100' + Halogen400 = 'Halogen400' + HalogenBF = 'HalogenBF' + + # Fixed illuminant per channel on the lightSTUDIO-S (from the CLI manual). CHANNEL_NAMES = { + 1: str(Illuminant.F12), + 2: str(Illuminant.F11), + 3: str(Illuminant.D50), + 4: str(Illuminant.D65), + 5: str(Illuminant.Halogen10), + 6: str(Illuminant.Halogen100), + 7: str(Illuminant.Halogen400), + 8: str(Illuminant.HalogenBF), +} + +# Descriptive labels for UIs; also accepted as illuminant names (matching folds +# case, spacing and punctuation, so e.g. 'halogen (10 lux)' resolves channel 5). +CHANNEL_LABELS = { 1: 'F12', 2: 'F11', 3: 'D50', @@ -179,12 +214,16 @@ def serial(self) -> str | None: def illuminants(self) -> dict[int, str]: return CHANNEL_NAMES + @property + def illuminant_labels(self) -> dict[int, str]: + return CHANNEL_LABELS + @property def illuminant_temps(self) -> dict[int, int]: return CHANNEL_TEMPS - # --- control primitives ------------------------------------------------ - def set_intensity(self, channel: int, percent: float) -> None: + # --- control primitives (the base class resolves names and validates) --- + def _set_intensity(self, channel: int, percent: float) -> None: """Set `channel` to `percent` (0–100 %). Also makes it the active channel. Mirrors the CLI's `--channel N --setIntensity P`; setting an intensity is how @@ -194,27 +233,19 @@ def set_intensity(self, channel: int, percent: float) -> None: with self._lock: self._set_intensity_locked(channel, self._clamp_percent(percent)) - def get_intensity(self) -> float: - """Read the active channel's intensity (0–100 %). - - The device only reports the *current* channel, so this takes no argument - (matching the CLI's `--getIntensity`). - """ - with self._lock: - return self._get_intensity_locked() - - def get_channel(self) -> int: - """Return the currently selected channel number.""" + def _get_state(self) -> tuple[int, float]: + """Read (channel, intensity) under one lock — the device only reports the + *current* channel (CLI `--getChannel` / `--getIntensity`).""" with self._lock: - return self._get_channel_locked() + return self._get_channel_locked(), self._get_intensity_locked() - def get_default_intensity(self, channel: int) -> float: + def _get_default_intensity(self, channel: int) -> float: """Return `channel`'s power-on default intensity (0–100 %).""" self._validate_channel(channel) with self._lock: return self._get_default_intensity_locked(channel) - def set_channel(self, channel: int) -> None: + def _set_channel(self, channel: int) -> None: """Switch on `channel` at its stored default intensity (CLI `--setChannel`).""" self._validate_channel(channel) with self._lock: diff --git a/docs/device-control.md b/docs/device-control.md index f0570e9..06dbeac 100644 --- a/docs/device-control.md +++ b/docs/device-control.md @@ -29,29 +29,49 @@ runs on re-enumeration. The user running the server must be in the `plugdev` gro ## Use +Every method that takes an illuminant accepts its name (case-insensitive — the +short name, the descriptive label or a driver alias), a channel number, or a +member of the driver's `Illuminant` enum. Note that only one channel is lit at +a time, and setting an intensity is what switches channels — intensities cannot +be staged on an inactive channel (the only stored per-channel value is the +device's power-on default). + ```python from ctt.devices import get_lightbox +from ctt.devices.lightstudio_s import Illuminant with get_lightbox() as box: # first attached, supported lightbox - box.set_illuminant('D65') # name → channel, at its default intensity - box.set_intensity(4, 50) # channel 4 (D65) at 50 % - print(box.info()) + box.set_illuminant('D65') # switch to D65 at its default intensity + box.set_illuminant('D65', 50) # ... at 50 % + box.set_illuminant(Illuminant.HalogenBF, 80) # typo-safe enum (a plain str) + box.set_illuminant(4, 50) # channel numbers work too + + state = box.get_state() # LightboxState(channel, illuminant, intensity) + print(f'{state.illuminant} at {state.intensity:.0f}%') + box.get_default_intensity('D65') # a channel's power-on default (100.0) + print(box.info()) # full snapshot: identity, illuminant maps, state + box.off() ``` ```bash ctt-lightbox probe # find the box, list illuminants ctt-lightbox status -ctt-lightbox set 1 50 # channel 1 (F12) → 50 % +ctt-lightbox set F12 50 # F12 (channel 1) → 50 % +ctt-lightbox set 1 50 # same, by channel number ctt-lightbox illuminant D65 # switch to D65 at its default ctt-lightbox off ``` ## lightSTUDIO-S channels -| Ch | Illuminant | Default % | | Ch | Illuminant | Default % | -|----|------------|-----------|----|----|------------|-----------| -| 1 | F12 | 100 | | 5 | Halogen (10 lux) | 2 | -| 2 | F11 | 100 | | 6 | Halogen (100 lux) | 25 | -| 3 | D50 | 100 | | 7 | Halogen (400 lux) | 100 | -| 4 | D65 | 100 | | 8 | Halogen + blue filter (400 lux) | 100 | +| Ch | Name | Description | Default % | +|----|------|-------------|-----------| +| 1 | `F12` | F12 fluorescent | 100 | +| 2 | `F11` | F11 fluorescent | 100 | +| 3 | `D50` | D50 daylight | 100 | +| 4 | `D65` | D65 daylight | 100 | +| 5 | `Halogen10` | Halogen (10 lux) | 2 | +| 6 | `Halogen100` | Halogen (100 lux) | 25 | +| 7 | `Halogen400` | Halogen (400 lux) | 100 | +| 8 | `HalogenBF` | Halogen + blue filter (400 lux) | 100 | diff --git a/tests/test_ctt_server_lightbox.py b/tests/test_ctt_server_lightbox.py index 85a52c8..d85f167 100644 --- a/tests/test_ctt_server_lightbox.py +++ b/tests/test_ctt_server_lightbox.py @@ -7,7 +7,7 @@ import pytest -from ctt.devices import LightboxError +from ctt.devices import LightboxError, LightboxState from ctt_server import app as app_module @@ -28,22 +28,19 @@ def info(self): 'channel': self._channel, 'illuminant': self.illuminants.get(self._channel), 'intensity': self._intensity, + 'illuminants': self.illuminants, } - def get_channel(self): - return self._channel + def get_state(self): + return LightboxState(self._channel, self.illuminants.get(self._channel), self._intensity) - def set_intensity(self, channel, percent): - self.calls.append(('set_intensity', channel, percent)) - self._channel, self._intensity = channel, percent - - def set_channel(self, channel): - self.calls.append(('set_channel', channel)) - self._channel, self._intensity = channel, 100.0 - - def set_illuminant(self, name, percent=None): - self.calls.append(('set_illuminant', name, percent)) - self._channel = next(c for c, n in self.illuminants.items() if n.lower() == name.lower()) + def set_illuminant(self, illuminant, percent=None): + self.calls.append(('set_illuminant', illuminant, percent)) + if isinstance(illuminant, int): + self._channel = illuminant + else: + self._channel = next(c for c, n in self.illuminants.items() if n.lower() == str(illuminant).lower()) + self._intensity = 100.0 if percent is None else percent def off(self): self.calls.append(('off',)) @@ -81,11 +78,14 @@ def test_post_sets_intensity_illuminant_and_off(client, monkeypatch): monkeypatch.setattr(app_module, 'get_shared_lightbox', lambda: box) assert client.post('/api/lightbox', json={'channel': 4, 'percent': 50}).status_code == 200 - assert ('set_intensity', 4, 50.0) in box.calls + assert ('set_illuminant', 4, 50.0) in box.calls assert client.post('/api/lightbox', json={'illuminant': 'D65'}).status_code == 200 assert ('set_illuminant', 'D65', None) in box.calls + assert client.post('/api/lightbox', json={'percent': 25}).status_code == 200 + assert ('set_illuminant', 4, 25.0) in box.calls # bare percent targets the active channel + assert client.post('/api/lightbox', json={'off': True}).status_code == 200 assert ('off',) in box.calls diff --git a/tests/test_devices_lightbox.py b/tests/test_devices_lightbox.py index 19da348..6ac7bac 100644 --- a/tests/test_devices_lightbox.py +++ b/tests/test_devices_lightbox.py @@ -27,20 +27,17 @@ def probe(cls, serial=None): def illuminants(self): return self._ILLUM - def set_intensity(self, channel, percent): + def _set_intensity(self, channel, percent): self._channel = channel self._intensity[channel] = percent - def get_intensity(self): - return self._intensity.get(self._channel, 0.0) + def _get_state(self): + return self._channel, self._intensity.get(self._channel, 0.0) - def get_channel(self): - return self._channel - - def set_channel(self, channel): + def _set_channel(self, channel): self._channel = channel - def get_default_intensity(self, channel): + def _get_default_intensity(self, channel): return 100.0 def close(self): @@ -60,10 +57,35 @@ def clean_registry(): def test_set_illuminant_resolves_name_case_insensitively(): box = FakeBox() assert box.set_illuminant('d65') == 4 - assert box.get_channel() == 4 + assert box.get_state().channel == 4 box.set_illuminant('F12', 50) - assert box.get_channel() == 1 - assert box.get_intensity() == 50 + assert box.get_state().channel == 1 + assert box.get_state().intensity == 50 + + +def test_set_illuminant_accepts_names_and_channels(): + box = FakeBox() + assert box.set_illuminant('d65', 40) == 4 + assert box.get_state().intensity == 40 + assert box.set_illuminant(1, 60) == 1 + assert box.set_illuminant('D65') == 4 + assert box.get_default_intensity('f12') == 100.0 + assert box.set_illuminant('4') == 4 # numeric strings are channel numbers + + +def test_resolve_folds_labels_and_aliases(): + class LabelledBox(FakeBox): + @property + def illuminant_labels(self): + return {1: 'F12 (fluorescent)', 4: 'D65'} + + @property + def illuminant_aliases(self): + return {4: {'daylight'}} + + box = LabelledBox() + assert box.set_illuminant('f12 (Fluorescent)') == 1 # label, case/punctuation folded + assert box.set_illuminant('Daylight') == 4 # driver alias def test_set_illuminant_unknown_name_raises(): @@ -75,7 +97,7 @@ def test_off_zeroes_active_channel(): box = FakeBox() box.set_illuminant('D65', 90) box.off() - assert box.get_intensity() == 0 + assert box.get_state().intensity == 0 def test_info_snapshot(): @@ -110,15 +132,12 @@ def probe(cls, serial=None): illuminants = {} - def set_intensity(self, channel, percent): ... - def get_intensity(self): - return 0.0 - - def get_channel(self): - return 0 + def _set_intensity(self, channel, percent): ... + def _get_state(self): + return 0, 0.0 - def set_channel(self, channel): ... - def get_default_intensity(self, channel): + def _set_channel(self, channel): ... + def _get_default_intensity(self, channel): return 0.0 def close(self): ... diff --git a/tests/test_devices_lightstudio.py b/tests/test_devices_lightstudio.py index 965ce7f..af7f9cc 100644 --- a/tests/test_devices_lightstudio.py +++ b/tests/test_devices_lightstudio.py @@ -9,7 +9,7 @@ import pytest from ctt.devices import LightboxError -from ctt.devices.lightstudio_s import CHANNEL_NAMES, CHANNEL_TEMPS, LightStudioS +from ctt.devices.lightstudio_s import CHANNEL_LABELS, CHANNEL_NAMES, CHANNEL_TEMPS, Illuminant, LightStudioS def _bare(): @@ -87,8 +87,18 @@ def test_illuminant_temps_property(): def test_resolve_illuminant_on_real_map(): # The generic name→channel resolver, exercised against the driver's illuminants. box = _bare() - assert box._resolve_illuminant('D65') == 4 - assert box._resolve_illuminant('f12') == 1 + assert box._resolve('D65') == 4 + assert box._resolve('f12') == 1 + assert box._resolve(Illuminant.HalogenBF) == 8 + assert box._resolve('halogenbf') == 8 + assert box._resolve('Halogen (10 lux)') == 5 # descriptive labels resolve too + assert box._resolve('halogen 100') == 6 + assert box._resolve(7) == 7 + + +def test_labels_cover_all_channels(): + assert set(CHANNEL_LABELS) == set(CHANNEL_NAMES) + assert CHANNEL_LABELS[8] == 'Halogen + blue filter (400 lux)' def test_unconfigured_id_raises(): From e4fa4a8e9c393782fc199902d180d4844fd0e9dc Mon Sep 17 00:00:00 2001 From: Naushir Patuck Date: Fri, 12 Jun 2026 12:16:26 +0100 Subject: [PATCH 2/2] ctt-server: handle a fresh lightbox with no channel selected A just-powered lightSTUDIO-S reports channel 0 until something is set, so the UI dropdown displayed the first illuminant while its model held 0, and the first intensity change failed with 'invalid channel 0'. Snap the UI model to the first illuminant when the reported channel is not a valid one, and give the bare-percent API path a friendly error when no illuminant is active. --- apps/ctt-server/ctt_server/app.py | 5 ++++- apps/ctt-server/ctt_server/static/app.js | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ctt-server/ctt_server/app.py b/apps/ctt-server/ctt_server/app.py index b66186f..835e434 100644 --- a/apps/ctt-server/ctt_server/app.py +++ b/apps/ctt-server/ctt_server/app.py @@ -364,7 +364,10 @@ def api_set_lightbox(): elif target is not None: box.set_illuminant(target, percent) elif percent is not None: - box.set_illuminant(box.get_state().channel, percent) + state = box.get_state() + if state.illuminant is None: # fresh device: channel 0, nothing selected yet + return jsonify({'error': 'No illuminant is active — select one first.'}), 400 + box.set_illuminant(state.channel, percent) except (LightboxError, TypeError, ValueError) as err: return jsonify({'error': str(err)}), 400 return jsonify(lightbox_status()) diff --git a/apps/ctt-server/ctt_server/static/app.js b/apps/ctt-server/ctt_server/static/app.js index 0934626..c5323d6 100644 --- a/apps/ctt-server/ctt_server/static/app.js +++ b/apps/ctt-server/ctt_server/static/app.js @@ -224,7 +224,10 @@ function captureApp(cfg) { const r = await fetch('/api/lightbox'); if (!r.ok) return; const lb = await r.json(); - const ch = lb.channel; // capture before mutating: lb IS this.lightbox below + let ch = lb.channel; // capture before mutating: lb IS this.lightbox below + // A fresh device reports channel 0 (nothing selected yet); snap to the + // first illuminant so the model matches what the dropdown renders. + if (lb.present && lb.illuminants && lb.illuminants[ch] == null) ch = Number(Object.keys(lb.illuminants)[0]); this.lightbox = lb; if (lb.present && ch != null) { // Re-assert the active channel once the 's x-for options exist