Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions src/wled/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@
# - Usermod palettes: IDs 255-201 (55 slots)
# - User custom palettes: IDs 200-FIXED_PALETTE_COUNT+1 (129 slots)
# In versions < 16.0.0, custom palettes counted down from 255.
WLED_USERMOD_PALETTE_ID_BASE = 255
WLED_CUSTOM_PALETTE_ID_BASE = 200
WLED_CUSTOM_PALETTE_ID_BASE_LEGACY = 255
WLED_USERMOD_PALETTE_MAX_COUNT = (
WLED_USERMOD_PALETTE_ID_BASE - WLED_CUSTOM_PALETTE_ID_BASE
)


class AwesomeVersionSerializationStrategy(SerializationStrategy, use_annotations=True):
Expand Down Expand Up @@ -497,6 +501,16 @@ class Info(BaseModel): # pylint: disable=too-many-instance-attributes
palette_count: int = field(default=0, metadata=field_options(alias="palcount"))
"""Number of palettes configured."""

usermod_palette_count: int = field(
default=0, metadata=field_options(alias="umpalcount")
)
"""Number of usermod palettes configured."""

usermod_palette_names: list[str] | None = field(
default=None, metadata=field_options(alias="umpalnames")
)
"""Names of usermod palettes."""

product: str = "DIY Light"
"""The product name. Always FOSS for standard installations."""

Expand Down Expand Up @@ -775,6 +789,45 @@ class Device(BaseModel):
playlists: dict[int, Playlist] = field(default_factory=dict)
presets: dict[int, Preset] = field(default_factory=dict)

@staticmethod
def _build_usermod_palettes(
umpalcount: int,
umpalnames: list[str] | None,
version: AwesomeVersion | None,
) -> dict[int, dict[str, Any]]:
"""Build usermod palettes dict.

Args:
----
umpalcount: Number of usermod palettes.
umpalnames: List of usermod palette names (None if not present in JSON).
version: The firmware version (gating feature to >= 16.0.0).

Returns:
-------
A dict of usermod palette entries keyed by palette ID.

"""
is_v16_plus = (
version is not None
and get_awesome_version(f"{version.major}.{version.minor}.{version.patch}")
>= CUSTOM_PALETTE_ID_CHANGE_VERSION
)
if not is_v16_plus:
return {}
names = umpalnames or []
result: dict[int, dict[str, Any]] = {}
safe_count = min(umpalcount, WLED_USERMOD_PALETTE_MAX_COUNT)
for i in range(safe_count):
palette_id = WLED_USERMOD_PALETTE_ID_BASE - i
palette_name = names[i] if i < len(names) else f"Usermod {i + 1}"
result[palette_id] = {
"palette_id": palette_id,
Comment thread
mik-laj marked this conversation as resolved.
"name": palette_name,
"custom": False,
}
return result

@staticmethod
def _build_custom_palettes(
cpalcount: int,
Expand Down Expand Up @@ -842,9 +895,13 @@ def __pre_deserialize__(cls, d: dict[Any, Any]) -> dict[Any, Any]:
palette_id: {"palette_id": palette_id, "name": name}
for palette_id, name in enumerate(_palettes)
}
cpalcount = d.get("info", {}).get("cpalcount", 0)
info = d.get("info", {})
cpalcount = info.get("cpalcount", 0)
custom_palettes = cls._build_custom_palettes(cpalcount, version)
d["palettes"] = built_in_palettes | custom_palettes
usermod_palettes = cls._build_usermod_palettes(
info.get("umpalcount", 0), info.get("umpalnames"), version
)
Comment thread
mik-laj marked this conversation as resolved.
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`.
Expand Down Expand Up @@ -911,8 +968,13 @@ def update_from_dict(self, data: dict[str, Any]) -> Device:
custom_palettes = self._build_custom_palettes(
self.info.custom_palette_count, self.info.version
)
usermod_palettes = self._build_usermod_palettes(
self.info.usermod_palette_count,
self.info.usermod_palette_names,
self.info.version,
)
result = {}
for pal_id, pal_data in custom_palettes.items():
for pal_id, pal_data in (custom_palettes | usermod_palettes).items():
result[pal_id] = Palette(**pal_data)
self.palettes = built_in_palettes | result

Expand Down
10 changes: 10 additions & 0 deletions tests/__snapshots__/test_models.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,8 @@
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=79769),
usermod_palette_count=0,
usermod_palette_names=None,
version=<AwesomeVersion SemVer '0.15.0-b3'>,
websocket=1,
wifi=Wifi(
Expand Down Expand Up @@ -1995,6 +1997,8 @@
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=966),
usermod_palette_count=0,
usermod_palette_names=None,
version=<AwesomeVersion SemVer '0.14.4'>,
websocket=None,
wifi=Wifi(
Expand Down Expand Up @@ -3257,6 +3261,8 @@
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=461),
usermod_palette_count=0,
usermod_palette_names=None,
version=<AwesomeVersion PEP 440 '1.0.0b4'>,
websocket=None,
wifi=Wifi(
Expand Down Expand Up @@ -4466,6 +4472,8 @@
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=461),
usermod_palette_count=0,
usermod_palette_names=None,
version=<AwesomeVersion SemVer '0.99.0'>,
websocket=0,
wifi=Wifi(
Expand Down Expand Up @@ -5675,6 +5683,8 @@
sync_toggle_receive=False,
udp_port=21324,
uptime=datetime.timedelta(seconds=461),
usermod_palette_count=0,
usermod_palette_names=None,
version=<AwesomeVersion PEP 440 '0.99.0b1'>,
websocket=None,
wifi=Wifi(
Expand Down
98 changes: 98 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,104 @@ def test_device_update_from_dict_palettes(
assert device.palettes[expected_custom_ids[1]].custom is True


def test_device_usermod_palettes() -> None:
"""Test usermod palettes are correctly added to device palettes."""
data = full_device_data()
# Usermod palettes are available in WLED >= 16.0.0
data["info"]["ver"] = "16.0.0"
data["info"]["umpalcount"] = 2
data["info"]["umpalnames"] = ["Plasma Effect", "Rainbow Shift"]
Comment thread
mik-laj marked this conversation as resolved.
device = Device.from_dict(data)
# 3 standard + 2 custom (ID 200, 199) + 2 usermod (ID 255, 254) = 7
assert len(device.palettes) == 7
assert device.palettes[255].custom is False
assert device.palettes[255].name == "Plasma Effect"
assert device.palettes[254].custom is False
assert device.palettes[254].name == "Rainbow Shift"
assert device.palettes[200].custom is True
assert device.palettes[200].name == "Custom 1"


def test_device_update_from_dict_usermod_palettes() -> None:
"""Test update_from_dict re-synthesizes usermod palettes from self.info."""
data = full_device_data()
data["info"]["ver"] = "16.0.0"
data["info"]["umpalcount"] = 2
data["info"]["umpalnames"] = ["Plasma Effect", "Rainbow Shift"]
device = Device.from_dict(data)
Comment thread
mik-laj marked this conversation as resolved.

# Verify initial state has usermod palettes
assert device.palettes[255].custom is False
assert device.palettes[255].name == "Plasma Effect"
assert device.palettes[254].custom is False
assert device.palettes[254].name == "Rainbow Shift"
assert device.palettes[200].custom is True

# Update with new palette list (re-synthesizes all palettes)
device.update_from_dict({"palettes": ["NewPalette"]})

# Verify standard palette was updated
assert device.palettes[0].name == "NewPalette"

# Verify usermod palettes were re-synthesized with stored names from self.info
assert device.palettes[255].custom is False
assert device.palettes[255].name == "Plasma Effect"
assert device.palettes[254].custom is False
assert device.palettes[254].name == "Rainbow Shift"

# Verify custom palettes still present with correct IDs
assert device.palettes[200].custom is True
assert device.palettes[200].name == "Custom 1"


def test_device_usermod_palettes_count_bounded() -> None:
"""Test usermod palette count is bounded to 55 slots."""
data = full_device_data()
data["info"]["ver"] = "16.0.0"
data["info"]["umpalcount"] = 60 # Exceeds max of 55
data["info"]["umpalnames"] = [f"Palette {i}" for i in range(60)]
device = Device.from_dict(data)
# Verify only 55 usermod palettes are generated (IDs 255..201)
assert device.palettes[255].name == "Palette 0"
assert device.palettes[201].name == "Palette 54"
# ID 200 is reserved for custom palettes, should not be a usermod palette
assert device.palettes[200].custom is True
# Count: 3 built-in + 2 custom + 55 usermod = 60
assert len(device.palettes) == 60


def test_device_usermod_palettes_null_names() -> None:
"""Test usermod palettes with null names use fallback."""
data = full_device_data()
data["info"]["ver"] = "16.0.0"
data["info"]["umpalcount"] = 2
data["info"]["umpalnames"] = None # JSON null
device = Device.from_dict(data)
# Should not raise TypeError; fallback names should be used
assert len(device.palettes) == 7 # 3 built-in + 2 custom + 2 usermod
assert device.palettes[255].name == "Usermod 1"
assert device.palettes[254].name == "Usermod 2"
assert device.palettes[255].custom is False


def test_device_usermod_palettes_pre_v16_skipped() -> None:
"""Test usermod palettes are not synthesized on pre-16.0.0 firmware."""
data = full_device_data()
data["info"]["ver"] = "15.0.0"
data["info"]["umpalcount"] = 2
data["info"]["umpalnames"] = ["Plasma Effect", "Rainbow Shift"]
device = Device.from_dict(data)
# Usermod palettes should not be present on pre-16 firmware
# Count: 3 built-in + 2 custom (at IDs 255, 254 for pre-16) = 5
assert len(device.palettes) == 5
assert 255 in device.palettes
assert device.palettes[255].custom is True # Custom palette, not usermod
assert device.palettes[255].name == "Custom 1"
assert 254 in device.palettes
assert device.palettes[254].custom is True
assert device.palettes[254].name == "Custom 2"


def test_device_update_from_dict_presets() -> None:
"""Test update_from_dict updates presets and playlists."""
data = full_device_data()
Expand Down
Loading