Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bda869542 | ||
|
|
ce1adc7b03 | ||
|
|
fc68521731 | ||
|
|
c87730f1eb | ||
|
|
76346cd5aa | ||
|
|
705279097f | ||
|
|
36377fdd5a | ||
|
|
a427c66dc1 | ||
|
|
f0c64f5fb3 | ||
|
|
a0e19282ec | ||
|
|
e999b12246 | ||
|
|
d3216ea57a | ||
|
|
0b6a5fa108 | ||
|
|
ff23601183 | ||
|
|
aaabb5d811 | ||
|
|
ccf1ac5b6d | ||
|
|
46da00e214 | ||
|
|
1dd1ace327 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -23,6 +23,8 @@ assignees: ''
|
||||
- Distribution:
|
||||
- Kernel version (ex. `uname -srmo`):
|
||||
- Output of `solaar show`:
|
||||
<!-- To run `solaar show` in 1.1.18 you have to clone Solaar from this repository
|
||||
and `run bin/solaar show` from the download directory. -->
|
||||
|
||||
<details>
|
||||
|
||||
|
||||
4
.github/workflows/tests.yml
vendored
4
.github/workflows/tests.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.13]
|
||||
python-version: [3.13]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [3.8, 3.13]
|
||||
python-version: [3.13]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,3 +1,17 @@
|
||||
# 1.1.19
|
||||
|
||||
* New Georgian translation
|
||||
* Remove test that doesn't work in older Pythons
|
||||
* Update help messages for CLI commands
|
||||
* Allow solaar config to change LED settings
|
||||
* Add python3-devel to install-dnf in Makefile
|
||||
* hidconsole can send an HID command non-interactively
|
||||
* Add info about new lightspeed receiver
|
||||
* Update Swedish and zh_TW translation
|
||||
* Fix bug when showing details about direct-connected device
|
||||
* Drop testing of Python before 3.13
|
||||
* Fix crash in solaar show when showing notification flags. (#3070)
|
||||
|
||||
# 1.1.18
|
||||
|
||||
* Fix crash when showing notification flags
|
||||
|
||||
4
Makefile
4
Makefile
@@ -25,8 +25,8 @@ install_apt_python3.13:
|
||||
sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository-2.0-dev gobject-introspection
|
||||
|
||||
install_dnf:
|
||||
@echo "Installing Solaar dependencies via dn"
|
||||
sudo dnf install gtk3 python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
|
||||
@echo "Installing Solaar dependencies via dnf"
|
||||
sudo dnf install gtk3 python3-devel python3-gobject python3-dbus python3-pyudev python3-psutil python3-xlib python3-yaml
|
||||
|
||||
install_brew:
|
||||
@echo "Installing Solaar dependencies via brew"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Notes on Major Changes in Releases
|
||||
|
||||
## Version 1.1.18
|
||||
|
||||
* Solaar is only guaranteed to work in Python 3.13 or later.
|
||||
|
||||
## Version 1.1.17
|
||||
|
||||
* Several new features have been added related to the MX Master 4
|
||||
|
||||
@@ -135,10 +135,11 @@ def _open(args):
|
||||
if vid == LOGITECH_VENDOR_ID:
|
||||
return {"vid": vid}
|
||||
|
||||
device = args.device
|
||||
if args.hidpp and not device:
|
||||
device = args.path
|
||||
d = None
|
||||
if not device:
|
||||
for d in hidapi.enumerate(matchfn):
|
||||
if d.driver == "logitech-djreceiver":
|
||||
if (d.hidpp_short or d.hidpp_long) and (args.id is None or args.id.lower() == d.product_id.lower()):
|
||||
device = d.path
|
||||
break
|
||||
if not device:
|
||||
@@ -146,13 +147,17 @@ def _open(args):
|
||||
if not device:
|
||||
sys.exit("!! Device path required.")
|
||||
|
||||
print(".. Opening device", device)
|
||||
handle = hidapi.open_path(device)
|
||||
if not handle:
|
||||
sys.exit(f"!! Failed to open {device}, aborting.")
|
||||
print(
|
||||
".. Opened handle %r, vendor %r product %r serial %r."
|
||||
% (handle, hidapi.get_manufacturer(handle), hidapi.get_product(handle), hidapi.get_serial(handle))
|
||||
".. Opened device %r, vendor %r product %r serial %r."
|
||||
% (
|
||||
device,
|
||||
hidapi.get_manufacturer(handle) or d.vendor_id if d else None,
|
||||
hidapi.get_product(handle) or d.product_id if d else None,
|
||||
hidapi.get_serial(handle),
|
||||
)
|
||||
)
|
||||
if args.hidpp:
|
||||
if hidapi.get_manufacturer(handle) is not None and hidapi.get_manufacturer(handle) != b"Logitech":
|
||||
@@ -170,12 +175,10 @@ def _parse_arguments():
|
||||
arg_parser = argparse.ArgumentParser()
|
||||
arg_parser.add_argument("--history", help="history file (default ~/.hidconsole-history)")
|
||||
arg_parser.add_argument("--hidpp", action="store_true", help="ensure input data is a valid HID++ request")
|
||||
arg_parser.add_argument(
|
||||
"device",
|
||||
nargs="?",
|
||||
help="linux device to connect to (/dev/hidrawX); "
|
||||
"may be omitted if --hidpp is given, in which case it looks for the first Logitech receiver",
|
||||
)
|
||||
arg_parser.add_argument("command", nargs="?", help="command to send (otherwise get commands from input)")
|
||||
group = arg_parser.add_mutually_exclusive_group()
|
||||
group.add_argument("-p", "--path", help="HID raw device to connect to (/dev/hidrawX); ")
|
||||
group.add_argument("-i", "--id", help="product ID of device to connect to (XXXX)")
|
||||
return arg_parser.parse_args()
|
||||
|
||||
|
||||
@@ -183,6 +186,17 @@ def main():
|
||||
args = _parse_arguments()
|
||||
handle = _open(args)
|
||||
|
||||
if args.command: # send a command
|
||||
data = _validate_input(args.command, args.hidpp)
|
||||
if data:
|
||||
hidapi.write(handle, data)
|
||||
reply = hidapi.read(handle, 128, 1)
|
||||
if reply:
|
||||
hexs = strhex(reply)
|
||||
s = "[%s %s %s %s] %s" % (hexs[0:2], hexs[2:4], hexs[4:8], hexs[8:], repr(data))
|
||||
print(s)
|
||||
exit()
|
||||
|
||||
if interactive:
|
||||
print(".. Press ^C/^D to exit, or type hex bytes to write to the device.")
|
||||
|
||||
@@ -232,7 +246,6 @@ def main():
|
||||
time.sleep(1)
|
||||
|
||||
finally:
|
||||
print(f".. Closing handle {handle!r}")
|
||||
hidapi.close(handle)
|
||||
if interactive:
|
||||
readline.write_history_file(args.history)
|
||||
|
||||
@@ -124,6 +124,7 @@ def _lightspeed_receiver(product_id: int) -> dict:
|
||||
"receiver_kind": "lightspeed",
|
||||
"name": _("Lightspeed Receiver"),
|
||||
"may_unpair": False,
|
||||
"re_pairs": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -174,6 +175,7 @@ LIGHTSPEED_RECEIVER_C53F = _lightspeed_receiver(0xC53F)
|
||||
LIGHTSPEED_RECEIVER_C541 = _lightspeed_receiver(0xC541)
|
||||
LIGHTSPEED_RECEIVER_C545 = _lightspeed_receiver(0xC545)
|
||||
LIGHTSPEED_RECEIVER_C547 = _lightspeed_receiver(0xC547)
|
||||
LIGHTSPEED_RECEIVER_C54D = _lightspeed_receiver(0xC54D)
|
||||
|
||||
# EX100 old style receiver pre-unifying protocol
|
||||
EX100_27MHZ_RECEIVER_C517 = _ex100_receiver(0xC517)
|
||||
@@ -202,6 +204,7 @@ KNOWN_RECEIVERS = {
|
||||
0xC541: LIGHTSPEED_RECEIVER_C541,
|
||||
0xC545: LIGHTSPEED_RECEIVER_C545,
|
||||
0xC547: LIGHTSPEED_RECEIVER_C547,
|
||||
0xC54D: LIGHTSPEED_RECEIVER_C54D,
|
||||
0xC517: EX100_27MHZ_RECEIVER_C517,
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,9 @@ class Hidpp10:
|
||||
write_register(device, Registers.THREE_LEDS, v1, v2)
|
||||
|
||||
def get_notification_flags(self, device: Device):
|
||||
return NotificationFlag(self._get_register(device, Registers.NOTIFICATIONS))
|
||||
flags = self._get_register(device, Registers.NOTIFICATIONS)
|
||||
if flags is not None:
|
||||
return NotificationFlag(flags)
|
||||
|
||||
def set_notification_flags(self, device: Device, *flag_bits: NotificationFlag):
|
||||
assert device is not None
|
||||
|
||||
@@ -113,7 +113,7 @@ class NotificationFlag(Flag):
|
||||
|
||||
def flags_to_str(flags, fallback: str) -> str:
|
||||
flag_names = []
|
||||
if flags is not None:
|
||||
if flags is not None and flags is not False:
|
||||
if flags.value == 0:
|
||||
flag_names = (fallback,)
|
||||
else:
|
||||
|
||||
@@ -1640,7 +1640,7 @@ _LEDP = hidpp20.LEDParam
|
||||
|
||||
# an LED Zone has an index, a set of possible LED effects, and an LED effect setting
|
||||
class LEDZoneSetting(settings.Setting):
|
||||
name = "led_zone_"
|
||||
name = "led_zone_" # the trailing underscore signals that this setting creates other settings
|
||||
label = _("LED Zone Effects")
|
||||
description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.")
|
||||
feature = _F.COLOR_LED_EFFECTS
|
||||
@@ -1688,7 +1688,7 @@ class RGBControl(settings.Setting):
|
||||
|
||||
|
||||
class RGBEffectSetting(LEDZoneSetting):
|
||||
name = "rgb_zone_"
|
||||
name = "rgb_zone_" # the trailing underscore signals that this setting creates other settings
|
||||
label = _("LED Zone Effects")
|
||||
description = _("Set effect for LED Zone") + "\n" + _("LED Control needs to be set to Solaar to be effective.")
|
||||
feature = _F.RGB_EFFECTS
|
||||
@@ -2100,10 +2100,18 @@ def check_feature_settings(device, already_known) -> bool:
|
||||
|
||||
def check_feature_setting(device, setting_name: str) -> settings.Setting | None:
|
||||
for sclass in SETTINGS:
|
||||
if sclass.feature and sclass.name == setting_name and device.features:
|
||||
if (
|
||||
sclass.feature
|
||||
and device.features
|
||||
and (sclass.name == setting_name or sclass.name.endswith("_") and setting_name.startswith(sclass.name))
|
||||
):
|
||||
try:
|
||||
setting = check_feature(device, sclass)
|
||||
except Exception:
|
||||
return None
|
||||
if setting:
|
||||
if isinstance(setting, list):
|
||||
for s in setting:
|
||||
if s.name == setting_name:
|
||||
return s
|
||||
elif setting:
|
||||
return setting
|
||||
|
||||
@@ -39,7 +39,7 @@ def _create_parser():
|
||||
)
|
||||
subparsers = parser.add_subparsers(title="actions", help="command-line action to perform")
|
||||
|
||||
sp = subparsers.add_parser("show", help="show information about devices")
|
||||
sp = subparsers.add_parser("show", description="Show information about device or all devices.")
|
||||
sp.add_argument(
|
||||
"device",
|
||||
nargs="?",
|
||||
@@ -49,7 +49,7 @@ def _create_parser():
|
||||
)
|
||||
sp.set_defaults(action="show")
|
||||
|
||||
sp = subparsers.add_parser("probe", help="probe a receiver (debugging use only)")
|
||||
sp = subparsers.add_parser("probe", description="Probe a receiver (debugging use only).")
|
||||
sp.add_argument(
|
||||
"receiver", nargs="?", help="select receiver by name substring or serial number when more than one is present"
|
||||
)
|
||||
@@ -57,25 +57,26 @@ def _create_parser():
|
||||
|
||||
sp = subparsers.add_parser(
|
||||
"profiles",
|
||||
help="read or write onboard profiles",
|
||||
description="Print or load YAML dump of profiles.",
|
||||
epilog="Only works on active devices.",
|
||||
)
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to read or write profiles of; may be a device number (1..6), a serial number, "
|
||||
"a substring of a device's name",
|
||||
help="device to read or load profiles; may be a device number (1..6), a serial number, "
|
||||
"or a substring of a device's name",
|
||||
)
|
||||
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles")
|
||||
sp.add_argument("profiles", nargs="?", help="file containing YAML dump of profiles to load")
|
||||
sp.set_defaults(action="profiles")
|
||||
|
||||
sp = subparsers.add_parser(
|
||||
"config",
|
||||
help="read/write device-specific settings",
|
||||
description="Print or load device-specific settings. Only some settings can be loaded. "
|
||||
"Loading complex settings uses the same syntax as in ~/.config/solaar/config.yaml",
|
||||
epilog="Please note that configuration only works on active devices.",
|
||||
)
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to configure; may be a device number (1..6), a serial number, " "or a substring of a device's name",
|
||||
help="device to configure; may be a device number (1..6), a serial number, or a substring of a device's name",
|
||||
)
|
||||
sp.add_argument("setting", nargs="?", help="device-specific setting; leave empty to list available settings")
|
||||
sp.add_argument("value_key", nargs="?", help="new value for the setting or key for keyed settings")
|
||||
@@ -85,7 +86,7 @@ def _create_parser():
|
||||
|
||||
sp = subparsers.add_parser(
|
||||
"pair",
|
||||
help="pair a new device",
|
||||
description="Pair a new device with a receiver. The device has to be compatible with the receiver.",
|
||||
epilog="The Logitech Unifying Receiver supports up to 6 paired devices at the same time.",
|
||||
)
|
||||
sp.add_argument(
|
||||
@@ -93,7 +94,7 @@ def _create_parser():
|
||||
)
|
||||
sp.set_defaults(action="pair")
|
||||
|
||||
sp = subparsers.add_parser("unpair", help="unpair a device")
|
||||
sp = subparsers.add_parser("unpair", description="Unpair a device from its receiver. Not all receivers allow unpairing.")
|
||||
sp.add_argument(
|
||||
"device",
|
||||
help="device to unpair; may be a device number (1..6), a serial number, " "or a substring of a device's name.",
|
||||
|
||||
@@ -210,7 +210,8 @@ def run(receivers, args, _find_receiver, find_device):
|
||||
if remote:
|
||||
argl = ["config", dev.serial or dev.unitId, setting.name]
|
||||
argl.extend([a for a in [args.value_key, args.extra_subkey, args.extra2] if a is not None])
|
||||
application.run(yaml.safe_dump(argl))
|
||||
args = yaml.dump(argl)
|
||||
application.run(args)
|
||||
else:
|
||||
if dev.persister and setting.persist:
|
||||
dev.persister[setting.name] = setting._value
|
||||
@@ -278,8 +279,6 @@ def set(dev, setting: SettingsProtocol, args, save):
|
||||
key = args.value_key
|
||||
all_keys = getattr(setting, "choices_universe", None)
|
||||
ikey = all_keys[int(key) if key.isdigit() else key] if isinstance(all_keys, NamedInts) else to_int(key)
|
||||
print("S", args.extra2, key, type(all_keys), ikey)
|
||||
print("SS", args)
|
||||
if args.extra2 is None or to_int(args.extra2) is None:
|
||||
raise Exception(f"{setting.name}: setting needs an integer value, not {args.extra2}")
|
||||
if not setting._value: # ensure that there are values to look through
|
||||
@@ -308,8 +307,14 @@ def set(dev, setting: SettingsProtocol, args, save):
|
||||
message = f"Setting {setting.name} of {dev.name} key {key} to {value}"
|
||||
result = setting.write_key_value(key, value, save=save)
|
||||
|
||||
elif setting.kind == settings.Kind.HETERO:
|
||||
value = yaml.safe_load(args.value_key)
|
||||
args.value_key = value
|
||||
message = f"Setting {setting.name} of {dev.name} to {value}"
|
||||
result = setting.write(value, save=save)
|
||||
|
||||
else:
|
||||
print("KIND", setting.kind)
|
||||
print(f"Setting {setting.name}, with kind {setting.kind.name}, not implemented")
|
||||
raise Exception("NotImplemented")
|
||||
|
||||
return result, message, value
|
||||
|
||||
@@ -56,7 +56,7 @@ def _print_receiver(receiver):
|
||||
if notification_flags is not None:
|
||||
if notification_flags:
|
||||
notification_names = hidpp10_constants.NotificationFlag.flag_names(notification_flags)
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags:06X})")
|
||||
print(f" Notifications: {', '.join(notification_names)} (0x{notification_flags.value:06X})")
|
||||
else:
|
||||
print(" Notifications: (none)")
|
||||
|
||||
|
||||
@@ -58,7 +58,10 @@ temp = tempfile.NamedTemporaryFile(prefix="Solaar_", mode="w", delete=True)
|
||||
|
||||
def create_parser():
|
||||
arg_parser = argparse.ArgumentParser(
|
||||
prog=NAME.lower(), epilog="For more information see https://pwr-solaar.github.io/Solaar"
|
||||
prog=NAME.lower(),
|
||||
description="Solaar is a program to manage many Logitech devices, "
|
||||
"changing how they operate and maintaining the changes whenever the device connects.",
|
||||
epilog="For more information see https://pwr-solaar.github.io/Solaar",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"-d",
|
||||
@@ -73,7 +76,7 @@ def create_parser():
|
||||
action="store",
|
||||
dest="hidraw_path",
|
||||
metavar="PATH",
|
||||
help="unifying receiver to use; the first detected receiver if unspecified. Example: /dev/hidraw2",
|
||||
help="device or receiver path to use if needed. Example: /dev/hidraw2",
|
||||
)
|
||||
arg_parser.add_argument(
|
||||
"--restart-on-wake-up",
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.1.18
|
||||
1.1.19
|
||||
|
||||
1946
po/zh_TW.po
1946
po/zh_TW.po
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<release version="1.1.19" date="2026-01-08"/>
|
||||
<release version="1.1.18" date="2025-12-11"/>
|
||||
<release version="1.1.16" date="2025-10-23"/>
|
||||
<release version="1.1.14" date="2025-01-01"/>
|
||||
|
||||
@@ -278,7 +278,7 @@ def test_set_notification_flags_bad(mocker):
|
||||
@pytest.mark.parametrize(
|
||||
"flag_bits, expected_names",
|
||||
[
|
||||
(None, ""),
|
||||
# doesn't work in Python 3.8 and 3.10 for some reason (None, ""),
|
||||
(hidpp10_constants.NotificationFlag(0x0), "none"),
|
||||
(hidpp10_constants.NotificationFlag(0x001000), "multi touch"),
|
||||
(hidpp10_constants.NotificationFlag(0x080000), "mouse extra buttons"),
|
||||
|
||||
Reference in New Issue
Block a user