## Copyright (C) 2012-2013 Daniel Pavel ## Copyright (C) 2014-2024 Solaar Contributors https://pwr-solaar.github.io/Solaar/ ## ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by ## the Free Software Foundation; either version 2 of the License, or ## (at your option) any later version. ## ## This program is distributed in the hope that it will be useful, ## but WITHOUT ANY WARRANTY; without even the implied warranty of ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## GNU General Public License for more details. ## ## You should have received a copy of the GNU General Public License along ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. import json import logging import os import threading import yaml from logitech_receiver.common import NamedInt from solaar import __version__ logger = logging.getLogger(__name__) _XDG_CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser(os.path.join("~", ".config")) _yaml_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "config.yaml") _json_file_path = os.path.join(_XDG_CONFIG_HOME, "solaar", "config.json") _KEY_VERSION = "_version" _KEY_NAME = "_NAME" _KEY_WPID = "_wpid" _KEY_SERIAL = "_serial" _KEY_MODEL_ID = "_modelId" _KEY_UNIT_ID = "_unitId" _KEY_ABSENT = "_absent" _KEY_SENSITIVE = "_sensitive" _config = [] def _load(): loaded_config = [] if os.path.isfile(_yaml_file_path): path = _yaml_file_path try: with open(_yaml_file_path) as config_file: loaded_config = yaml.safe_load(config_file) except Exception as e: logger.error("failed to load from %s: %s", _yaml_file_path, e) elif os.path.isfile(_json_file_path): path = _json_file_path try: with open(_json_file_path) as config_file: loaded_config = json.load(config_file) except Exception as e: logger.error("failed to load from %s: %s", _json_file_path, e) loaded_config = _convert_json(loaded_config) else: path = None logger.debug("load => %s", loaded_config) global _config _config = _parse_config(loaded_config, path) def _parse_config(loaded_config, config_path): current_version = __version__ parsed_config = [current_version] try: if not loaded_config: return parsed_config loaded_version = loaded_config[0] discard_derived_properties = loaded_version != current_version if discard_derived_properties: logger.info( "config file '%s' was generated by another version of solaar " "(config: %s, current: %s). refreshing detected device capabilities", config_path, loaded_version, current_version, ) for device in loaded_config[1:]: assert isinstance(device, dict) parsed_config.append(_device_entry_from_config_dict(device, discard_derived_properties)) except Exception as e: logger.warning("Exception processing config file '%s', ignoring contents: %s", config_path, e) return parsed_config def _device_entry_from_config_dict(data, discard_derived_properties): divert = data.get("divert-keys") if divert: sliding = data.get("dpi-sliding") if sliding: # convert old-style dpi-sliding setting to divert-keys entry divert[int(sliding)] = 3 data.pop("dpi-sliding", None) gestures = data.get("mouse-gestures") if gestures: # convert old-style mouse-gestures setting to divert-keys entry divert[int(gestures)] = 2 data.pop("mouse-gestures", None) # remove any string entries (from bad conversions) data["divert-keys"] = {k: v for k, v in divert.items() if isinstance(k, int)} if data.get("_sensitive", None) is None: # make scroll wheel settings default to ignore data["_sensitive"] = { "hires-smooth-resolution": "ignore", "hires-smooth-invert": "ignore", "hires-scroll-mode": "ignore", } if discard_derived_properties: data.pop("_absent", None) data.pop("_battery", None) return _DeviceEntry(**data) save_timer = None configuration_lock = threading.Lock() defer_saves = False # don't allow configuration saves to be deferred def save(defer=False): global save_timer if not _config: return dirname = os.path.dirname(_yaml_file_path) if not os.path.isdir(dirname): try: os.makedirs(dirname) except Exception: logger.error("failed to create %s", dirname) return if not defer or not defer_saves: do_save() else: with configuration_lock: if not save_timer: save_timer = threading.Timer(5.0, do_save) save_timer.start() def do_save(): global save_timer with configuration_lock: if save_timer: save_timer.cancel() save_timer = None try: with open(_yaml_file_path, "w") as config_file: yaml.dump(_config, config_file, default_flow_style=None, width=150) logger.info("saved %s to %s", _config, _yaml_file_path) except Exception as e: logger.error("failed to save to %s: %s", _yaml_file_path, e) def _convert_json(json_dict): config = [json_dict.get(_KEY_VERSION)] for key, dev in json_dict.items(): key = key.split(":") if len(key) == 2: dev[_KEY_WPID] = dev.get(_KEY_WPID) if dev.get(_KEY_WPID) else key[0] dev[_KEY_SERIAL] = dev.get(_KEY_SERIAL) if dev.get(_KEY_SERIAL) else key[1] for k, v in dev.items(): if isinstance(k, str) and not k.startswith("_") and isinstance(v, dict): # convert string keys to ints v = {int(dk) if isinstance(dk, str) else dk: dv for dk, dv in v.items()} dev[k] = v for k in ["mouse-gestures", "dpi-sliding"]: v = dev.get(k, None) if v is True or v is False: dev.pop(k) if "_name" in dev: dev[_KEY_NAME] = dev["_name"] dev.pop("_name") config.append(dev) return config class _DeviceEntry(dict): def __init__(self, **kwargs): super().__init__(**kwargs) def __setitem__(self, key, value): super().__setitem__(key, value) save(defer=True) def update(self, name, wpid, serial, modelId, unitId): if name and name != self.get(_KEY_NAME): super().__setitem__(_KEY_NAME, name) if wpid and wpid != self.get(_KEY_WPID): super().__setitem__(_KEY_WPID, wpid) if serial and serial != self.get(_KEY_SERIAL): super().__setitem__(_KEY_SERIAL, serial) if modelId and modelId != self.get(_KEY_MODEL_ID): super().__setitem__(_KEY_MODEL_ID, modelId) if unitId and unitId != self.get(_KEY_UNIT_ID): super().__setitem__(_KEY_UNIT_ID, unitId) def get_sensitivity(self, name): return self.get(_KEY_SENSITIVE, {}).get(name, False) def set_sensitivity(self, name, value): sensitives = self.get(_KEY_SENSITIVE, {}) if sensitives.get(name) != value: sensitives[name] = value self.__setitem__(_KEY_SENSITIVE, sensitives) def device_representer(dumper, data): return dumper.represent_mapping("tag:yaml.org,2002:map", data) yaml.add_representer(_DeviceEntry, device_representer) def named_int_representer(dumper, data): return dumper.represent_scalar("tag:yaml.org,2002:int", str(int(data))) yaml.add_representer(NamedInt, named_int_representer) # A device can be identified by a combination of WPID and serial number (for receiver-connected devices) # or a combination of modelId and unitId (for direct-connected devices). # But some devices have empty (all zero) modelIds and unitIds. Use the device name as a backup for the modelId. # The worst situation is a receiver-connected device that Solaar has never seen on-line # that is directly connected. Here there is no way to realize that the two devices are the same. # So new entries are not created for unseen off-line receiver-connected devices def persister(device): def match(wpid, serial, modelId, unitId, c): return (wpid and wpid == c.get(_KEY_WPID) and serial and serial == c.get(_KEY_SERIAL)) or ( modelId and modelId == c.get(_KEY_MODEL_ID) and unitId and unitId == c.get(_KEY_UNIT_ID) ) with configuration_lock: if not _config: _load() entry = None # some devices report modelId and unitId as zero so use name and serial for them modelId = device.modelId if device.modelId != "000000000000" else device._name if device.modelId else None unitId = device.unitId if device.modelId != "000000000000" else device._serial if device.unitId else None for c in _config: if isinstance(c, _DeviceEntry) and match(device.wpid, device._serial, modelId, unitId, c): entry = c break if not entry: if not device.online: # don't create entry for offline devices logger.info("not setting up persister for offline device %s", device._name) return logger.info("setting up persister for device %s", device.name) entry = _DeviceEntry() _config.append(entry) entry.update(device.name, device.wpid, device.serial, modelId, unitId) return entry def attach_to(device): pass