From bfc38e95c7eaa6b216fb2c4df1502962fbd574ce Mon Sep 17 00:00:00 2001 From: Frenckatron Date: Wed, 20 May 2026 22:25:52 +0200 Subject: [PATCH 1/2] Extract shared deserialize helpers on Device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `Device.__pre_deserialize__` and `Device.update_from_dict` carried near-identical logic for effects filtering, palette synthesis, and preset/playlist splitting. Any fix on one side risked silently drifting from the other. Introduce three helpers — `_filter_effects`, `_build_palette_dict`, and `_split_presets_playlists` — alongside the existing palette builders. They return raw `dict[str, Any]` so the pre-deserialize path (which hands off to mashumaro) and the `update_from_dict` path (which constructs typed objects via `from_dict` / `**kwargs`) can share the same source of truth. Behavior is preserved: RSVD skip, null-palette fallback, defensive copy of the presets payload, and `pop(0, None)` on both maps. --- src/wled/models.py | 189 ++++++++++++++++++++++++++++----------------- 1 file changed, 117 insertions(+), 72 deletions(-) diff --git a/src/wled/models.py b/src/wled/models.py index c068c7d8..d9a729be 100644 --- a/src/wled/models.py +++ b/src/wled/models.py @@ -862,6 +862,101 @@ def _build_custom_palettes( } return result + @staticmethod + def _filter_effects( + effects_list: list[str], + ) -> dict[int, dict[str, Any]]: + """Build the effect-id → raw-dict mapping, dropping reserved entries. + + Args: + ---- + effects_list: List of effect names as returned by the device. + + Returns: + ------- + A dict of `{effect_id: {"effect_id": id, "name": name}}` entries, + skipping any name containing ``RSVD``. + + """ + return { + effect_id: {"effect_id": effect_id, "name": name} + for effect_id, name in enumerate(effects_list) + if "RSVD" not in name + } + + @classmethod + def _build_palette_dict( + cls, + palettes_list: list[str], + cpalcount: int, + umpalcount: int, + umpalnames: list[str] | None, + version: AwesomeVersion | None, + ) -> dict[int, dict[str, Any]]: + """Build the merged palette dict (built-in | custom | usermod). + + Args: + ---- + palettes_list: List of built-in palette names from the device. + cpalcount: Number of custom palettes. + umpalcount: Number of usermod palettes. + umpalnames: List of usermod palette names (may be ``None``). + version: The firmware version, used to gate palette ID schemes. + + Returns: + ------- + A dict of `{palette_id: raw_palette_dict}` entries combining + built-in, custom, and usermod palettes. + + """ + built_in = { + palette_id: {"palette_id": palette_id, "name": name} + for palette_id, name in enumerate(palettes_list) + } + custom = cls._build_custom_palettes(cpalcount, version) + usermod = cls._build_usermod_palettes(umpalcount, umpalnames, version) + return built_in | custom | usermod + + @staticmethod + def _split_presets_playlists( + presets_data: dict[Any, dict[str, Any]], + ) -> tuple[dict[int, dict[str, Any]], dict[int, dict[str, Any]]]: + """Split the combined presets payload into presets and playlists. + + The WLED ``presets`` endpoint returns both presets and playlists in + a single mapping; this helper partitions them and injects the + ``preset_id`` / ``playlist_id`` field expected downstream. The + sentinel ``0`` entry is dropped from both maps. + + Args: + ---- + presets_data: Raw preset/playlist mapping from the device. + + Returns: + ------- + A ``(presets, playlists)`` tuple of raw-dict mappings keyed by + integer ID. + + """ + presets_data = presets_data.copy() + presets = { + int(preset_id): preset | {"preset_id": int(preset_id)} + for preset_id, preset in presets_data.items() + if "playlist" not in preset + or "ps" not in preset["playlist"] + or not preset["playlist"]["ps"] + } + presets.pop(0, None) + playlists = { + int(playlist_id): playlist | {"playlist_id": int(playlist_id)} + for playlist_id, playlist in presets_data.items() + if "playlist" in playlist + and "ps" in playlist["playlist"] + and playlist["playlist"]["ps"] + } + playlists.pop(0, None) + return presets, playlists + @classmethod def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: """Pre deserialize hook for Device object.""" @@ -884,24 +979,17 @@ def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: raise WLEDUnsupportedVersionError(msg) if _effects := d.get("effects"): - d["effects"] = { - effect_id: {"effect_id": effect_id, "name": name} - for effect_id, name in enumerate(_effects) - if "RSVD" not in name - } + d["effects"] = cls._filter_effects(_effects) if _palettes := d.get("palettes"): - built_in_palettes = { - palette_id: {"palette_id": palette_id, "name": name} - for palette_id, name in enumerate(_palettes) - } info = d.get("info", {}) - cpalcount = info.get("cpalcount", 0) - custom_palettes = cls._build_custom_palettes(cpalcount, version) - usermod_palettes = cls._build_usermod_palettes( - info.get("umpalcount", 0), info.get("umpalnames"), version + d["palettes"] = cls._build_palette_dict( + _palettes, + info.get("cpalcount", 0), + info.get("umpalcount", 0), + info.get("umpalnames"), + version, ) - d["palettes"] = built_in_palettes | custom_palettes | usermod_palettes elif _palettes is None: # Some less capable devices don't have palettes and # will return `null`. @@ -911,28 +999,7 @@ def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]: d["palettes"] = {} if _presets := d.get("presets"): - _presets = _presets.copy() - # The preset data contains both presets and playlists, - # we split those out, so we can handle those correctly. - d["presets"] = { - int(preset_id): preset | {"preset_id": int(preset_id)} - for preset_id, preset in _presets.items() - if "playlist" not in preset - or "ps" not in preset["playlist"] - or not preset["playlist"]["ps"] - } - # Nobody cares about 0. - d["presets"].pop(0, None) - - d["playlists"] = { - int(playlist_id): playlist | {"playlist_id": int(playlist_id)} - for playlist_id, playlist in _presets.items() - if "playlist" in playlist - and "ps" in playlist["playlist"] - and playlist["playlist"]["ps"] - } - # Nobody cares about 0. - d["playlists"].pop(0, None) + d["presets"], d["playlists"] = cls._split_presets_playlists(_presets) return d @@ -955,55 +1022,33 @@ def update_from_dict(self, data: dict[str, Any]) -> Device: if _effects := data.get("effects"): self.effects = { - effect_id: Effect(effect_id=effect_id, name=name) - for effect_id, name in enumerate(_effects) - if "RSVD" not in name + effect_id: Effect(**entry) + for effect_id, entry in self._filter_effects(_effects).items() } if _palettes := data.get("palettes"): - built_in_palettes = { - palette_id: Palette(palette_id=palette_id, name=name) - for palette_id, name in enumerate(_palettes) - } - custom_palettes = self._build_custom_palettes( - self.info.custom_palette_count, self.info.version - ) - usermod_palettes = self._build_usermod_palettes( + palette_dict = self._build_palette_dict( + _palettes, + self.info.custom_palette_count, self.info.usermod_palette_count, self.info.usermod_palette_names, self.info.version, ) - result = {} - for pal_id, pal_data in (custom_palettes | usermod_palettes).items(): - result[pal_id] = Palette(**pal_data) - self.palettes = built_in_palettes | result + self.palettes = { + palette_id: Palette(**entry) + for palette_id, entry in palette_dict.items() + } if _presets := data.get("presets"): - # The preset data contains both presets and playlists, - # we split those out, so we can handle those correctly. + presets, playlists = self._split_presets_playlists(_presets) self.presets = { - int(preset_id): Preset.from_dict( - preset | {"preset_id": int(preset_id)}, - ) - for preset_id, preset in _presets.items() - if "playlist" not in preset - or "ps" not in preset["playlist"] - or not preset["playlist"]["ps"] + preset_id: Preset.from_dict(entry) + for preset_id, entry in presets.items() } - # Nobody cares about 0. - self.presets.pop(0, None) - self.playlists = { - int(playlist_id): Playlist.from_dict( - playlist | {"playlist_id": int(playlist_id)} - ) - for playlist_id, playlist in _presets.items() - if "playlist" in playlist - and "ps" in playlist["playlist"] - and playlist["playlist"]["ps"] + playlist_id: Playlist.from_dict(entry) + for playlist_id, entry in playlists.items() } - # Nobody cares about 0. - self.playlists.pop(0, None) if _state := data.get("state"): self.state = State.from_dict(_state) From e701bb693cce78da296912370ade7c217bd23adc Mon Sep 17 00:00:00 2001 From: Frenckatron Date: Wed, 20 May 2026 22:44:40 +0200 Subject: [PATCH 2/2] Suppress pylint too-many-arguments on _build_palette_dict Co-Authored-By: Claude Opus 4.7 (1M context) --- src/wled/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wled/models.py b/src/wled/models.py index d9a729be..482bf0f2 100644 --- a/src/wled/models.py +++ b/src/wled/models.py @@ -885,6 +885,7 @@ def _filter_effects( } @classmethod + # pylint: disable-next=too-many-arguments,too-many-positional-arguments def _build_palette_dict( cls, palettes_list: list[str],