Merge branch 'master' into fix_2827

This commit is contained in:
MattHag 2025-09-20 13:17:34 +02:00 committed by GitHub
commit b2b032d8bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 158 additions and 31 deletions

View File

@ -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

View File

@ -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"

View File

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

View File

@ -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%.

View File

@ -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.

View File

@ -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

View File

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

View File

@ -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:

View File

@ -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

View File

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

View File

@ -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 ""

View File

@ -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

View File

@ -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

View File

@ -8,7 +8,8 @@
<developer id="pwr-solaar.github.io">
<name>pwr-Solaar</name>
</developer>
<url type="homepage">https://github.com/pwr-Solaar/Solaar</url>
<url type="homepage">https://pwr-solaar.github.io/Solaar/</url>
<url type="vcs-browser">https://github.com/pwr-Solaar/Solaar</url>
<content_rating type="oars-1.0" />
<update_contact>pfpschneider_AT_gmail.com</update_contact>

View File

@ -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

View File

@ -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

View File

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