diff --git a/lib/solaar/configuration.py b/lib/solaar/configuration.py index ecb814ac..1efadffc 100644 --- a/lib/solaar/configuration.py +++ b/lib/solaar/configuration.py @@ -16,15 +16,17 @@ ## with this program; if not, write to the Free Software Foundation, Inc., ## 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +import json as _json import os as _os import os.path as _path -from json import dump as _json_save -from json import load as _json_load from logging import DEBUG as _DEBUG from logging import INFO as _INFO from logging import getLogger +import yaml as _yaml + +from logitech_receiver.common import NamedInt as _NamedInt from solaar import __version__ _log = getLogger(__name__) @@ -32,43 +34,45 @@ del getLogger _XDG_CONFIG_HOME = _os.environ.get('XDG_CONFIG_HOME') or _path.expanduser(_path.join('~', '.config')) _file_path = _path.join(_XDG_CONFIG_HOME, 'solaar', 'config.json') +_yaml_file_path = _path.join(_XDG_CONFIG_HOME, 'solaar', 'config.yaml') _KEY_VERSION = '_version' -_KEY_NAME = '_name' +_KEY_NAME = '_NAME' +_KEY_WPID = '_wpid' _KEY_SERIAL = '_serial' _KEY_MODEL_ID = '_modelId' _KEY_UNIT_ID = '_unitId' -_configuration = {} +_KEY_ABSENT = '_absent' +_KEY_SENSITIVE = '_sensitive' +_config = [] def _load(): - if _path.isfile(_file_path): - loaded_configuration = {} + global _config + loaded_config = [] + if _path.isfile(_yaml_file_path): + try: + with open(_yaml_file_path) as config_file: + loaded_config = _yaml.safe_load(config_file) + except Exception as e: + _log.error('failed to load from %s: %s', _yaml_file_path, e) + elif _path.isfile(_file_path): try: with open(_file_path) as config_file: - loaded_configuration = _json_load(config_file) - except Exception: - _log.error('failed to load from %s', _file_path) - - # loaded_configuration.update(_configuration) - _configuration.clear() - _configuration.update(loaded_configuration) + loaded_config = _json.load(config_file) + loaded_config = _convert_json(loaded_config) + except Exception as e: + _log.error('failed to load from %s: %s', _file_path, e) if _log.isEnabledFor(_DEBUG): - _log.debug('load => %s', _configuration) - - _cleanup(_configuration) - _cleanup_load(_configuration) - _configuration[_KEY_VERSION] = __version__ - return _configuration + _log.debug('load => %s', loaded_config) + _config = _cleanup_load(loaded_config) def save(): - # don't save if the configuration hasn't been loaded - if _KEY_VERSION not in _configuration: + if not _config: return - - dirname = _os.path.dirname(_file_path) + dirname = _os.path.dirname(_yaml_file_path) if not _path.isdir(dirname): try: _os.makedirs(dirname) @@ -76,108 +80,110 @@ def save(): _log.error('failed to create %s', dirname) return False - _cleanup(_configuration) - try: - with open(_file_path, 'w') as config_file: - _json_save(_configuration, config_file, skipkeys=True, indent=2, sort_keys=True) + with open(_yaml_file_path, 'w') as config_file: + _yaml.dump(_config, config_file) if _log.isEnabledFor(_INFO): - _log.info('saved %s to %s', _configuration, _file_path) + _log.info('saved %s to %s', _config, _yaml_file_path) return True except Exception as e: - _log.error('failed to save to %s: %s', _file_path, e) + _log.error('failed to save to %s: %s', _yaml_file_path, e) -def _cleanup(d): - # remove None values from the dict - for key in list(d.keys()): - value = d.get(key) - if value is None: - del d[key] - elif isinstance(value, dict): - _cleanup(value) +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] + config.append(dev) + return config -def _cleanup_load(d): - # remove boolean values for mouse-gesture and dpi-sliding - for device in d.values(): - if isinstance(device, dict): +def _cleanup_load(c): + _config = [__version__] + for element in c: + if isinstance(element, dict): + # remove boolean values for mouse-gesture and dpi-sliding for setting in ['mouse-gestures', 'dpi-sliding']: - mg = device.get(setting, None) + mg = element.get(setting, None) if mg is True or mg is False: - del device[setting] + del element[setting] + # convert to device entries + element = _DeviceEntry(**element) + _config.append(element) + return _config class _DeviceEntry(dict): - def __init__(self, device, **kwargs): + def __init__(self, **kwargs): super().__init__(**kwargs) - if self.get(_KEY_NAME) != device.name: - self[_KEY_NAME] = device.name - self.update(device) def __setitem__(self, key, value): super().__setitem__(key, value) save() def update(self, device): - if device.modelId and device.modelId != self.get(_KEY_MODEL_ID): - self[_KEY_MODEL_ID] = device.modelId - if device.unitId and device.unitId != self.get(_KEY_UNIT_ID): - self[_KEY_UNIT_ID] = device.unitId + if device.name and device.name != self.get(_KEY_NAME): + super().__setitem__(_KEY_NAME, device.name) + if device.wpid and device.wpid != self.get(_KEY_WPID): + super().__setitem__(_KEY_WPID, device.wpid) if device.serial and device.serial != '?' and device.serial != self.get(_KEY_SERIAL): - self[_KEY_SERIAL] = device.serial + super().__setitem__(_KEY_SERIAL, device.serial) + if device.modelId and device.modelId != self.get(_KEY_MODEL_ID): + super().__setitem__(_KEY_MODEL_ID, device.modelId) + if device.unitId and device.unitId != self.get(_KEY_UNIT_ID): + super().__setitem__(_KEY_UNIT_ID, device.unitId) def get_sensitivity(self, name): - return self.get('_sensitive', {}).get(name, False) + return self.get(_KEY_SENSITIVE, {}).get(name, False) def set_sensitivity(self, name, value): - sensitives = self.get('_sensitive', {}) + sensitives = self.get(_KEY_SENSITIVE, {}) if sensitives.get(name) != value: sensitives[name] = value - self.__setitem__('_sensitive', sensitives) + self.__setitem__(_KEY_SENSITIVE, sensitives) -# This is neccessarily complicate because the same device can be attached in several different ways. -# All HID++ 2.0 devices have a modelId and unitId, which can be accessed when they are connected. -# When paired via a receiver the receiver provides a WPID and a serial number. -# The unitId and serial number are supposed to be the same, but for some models they are not -# so even though the modelId includes the WPID it is not always possible to determine the identity of a -# paired but not receiver-connected device for which the unitId is not known. -# This only happens is Solaar has never seen the device while it is paired and connected through a receiver. +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). +# 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 except for those with protocol 1.0 def persister(device): - if not _configuration: + 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))) + + if not _config: _load() - - entry = {} - key = None - if device.wpid: # connected via receiver - entry = _configuration.get('%s:%s' % (device.wpid, device.serial), {}) - if entry or device.protocol == 1.0: # found entry or create entry for old-style devices - key = '%s:%s' % (device.wpid, device.serial) - elif not entry and device.modelId: # online new-style device so look for modelId and unitId - for k, c in _configuration.items(): - if isinstance(c, dict) and c.get(_KEY_MODEL_ID) == device.modelId and c.get(_KEY_UNIT_ID) == device.unitId: - entry = c # use the entry that matches modelId and unitId - key = k - break - if device.wpid and entry: # move entry to wpid:serial - del _configuration[key] - key = '%s:%s' % (device.wpid, device.serial) - _configuration[key] = entry - elif device.wpid and not entry: # create now with wpid:serial - key = '%s:%s' % (device.wpid, device.serial) - elif not entry: # create now with modelId:unitId - key = '%s:%s' % (device.modelId, device.unitId) - else: # defer until more is known (i.e., device comes on line) - return - - if key and not isinstance(entry, _DeviceEntry): - entry = _DeviceEntry(device, **entry) - _configuration[key] = entry - if isinstance(entry, _DeviceEntry): - entry.update(device) - + entry = None + for c in _config: + if isinstance(c, _DeviceEntry) and match(device.wpid, device.serial, device.modelId, device.unitId, c): + entry = c + break + if not entry: + if not device.online and device.protocol > 1.0: # don't create entry for unseen offline modern devices + return + entry = _DeviceEntry() + _config.append(entry) + entry.update(device) return entry