Repository: jschlyter/ttls (language: python)

Issue:
Support RGBW-colors
When setting a static color, the current version only supports RGB-colors.
I have a PR to also support RGBW for static colors almost ready.  

However, we need to agree on an architectural point before I submit it.

Devices with old software dont support the "set_static_colour()"-function, and must use the new "set_cycle_colours()" instead.
The "problem" is that Twinkly RGBW-frames have the colors in "WRGB"-order.    
In HA, that uses RGBW internally, we just move the W to the first entry in the tuple for relevant devices before calling tha API.

But the function "set_static_colour()" does not handle RGBW at all today.  It just ignores the W-part.

Now, I want to support RGBW for "set_static_colour()" as well.  But should I stick to the rather odd "WRGB" order to make it consistent between the two functions, or should "set_static_colour()" use the more common "RGBW" order?

Another option is to introduce it as a breaking change in ttls, and have both functions use RGBW, and fix the order for the older devices directly in ttls. 

I can handle both in HA, but I don't know about other implementations.

What do you suggest?



Discussion / hints:

Fix patch:
diff --git a/ttls/client.py b/ttls/client.py
index a90c3bb..dd47cee 100644
--- a/ttls/client.py
+++ b/ttls/client.py
@@ -37,10 +37,11 @@
 from aiohttp import ClientResponseError, ClientSession, ClientTimeout
 from aiohttp.web_exceptions import HTTPUnauthorized
 
+from .colours import TwinklyColour, TwinklyColourTuple
+
 _LOGGER = logging.getLogger(__name__)
 
-TwinklyColour = Tuple[int, int, int]
-TwinklyFrame = List[TwinklyColour]
+TwinklyFrame = List[TwinklyColourTuple]
 TwinklyResult = Optional[dict]
 
 
@@ -352,28 +353,44 @@ async def upload_movie(self, movie: bytes) -> Any:
         )
 
     async def set_static_colour(
-        self, colour: Union[TwinklyColour, List[TwinklyColour]]
+        self,
+        colour: Union[
+            TwinklyColour,
+            TwinklyColourTuple,
+            List[TwinklyColour],
+            List[TwinklyColourTuple],
+        ],
     ) -> None:
         if isinstance(colour, List):
-            sequence = colour[0]
-        else:
-            sequence = colour
-        if len(sequence) == 4:
-            sequence = sequence[1:]
-
+            colour = colour[0]
+        if isinstance(colour, Tuple):
+            colour = TwinklyColour.from_twinkly_tuple(colour)
         await self._post(
             "led/color",
-            json={"red": sequence[0], "green": sequence[1], "blue": sequence[2]},
+            json={"red": colour.red, "green": colour.green, "blue": colour.blue},
         )
         await self.set_mode("color")
 
     async def set_cycle_colours(
-        self, colour: Union[TwinklyColour, List[TwinklyColour]]
+        self,
+        colour: Union[
+            TwinklyColour,
+            TwinklyColourTuple,
+            List[TwinklyColour],
+            List[TwinklyColourTuple],
+        ],
     ) -> None:
-        if isinstance(colour, Tuple):
+        if isinstance(colour, TwinklyColour):
+            sequence = [colour.as_twinkly_tuple()]
+        elif isinstance(colour, Tuple):
             sequence = [colour]
+        elif isinstance(colour, List):
+            if isinstance(colour[0], TwinklyColour):
+                sequence = [c.as_twinkly_tuple() for c in colour]
+            else:
+                sequence = colour
         else:
-            sequence = colour
+            raise TypeError("Unknown colour format")
         frame = list(islice(cycle(sequence), self.length))
         movie = bytes([item for t in frame for item in t])
         await self.upload_movie(movie)
diff --git a/ttls/colours.py b/ttls/colours.py
new file mode 100644
index 0000000..b683614
--- /dev/null
+++ b/ttls/colours.py
@@ -0,0 +1,52 @@
+from dataclasses import dataclass
+from typing import Dict, Optional, Tuple, Union
+
+ColourDict = Dict[str, int]
+ColourTuple = Union[Tuple[int, int, int], Tuple[int, int, int, int]]
+TwinklyColourTuple = Union[Tuple[int, int, int], Tuple[int, int, int, int]]
+
+
+@dataclass(frozen=True)
+class TwinklyColour:
+    red: int
+    green: int
+    blue: int
+    white: Optional[int] = None
+
+    def as_twinkly_tuple(self) -> TwinklyColourTuple:
+        """Convert TwinklyColour to a tuple as used by Twinkly: (R,G,B) or (W,R,G,B)"""
+        if self.white is not None:
+            return (self.white, self.red, self.green, self.blue)
+        else:
+            return (self.red, self.green, self.blue)
+
+    def as_tuple(self) -> ColourTuple:
+        """Convert TwinklyColour to a tuple: (R,G,B) or (R,G,B,W)"""
+        if self.white is not None:
+            return (self.red, self.green, self.blue, self.white)
+        else:
+            return (self.red, self.green, self.blue)
+
+    def __iter__(self):
+        for i in self.as_tuple():
+            yield i
+
+    def as_dict(self) -> ColourDict:
+        """Convert TwinklyColour to a dict wth color names used by set-led functions."""
+        if self.white is not None:
+            return {
+                "red": self.red,
+                "green": self.green,
+                "blue": self.blue,
+                "white": self.white,
+            }
+        else:
+            return {"red": self.red, "green": self.green, "blue": self.blue}
+
+    @classmethod
+    def from_twinkly_tuple(cls, t):
+        if len(t) == 4:
+            return cls(red=t[1], green=t[2], blue=t[3], white=t[0])
+        elif len(t) == 3:
+            return cls(red=t[0], green=t[1], blue=t[2])
+        raise TypeError("Unknown colour format")


Test patch:
diff --git a/tests/test_colours.py b/tests/test_colours.py
new file mode 100644
index 0000000..985c000
--- /dev/null
+++ b/tests/test_colours.py
@@ -0,0 +1,29 @@
+import unittest
+from typing import Dict, List, Optional, Tuple, Union
+
+from ttls.colours import TwinklyColour
+
+
+class TestTwinklyColours(unittest.TestCase):
+    def test_colours_rgb(self):
+        col = TwinklyColour(1, 2, 3)
+        self.assertEqual(col.as_twinkly_tuple(), (1, 2, 3))
+        self.assertEqual(tuple(col), (1, 2, 3))
+        self.assertEqual(col.as_dict(), {"blue": 3, "green": 2, "red": 1})
+
+        col = TwinklyColour.from_twinkly_tuple((1, 2, 3))
+        self.assertEqual(col.as_twinkly_tuple(), (1, 2, 3))
+        self.assertEqual(tuple(col), (1, 2, 3))
+        self.assertEqual(col.as_dict(), {"blue": 3, "green": 2, "red": 1})
+
+        rgb = TwinklyColour.from_twinkly_tuple((1, 2, 3))
+        self.assertEqual(rgb.as_dict(), {"blue": 3, "green": 2, "red": 1})
+
+    def test_colours_rgbw(self):
+        col = TwinklyColour(1, 2, 3, 4)
+        self.assertEqual(col.as_twinkly_tuple(), (4, 1, 2, 3))
+        self.assertEqual(tuple(col), (1, 2, 3, 4))
+        self.assertEqual(col.as_dict(), {"blue": 3, "green": 2, "red": 1, "white": 4})
+
+        rgbw = TwinklyColour.from_twinkly_tuple((1, 2, 3, 4))
+        self.assertEqual(rgbw.as_dict(), {"blue": 4, "green": 3, "red": 2, "white": 1})