Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions apps/ctt-server/ctt_server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -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())
Expand Down
10 changes: 8 additions & 2 deletions apps/ctt-server/ctt_server/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <select>'s x-for <option>s exist:
Expand Down Expand Up @@ -939,7 +942,10 @@ function resultsApp(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 <select>'s x-for options exist
Expand Down
3 changes: 2 additions & 1 deletion apps/ctt-server/ctt_server/templates/capture.html
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ <h2>Lightbox</h2>
<label>Illuminant</label>
<select x-model.number="lightbox.channel" @change="setIlluminant()">
<template x-for="[ch, name] in Object.entries(lightbox.illuminants)" :key="ch">
<option :value="Number(ch)" x-text="ch + ' — ' + name"></option>
<option :value="Number(ch)" x-text="ch + ' — ' + name"
:title="(lightbox.illuminant_labels && lightbox.illuminant_labels[ch]) || name"></option>
</template>
</select>
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/ctt-server/ctt_server/templates/preview.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ <h2>Lightbox</h2>
<label>Illuminant</label>
<select x-model.number="lightbox.channel" @change="setIlluminant()">
<template x-for="[ch, name] in Object.entries(lightbox.illuminants)" :key="ch">
<option :value="Number(ch)" x-text="ch + ' — ' + name"></option>
<option :value="Number(ch)" x-text="ch + ' — ' + name"
:title="(lightbox.illuminant_labels && lightbox.illuminant_labels[ch]) || name"></option>
</template>
</select>
</div>
Expand Down
3 changes: 2 additions & 1 deletion apps/ctt/ctt/devices/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
22 changes: 13 additions & 9 deletions apps/ctt/ctt/devices/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
Expand Down
134 changes: 100 additions & 34 deletions apps/ctt/ctt/devices/lightbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) -----------------------------------
Expand All @@ -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]:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions apps/ctt/ctt/devices/lightstudio_s/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Loading
Loading