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"), ),