diff --git a/apps/ctt-server/ctt_server/app.py b/apps/ctt-server/ctt_server/app.py index 18b2913..835e434 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,21 @@ 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)) + 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 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():