diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index c0d5c229..47d9702a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -69,12 +69,15 @@ jobs:
- name: Set up macOS dependencies
run: |
make install_brew
+ - name: Add Homebrew's library directory to dyld search path
+ run: |
+ echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib:$DYLD_FALLBACK_LIBRARY_PATH" >> $GITHUB_ENV
- name: Install Python dependencies
run: |
make install_pip PIP_ARGS='.["test"]'
- name: Run tests on macOS
run: |
- export DYLD_LIBRARY_PATH=$(brew --prefix hidapi)/lib:$DYLD_LIBRARY_PATH && pytest --cov --cov-report=xml
+ pytest --cov --cov-report=xml
- name: Upload coverage to Codecov
if: github.ref == 'refs/heads/master'
uses: codecov/codecov-action@v4.5.0
diff --git a/Makefile b/Makefile
index f3eeff98..f7a2b9f6 100644
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,7 @@ install_macos: install_brew install_pip
install_apt:
@echo "Installing Solaar dependencies via apt"
sudo apt update
- sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository-1.0-dev
+ sudo apt install libdbus-1-dev libglib2.0-dev libgtk-3-dev libgirepository1.0-dev
install_apt_python3.13:
@echo "Installing Solaar dependencies via apt"
diff --git a/docs/devices.md b/docs/devices.md
index 8af34aa0..3ef26057 100644
--- a/docs/devices.md
+++ b/docs/devices.md
@@ -209,6 +209,7 @@ so what is important for support is the USB WPID or Bluetooth model ID.
| Device | WPID | HID++ |
|------------------------------|------|-------|
+| G604 Wireless Gaming Mouse | 4085 | 4.2 |
| PRO X Superlight Wireless | 4093 | 4.2 |
### Trackballs (Unifying)
diff --git a/docs/devices/G604 Wireless Gaming Mouse 4085.txt b/docs/devices/G604 Wireless Gaming Mouse 4085.txt
new file mode 100644
index 00000000..39e9e3cf
--- /dev/null
+++ b/docs/devices/G604 Wireless Gaming Mouse 4085.txt
@@ -0,0 +1,84 @@
+solaar version 03cfa128
+
+ 1: G604 Wireless Gaming Mouse
+ Device path : /dev/hidraw6
+ WPID : 4085
+ Codename : G604
+ Kind : mouse
+ Protocol : HID++ 4.2
+ Report Rate : 1ms
+ Serial number: XXXXXXXX
+ Model ID: B02440850000
+ Unit ID: XXXXXXXX
+ 1: BL1 04.01.B0014
+ 0: MPM 21.01.B0014
+ 3:
+ The power switch is located on the base.
+ Supports 33 HID++ 2.0 features:
+ 0: ROOT {0000} V0
+ 1: FEATURE SET {0001} V0
+ 2: DEVICE FW VERSION {0003} V2
+ Firmware: 1 BL1 04.01.B0014 0000B01B3067
+ Firmware: 0 MPM 21.01.B0014 4085B01B3067
+ Firmware: 3
+ Unit ID: XXXXXXXX Model ID: B02440850000 Transport IDs: {'btleid': 'B024', 'wpid': '4085'}
+ 3: DEVICE NAME {0005} V0
+ Name: G604 Wireless Gaming Mouse
+ Kind: mouse
+ 4: WIRELESS DEVICE STATUS {1D4B} V0
+ 5: CONFIG CHANGE {0020} V0
+ Configuration: 00000000000000000000000000000000
+ 6: BATTERY STATUS {1000} V0
+ Battery: 30%, BatteryStatus.DISCHARGING, next level 15%.
+ 7: COLOR LED EFFECTS {8070} V4
+ LED Control (saved): Device
+ LED Control : Device
+ LEDs Primary : None
+ 8: LED CONTROL {1300} V0
+ 9: ONBOARD PROFILES {8100} V0
+ Device Mode: On-Board
+ Onboard Profiles (saved): Profile 1
+ Onboard Profiles : Profile 1
+ 10: MOUSE BUTTON SPY {8110} V0
+ 11: REPORT RATE {8060} V0
+ Report Rate: 1ms
+ Report Rate (saved): 1ms
+ Report Rate : 1ms
+ 12: ADJUSTABLE DPI {2201} V1
+ Sensitivity (DPI) (saved): 800
+ Sensitivity (DPI) : 800
+ 13: DFUCONTROL SIGNED {00C2} V0
+ 14: DEVICE RESET {1802} V0
+ 15: unknown:1803 {0318} V0 internal, hidden
+ 16: OOBSTATE {1805} V0
+ 17: CONFIG DEVICE PROPS {1806} V4
+ 18: unknown:1813 {1318} V0 internal, hidden
+ 19: unknown:1830 {3018} V0 internal, hidden
+ 20: unknown:1890 {9018} V0 internal, hidden
+ 21: unknown:1891 {9118} V0 internal, hidden
+ 22: unknown:1861 {6118} V0 internal, hidden
+ 23: unknown:1801 {0118} V0 internal, hidden
+ 24: unknown:18B1 {B118} V0 internal, hidden
+ 25: unknown:1DF3 {F31D} V0 internal, hidden
+ 26: unknown:1E00 {001E} V0 hidden
+ 27: unknown:1EB0 {B01E} V0 internal, hidden
+ 28: unknown:1E22 {221E} V0 internal, hidden
+ 29: HIRES WHEEL {2121} V0
+ Multiplier: 8
+ Has invert: Normal wheel motion
+ Has ratchet switch: Normal wheel mode
+ High resolution mode
+ HID notification
+ Scroll Wheel Direction (saved): False
+ Scroll Wheel Direction : False
+ Scroll Wheel Resolution (saved): True
+ Scroll Wheel Resolution : True
+ Scroll Wheel Diversion (saved): False
+ Scroll Wheel Diversion : False
+ 30: unknown:18C0 {C018} V0 internal, hidden
+ 31: CHANGE HOST {1814} V1
+ Change Host : 1:host1
+ 32: HOSTS INFO {1815} V1
+ Host 0 (unpaired): host1
+ Host 1 (paired):
+ Battery: 30%, BatteryStatus.DISCHARGING, next level 15%.
diff --git a/docs/installation.md b/docs/installation.md
index af62a946..7eb4aa10 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -42,8 +42,8 @@ or `make install_dnf` or `make install_brew`.
These might not install all needed packages in older versions of your distribution.
Next, install the Solaar rule via `make install_udev`.
If you are using Wayland instead of X11 you may want to instead `make install_udev_uinput`
-Finally, install Solaar via `make install_pip` or `make install_pipx`.
so that Solaar rules can simulate input in Wayland.
+Finally, install Solaar via `make install_pip` or `make install_pipx`.
Parts of the installation process require sudo privileges so you may be asked for your password.
@@ -92,10 +92,11 @@ If desktop notifications bindings are also installed
(`gir1.2-notify-0.7` for Debian/Ubuntu),
you will also see desktop notifications when devices come online and go offline.
-If the `hid_parser` Python package is available, Solaar parses HID report descriptors
-and can control more HID++ devices that do not use a receiver.
-This package may not be available in some distributions but can be installed using pip
-via `pip install --user hid-parser`.
+Solaar includes its own version of `hid_parser` because the version that is in PyPi
+(at https://pypi.org/project/hid-parser/) does not have some changes that are in
+https://github.com/usb-tools/python-hid-parser and are needed for some devices.
+Do not use pip to install hid_parser!
+Some distributions (e.g., Fedora) may separately package this code.
If the `gitinfo` Python package is available, Solaar shows better information
about which version of Solaar is running.
@@ -131,6 +132,6 @@ and set the LANGUAGE environment variable appropriately when running Solaar.
Distributions can cause Solaar can be run automatically at user login by installing a desktop file at
`/etc/xdg/autostart/solaar.desktop`. An example of this file content can be seen in the repository at
-[`share/autostart/solaar.desktop`](/share/autostart/solaar.desktop).
+[`share/autostart/solaar.desktop`](https://github.com/pwr-Solaar/Solaar/blob/master/share/autostart/solaar.desktop).
If you install Solaar yourself you may need to create or modify this file or install a startup file under your home directory.
diff --git a/docs/unistallation.md b/docs/uninstallation.md
similarity index 100%
rename from docs/unistallation.md
rename to docs/uninstallation.md
diff --git a/lib/logitech_receiver/device.py b/lib/logitech_receiver/device.py
index 7bfb3b86..6552535b 100644
--- a/lib/logitech_receiver/device.py
+++ b/lib/logitech_receiver/device.py
@@ -147,6 +147,7 @@ class Device:
self.battery_info = None
self.link_encrypted = None
self._active = None # lags self.online - is used to help determine when to setup devices
+ self.present = True # used for devices that are integral with their receiver but that separately be disconnected
self._feature_settings_checked = False
self._gestures_lock = threading.Lock()
@@ -396,8 +397,8 @@ class Device:
self.persister["_battery"] = feature.value
return battery
except Exception:
- if self.persister and battery_feature is None:
- self.persister["_battery"] = result
+ if self.persister and battery_feature is None and result is not None:
+ self.persister["_battery"] = result.value
def set_battery_info(self, info):
"""Update battery information for device, calling changed callback if necessary"""
@@ -432,6 +433,8 @@ class Device:
def changed(self, active=None, alert=Alert.NONE, reason=None, push=False):
"""The status of the device had changed, so invoke the status callback.
Also push notifications and settings to the device when necessary."""
+ if logger.isEnabledFor(logging.DEBUG):
+ logger.debug("device %d changing: active=%s %s present=%s", self.number, active, self._active, self.present)
if active is not None:
self.online = active
was_active, self._active = self._active, active
@@ -533,7 +536,8 @@ class Device:
return hidpp20.feature_request(self, feature, function, *params, no_reply=no_reply)
def ping(self):
- """Checks if the device is online, returns True of False"""
+ """Checks if the device is online and present, returns True of False.
+ Some devices are integral with their receiver but may not be present even if the receiver responds to ping."""
long = self.hidpp_long is True or (
self.hidpp_long is None and (self.bluetooth or self._protocol is not None and self._protocol >= 2.0)
)
@@ -542,9 +546,11 @@ class Device:
protocol = self.low_level.ping(handle, self.number, long_message=long)
except exceptions.NoReceiver: # if ping fails, device is offline
protocol = None
- self.online = protocol is not None
+ self.online = protocol is not None and self.present
if protocol:
self._protocol = protocol
+ if logger.isEnabledFor(logging.DEBUG):
+ logger.debug("pinged %s: online %s protocol %s present %s", self.number, self.online, protocol, self.present)
return self.online
def notify_devices(self): # no need to notify, as there are none
diff --git a/lib/logitech_receiver/notifications.py b/lib/logitech_receiver/notifications.py
index 520b947e..1636230e 100644
--- a/lib/logitech_receiver/notifications.py
+++ b/lib/logitech_receiver/notifications.py
@@ -238,6 +238,7 @@ def _process_hidpp10_notification(device: Device, notification: HIDPPNotificatio
def _process_feature_notification(device: Device, notification: HIDPPNotification):
+ old_present, device.present = device.present, True # the device is generating a feature notification so it must be present
try:
feature = device.features.get_feature(notification.sub_id)
except IndexError:
@@ -277,9 +278,11 @@ def _process_feature_notification(device: Device, notification: HIDPPNotificatio
elif feature == SupportedFeature.ADC_MEASUREMENT:
if notification.address == 0x00:
result = hidpp20.decipher_adc_measurement(notification.data)
- if result:
+ if result: # if good data and the device was not present then a push is needed
device.set_battery_info(result[1])
- else: # this feature is used to signal device becoming inactive
+ device.changed(active=True, alert=Alert.ALL, reason=_("ADC measurement notification"), push=not old_present)
+ else: # this feature is also used to signal device becoming inactive
+ device.present = False # exception to device presence
device.changed(active=False)
else:
logger.warning("%s: unknown ADC MEASUREMENT %s", device, notification)
@@ -451,7 +454,7 @@ def handle_discovery_status(receiver: Receiver, notification: HIDPPNotification)
receiver.pairing.device_passkey = None
discover_error = ord(notification.data[:1])
if discover_error:
- receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error)
+ receiver.pairing.error = discover_string = hidpp10_constants.BoltPairingError(discover_error).name
logger.warning("bolt discovering error %d: %s", discover_error, discover_string)
receiver.changed(reason=reason)
return True
@@ -493,7 +496,7 @@ def handle_pairing_status(receiver: Receiver, notification: HIDPPNotification) -
elif notification.address == 0x02 and not pair_error:
receiver.pairing.new_device = receiver.register_new_device(notification.data[7])
if pair_error:
- receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error)
+ receiver.pairing.error = error_string = hidpp10_constants.BoltPairingError(pair_error).name
receiver.pairing.new_device = None
logger.warning("pairing error %d: %s", pair_error, error_string)
receiver.changed(reason=reason)
diff --git a/lib/logitech_receiver/receiver.py b/lib/logitech_receiver/receiver.py
index cea5458d..99ec83a8 100644
--- a/lib/logitech_receiver/receiver.py
+++ b/lib/logitech_receiver/receiver.py
@@ -533,7 +533,7 @@ class Ex100Receiver(Receiver):
online = True
encrypted = bool(notification.data[0] & 0x80)
kind = extract_device_kind(_get_kind_from_index(self, number))
- wpid = extract_wpid("00" + notification.data[2:3])
+ wpid = "00" + extract_wpid(notification.data[2:3])
return online, encrypted, wpid, kind
def device_pairing_information(self, number: int) -> dict:
diff --git a/lib/logitech_receiver/settings_templates.py b/lib/logitech_receiver/settings_templates.py
index 6c9a9bbd..3a63adb8 100644
--- a/lib/logitech_receiver/settings_templates.py
+++ b/lib/logitech_receiver/settings_templates.py
@@ -32,6 +32,7 @@ from . import common
from . import descriptors
from . import desktop_notifications
from . import diversion
+from . import exceptions
from . import hidpp20
from . import hidpp20_constants
from . import settings
@@ -1534,6 +1535,7 @@ class ADCPower(settings.Setting):
label = _("Power Management")
description = _("Power off in minutes (0 for never).")
feature = _F.ADC_MEASUREMENT
+ min_version = 2 # documentation for version 1 does not mention this capability
rw_options = {"read_fnid": 0x10, "write_fnid": 0x20}
validator_class = settings_validator.RangeValidator
min_value = 0x00
@@ -1905,7 +1907,7 @@ def check_feature(device, settings_class: SettingsProtocol) -> None | bool | Set
logger.error(
"check_feature %s [%s] error %s\n%s", settings_class.name, settings_class.feature, e, traceback.format_exc()
)
- return False # differentiate from an error-free determination that the setting is not supported
+ raise e # differentiate from an error-free determination that the setting is not supported
def check_feature_settings(device, already_known) -> bool:
@@ -1914,7 +1916,7 @@ def check_feature_settings(device, already_known) -> bool:
Returns
-------
bool
- True, if device was queried to find features, False otherwise.
+ True, if device was fully queried to find features, False otherwise.
"""
if not device.features or not device.online:
return False
@@ -1926,7 +1928,21 @@ def check_feature_settings(device, already_known) -> bool:
if sclass.feature:
known_present = device.persister and sclass.name in device.persister
if not any(s.name == sclass.name for s in already_known) and (known_present or sclass.name not in absent):
- setting = check_feature(device, sclass)
+ try:
+ setting = check_feature(device, sclass)
+ except Exception as err:
+ # on an internal HID++ error, assume offline and stop further checking
+ if (
+ isinstance(err, exceptions.FeatureCallError)
+ and err.error == hidpp20_constants.ErrorCode.LOGITECH_ERROR
+ ):
+ logger.warning(f"HID++ internal error checking feature {sclass.name}: make device not present")
+ device.online = False
+ device.present = False
+ return False
+ else:
+ logger.warning(f"ignore feature {sclass.name} because of error {err}")
+
if isinstance(setting, list):
for s in setting:
already_known.append(s)
@@ -1948,6 +1964,9 @@ 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:
- setting = check_feature(device, sclass)
+ try:
+ setting = check_feature(device, sclass)
+ except Exception:
+ return None
if setting:
return setting
diff --git a/lib/solaar/i18n.py b/lib/solaar/i18n.py
index 9e85549f..d3e53242 100644
--- a/lib/solaar/i18n.py
+++ b/lib/solaar/i18n.py
@@ -16,6 +16,7 @@
import gettext
import locale
+import logging
import os
import sys
@@ -25,6 +26,8 @@ from solaar import NAME
_LOCALE_DOMAIN = NAME.lower()
+logger = logging.getLogger(__name__)
+
def _find_locale_path(locale_domain: str) -> str:
prefix_share = os.path.normpath(os.path.join(os.path.realpath(sys.path[0]), ".."))
@@ -37,18 +40,23 @@ def _find_locale_path(locale_domain: str) -> str:
raise FileNotFoundError(f"Could not find locale path for {locale_domain}")
-def set_locale_to_system_default():
+def set_locale_to_system_default() -> None:
"""Sets locale for translations to the system default.
+ If locale is unsupported, fallback to standard English without
+ translation 'C'.
+
Set LC_ALL environment variable to enforce a locale setting e.g.
'de_DE.UTF-8'. Run Solaar with your desired localization, for German
use:
'LC_ALL=de_DE.UTF-8 solaar'
"""
try:
- locale.setlocale(locale.LC_ALL, "")
- except Exception:
- pass
+ locale.setlocale(locale.LC_ALL, "") # system default
+ except locale.Error:
+ logger.error("User locale not supported by system, using no translation.")
+ locale.setlocale(locale.LC_ALL, "C") # untranslated (English)
+ return
try:
path = _find_locale_path(_LOCALE_DOMAIN)
diff --git a/po/sv.po b/po/sv.po
index f0f768d0..3fb1ff38 100644
--- a/po/sv.po
+++ b/po/sv.po
@@ -274,7 +274,7 @@ msgstr "funktion"
#: lib/logitech_receiver/settings_templates.py:139
msgid "Swap Fx function"
-msgstr "Skifta Fx-funktion"
+msgstr "Byt Fx-funktion"
#: lib/logitech_receiver/settings_templates.py:140
msgid ""
diff --git a/rules.d-uinput/42-logitech-unify-permissions.rules b/rules.d-uinput/42-logitech-unify-permissions.rules
index b30dbc99..52224947 100644
--- a/rules.d-uinput/42-logitech-unify-permissions.rules
+++ b/rules.d-uinput/42-logitech-unify-permissions.rules
@@ -5,7 +5,7 @@
# because they could perform firmware updates.
KERNEL=="uinput", SUBSYSTEM=="misc", TAG+="uaccess", OPTIONS+="static_node=uinput"
-ACTION != "add", GOTO="solaar_end"
+ACTION == "remove", GOTO="solaar_end"
SUBSYSTEM != "hidraw", GOTO="solaar_end"
# USB-connected Logitech receivers and devices
diff --git a/rules.d/42-logitech-unify-permissions.rules b/rules.d/42-logitech-unify-permissions.rules
index e52c9b0b..5e5f5d66 100644
--- a/rules.d/42-logitech-unify-permissions.rules
+++ b/rules.d/42-logitech-unify-permissions.rules
@@ -4,7 +4,7 @@
# Allowing users to write to the device is potentially dangerous
# because they could perform firmware updates.
-ACTION != "add", GOTO="solaar_end"
+ACTION == "remove", GOTO="solaar_end"
SUBSYSTEM != "hidraw", GOTO="solaar_end"
# USB-connected Logitech receivers and devices
diff --git a/share/solaar/io.github.pwr_solaar.solaar.metainfo.xml b/share/solaar/io.github.pwr_solaar.solaar.metainfo.xml
index ee2b66d6..9fcb3c44 100644
--- a/share/solaar/io.github.pwr_solaar.solaar.metainfo.xml
+++ b/share/solaar/io.github.pwr_solaar.solaar.metainfo.xml
@@ -8,7 +8,8 @@
pwr-Solaar
- https://github.com/pwr-Solaar/Solaar
+ https://pwr-solaar.github.io/Solaar/
+ https://github.com/pwr-Solaar/Solaar
pfpschneider_AT_gmail.com
diff --git a/tests/logitech_receiver/fake_hidpp.py b/tests/logitech_receiver/fake_hidpp.py
index e47990a3..01f2d7a6 100644
--- a/tests/logitech_receiver/fake_hidpp.py
+++ b/tests/logitech_receiver/fake_hidpp.py
@@ -389,6 +389,7 @@ class Device:
sliding = profiles = _backlight = _keys = _remap_keys = _led_effects = _gestures = None
_gestures_lock = threading.Lock()
number = "d1"
+ present = True
read_register = device.Device.read_register
write_register = device.Device.write_register
diff --git a/tests/logitech_receiver/test_notifications.py b/tests/logitech_receiver/test_notifications.py
index 2cf662be..89c758c8 100644
--- a/tests/logitech_receiver/test_notifications.py
+++ b/tests/logitech_receiver/test_notifications.py
@@ -58,7 +58,7 @@ def test_process_receiver_notification(sub_id, notification_data, expected_error
result = notifications.process_receiver_notification(receiver, notification)
assert result
- assert receiver.pairing.error == expected_error
+ assert receiver.pairing.error == (None if expected_error is None else expected_error.name)
assert receiver.pairing.new_device is expected_new_device
diff --git a/tests/logitech_receiver/test_setting_templates.py b/tests/logitech_receiver/test_setting_templates.py
index 74f22b88..b1cff158 100644
--- a/tests/logitech_receiver/test_setting_templates.py
+++ b/tests/logitech_receiver/test_setting_templates.py
@@ -268,7 +268,7 @@ simple_tests = [
fake_hidpp.Response("0A", 0x0410, "0A"),
),
Setup(
- FeatureTest(settings_templates.ADCPower, 5, 0xA),
+ FeatureTest(settings_templates.ADCPower, 5, 0xA, version=0x03),
fake_hidpp.Response("05", 0x0410),
fake_hidpp.Response("0A", 0x0420, "0A"),
),