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()