Repository: sbidy/pywizlight

Issue:
Comment: Determining Bulb Kind
In the previous versions of the firmware, there used to be an integer array returned with getSystemConfig called 'drvConf'.

Using those two numbers I figured out that it was possible to get the 'type' of the bulb pretty consistently with some experimentation.  

In the latest version they took drvConf away, bot those two numbers are instead now found in getModelConfig, the values 'wcr' and 'nowc' are actually the two numbers from drvConf.  

So, I've come up with a list so far, and I'd appreciate any feedback you have on this matter.

{ (wcr, nowc) **or** drvConf: [1,2], "Bulb Kind" }

{ (37, 1), "Philips Color & Tunable-White BR30" },
{ (50, 1), "Philips Color & Tunable-White BR30" },
{ (33, 1), "Philips Color & Tunable-White A19" },
{ (60, 1), "Philips Color & Tunable-White A19" },
{ (20, 2), "Philips Color & Tunable-White A21" },
{ (40, 1), "Philips Color & Tunable-White E14 Candelabra" }

I have about 20 different bulbs in my house.  But I would love to know if these match up with what you have?


Discussion:
bdraco: The descriptions may be too specific as I think the models are a bit more generic 
codecov-commenter: # [Codecov](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub) Report
> Merging [#111](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub) (b0b6ff6) into [master](https://codecov.io/gh/sbidy/pywizlight/commit/772566fd1650d55f7b0a0c15b9b675898e002703?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub) (772566f) will **decrease** coverage by `0.15%`.
> The diff coverage is `14.28%`.

[![Impacted file tree graph](https://codecov.io/gh/sbidy/pywizlight/pull/111/graphs/tree.svg?width=650&height=150&src=pr&token=W4AN8HHP41&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub)](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub)

```diff
@@            Coverage Diff             @@
##           master     #111      +/-   ##
==========================================
- Coverage   29.59%   29.43%   -0.16%     
==========================================
  Files          13       13              
  Lines         821      839      +18     
==========================================
+ Hits          243      247       +4     
- Misses        578      592      +14     
```


| [Impacted Files](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub) | Coverage Δ | |
|---|---|---|
| [pywizlight/bulb.py](https://codecov.io/gh/sbidy/pywizlight/pull/111/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub#diff-cHl3aXpsaWdodC9idWxiLnB5) | `24.67% <6.89%> (-0.33%)` | :arrow_down: |
| [pywizlight/bulblibrary.py](https://codecov.io/gh/sbidy/pywizlight/pull/111/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub#diff-cHl3aXpsaWdodC9idWxibGlicmFyeS5weQ==) | `62.74% <50.00%> (-2.48%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub). Last update [772566f...b0b6ff6](https://codecov.io/gh/sbidy/pywizlight/pull/111?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Stephan+Traub).

bdraco: So `wcr` is White to Color Ratio, and `nowc` is number of white channels.

I don't think this actually gives enough info to identify the device

Patch:
diff --git a/.coveragerc b/.coveragerc
index 5aeeb8f..3d6a54a 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -3,3 +3,5 @@ source = pywizlight
 
 omit =
      pywizlight/tests/*
+     pywizlight/_version.py
+     pywizlight/cli.py
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 75fd20e..78b3516 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -27,7 +27,7 @@ jobs:
           python-version: 3.8
 
       - name: Install Python dependencies
-        run: pip install black flake8 mypy pytest pytest-cov -e .
+        run: pip install black flake8 mypy pytest pytest-cov pytest-asyncio -e .
 
       - name: Run linters
         uses: samuelmeuli/lint-action@v1
diff --git a/pywizlight/bulb.py b/pywizlight/bulb.py
index 7e46030..120bee5 100755
--- a/pywizlight/bulb.py
+++ b/pywizlight/bulb.py
@@ -3,26 +3,25 @@ import asyncio
 import contextlib
 import json
 import logging
-import socket
 import time
 from typing import Any, Callable, Dict, List, Optional, Tuple, Union, cast
 
-from pywizlight.bulblibrary import BulbClass, BulbType
+from pywizlight.bulblibrary import BulbType
 from pywizlight.exceptions import (
     WizLightConnectionError,
     WizLightMethodNotFound,
     WizLightTimeOutError,
 )
+from pywizlight.models import DiscoveredBulb
 from pywizlight.protocol import WizProtocol
 from pywizlight.push_manager import PushManager
 from pywizlight.rgbcw import hs2rgbcw, rgb2rgbcw
-from pywizlight.scenes import SCENES
-from pywizlight.utils import hex_to_percent, percent_to_hex
+from pywizlight.scenes import SCENES, SCENES_BY_CLASS
+from pywizlight.utils import hex_to_percent, percent_to_hex, to_wiz_json
 from pywizlight.vec import Vector
 
 _LOGGER = logging.getLogger(__name__)
-TW_SCENES = [6, 9, 10, 11, 12, 13, 14, 15, 16, 18, 29, 30, 31, 32]
-DW_SCENES = [9, 10, 13, 14, 29, 30, 31, 32]
+
 
 BulbResponse = Dict[str, Any]
 
@@ -85,42 +84,38 @@ class PilotBuilder:
 
     def set_pilot_message(self) -> str:
         """Return the pilot message."""
-        return json.dumps({"method": "setPilot", "params": self.pilot_params})
+        return to_wiz_json({"method": "setPilot", "params": self.pilot_params})
 
     def set_state_message(self, state: bool) -> str:
         """Return the setState message. It doesn't change the current status of the light."""
         self.pilot_params["state"] = state
-        return json.dumps({"method": "setState", "params": self.pilot_params})
+        return to_wiz_json({"method": "setState", "params": self.pilot_params})
 
     def _set_warm_white(self, value: int) -> None:
         """Set the value of the warm white led."""
-        if 0 <= value < 256:
-            self.pilot_params["w"] = value
-        else:
+        if not 0 <= value < 256:
             raise ValueError("Value must be between 0 and 255")
+        self.pilot_params["w"] = value
 
     def _set_cold_white(self, value: int) -> None:
         """Set the value of the cold white led."""
-        if 0 <= value < 256:
-            self.pilot_params["c"] = value
-        else:
+        if not 0 <= value < 256:
             raise ValueError("Value must be between 0 and 255")
+        self.pilot_params["c"] = value
 
     def _set_speed(self, value: int) -> None:
         """Set the color changing speed in precent (0-100)."""
         # This applies only to changing effects.
-        if 0 < value < 101:
-            self.pilot_params["speed"] = value
-        else:
+        if not 0 < value < 101:
             raise ValueError("Value must be between 0 and 100")
+        self.pilot_params["speed"] = value
 
     def _set_scene(self, scene_id: int) -> None:
         """Set the scene by id."""
-        if scene_id in SCENES:
-            self.pilot_params["sceneId"] = scene_id
-        else:
+        if scene_id not in SCENES:
             # id not in SCENES !
             raise ValueError("Scene is not available. Only 0 to 32 are supported")
+        self.pilot_params["sceneId"] = scene_id
 
     def _set_rgb(self, values: Tuple[float, float, float]) -> None:
         """Set the RGB color state of the bulb."""
@@ -168,22 +163,27 @@ class PilotBuilder:
     def _set_brightness(self, value: int) -> None:
         """Set the value of the brightness 0-255."""
         percent = hex_to_percent(value)
-        # hardware limitation - values less than 10% are not supported
-        if percent < 10:
-            percent = 10
         if percent > 101:
             raise ValueError("Max value can be 100% with 255.")
-        self.pilot_params["dimming"] = percent
+        # hardware limitation - values less than 10% are not supported
+        self.pilot_params["dimming"] = max(10, percent)
 
     def _set_colortemp(self, kelvin: int) -> None:
         """Set the color temperature for the white led in the bulb."""
         # normalize the kelvin values - should be removed
-        if kelvin < 1000:
-            kelvin = 1000
-        if kelvin > 10000:
-            kelvin = 10000
+        self.pilot_params["temp"] = min(10000, max(1000, kelvin))
 
-        self.pilot_params["temp"] = kelvin
+
+def _extract_bool(response: BulbResponse, key: str) -> Optional[bool]:
+    return bool(response[key]) if key in response else None
+
+
+def _extract_str(response: BulbResponse, key: str) -> Optional[str]:
+    return str(response[key]) if key in response else None
+
+
+def _extract_int(response: BulbResponse, key: str) -> Optional[int]:
+    return int(response[key]) if key in response else None
 
 
 class PilotParser:
@@ -195,31 +195,21 @@ class PilotParser:
 
     def get_state(self) -> Optional[bool]:
         """Return the state of the bulb."""
-        if "state" in self.pilotResult:
-            return bool(self.pilotResult["state"])
-        else:
-            return None
+        return _extract_bool(self.pilotResult, "state")
 
     def get_mac(self) -> Optional[str]:
         """Return MAC from the bulb."""
-        if "mac" in self.pilotResult:
-            return str(self.pilotResult["mac"])
-        else:
-            return None
+        return _extract_str(self.pilotResult, "mac")
 
     def get_warm_white(self) -> Optional[int]:
         """Get the value of the warm white led."""
-        if "w" in self.pilotResult:
-            return int(self.pilotResult["w"])
-        else:
-            return None
+        return _extract_int(self.pilotResult, "w")
 
     def get_white_range(self) -> Optional[List[float]]:
         """Get the value of the whiteRange property."""
         if "whiteRange" in self.pilotResult:
             return [float(x) for x in self.pilotResult["whiteRange"]]
-        else:
-            return None
+        return None
 
     def get_extended_white_range(self) -> Optional[List[float]]:
         """Get the value of the extended whiteRange property."""
@@ -228,33 +218,21 @@ class PilotParser:
         # New after v1.22 FW - "cctRange":[2200,2700,6500,6500]
         elif "cctRange" in self.pilotResult:
             return [float(x) for x in self.pilotResult["cctRange"]]
-        else:
-            return None
+        return None
 
     def get_speed(self) -> Optional[int]:
         """Get the color changing speed."""
-        if "speed" in self.pilotResult:
-            return int(self.pilotResult["speed"])
-        else:
-            return None
+        return _extract_int(self.pilotResult, "speed")
 
     def get_scene(self) -> Optional[str]:
         """Get the current scene name."""
         if "schdPsetId" in self.pilotResult:  # rhythm
             return SCENES[1000]
-
-        scene_id = self.pilotResult["sceneId"]
-        if scene_id in SCENES:
-            return SCENES[scene_id]
-        else:
-            return None
+        return SCENES.get(self.pilotResult["sceneId"])
 
     def get_cold_white(self) -> Optional[int]:
         """Get the value of the cold white led."""
-        if "c" in self.pilotResult:
-            return int(self.pilotResult["c"])
-        else:
-            return None
+        return _extract_int(self.pilotResult, "c")
 
     def get_rgb(self) -> Union[Tuple[None, None, None], Vector]:
         """Get the RGB color state of the bulb and turns it on."""
@@ -267,9 +245,8 @@ class PilotParser:
             g = self.pilotResult["g"]
             b = self.pilotResult["b"]
             return float(r), float(g), float(b)
-        else:
             # no RGB color value was set
-            return None, None, None
+        return None, None, None
 
     def get_brightness(self) -> Optional[int]:
         """Get the value of the brightness 0-255."""
@@ -279,10 +256,7 @@ class PilotParser:
 
     def get_colortemp(self) -> Optional[int]:
         """Get the color temperature from the bulb."""
-        if "temp" in self.pilotResult:
-            return int(self.pilotResult["temp"])
-        else:
-            return None
+        return _extract_int(self.pilotResult, "temp")
 
 
 async def _send_udp_message_with_retry(
@@ -311,7 +285,6 @@ class wizlight:
     def __init__(
         self,
         ip: str,
-        connect_on_init: bool = False,
         port: int = 38899,
         mac: Optional[str] = None,
     ) -> None:
@@ -321,6 +294,7 @@ class wizlight:
         self.state: Optional[PilotParser] = None
         self.mac = mac
         self.bulbtype: Optional[BulbType] = None
+        self.modelConfig: Optional[Dict] = None
         self.whiteRange: Optional[List[float]] = None
         self.extwhiteRange: Optional[List[float]] = None
         self.transport: Optional[asyncio.DatagramTransport] = None
@@ -328,14 +302,12 @@ class wizlight:
 
         self.lock = asyncio.Lock()
         self.loop = asyncio.get_event_loop()
-        self.push_callback: Optional[Callable] = None
+        self.push_callback: Optional[Callable[[PilotParser], None]] = None
         self.response_future: Optional[asyncio.Future] = None
         self.push_cancel: Optional[Callable] = None
         self.last_push: float = NEVER_TIME
         self.push_running: bool = False
-        # check the state on init
-        if connect_on_init:
-            self._check_connection()
+        # Check connection removed as it did blocking I/O in the event loop
 
     @property
     def status(self) -> Optional[bool]:
@@ -372,7 +344,9 @@ class wizlight:
         except (WizLightTimeOutError, WizLightConnectionError) as ex:
             _LOGGER.debug("%s: Registration for push updates failed: %s", self.ip, ex)
 
-    async def start_push(self, callback: Callable) -> None:
+    async def start_push(
+        self, callback: Optional[Callable[[PilotParser], None]]
+    ) -> None:
         """Start periodic register calls to get push updates via syncPilot."""
         _LOGGER.debug("Enabling push updates for %s", self.mac)
         self.push_callback = callback
@@ -382,6 +356,12 @@ class wizlight:
             self.push_running = True
             self.register()
 
+    def set_discovery_callback(
+        self, callback: Optional[Callable[[DiscoveredBulb], None]]
+    ) -> None:
+        """Set the callback for when a new device is discovered."""
+        PushManager().get().set_discovery_callback(callback)
+
     def _on_push(self, resp: dict, addr: Tuple[str, int]) -> None:
         """Handle a syncPilot from the device."""
         self.last_push = time.monotonic()
@@ -404,23 +384,6 @@ class wizlight:
         if exception and self.response_future and not self.response_future.done():
             self.response_future.set_exception(exception)
 
-    def _check_connection(self) -> None:
-        """Check the connection to the bulb."""
-        message = r'{"method":"getPilot","params":{}}'
-        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-        sock.settimeout(2)
-        try:
-            # send a udp package
-            sock.sendto(bytes(message, "utf-8"), (self.ip, self.port))
-            # get response data
-            data, addr = sock.recvfrom(1024)
-            if data:
-                return
-        except socket.timeout:
-            raise WizLightTimeOutError(
-                "No connection was established by initialization."
-            )
-
     async def get_bulbtype(self) -> BulbType:
         """Return the bulb type as BulbType object."""
         if self.bulbtype is not None:
@@ -433,7 +396,12 @@ class wizlight:
         white_range = await self.getExtendedWhiteRange()
         module_name = result["moduleName"]
         fw_version = result.get("fwVersion")
-        self.bulbtype = BulbType.from_data(module_name, white_range, fw_version)
+        model_result = self.modelConfig["result"] if self.modelConfig else {}
+        white_channels = model_result.get("nowc")
+        white_to_color_ratio = model_result.get("wcr")
+        self.bulbtype = BulbType.from_data(
+            module_name, white_range, fw_version, white_channels, white_to_color_ratio
+        )
         return self.bulbtype
 
     async def getWhiteRange(self) -> Optional[List[float]]:
@@ -454,15 +422,13 @@ class wizlight:
 
         # First for FW > 1.22
         resp = await self.getModelConfig()
-        if resp is not None and "result" in resp:
-            self.extwhiteRange = PilotParser(resp["result"]).get_extended_white_range()
-        else:
+        if resp is None or "result" not in resp:
             # For old FW < 1.22
             resp = await self.getUserConfig()
-            if "result" in resp:
-                self.extwhiteRange = PilotParser(
-                    resp["result"]
-                ).get_extended_white_range()
+
+        if "result" in resp:
+            self.extwhiteRange = PilotParser(resp["result"]).get_extended_white_range()
+
         return self.extwhiteRange
 
     async def getSupportedScenes(self) -> List[str]:
@@ -473,29 +439,19 @@ class wizlight:
         if self.bulbtype is None:
             await self.get_bulbtype()
         assert self.bulbtype  # Should have gotten set by get_bulbtype
-        # return for TW
-        if self.bulbtype.bulb_type == BulbClass.TW:
-            return [SCENES[key] for key in TW_SCENES]
-        # retrun for DW
-        if self.bulbtype.bulb_type == BulbClass.DW:
-            return [SCENES[key] for key in DW_SCENES]
-        # return for RGB - all scenes supported
-        return list(SCENES.values())
+        return SCENES_BY_CLASS.get(self.bulbtype.bulb_type, [])
 
     async def turn_off(self) -> None:
         """Turn the light off."""
-        message = r'{"method":"setPilot","params":{"state":false}}'
-        await self.sendUDPMessage(message)
+        await self.sendUDPMessage(r'{"method":"setPilot","params":{"state":false}}')
 
     async def reboot(self) -> None:
         """Reboot the bulb."""
-        message = r'{"method":"reboot","params":{}}'
-        await self.sendUDPMessage(message)
+        await self.sendUDPMessage(r'{"method":"reboot","params":{}}')
 
     async def reset(self) -> None:
         """Reset the bulb to factory defaults."""
-        message = r'{"method":"reset","params":{}}'
-        await self.sendUDPMessage(message)
+        await self.sendUDPMessage(r'{"method":"reset","params":{}}')
 
     async def turn_on(self, pilot_builder: PilotBuilder = PilotBuilder()) -> None:
         """Turn the light on with defined message.
@@ -524,8 +480,7 @@ class wizlight:
         {"method": "getPilot", "id": 24}
         """
         if self.last_push + MAX_TIME_BETWEEN_PUSH < time.monotonic():
-            message = r'{"method":"getPilot","params":{}}'
-            resp = await self.sendUDPMessage(message)
+            resp = await self.sendUDPMessage(r'{"method":"getPilot","params":{}}')
             if resp is not None and "result" in resp:
                 self.state = PilotParser(resp["result"])
             else:
@@ -545,8 +500,7 @@ class wizlight:
 
     async def getBulbConfig(self) -> BulbResponse:
         """Return the configuration from the bulb."""
-        message = r'{"method":"getSystemConfig","params":{}}'
-        resp = await self.sendUDPMessage(message)
+        resp = await self.sendUDPMessage(r'{"method":"getSystemConfig","params":{}}')
         self._cache_mac_from_bulb_config(resp)
         return resp
 
@@ -554,16 +508,16 @@ class wizlight:
         """Return the new model capabilities from the bulb.
         Only available in bulb FW >1.22!
         """
-        try:
-            message = r'{"method":"getModelConfig","params":{}}'
-            return await self.sendUDPMessage(message)
-        except WizLightMethodNotFound:
-            return None
+        if self.modelConfig is None:
+            with contextlib.suppress(WizLightMethodNotFound):
+                self.modelConfig = await self.sendUDPMessage(
+                    r'{"method":"getModelConfig","params":{}}'
+                )
+        return self.modelConfig
 
     async def getUserConfig(self) -> BulbResponse:
         """Return the user configuration from the bulb."""
-        message = r'{"method":"getUserConfig","params":{}}'
-        return await self.sendUDPMessage(message)
+        return await self.sendUDPMessage(r'{"method":"getUserConfig","params":{}}')
 
     async def lightSwitch(self) -> None:
         """Turn the light bulb on or off like a switch."""
diff --git a/pywizlight/bulblibrary.py b/pywizlight/bulblibrary.py
index 26565c3..0328276 100644
--- a/pywizlight/bulblibrary.py
+++ b/pywizlight/bulblibrary.py
@@ -12,7 +12,7 @@ RGB -- Fullstack bulb
 """
 import dataclasses
 from enum import Enum
-from typing import Optional, List
+from typing import List, Optional
 
 from pywizlight.exceptions import WizLightNotKnownBulb
 
@@ -71,10 +71,16 @@ class BulbType:
     kelvin_range: Optional[KelvinRange]
     bulb_type: BulbClass
     fw_version: Optional[str]
+    white_channels: Optional[int]
+    white_to_color_ratio: Optional[int]
 
     @staticmethod
     def from_data(
-        module_name: str, kelvin_list: Optional[List[float]], fw_version: Optional[str]
+        module_name: str,
+        kelvin_list: Optional[List[float]],
+        fw_version: Optional[str],
+        white_channels: Optional[int],
+        white_to_color_ratio: Optional[int],
     ) -> "BulbType":
         if kelvin_list:
             kelvin_range: Optional[KelvinRange] = KelvinRange(
@@ -107,4 +113,6 @@ class BulbType:
             features=features,
             kelvin_range=kelvin_range,
             fw_version=fw_version,
+            white_channels=white_channels,
+            white_to_color_ratio=white_to_color_ratio,
         )
diff --git a/pywizlight/cli.py b/pywizlight/cli.py
index 8656e09..7db8910 100644
--- a/pywizlight/cli.py
+++ b/pywizlight/cli.py
@@ -1,7 +1,7 @@
 """Command-line interface to interact with wizlight devices."""
 import asyncio
 from functools import wraps
-from typing import Callable, Any, TypeVar, Coroutine
+from typing import Any, Callable, Coroutine, TypeVar
 
 import click
 
diff --git a/pywizlight/discovery.py b/pywizlight/discovery.py
index 52d46df..809257f 100644
--- a/pywizlight/discovery.py
+++ b/pywizlight/discovery.py
@@ -1,12 +1,12 @@
 """Discover bulbs in a network."""
 import asyncio
-import dataclasses
 import json
 import logging
-from asyncio import DatagramTransport, BaseTransport, AbstractEventLoop, Future
-from typing import cast, Dict, List, Tuple, Optional, Any
+from asyncio import AbstractEventLoop, BaseTransport, DatagramTransport, Future
+from typing import Any, List, Optional, Tuple, cast
 
 from pywizlight import wizlight
+from pywizlight.models import BulbRegistry, DiscoveredBulb
 from pywizlight.utils import create_udp_broadcast_socket
 
 _LOGGER = logging.getLogger(__name__)
@@ -20,29 +20,6 @@ DEFAULT_WAIT_TIME = 5.0
 REGISTER_MESSAGE = b'{"method":"registration","params":{"phoneMac":"AAAAAAAAAAAA","register":false,"phoneIp":"1.2.3.4","id":"1"}}'  # noqa: E501
 
 
-@dataclasses.dataclass(frozen=True)
-class DiscoveredBulb:
-    """Representation of discovered bulb."""
-
-    ip_address: str
-    mac_address: str
-
-
-@dataclasses.dataclass
-class BulbRegistry:
-    """Representation of the bulb registry."""
-
-    bulbs_by_mac: Dict[str, DiscoveredBulb] = dataclasses.field(default_factory=dict)
-
-    def register(self, bulb: DiscoveredBulb) -> None:
-        """Register a new bulb."""
-        self.bulbs_by_mac[bulb.mac_address] = bulb
-
-    def bulbs(self) -> List[DiscoveredBulb]:
-        """Get all present bulbs."""
-        return list(self.bulbs_by_mac.values())
-
-
 class BroadcastProtocol(asyncio.DatagramProtocol):
     """Protocol that sends an UDP broadcast message for bulb discovery."""
 
@@ -69,7 +46,7 @@ class BroadcastProtocol(asyncio.DatagramProtocol):
         """Send a registration method as UDP broadcast."""
         if not self.transport:
             return
-        self.transport.sendto(REGISTER_MESSAGE, (self.broadcast_address, 38899))
+        self.transport.sendto(REGISTER_MESSAGE, (self.broadcast_address, PORT))
         self.loop.call_later(1, self.broadcast_registration)
 
     def datagram_received(self, data: bytes, addr: Tuple[str, int]) -> None:
diff --git a/pywizlight/models.py b/pywizlight/models.py
new file mode 100644
index 0000000..334ffbd
--- /dev/null
+++ b/pywizlight/models.py
@@ -0,0 +1,26 @@
+"""Models."""
+import dataclasses
+from typing import Dict, List
+
+
+@dataclasses.dataclass(frozen=True)
+class DiscoveredBulb:
+    """Representation of discovered bulb."""
+
+    ip_address: str
+    mac_address: str
+
+
+@dataclasses.dataclass
+class BulbRegistry:
+    """Representation of the bulb registry."""
+
+    bulbs_by_mac: Dict[str, DiscoveredBulb] = dataclasses.field(default_factory=dict)
+
+    def register(self, bulb: DiscoveredBulb) -> None:
+        """Register a new bulb."""
+        self.bulbs_by_mac[bulb.mac_address] = bulb
+
+    def bulbs(self) -> List[DiscoveredBulb]:
+        """Get all present bulbs."""
+        return list(self.bulbs_by_mac.values())
diff --git a/pywizlight/push_manager.py b/pywizlight/push_manager.py
index 268b63d..e32127b 100755
--- a/pywizlight/push_manager.py
+++ b/pywizlight/push_manager.py
@@ -2,12 +2,11 @@
 import asyncio
 import json
 import logging
-import random
-import socket
 from typing import Callable, Dict, Optional, Tuple, cast
 
+from pywizlight.models import DiscoveredBulb
 from pywizlight.protocol import WizProtocol
-from pywizlight.utils import to_wiz_json, create_udp_socket
+from pywizlight.utils import create_udp_socket, generate_mac, get_source_ip, to_wiz_json
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -15,30 +14,6 @@ RESPOND_PORT = 38899
 LISTEN_PORT = 38900
 
 
-def generate_mac():
-    """Generates a fake mac address."""
-    return "{}{}{}{}{}{}{}{}{}{}{}{}".format(
-        *(random.SystemRandom().choice("0123456789abcdef") for _ in range(12))
-    )
-
-
-def get_source_ip(target_ip: str) -> Optional[str]:
-    """Return the source ip that will reach target_ip."""
-    test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-    test_sock.setblocking(False)  # must be non-blocking for async
-    try:
-        test_sock.connect((target_ip, 1))
-        return cast(str, test_sock.getsockname()[0])
-    except Exception:  # pylint: disable=broad-except
-        _LOGGER.debug(
-            "The system could not auto detect the source ip for %s on your operating system",
-            target_ip,
-        )
-        return None
-    finally:
-        test_sock.close()
-
-
 class PushManager:
 
     _manager = None
@@ -55,11 +30,18 @@ class PushManager:
         self.push_transport: Optional[asyncio.DatagramTransport] = None
         self.push_protocol: Optional[WizProtocol] = None
         self.push_running = False
+        self.discovery_callback: Optional[Callable[[DiscoveredBulb], None]] = None
         self.lock = asyncio.Lock()
         self.loop = asyncio.get_event_loop()
         self.subscriptions: Dict[str, Callable[[Dict, Tuple[str, int]], None]] = {}
         self.register_msg: Optional[str] = None
 
+    def set_discovery_callback(
+        self, callback: Optional[Callable[[DiscoveredBulb], None]]
+    ) -> None:
+        """Set the callback to run when there is a new discovery."""
+        self.discovery_callback = callback
+
     async def start(self, target_ip: str) -> bool:
         """Start listening for push updates on LISTEN_PORT if we are not already listening."""
         async with self.lock:
@@ -106,6 +88,7 @@ class PushManager:
             if not self.push_running or self.subscriptions:
                 return
             self.push_running = False
+            self.discovery_callback = None
             if self.push_transport:
                 self.push_transport.close()
                 self.push_transport = None
@@ -135,5 +118,7 @@ class PushManager:
         # but the devices keeps sending them even if we do not
         # so we no longer send them since all it effectively
         # does it generate additional network traffic.
+        if method == "firstBeat" and self.discovery_callback:
+            self.discovery_callback(DiscoveredBulb(addr[0], mac))
         if method == "syncPilot" and mac in self.subscriptions:
             self.subscriptions[mac](resp, addr)
diff --git a/pywizlight/rgbcw.py b/pywizlight/rgbcw.py
index 9514df9..d663de3 100644
--- a/pywizlight/rgbcw.py
+++ b/pywizlight/rgbcw.py
@@ -1,9 +1,11 @@
 """Manages the RGBCW color."""
 import logging
 from math import atan2, cos, pi
-from typing import Tuple, Iterable
+from typing import Iterable, Tuple
 
 from .vec import (
+    EPSILON,
+    Vector,
     vecAdd,
     vecDot,
     vecFormat,
@@ -11,8 +13,6 @@ from .vec import (
     vecInt,
     vecLen,
     vecMul,
-    EPSILON,
-    Vector,
 )
 
 _LOGGER = logging.getLogger(__name__)
diff --git a/pywizlight/scenes.py b/pywizlight/scenes.py
index e8c3370..c98db9a 100755
--- a/pywizlight/scenes.py
+++ b/pywizlight/scenes.py
@@ -1,4 +1,9 @@
 """Definition of the supported bulb effects."""
+
+from typing import Dict, Iterable, List, cast
+
+from pywizlight.bulblibrary import BulbClass
+
 SCENES = {
     1: "Ocean",
     2: "Romance",
@@ -35,6 +40,14 @@ SCENES = {
     1000: "Rhythm",
 }
 SCENE_NAME_TO_ID = {scene_name: scene_id for (scene_id, scene_name) in SCENES.items()}
+TW_SCENES = [6, 9, 10, 11, 12, 13, 14, 15, 16, 18, 29, 30, 31, 32]
+DW_SCENES = [9, 10, 13, 14, 29, 30, 31, 32]
+
+SCENES_BY_CLASS: Dict[BulbClass, List[str]] = {
+    BulbClass.RGB: list(cast(Iterable, SCENES)),
+    BulbClass.TW: [SCENES[key] for key in TW_SCENES],
+    BulbClass.DW: [SCENES[key] for key in DW_SCENES],
+}
 
 
 def get_id_from_scene_name(scene: str) -> int:
diff --git a/pywizlight/utils.py b/pywizlight/utils.py
index b1ab2fd..c929d9b 100644
--- a/pywizlight/utils.py
+++ b/pywizlight/utils.py
@@ -1,5 +1,10 @@
 import json
+import logging
+import random
 import socket
+from typing import Optional, cast
+
+_LOGGER = logging.getLogger(__name__)
 
 
 def hex_to_percent(hex_value: float) -> float:
@@ -19,17 +24,47 @@ def to_wiz_json(dump_obj):
 
 def create_udp_broadcast_socket(listen_port: int) -> socket.socket:
     """Create a udp broadcast socket used for communicating with the device."""
-    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
-    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
-    sock.bind(("", listen_port))
-    sock.setblocking(False)
-    return sock
+    return _create_udp_socket(listen_port, reuseaddr=True, broadcast=True)
 
 
 def create_udp_socket(listen_port: int) -> socket.socket:
+    """Create a udp socket used for communicating with the device."""
+    return _create_udp_socket(listen_port, reuseaddr=False, broadcast=False)
+
+
+def _create_udp_socket(
+    listen_port: int, reuseaddr: bool = False, broadcast: bool = False
+) -> socket.socket:
     """Create a udp socket used for communicating with the device."""
     sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    if reuseaddr:
+        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+    if broadcast:
+        sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
     sock.bind(("", listen_port))
     sock.setblocking(False)
     return sock
+
+
+def generate_mac():
+    """Generates a fake mac address."""
+    return "{}{}{}{}{}{}{}{}{}{}{}{}".format(
+        *(random.SystemRandom().choice("0123456789abcdef") for _ in range(12))
+    )
+
+
+def get_source_ip(target_ip: str) -> Optional[str]:
+    """Return the source ip that will reach target_ip."""
+    test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    test_sock.setblocking(False)  # must be non-blocking for async
+    try:
+        test_sock.connect((target_ip, 1))
+        return cast(str, test_sock.getsockname()[0])
+    except Exception:  # pylint: disable=broad-except
+        _LOGGER.debug(
+            "The system could not auto detect the source ip for %s on your operating system",
+            target_ip,
+        )
+        return None
+    finally:
+        test_sock.close()
diff --git a/pywizlight/vec.py b/pywizlight/vec.py
index c51de6b..49de126 100644
--- a/pywizlight/vec.py
+++ b/pywizlight/vec.py
@@ -2,8 +2,8 @@
 
     A bunch of utility functions, just so we don't have to bring in any external dependencies.
 """
-from math import sqrt, sin, cos
-from operator import mul, sub, add
+from math import cos, sin, sqrt
+from operator import add, mul, sub
 from typing import Tuple
 
 # a small value, really close to zero, more than adequate for our 3 orders of magnitude


Test patch:
diff --git a/pywizlight/tests/fake_bulb.py b/pywizlight/tests/fake_bulb.py
index f7acacf..8c5381d 100644
--- a/pywizlight/tests/fake_bulb.py
+++ b/pywizlight/tests/fake_bulb.py
@@ -4,6 +4,46 @@ import socketserver
 import threading
 from typing import Any, Callable, Dict
 
+MODULE_CONFIGS = {
+    "ESP01_SHRGB_03": {
+        "ps": 1,
+        "pwmFreq": 1000,
+        "pwmRange": [3, 100],
+        "wcr": 30,
+        "nowc": 1,
+        "cctRange": [2200, 2700, 4800, 6500],
+        "renderFactor": [171, 255, 75, 255, 43, 85, 0, 0, 0, 0],
+    },
+    "ESP10_SOCKET_06": {
+        "ps": 1,
+        "pwmFreq": 200,
+        "pwmRange": [1, 100],
+        "wcr": 20,
+        "nowc": 2,
+        "cctRange": [2700, 2700, 2700, 2700],
+        "renderFactor": [255, 0, 255, 255, 0, 0, 0, 0, 0, 0],
+    },
+    "ESP05_SHDW_21": {
+        "ps": 1,
+        "pwmFreq": 1000,
+        "pwmRange": [5, 100],
+        "wcr": 20,
+        "nowc": 1,
+        "cctRange": [2700, 2700, 2700, 2700],
+        "renderFactor": [255, 0, 255, 255, 0, 0, 0, 0, 0, 0],
+    },
+    "ESP20_SHRGB_01ABI": {
+        "ps": 0,
+        "pwmFreq": 2000,
+        "pwmRange": [5, 100],
+        "wcr": 80,
+        "nowc": 2,
+        "cctRange": [2700, 2700, 6500, 6500],
+        "renderFactor": [255, 0, 255, 255, 0, 0, 0, 81, 245, 178],
+        "drvIface": 0,
+    },
+}
+
 
 def get_initial_pilot() -> Dict[str, Any]:
     return {
@@ -45,19 +85,11 @@ def get_initial_sys_config() -> Dict[str, Any]:
     }
 
 
-def get_initial_model_config() -> Dict[str, Any]:
+def get_initial_model_config(module_name: str) -> Dict[str, Any]:
     return {
         "method": "getModelConfig",
         "env": "pro",
-        "result": {
-            "ps": 1,
-            "pwmFreq": 1000,
-            "pwmRange": [3, 100],
-            "wcr": 30,
-            "nowc": 1,
-            "cctRange": [2200, 2700, 4800, 6500],
-            "renderFactor": [171, 255, 75, 255, 43, 85, 0, 0, 0, 0],
-        },
+        "result": MODULE_CONFIGS[module_name],
     }
 
 
@@ -106,7 +138,7 @@ def make_udp_fake_bulb_server(module_name: str) -> socketserver.ThreadingUDPServ
     """Configure a fake bulb instance."""
     pilot_state = get_initial_pilot()
     sys_config = get_initial_sys_config()
-    model_config = get_initial_model_config()
+    model_config = get_initial_model_config(module_name)
     sys_config["result"]["moduleName"] = module_name
 
     BulbUDPRequestHandler = type(
diff --git a/pywizlight/tests/test_bulb.py b/pywizlight/tests/test_bulb.py
index 0187b74..46ee92c 100644
--- a/pywizlight/tests/test_bulb.py
+++ b/pywizlight/tests/test_bulb.py
@@ -1,14 +1,14 @@
 """Tests for the Bulb API."""
 from typing import AsyncGenerator
-import pytest
-
 from unittest.mock import patch
 
+import pytest
+
 from pywizlight import SCENES, PilotBuilder, wizlight
+from pywizlight.bulblibrary import BulbClass, BulbType, Features, KelvinRange
 from pywizlight.discovery import discover_lights
 from pywizlight.exceptions import WizLightTimeOutError
 from pywizlight.tests.fake_bulb import startup_bulb
-from pywizlight.bulblibrary import BulbType, Features, KelvinRange, BulbClass
 
 
 @pytest.fixture(scope="module")
@@ -18,7 +18,9 @@ def startup_fake_bulb(request: pytest.FixtureRequest) -> None:
 
 
 @pytest.fixture()
-async def correct_bulb(startup_fake_bulb: None) -> AsyncGenerator[wizlight, None]:
+async def correct_bulb(
+    startup_fake_bulb: pytest.FixtureRequest,
+) -> AsyncGenerator[wizlight, None]:
     bulb = wizlight(ip="127.0.0.1")
     yield bulb
     await bulb.async_close()
@@ -99,15 +101,34 @@ async def test_PilotBuilder_rgb(correct_bulb: wizlight) -> None:
     assert state and state.get_rgb() == (0, 127, 255)
 
 
+@pytest.mark.asyncio
+async def test_PilotBuilder_hucolor(correct_bulb: wizlight) -> None:
+    """Test RGB Value via hucolor."""
+    await correct_bulb.turn_on(PilotBuilder(hucolor=(100, 50)))
+    state = await correct_bulb.updateState()
+
+    assert state and state.get_rgb() == (88.0, 255.0, 0.0)
+
+
 @pytest.mark.asyncio
 async def test_PilotBuilder_scene(correct_bulb: wizlight) -> None:
-    """Test Screen."""
+    """Test scene."""
     await correct_bulb.turn_on(PilotBuilder(scene=1))
     state = await correct_bulb.updateState()
 
     assert state and state.get_scene() == SCENES[1]
 
 
+@pytest.mark.asyncio
+async def test_PilotBuilder_speed(correct_bulb: wizlight) -> None:
+    """Test speed."""
+    await correct_bulb.turn_on(PilotBuilder(scene=1, speed=50))
+    state = await correct_bulb.updateState()
+
+    assert state and state.get_scene() == SCENES[1]
+    assert state and state.get_speed() == 50
+
+
 # ------ Error states -------------------------------------
 @pytest.mark.asyncio
 async def test_error_PilotBuilder_brightness(correct_bulb: wizlight) -> None:
@@ -160,13 +181,27 @@ async def test_error_PilotBuilder_blue(correct_bulb: wizlight) -> None:
         await correct_bulb.turn_on(PilotBuilder(rgb=(0, 0, 300)))
 
 
+@pytest.mark.asyncio
+async def test_error_PilotBuilder_cold_white(correct_bulb: wizlight) -> None:
+    """Error Cold White Value."""
+    with pytest.raises(ValueError):
+        await correct_bulb.turn_on(PilotBuilder(cold_white=9999))
+
+
 @pytest.mark.asyncio
 async def test_error_PilotBuilder_scene(correct_bulb: wizlight) -> None:
-    """Error Screen."""
+    """Error scene."""
     with pytest.raises(ValueError):
         await correct_bulb.turn_on(PilotBuilder(scene=532))
 
 
+@pytest.mark.asyncio
+async def test_error_PilotBuilder_speed(correct_bulb: wizlight) -> None:
+    """Error speed."""
+    with pytest.raises(ValueError):
+        await correct_bulb.turn_on(PilotBuilder(speed=532))
+
+
 @pytest.mark.asyncio
 async def test_fw_version(correct_bulb: wizlight) -> None:
     """Test fetching the firmware version."""
@@ -177,6 +212,8 @@ async def test_fw_version(correct_bulb: wizlight) -> None:
         kelvin_range=KelvinRange(max=6500, min=2200),
         bulb_type=BulbClass.RGB,
         fw_version="1.21.0",
+        white_channels=1,
+        white_to_color_ratio=30,
     )
     assert correct_bulb.mac == "a8bb5006033d"
 
diff --git a/pywizlight/tests/test_bulb_dimmable_white.py b/pywizlight/tests/test_bulb_dimmable_white.py
new file mode 100644
index 0000000..8004cb6
--- /dev/null
+++ b/pywizlight/tests/test_bulb_dimmable_white.py
@@ -0,0 +1,32 @@
+"""Tests for the Bulb API with a light strip."""
+from typing import AsyncGenerator
+
+import pytest
+
+from pywizlight import wizlight
+from pywizlight.bulblibrary import BulbClass, BulbType, Features, KelvinRange
+from pywizlight.tests.fake_bulb import startup_bulb
+
+
+@pytest.fixture()
+async def dimmable_bulb() -> AsyncGenerator[wizlight, None]:
+    shutdown = startup_bulb(module_name="ESP05_SHDW_21")
+    bulb = wizlight(ip="127.0.0.1")
+    yield bulb
+    await bulb.async_close()
+    shutdown()
+
+
+@pytest.mark.asyncio
+async def test_model_description_dimmable_bulb(dimmable_bulb: wizlight) -> None:
+    """Test fetching the model description dimmable bulb."""
+    bulb_type = await dimmable_bulb.get_bulbtype()
+    assert bulb_type == BulbType(
+        features=Features(color=False, color_tmp=False, effect=False, brightness=True),
+        name="ESP05_SHDW_21",
+        kelvin_range=KelvinRange(max=2700, min=2700),
+        bulb_type=BulbClass.DW,
+        fw_version="1.21.0",
+        white_channels=1,
+        white_to_color_ratio=20,
+    )
diff --git a/pywizlight/tests/test_bulb_light_strip.py b/pywizlight/tests/test_bulb_light_strip.py
new file mode 100644
index 0000000..3ef7bc4
--- /dev/null
+++ b/pywizlight/tests/test_bulb_light_strip.py
@@ -0,0 +1,32 @@
+"""Tests for the Bulb API with a light strip."""
+from typing import AsyncGenerator
+
+import pytest
+
+from pywizlight import wizlight
+from pywizlight.bulblibrary import BulbClass, BulbType, Features, KelvinRange
+from pywizlight.tests.fake_bulb import startup_bulb
+
+
+@pytest.fixture()
+async def light_strip() -> AsyncGenerator[wizlight, None]:
+    shutdown = startup_bulb(module_name="ESP20_SHRGB_01ABI")
+    bulb = wizlight(ip="127.0.0.1")
+    yield bulb
+    await bulb.async_close()
+    shutdown()
+
+
+@pytest.mark.asyncio
+async def test_model_description_light_strip(light_strip: wizlight) -> None:
+    """Test fetching the model description for a light strip."""
+    bulb_type = await light_strip.get_bulbtype()
+    assert bulb_type == BulbType(
+        features=Features(color=True, color_tmp=True, effect=True, brightness=True),
+        name="ESP20_SHRGB_01ABI",
+        kelvin_range=KelvinRange(max=6500, min=2700),
+        bulb_type=BulbClass.RGB,
+        fw_version="1.21.0",
+        white_channels=2,
+        white_to_color_ratio=80,
+    )
diff --git a/pywizlight/tests/test_bulb_socket.py b/pywizlight/tests/test_bulb_socket.py
new file mode 100644
index 0000000..ffc5e5a
--- /dev/null
+++ b/pywizlight/tests/test_bulb_socket.py
@@ -0,0 +1,32 @@
+"""Tests for the Bulb API with a socket."""
+from typing import AsyncGenerator
+
+import pytest
+
+from pywizlight import wizlight
+from pywizlight.bulblibrary import BulbClass, BulbType, Features, KelvinRange
+from pywizlight.tests.fake_bulb import startup_bulb
+
+
+@pytest.fixture()
+async def socket() -> AsyncGenerator[wizlight, None]:
+    shutdown = startup_bulb(module_name="ESP10_SOCKET_06")
+    bulb = wizlight(ip="127.0.0.1")
+    yield bulb
+    await bulb.async_close()
+    shutdown()
+
+
+@pytest.mark.asyncio
+async def test_model_description_socket(socket: wizlight) -> None:
+    """Test fetching the model description of a socket is None."""
+    bulb_type = await socket.get_bulbtype()
+    assert bulb_type == BulbType(
+        features=Features(color=False, color_tmp=False, effect=False, brightness=False),
+        name="ESP10_SOCKET_06",
+        kelvin_range=KelvinRange(max=2700, min=2700),
+        bulb_type=BulbClass.SOCKET,
+        fw_version="1.21.0",
+        white_channels=2,
+        white_to_color_ratio=20,
+    )
diff --git a/pywizlight/tests/test_scenes.py b/pywizlight/tests/test_scenes.py
new file mode 100644
index 0000000..02fbc93
--- /dev/null
+++ b/pywizlight/tests/test_scenes.py
@@ -0,0 +1,14 @@
+"""Tests for the Scenes."""
+
+
+import pytest
+
+from pywizlight import scenes
+
+
+@pytest.mark.asyncio
+async def test_get_id_from_scene_name() -> None:
+    """Test looking up a scene id from the name."""
+    with pytest.raises(ValueError):
+        scenes.get_id_from_scene_name("non_exist")
+    assert scenes.get_id_from_scene_name("Ocean") == 1
diff --git a/pywizlight/tests/test_utils.py b/pywizlight/tests/test_utils.py
new file mode 100644
index 0000000..76478e8
--- /dev/null
+++ b/pywizlight/tests/test_utils.py
@@ -0,0 +1,18 @@
+"""Tests for the Utils."""
+
+
+import pytest
+
+from pywizlight import utils
+
+
+@pytest.mark.asyncio
+async def test_generate_mac() -> None:
+    """Test generating a mac address."""
+    assert len(utils.generate_mac()) == 12
+
+
+@pytest.mark.asyncio
+async def test_get_source_ip() -> None:
+    """Test we can get the source ip for loopback."""
+    assert utils.get_source_ip("127.0.0.1") == "127.0.0.1"