From 4411e4e4e1b2065cf124cd776ae6d69904f2157b Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 18:53:06 +0300 Subject: [PATCH 1/7] Add --json output to solaar show for machine-readable device info --- lib/solaar/cli/__init__.py | 5 ++ lib/solaar/cli/show.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/lib/solaar/cli/__init__.py b/lib/solaar/cli/__init__.py index 3e4928c9..91d50c82 100644 --- a/lib/solaar/cli/__init__.py +++ b/lib/solaar/cli/__init__.py @@ -47,6 +47,11 @@ def _create_parser(): help="device to show information about; may be a device number (1..6), a serial number, " 'a substring of a device\'s name, or "all" (the default)', ) + sp.add_argument( + "--json", + action="store_true", + help="output the device information as JSON on a single document, for consumption by other programs", + ) sp.set_defaults(action="show") sp = subparsers.add_parser("probe", description="Probe a receiver (debugging use only).") diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 0f41b3f1..9e48582d 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -14,6 +14,8 @@ ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +import json + from logitech_receiver import common from logitech_receiver import exceptions from logitech_receiver import hidpp10 @@ -23,6 +25,8 @@ from logitech_receiver import hidpp20_constants from logitech_receiver import receiver from logitech_receiver import settings_templates from logitech_receiver.common import LOGITECH_VENDOR_ID +from logitech_receiver.common import BatteryLevelApproximation +from logitech_receiver.common import BatteryStatus from logitech_receiver.common import NamedInt from logitech_receiver.common import strhex from logitech_receiver.device import CenturionReceiver @@ -146,6 +150,95 @@ def _battery_line(dev): print(" Battery status unavailable.") +def _battery_json(battery): + """Serialize a Battery as a JSON-friendly dict, or None if unavailable.""" + if battery is None: + return None + level = battery.level + if isinstance(level, BatteryLevelApproximation): + # A qualitative level (e.g. "good"), not an actual percentage + level_kind = "reported" + elif isinstance(level, int): + level_kind = "level" + else: + level_kind = None + status = battery.status + return { + "level": int(level) if level is not None else None, + "level_kind": level_kind, + "next_level": int(battery.next_level) if battery.next_level is not None else None, + "status": status.name.lower().replace("_", " ") if isinstance(status, BatteryStatus) else None, + "voltage": battery.voltage, + } + + +def _receiver_json(receiver): + """Serialize the receiver a device is paired to, or None for standalone devices.""" + if receiver is None: + return None + return { + "name": receiver.name, + "serial": receiver.serial, + "path": receiver.path, + } + + +def _device_json(dev): + """Serialize a device as a JSON-friendly dict, or None if the device is gone.""" + try: + online = dev.ping() + except exceptions.NoSuchDevice: + return None + battery = None + if online: + try: + battery = dev.battery() + except Exception: + battery = None + protocol = float(dev.protocol) if dev.protocol else None + receiver = getattr(dev, "receiver", None) + return { + "name": dev.name, + "number": dev.number, + "receiver": _receiver_json(receiver), + "serial": dev.serial, + "unitId": dev.unitId, + "modelId": dev.modelId, + "codename": dev.codename, + "kind": str(dev.kind) if dev.kind is not None else None, + "protocol": protocol, + "online": bool(online), + "battery": _battery_json(battery), + } + + +def _json_output(devices, device_name, find_receiver, find_device): + result = {"solaar_version": __version__, "devices": []} + + def add(device): + if device is not None: + result["devices"].append(device) + + if device_name == "all": + for d in devices: + if isinstance(d, (receiver.Receiver, CenturionReceiver)): + for dev in d: + add(_device_json(dev)) + else: + add(_device_json(d)) + else: + dev = find_receiver(devices, device_name) + if dev and not dev.isDevice: + for child in dev: + add(_device_json(child)) + else: + dev = next(find_device(devices, device_name), None) + if not dev: + raise Exception(f"no device found matching '{device_name}'") + add(_device_json(dev)) + print(json.dumps(result, indent=2)) + + def _print_device(dev, num=None): assert dev is not None is_centurion = getattr(dev, "centurion", False) @@ -464,6 +557,12 @@ def run(devices, args, find_receiver, find_device): assert devices assert args.device + if args.json: + _json_output(devices, args.device.lower(), find_receiver, find_device) + for d in Device.instances: + d.close() + return + print(f"{NAME.lower()} version {__version__}") print("") From 5a4bc61d5870a44ed73c3fb7c47227fc73df422c Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 19:11:01 +0300 Subject: [PATCH 2/7] Add unit tests for the --json output Fixed a bug where the protocol was overridden by running ping --- lib/solaar/cli/show.py | 3 +- tests/solaar/cli/test_show_json.py | 242 +++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 tests/solaar/cli/test_show_json.py diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 9e48582d..39fd7846 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -185,6 +185,8 @@ def _receiver_json(receiver): def _device_json(dev): """Serialize a device as a JSON-friendly dict, or None if the device is gone.""" + # Save the protocol before the ping, as it overrides it + protocol = float(dev.protocol) if dev.protocol else None try: online = dev.ping() except exceptions.NoSuchDevice: @@ -195,7 +197,6 @@ def _device_json(dev): battery = dev.battery() except Exception: battery = None - protocol = float(dev.protocol) if dev.protocol else None receiver = getattr(dev, "receiver", None) return { "name": dev.name, diff --git a/tests/solaar/cli/test_show_json.py b/tests/solaar/cli/test_show_json.py new file mode 100644 index 00000000..b263dca4 --- /dev/null +++ b/tests/solaar/cli/test_show_json.py @@ -0,0 +1,242 @@ +## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ +## +## This program is free software; you can redistribute it and/or modify +## it under the terms of the GNU General Public License as published by +## the Free Software Foundation; either version 2 of the License, or +## (at your option) any later version. +## +## This program is distributed in the hope that it will be useful, +## but WITHOUT ANY WARRANTY; without even the implied warranty of +## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +## GNU General Public License for more details. +## +## You should have received a copy of the GNU General Public License along +## with this program; if not, write to the Free Software Foundation, Inc., +## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import json + +from dataclasses import dataclass +from functools import partial + +import pytest + +from logitech_receiver import base +from logitech_receiver import exceptions +from logitech_receiver import receiver as receiver_mod +from logitech_receiver.common import Battery +from logitech_receiver.common import BatteryLevelApproximation +from logitech_receiver.common import BatteryStatus +from logitech_receiver.common import NamedInt +from logitech_receiver.device import Device +from solaar.cli.show import _battery_json +from solaar.cli.show import _device_json +from solaar.cli.show import _json_output +from solaar.cli.show import _receiver_json + +from tests.logitech_receiver import fake_hidpp + + +class LowLevelInterfaceFake: + def __init__(self, responses=None): + self.responses = responses + + def open_path(self, path) -> int: + return fake_hidpp.open_path(path) + + def find_paired_node(self, receiver_path: str, index: int, timeout: int): + return None + + def product_information(self, usb_id: int) -> dict: + return base.product_information(usb_id) + + def request(self, response, *args, **kwargs): + func = partial(fake_hidpp.request, self.responses) + return func(response, *args, **kwargs) + + def ping(self, response, *args, **kwargs): + func = partial(fake_hidpp.ping, self.responses) + return func(response, *args, **kwargs) + + def close(self, *args, **kwargs): + pass + + +class FakeReceiver: + name = "Nano Receiver" + serial = "F14890D2" + path = "/dev/hidraw2" + + def device_codename(self, number): + return None + + def __contains__(self, dev): + return True + + +@dataclass +class DeviceInfo: + path: str + vendor_id: int = 1133 + product_id: int = 0xC534 + + +pi_4066 = {"wpid": "4066", "kind": NamedInt(1, "keyboard"), "serial": "5678", "polling": "4ms", "power_switch": "left"} + +responses_receiver = [ + fake_hidpp.Response("000000", 0x8003, "FF"), + fake_hidpp.Response("000300", 0x8102), +] + + +def _keyboard_device(): + responses = fake_hidpp.replace_number(fake_hidpp.r_keyboard_2, 3) + return Device(LowLevelInterfaceFake(responses), FakeReceiver(), 3, True, pi_4066, handle=0x11) + + +def _json_dump(mocker, *args): + printed = [] + mocker.patch("solaar.cli.show.print", side_effect=lambda *a, **kw: printed.append(a[0])) + _json_output(*args) + return json.loads(printed[-1]) + + +def _battery(level, status=BatteryStatus.DISCHARGING, next_level=None, voltage=None): + return Battery(level, next_level, status, voltage) + + +def test_battery_json_none(): + assert _battery_json(None) is None + + +def test_battery_json_percentage(): + info = _battery_json(_battery(55, voltage=3800)) + assert info == { + "level": 55, + "level_kind": "level", + "next_level": None, + "status": "discharging", + "voltage": 3800, + } + + +def test_battery_json_approximation(): + info = _battery_json(_battery(BatteryLevelApproximation.GOOD)) + assert info["level"] == 50 + assert info["level_kind"] == "reported" + + +def test_battery_json_full_approximation(): + info = _battery_json(_battery(BatteryLevelApproximation.FULL, next_level=BatteryLevelApproximation.LOW)) + assert info["level"] == 90 + assert info["level_kind"] == "reported" + assert info["next_level"] == 20 + + +@pytest.mark.parametrize( + "status, expected", + [ + (BatteryStatus.DISCHARGING, "discharging"), + (BatteryStatus.RECHARGING, "recharging"), + (BatteryStatus.ALMOST_FULL, "almost full"), + (BatteryStatus.SLOW_RECHARGE, "slow recharge"), + ], +) +def test_battery_json_status_names(status, expected): + assert _battery_json(_battery(50, status))["status"] == expected + + +def test_battery_json_no_status(): + assert _battery_json(_battery(50, None))["status"] is None + + +def test_receiver_json_none(): + assert _receiver_json(None) is None + + +def test_receiver_json_info(): + assert _receiver_json(FakeReceiver()) == { + "name": "Nano Receiver", + "serial": "F14890D2", + "path": "/dev/hidraw2", + } + + +def test_device_json_online(): + info = _device_json(_keyboard_device()) + assert info["name"] == "Craft Advanced Keyboard" + assert info["number"] == 3 + assert info["receiver"] == {"name": "Nano Receiver", "serial": "F14890D2", "path": "/dev/hidraw2"} + assert info["serial"] == "5678" + assert info["unitId"] == "12345678" + assert info["modelId"] == "1234567890AB" + assert info["kind"] == "keyboard" + assert info["protocol"] == 4.5 + assert info["online"] is True + assert info["battery"] == {"level": 18, "level_kind": "level", "next_level": 52, "status": None, "voltage": None} + + +def test_device_json_reported_battery(mocker): + dev = _keyboard_device() + mocker.patch.object(dev, "battery", return_value=_battery(BatteryLevelApproximation.GOOD)) + info = _device_json(dev) + assert info["battery"]["level"] == 50 + assert info["battery"]["level_kind"] == "reported" + + +def test_device_json_offline_battery_unavailable(): + pi = {"wpid": "4066", "kind": 1, "serial": None, "polling": "4ms", "power_switch": "left"} + dev = Device(LowLevelInterfaceFake([]), FakeReceiver(), 1, True, pi, handle=0x11) + info = _device_json(dev) + assert info["online"] is False + assert info["battery"] is None + + +def test_device_json_no_such_device_returns_none(mocker): + dev = _keyboard_device() + mocker.patch.object(dev, "ping", side_effect=exceptions.NoSuchDevice()) + assert _device_json(dev) is None + + +def test_json_output_all_flattens_receiver(mocker): + dev = _keyboard_device() + r = receiver_mod.create_receiver(LowLevelInterfaceFake(responses_receiver), DeviceInfo("14"), lambda x: x) + r._devices[1] = dev + mocker.patch.object(r, "count", return_value=1) + + output = _json_dump(mocker, [r], "all", None, None) + + assert "solaar_version" in output + assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] + assert output["devices"][0]["number"] == 3 + + +def test_json_output_single_device(mocker): + dev = _keyboard_device() + find_receiver = mocker.Mock(return_value=None) + find_device = mocker.Mock(return_value=iter([dev])) + + output = _json_dump(mocker, [dev], "Craft Advanced Keyboard", find_receiver, find_device) + + assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] + assert output["devices"][0]["battery"]["level"] == 18 + + +def test_json_output_single_device_on_receiver(mocker): + dev = _keyboard_device() + r = receiver_mod.create_receiver(LowLevelInterfaceFake(responses_receiver), DeviceInfo("14"), lambda x: x) + r._devices[1] = dev + mocker.patch.object(r, "count", return_value=1) + find_receiver = mocker.Mock(return_value=r) + + output = _json_dump(mocker, [r], "Craft Advanced Keyboard", find_receiver, None) + + assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] + + +def test_json_output_no_matching_device_raises(mocker): + find_receiver = mocker.Mock(return_value=None) + find_device = mocker.Mock(return_value=iter([])) + + with pytest.raises(Exception, match="no device found"): + _json_output([], "Missing", find_receiver, find_device) From 044f962f970c30126924ed770fbeeda1880c53da Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 20:04:52 +0300 Subject: [PATCH 3/7] Add pid (wpid or product id), path, bluetooth flag, and Bluetooth MAC address to the JSON device output, and add tests for receiver-paired, direct-USB, and Bluetooth devices --- lib/solaar/cli/show.py | 4 ++ tests/solaar/cli/test_show_json.py | 73 ++++++++++++++++++++++++++++-- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 39fd7846..5aa4283e 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -202,6 +202,10 @@ def _device_json(dev): "name": dev.name, "number": dev.number, "receiver": _receiver_json(receiver), + "pid": dev.wpid or dev.product_id, + "path": dev.path, + "bluetooth": dev.bluetooth, + "mac": dev.hid_serial if dev.bluetooth else None, "serial": dev.serial, "unitId": dev.unitId, "modelId": dev.modelId, diff --git a/tests/solaar/cli/test_show_json.py b/tests/solaar/cli/test_show_json.py index b263dca4..8efb90f6 100644 --- a/tests/solaar/cli/test_show_json.py +++ b/tests/solaar/cli/test_show_json.py @@ -81,6 +81,19 @@ class DeviceInfo: product_id: int = 0xC534 +@dataclass +class DeviceInfoStub: + path: str + product_id: str + vendor_id: int = 1133 + hidpp_short: bool = False + hidpp_long: bool = True + bus_id: int = 0x0003 + serial: str = "aa:aa:aa;aa" + centurion: bool = False + centurion_report_id: int | None = None + + pi_4066 = {"wpid": "4066", "kind": NamedInt(1, "keyboard"), "serial": "5678", "polling": "4ms", "power_switch": "left"} responses_receiver = [ @@ -94,10 +107,10 @@ def _keyboard_device(): return Device(LowLevelInterfaceFake(responses), FakeReceiver(), 3, True, pi_4066, handle=0x11) -def _json_dump(mocker, *args): +def _capture_json(mocker, func, *args): printed = [] mocker.patch("solaar.cli.show.print", side_effect=lambda *a, **kw: printed.append(a[0])) - _json_output(*args) + func(*args) return json.loads(printed[-1]) @@ -167,6 +180,10 @@ def test_device_json_online(): assert info["name"] == "Craft Advanced Keyboard" assert info["number"] == 3 assert info["receiver"] == {"name": "Nano Receiver", "serial": "F14890D2", "path": "/dev/hidraw2"} + assert info["pid"] == "4066" + assert info["path"] is None + assert info["bluetooth"] is False + assert info["mac"] is None assert info["serial"] == "5678" assert info["unitId"] == "12345678" assert info["modelId"] == "1234567890AB" @@ -176,6 +193,32 @@ def test_device_json_online(): assert info["battery"] == {"level": 18, "level_kind": "level", "next_level": 52, "status": None, "voltage": None} +def test_device_json_direct_usb_device(): + di = DeviceInfoStub("11", product_id="C318") + responses = fake_hidpp.replace_number(fake_hidpp.r_keyboard_1, 0x00) + dev = Device(LowLevelInterfaceFake(responses), None, None, None, handle=0x11, device_info=di) + info = _device_json(dev) + assert info["receiver"] is None + assert info["pid"] == "C318" + assert info["path"] == "11" + assert info["bluetooth"] is False + assert info["mac"] is None + assert info["protocol"] == 1.0 + assert info["online"] is True + assert info["battery"]["level"] == 50 + + +def test_device_json_bluetooth_device(): + di = DeviceInfoStub("11", product_id="B350", bus_id=0x0005) + dev = Device(LowLevelInterfaceFake(fake_hidpp.r_keyboard_1), None, None, None, handle=0x11, device_info=di) + info = _device_json(dev) + assert info["receiver"] is None + assert info["path"] == "11" + assert info["bluetooth"] is True + assert info["mac"] == "aa:aa:aa;aa" + assert info["online"] is True + + def test_device_json_reported_battery(mocker): dev = _keyboard_device() mocker.patch.object(dev, "battery", return_value=_battery(BatteryLevelApproximation.GOOD)) @@ -204,7 +247,7 @@ def test_json_output_all_flattens_receiver(mocker): r._devices[1] = dev mocker.patch.object(r, "count", return_value=1) - output = _json_dump(mocker, [r], "all", None, None) + output = _capture_json(mocker, _json_output, [r], "all", None, None) assert "solaar_version" in output assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] @@ -216,7 +259,7 @@ def test_json_output_single_device(mocker): find_receiver = mocker.Mock(return_value=None) find_device = mocker.Mock(return_value=iter([dev])) - output = _json_dump(mocker, [dev], "Craft Advanced Keyboard", find_receiver, find_device) + output = _capture_json(mocker, _json_output, [dev], "Craft Advanced Keyboard", find_receiver, find_device) assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] assert output["devices"][0]["battery"]["level"] == 18 @@ -229,7 +272,7 @@ def test_json_output_single_device_on_receiver(mocker): mocker.patch.object(r, "count", return_value=1) find_receiver = mocker.Mock(return_value=r) - output = _json_dump(mocker, [r], "Craft Advanced Keyboard", find_receiver, None) + output = _capture_json(mocker, _json_output, [r], "Craft Advanced Keyboard", find_receiver, None) assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] @@ -240,3 +283,23 @@ def test_json_output_no_matching_device_raises(mocker): with pytest.raises(Exception, match="no device found"): _json_output([], "Missing", find_receiver, find_device) + + +def test_parser_json_flag(): + from solaar import cli + + parser = cli._create_parser()[0] + args = parser.parse_args(["show", "--json"]) + assert args.json is True + assert args.device == "all" + + +def test_run_json_output(mocker): + from solaar.cli.show import run + + dev = _keyboard_device() + args = mocker.Mock(json=True, device="all") + + output = _capture_json(mocker, run, [dev], args, None, None) + + assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] From 93a0ef8e7fe0c1da6a43d1bc3b65f9c6cbfc35f8 Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 20:36:04 +0300 Subject: [PATCH 4/7] - Emit the enum member name (e.g. "SLOW_RECHARGE") as a stable identifier that consumers can match on, instead of a lowercased rendering. - Guard against combined status flags that have no .name on Python < 3.11, and add a test for that case. --- lib/solaar/cli/show.py | 5 ++--- tests/solaar/cli/test_show_json.py | 16 +++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 5aa4283e..3559193f 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -26,7 +26,6 @@ from logitech_receiver import receiver from logitech_receiver import settings_templates from logitech_receiver.common import LOGITECH_VENDOR_ID from logitech_receiver.common import BatteryLevelApproximation -from logitech_receiver.common import BatteryStatus from logitech_receiver.common import NamedInt from logitech_receiver.common import strhex from logitech_receiver.device import CenturionReceiver @@ -162,12 +161,12 @@ def _battery_json(battery): level_kind = "level" else: level_kind = None - status = battery.status + status = getattr(battery.status, "name", None) return { "level": int(level) if level is not None else None, "level_kind": level_kind, "next_level": int(battery.next_level) if battery.next_level is not None else None, - "status": status.name.lower().replace("_", " ") if isinstance(status, BatteryStatus) else None, + "status": status, "voltage": battery.voltage, } diff --git a/tests/solaar/cli/test_show_json.py b/tests/solaar/cli/test_show_json.py index 8efb90f6..46b8a619 100644 --- a/tests/solaar/cli/test_show_json.py +++ b/tests/solaar/cli/test_show_json.py @@ -128,7 +128,7 @@ def test_battery_json_percentage(): "level": 55, "level_kind": "level", "next_level": None, - "status": "discharging", + "status": "DISCHARGING", "voltage": 3800, } @@ -149,10 +149,10 @@ def test_battery_json_full_approximation(): @pytest.mark.parametrize( "status, expected", [ - (BatteryStatus.DISCHARGING, "discharging"), - (BatteryStatus.RECHARGING, "recharging"), - (BatteryStatus.ALMOST_FULL, "almost full"), - (BatteryStatus.SLOW_RECHARGE, "slow recharge"), + (BatteryStatus.DISCHARGING, "DISCHARGING"), + (BatteryStatus.RECHARGING, "RECHARGING"), + (BatteryStatus.ALMOST_FULL, "ALMOST_FULL"), + (BatteryStatus.SLOW_RECHARGE, "SLOW_RECHARGE"), ], ) def test_battery_json_status_names(status, expected): @@ -163,6 +163,12 @@ def test_battery_json_no_status(): assert _battery_json(_battery(50, None))["status"] is None +def test_battery_json_combined_status_flag(): + combined = BatteryStatus(0x07) # bits from multiple flags (no canonical name on Python < 3.11) + status = _battery_json(_battery(50, combined))["status"] + assert status is None or isinstance(status, str) + + def test_receiver_json_none(): assert _receiver_json(None) is None From 53d9c0c14563e804cd6a948f35b0cda48a1ce3db Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 21:06:12 +0300 Subject: [PATCH 5/7] Ran some AI CR grilling: - pid: normalize to a string (wpid is str, product_id is int for USB / hex str for Bluetooth) so consumers see one type. - battery.status: decompose combined flags into single-bit member names so output is identical on Python < 3.11 and 3.11+. - close open device handles on the --json path even when output raises (try/finally), matching the text path. - level_kind: rename "reported" to approximation" to contrast with "level". - tests: restore py3.8 import compatibility via __future__ annotations, assert the exact combined-flag rendering, cover the close-on-raise path. --- lib/solaar/cli/show.py | 26 +++++++++++++++++++------- tests/solaar/cli/test_show_json.py | 27 ++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index 3559193f..a516a76d 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -156,12 +156,21 @@ def _battery_json(battery): level = battery.level if isinstance(level, BatteryLevelApproximation): # A qualitative level (e.g. "good"), not an actual percentage - level_kind = "reported" + level_kind = "approximation" elif isinstance(level, int): level_kind = "level" else: level_kind = None - status = getattr(battery.status, "name", None) + status = getattr(battery.status, "name", None) # canonical member name; combined flags have none on Python < 3.11 + if status is None and battery.status is not None: + # Decompose combined flags into single-bit members so the output + # is identical on every Python version. + status = ( + "|".join( + m.name for m in type(battery.status) if m.value and m.value & (m.value - 1) == 0 and (battery.status & m) == m + ) + or None + ) return { "level": int(level) if level is not None else None, "level_kind": level_kind, @@ -184,7 +193,8 @@ def _receiver_json(receiver): def _device_json(dev): """Serialize a device as a JSON-friendly dict, or None if the device is gone.""" - # Save the protocol before the ping, as it overrides it + # Save the descriptor-known protocol; the ping below may update it, and + # for an offline device the ping fails and would leave it unset. protocol = float(dev.protocol) if dev.protocol else None try: online = dev.ping() @@ -201,7 +211,7 @@ def _device_json(dev): "name": dev.name, "number": dev.number, "receiver": _receiver_json(receiver), - "pid": dev.wpid or dev.product_id, + "pid": str(dev.wpid or dev.product_id), "path": dev.path, "bluetooth": dev.bluetooth, "mac": dev.hid_serial if dev.bluetooth else None, @@ -562,9 +572,11 @@ def run(devices, args, find_receiver, find_device): assert args.device if args.json: - _json_output(devices, args.device.lower(), find_receiver, find_device) - for d in Device.instances: - d.close() + try: + _json_output(devices, args.device.lower(), find_receiver, find_device) + finally: + for d in Device.instances: + d.close() return print(f"{NAME.lower()} version {__version__}") diff --git a/tests/solaar/cli/test_show_json.py b/tests/solaar/cli/test_show_json.py index 46b8a619..8181d8d6 100644 --- a/tests/solaar/cli/test_show_json.py +++ b/tests/solaar/cli/test_show_json.py @@ -14,6 +14,8 @@ ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +from __future__ import annotations + import json from dataclasses import dataclass @@ -136,13 +138,13 @@ def test_battery_json_percentage(): def test_battery_json_approximation(): info = _battery_json(_battery(BatteryLevelApproximation.GOOD)) assert info["level"] == 50 - assert info["level_kind"] == "reported" + assert info["level_kind"] == "approximation" def test_battery_json_full_approximation(): info = _battery_json(_battery(BatteryLevelApproximation.FULL, next_level=BatteryLevelApproximation.LOW)) assert info["level"] == 90 - assert info["level_kind"] == "reported" + assert info["level_kind"] == "approximation" assert info["next_level"] == 20 @@ -166,7 +168,7 @@ def test_battery_json_no_status(): def test_battery_json_combined_status_flag(): combined = BatteryStatus(0x07) # bits from multiple flags (no canonical name on Python < 3.11) status = _battery_json(_battery(50, combined))["status"] - assert status is None or isinstance(status, str) + assert status == "RECHARGING|ALMOST_FULL|SLOW_RECHARGE" def test_receiver_json_none(): @@ -225,12 +227,12 @@ def test_device_json_bluetooth_device(): assert info["online"] is True -def test_device_json_reported_battery(mocker): +def test_device_json_approximation_battery(mocker): dev = _keyboard_device() mocker.patch.object(dev, "battery", return_value=_battery(BatteryLevelApproximation.GOOD)) info = _device_json(dev) assert info["battery"]["level"] == 50 - assert info["battery"]["level_kind"] == "reported" + assert info["battery"]["level_kind"] == "approximation" def test_device_json_offline_battery_unavailable(): @@ -309,3 +311,18 @@ def test_run_json_output(mocker): output = _capture_json(mocker, run, [dev], args, None, None) assert [d["name"] for d in output["devices"]] == ["Craft Advanced Keyboard"] + + +def test_run_json_closes_devices_when_output_raises(mocker): + from solaar.cli.show import Device + from solaar.cli.show import run + + dev = _keyboard_device() + args = mocker.Mock(json=True, device="all") + mocker.patch("solaar.cli.show._json_output", side_effect=RuntimeError("boom")) + close = mocker.patch.object(Device, "close") + + with pytest.raises(RuntimeError): + run([dev], args, None, None) + + close.assert_called() From 8cf195bc958a71bb6d4f231cef0d54a6d8134eb4 Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 21:11:45 +0300 Subject: [PATCH 6/7] tests: remove accidentally AI copied copyright header from test_show_json.py --- tests/solaar/cli/test_show_json.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/tests/solaar/cli/test_show_json.py b/tests/solaar/cli/test_show_json.py index 8181d8d6..9f7f914a 100644 --- a/tests/solaar/cli/test_show_json.py +++ b/tests/solaar/cli/test_show_json.py @@ -1,19 +1,3 @@ -## Copyright (C) 2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ -## -## This program is free software; you can redistribute it and/or modify -## it under the terms of the GNU General Public License as published by -## the Free Software Foundation; either version 2 of the License, or -## (at your option) any later version. -## -## This program is distributed in the hope that it will be useful, -## but WITHOUT ANY WARRANTY; without even the implied warranty of -## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -## GNU General Public License for more details. -## -## You should have received a copy of the GNU General Public License along -## with this program; if not, write to the Free Software Foundation, Inc., -## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - from __future__ import annotations import json From ea4ec77f1f25eee848c526e42ae52ef9e791a348 Mon Sep 17 00:00:00 2001 From: Itay Avraham Date: Sat, 15 Aug 2026 21:26:18 +0300 Subject: [PATCH 7/7] More CR grilling - add next_level_kind and normalize pid casing in show --json output --- lib/solaar/cli/show.py | 28 ++++++++++++++++------------ tests/solaar/cli/test_show_json.py | 19 ++++++++++++++++++- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/lib/solaar/cli/show.py b/lib/solaar/cli/show.py index a516a76d..2e7af542 100644 --- a/lib/solaar/cli/show.py +++ b/lib/solaar/cli/show.py @@ -149,18 +149,21 @@ def _battery_line(dev): print(" Battery status unavailable.") +def _level_kind(level): + """Distinguish a real percentage ("level") from a qualitative approximation.""" + if isinstance(level, BatteryLevelApproximation): + return "approximation" + if isinstance(level, int): + return "level" + return None + + def _battery_json(battery): """Serialize a Battery as a JSON-friendly dict, or None if unavailable.""" if battery is None: return None level = battery.level - if isinstance(level, BatteryLevelApproximation): - # A qualitative level (e.g. "good"), not an actual percentage - level_kind = "approximation" - elif isinstance(level, int): - level_kind = "level" - else: - level_kind = None + next_level = battery.next_level status = getattr(battery.status, "name", None) # canonical member name; combined flags have none on Python < 3.11 if status is None and battery.status is not None: # Decompose combined flags into single-bit members so the output @@ -173,8 +176,9 @@ def _battery_json(battery): ) return { "level": int(level) if level is not None else None, - "level_kind": level_kind, - "next_level": int(battery.next_level) if battery.next_level is not None else None, + "level_kind": _level_kind(level), + "next_level": int(next_level) if next_level is not None else None, + "next_level_kind": _level_kind(next_level), "status": status, "voltage": battery.voltage, } @@ -193,8 +197,8 @@ def _receiver_json(receiver): def _device_json(dev): """Serialize a device as a JSON-friendly dict, or None if the device is gone.""" - # Save the descriptor-known protocol; the ping below may update it, and - # for an offline device the ping fails and would leave it unset. + # Save the descriptor-known protocol before the ping below updates it; for + # a descriptor-less device the property itself pings to determine it. protocol = float(dev.protocol) if dev.protocol else None try: online = dev.ping() @@ -211,7 +215,7 @@ def _device_json(dev): "name": dev.name, "number": dev.number, "receiver": _receiver_json(receiver), - "pid": str(dev.wpid or dev.product_id), + "pid": str(dev.wpid or dev.product_id).upper(), # hex, uppercase (product_id case varies by hid backend) "path": dev.path, "bluetooth": dev.bluetooth, "mac": dev.hid_serial if dev.bluetooth else None, diff --git a/tests/solaar/cli/test_show_json.py b/tests/solaar/cli/test_show_json.py index 9f7f914a..72d7a864 100644 --- a/tests/solaar/cli/test_show_json.py +++ b/tests/solaar/cli/test_show_json.py @@ -82,6 +82,13 @@ class DeviceInfoStub: pi_4066 = {"wpid": "4066", "kind": NamedInt(1, "keyboard"), "serial": "5678", "polling": "4ms", "power_switch": "left"} + +@pytest.fixture(autouse=True) +def _reset_device_instances(): + yield + Device.instances[:] = [] + + responses_receiver = [ fake_hidpp.Response("000000", 0x8003, "FF"), fake_hidpp.Response("000300", 0x8102), @@ -114,6 +121,7 @@ def test_battery_json_percentage(): "level": 55, "level_kind": "level", "next_level": None, + "next_level_kind": None, "status": "DISCHARGING", "voltage": 3800, } @@ -123,6 +131,7 @@ def test_battery_json_approximation(): info = _battery_json(_battery(BatteryLevelApproximation.GOOD)) assert info["level"] == 50 assert info["level_kind"] == "approximation" + assert info["next_level_kind"] is None def test_battery_json_full_approximation(): @@ -130,6 +139,7 @@ def test_battery_json_full_approximation(): assert info["level"] == 90 assert info["level_kind"] == "approximation" assert info["next_level"] == 20 + assert info["next_level_kind"] == "approximation" @pytest.mark.parametrize( @@ -182,7 +192,14 @@ def test_device_json_online(): assert info["kind"] == "keyboard" assert info["protocol"] == 4.5 assert info["online"] is True - assert info["battery"] == {"level": 18, "level_kind": "level", "next_level": 52, "status": None, "voltage": None} + assert info["battery"] == { + "level": 18, + "level_kind": "level", + "next_level": 52, + "next_level_kind": "level", + "status": None, + "voltage": None, + } def test_device_json_direct_usb_device():