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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

All notable changes to the Lager platform are documented here. For detailed release notes, see [docs.lagerdata.com](https://docs.lagerdata.com).

## [0.31.10] - 2026-07-13

### Added
- **`lager battery models` lists the battery models saved on the instrument** —
occupied memory slots plus the firmware built-ins, all valid inputs to the
existing `model` command. Read-only, and also exposed in the battery TUI and
the box's `/battery/command` endpoint (`list_models`).
- **8 more Logitech webcams are detected and usable as webcam nets**: Logi 4K
Pro, BRIO 4K Stream, C925e, C922 Pro, C920, C615, C270, and StreamCam join
the existing BRIO/BRIO HD/C930e. Camera detection is now catalog-driven
(adding a model is a table entry, not code) and maps each camera to its
actual /dev/video capture node via sysfs, so mixed setups with different
per-camera node counts resolve correctly.

### Fixed
- **Battery model readback reports the actual loaded model.** The driver read
`:BATT:STAT?`, which reports charge/discharge status rather than the model,
so `model`, `state`, and the TUI always showed "DISCHARGE" and successful
loads raised a false "slot is empty" error. Readback and verification now
use `:BATT:MOD:RCL?`, which also reliably detects the 2281S's silent
empty-slot recall failures.
- **Built-in battery models are loadable over SCPI.** The 2281S rejects the
manual's hyphenated names (`LI-ION4_2`) with a syntax error; the driver now
sends the underscore spellings the firmware accepts and takes either form
as input.
- **Battery driver errors are no longer masked as "[Errno 16] Resource busy".**
The box endpoint returned driver errors as 5xx, tripping the CLI's legacy
direct-USB fallback; they now come back as ordinary command failures so the
real message is shown.

## [0.31.9] - 2026-07-13

### Fixed
Expand Down
68 changes: 64 additions & 4 deletions box/lager/http_handlers/battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@
logger = logging.getLogger(__name__)


def _device_error_message(exc) -> str:
"""Extract the driver's own message from a hardware_service DeviceError.

Driver exceptions come back from /invoke as
DeviceError({'error': 'Function call failed: <msg>', 'details': <tb>});
return <msg> so callers see e.g. the set_model empty-slot guidance
instead of a JSON blob with a traceback in it.
"""
payload = exc.args[0] if getattr(exc, "args", None) else None
if isinstance(payload, dict):
msg = (payload.get('error') or '').strip()
prefix = 'Function call failed: '
if msg.startswith(prefix):
msg = msg[len(prefix):]
if msg:
return msg
return describe_error(exc)


def _resolve_battery_proxy(netname: str):
"""
Build a transient battery Device proxy + channel for `netname` by routing
Expand Down Expand Up @@ -280,9 +299,21 @@ def battery_command_http():
battery.set_model(partnumber)
result['message'] = f'Model set to {partnumber}'
else:
model = battery._safe_query(':BATT:STAT?', '') or 'Custom'
# current_model reads :BATT:MOD:RCL? — :BATT:STAT?
# reports charge/discharge status, not the model.
model = battery.current_model()
result['message'] = f'Model: {model}'

elif action in ('list_models', 'models'):
# Read-only catalog of battery models available on the
# instrument. Mirrors the 'state' action: the structured
# payload rides in the response ('models') alongside a
# human-readable message rendered by the shared formatter.
from lager.power.battery.dispatcher import format_model_catalog
models = battery.model_catalog()
result['models'] = models
result['message'] = format_model_catalog(models)

elif action == 'enable_battery':
battery.enable()
result['message'] = 'Battery output enabled'
Expand Down Expand Up @@ -346,7 +377,18 @@ def _fmt(value, unit):

logger.info(f"[HTTP] Battery command executed: {action} on {netname} (session {session_id})")
return jsonify(result)
except (ConnectionFailed, DeviceError) as e:
except DeviceError as e:
# The driver itself raised (e.g. set_model's empty-slot
# guidance): that is an ANSWER, not a transport failure.
# Return it as success:false at 200 — a 5xx makes the CLI
# treat the endpoint as unavailable and fall through to its
# legacy direct-USB path, which always fails with
# "[Errno 16] Resource busy" while hardware_service holds
# the instrument, masking the real message.
logger.warning(
f"[HTTP] Battery command {action} on {netname} rejected by driver: {e}")
return jsonify({'success': False, 'error': _device_error_message(e)}), 200
except ConnectionFailed as e:
logger.exception(f"[HTTP] Battery command {action} on {netname} failed at hardware_service")
return jsonify({'success': False, 'error': f'Hardware service error: {e}'}), 502

Expand Down Expand Up @@ -744,9 +786,19 @@ def handle_battery_command(data):
battery.set_model(partnumber)
result['message'] = f'Model set to {partnumber}'
else:
model = battery._safe_query(':BATT:STAT?', '') or 'Custom'
# current_model reads :BATT:MOD:RCL? — :BATT:STAT?
# reports charge/discharge status, not the model.
model = battery.current_model()
result['message'] = f'Model: {model}'

elif action == 'models':
# Read-only model catalog; same payload/message pair as
# the HTTP endpoint's 'list_models' action.
from lager.power.battery.dispatcher import format_model_catalog
models = battery.model_catalog()
result['models'] = models
result['message'] = format_model_catalog(models)

elif action == 'enable':
battery.enable()
result['message'] = 'Battery output enabled'
Expand Down Expand Up @@ -782,7 +834,15 @@ def handle_battery_command(data):
logger.info(f"[BATTERY-COMMAND-{thread_name}] Command completed successfully: {result}")
emit('battery_command_response', result)

except (ConnectionFailed, DeviceError) as e:
except DeviceError as e:
# Driver-raised error: show the driver's message, not the
# /invoke JSON blob (see _device_error_message).
logger.warning(f"[BATTERY-COMMAND-{thread_name}] driver error: {e}")
emit('battery_command_response', {
'success': False,
'error': _device_error_message(e)
})
except ConnectionFailed as e:
logger.error(f"[BATTERY-COMMAND-{thread_name}] hardware_service error: {e}")
emit('battery_command_response', {
'success': False,
Expand Down
77 changes: 60 additions & 17 deletions box/lager/http_handlers/usb_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,20 @@ def wrapper(*args, **kwargs) -> T:
"YKUSH_Hub": {"vid": "04d8", "pid": "f2f7", "net_type": ["usb"]},
# eload
"Rigol_DL3021": {"vid": "1ab1", "pid": "0e11", "net_type": ["eload"]},
# camera
# camera — detection is catalog-driven: any entry with a ``webcam``
# net_type is picked up by ``_by_camera`` below, so adding a camera
# is a one-line addition here (mirror it in cli/impl/query_instruments.py).
"Logitech_BRIO_HD": {"vid": "046d", "pid": "085e", "net_type": ["webcam"]},
"Logitech_BRIO": {"vid": "046d", "pid": "0856", "net_type": ["webcam"]},
"Logitech_BRIO_4K_Stream": {"vid": "046d", "pid": "086b", "net_type": ["webcam"]},
"Logitech_4K_Pro": {"vid": "046d", "pid": "087f", "net_type": ["webcam"]},
"Logitech_C930e": {"vid": "046d", "pid": "0843", "net_type": ["webcam"]},
"Logitech_C925e": {"vid": "046d", "pid": "085b", "net_type": ["webcam"]},
"Logitech_C922_Pro": {"vid": "046d", "pid": "085c", "net_type": ["webcam"]},
"Logitech_C920": {"vid": "046d", "pid": "082d", "net_type": ["webcam"]},
"Logitech_C615": {"vid": "046d", "pid": "082c", "net_type": ["webcam"]},
"Logitech_C270": {"vid": "046d", "pid": "0825", "net_type": ["webcam"]},
"Logitech_StreamCam": {"vid": "046d", "pid": "0893", "net_type": ["webcam"]},
# watt-meter
"Yocto_Watt": {"vid": "24e0", "pid": "002a", "net_type": ["watt-meter"]},
"Joulescope_JS220": {"vid": "16d0", "pid": "10ba", "net_type": ["watt-meter", "energy-analyzer"]},
Expand Down Expand Up @@ -545,30 +555,63 @@ def scan_usb() -> List[dict]:
# Camera detection
# ---------------------------------------------------------------------------

_CAM_VID = "046d"
_CAM_PIDS = {"085e", "0856", "0843"}
_WEBCAM_VIDPIDS = {
(_meta["vid"].lower(), _meta["pid"].lower())
for _meta in SUPPORTED_USB.values()
if "webcam" in _meta["net_type"]
}


def _by_camera() -> List[dict]:
usb_cams = [
dev for dev in scan_usb()
if dev["vid"] == _CAM_VID and dev["pid"] in _CAM_PIDS
]
if not usb_cams:
return []
def _read_sysfs_attr(path: Path) -> Optional[str]:
try:
return path.read_text().strip()
except OSError:
return None


def _by_camera(v4l_root: Path = Path("/sys/class/video4linux")) -> List[dict]:
"""Detect supported webcams and their /dev/video capture nodes.

Each ``/sys/class/video4linux/videoN`` links to the USB interface that
owns it; the interface's parent directory is the USB device carrying
idVendor/idProduct/serial. Walking that link keeps the node→camera
mapping correct when models expose different numbers of video nodes
(a C920 exposes two, a BRIO four), which an index-based heuristic
cannot get right on mixed setups.
"""
results: List[dict] = []
seen_devices: set = set()
video_nodes = sorted(
(Path(p) for p in glob.glob("/dev/video*")),
v4l_root.glob("video*"),
key=lambda p: int(p.name.replace("video", "")),
)

results: List[dict] = []
for idx, cam in enumerate(usb_cams):
for node in video_nodes:
try:
video_dev = str(video_nodes[idx * 4])
except IndexError:
usb_dev = (node / "device").resolve().parent
except OSError:
continue
vid = _read_sysfs_attr(usb_dev / "idVendor")
pid = _read_sysfs_attr(usb_dev / "idProduct")
if not vid or not pid:
continue
vid, pid = vid.lower(), pid.lower()
if (vid, pid) not in _WEBCAM_VIDPIDS:
continue
results.append({**cam, "channels": {"webcam": [video_dev]}})
if str(usb_dev) in seen_devices:
# Higher-numbered nodes on the same camera are metadata/IR
# streams; the first node is the capture stream.
continue
seen_devices.add(str(usb_dev))
serial = _read_sysfs_attr(usb_dev / "serial")
results.append({
"name": _VIDPID_TO_NAME[(vid, pid)],
"vid": vid,
"pid": pid,
"serial": serial,
"address": f"USB0::0x{vid.upper()}::0x{pid.upper()}::{serial or ''}::INSTR",
"net_type": ["webcam"],
"channels": {"webcam": [f"/dev/{node.name}"]},
})
return results


Expand Down
4 changes: 4 additions & 0 deletions box/lager/power/battery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
set_ovp,
set_ocp,
set_model,
list_models,
format_model_catalog,
enable_battery,
disable_battery,
print_state,
Expand Down Expand Up @@ -68,6 +70,8 @@
"set_ovp",
"set_ocp",
"set_model",
"list_models",
"format_model_catalog",
"enable_battery",
"disable_battery",
"print_state",
Expand Down
21 changes: 21 additions & 0 deletions box/lager/power/battery/battery_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,27 @@ def model(self, partnumber: str | None = None) -> None:
"""
pass

@abstractmethod
def model_catalog(self) -> list:
"""
Read the catalog of battery models available on the instrument.

Lists the same slot-storage mechanism that model() loads from:
numbered memory slots plus any firmware built-in models. Must be
read-only — assembling the catalog must not change instrument state.

Keithley 2281S:
- Slot 0 ('discharge') is always available
- Slots 1-9 are reported when a model has been saved there
- The five firmware built-in models are listed without a slot

Returns:
List of {"slot": int | None, "name": str | None} dicts, one per
available model. Slot-less entries are built-in model names;
nameless entries are custom models addressed by slot index.
"""
pass

@abstractmethod
def enable(self) -> None:
"""
Expand Down
32 changes: 31 additions & 1 deletion box/lager/power/battery/dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from lager.exceptions import BatteryBackendError, LibraryMissingError, DeviceNotFoundError

from .battery_net import BatteryNet
from .keithley import KeithleyBattery
from .keithley import KeithleyBattery, GREEN, RESET


class BatteryDispatcher(BaseDispatcher):
Expand Down Expand Up @@ -207,6 +207,36 @@ def set_model(netname: str, partnumber: str | None = None, **_):
drv.model(partnumber=partnumber)


def format_model_catalog(models) -> str:
"""Render a structured model catalog as a small slot/name table.

Shared by list_models (printed on the impl-script path) and the box's
/battery/command HTTP endpoint (returned as the response message) so
the two transports never drift.
"""
lines = ["Slot Model"]
for entry in models:
slot = entry.get("slot")
name = entry.get("name")
slot_str = "-" if slot is None else str(slot)
if name is None:
name = "(custom model)"
elif slot is None:
name = f"{name} (built-in)"
lines.append(f"{slot_str:<4} {name}")
lines.append("")
lines.append("Slots and names above are valid inputs to the 'model' command.")
return "\n".join(lines)


def list_models(netname: str, **_):
"""List battery models saved on the instrument."""
drv, _ = _dispatcher._resolve_net_and_driver(netname)
catalog = drv.model_catalog()
print(f"{GREEN}{format_model_catalog(catalog)}{RESET}")
return {"models": catalog}


def enable_battery(netname: str, **_):
"""Enable battery simulator output."""
drv, _ = _dispatcher._resolve_net_and_driver(netname)
Expand Down
Loading
Loading