Repository: TomerFi/aioswitcher

Issue:
Switcher runner S12 support
### What did you have in mind?

Hi,

Currently there's support for the single shutter Switcher runner.
Switcher since then started producing a double-shutter single-light device called [Switcher Runner S12](https://switcher.co.il/%D7%9E%D7%95%D7%A6%D7%A8/runner-lights-s12/).
Any chance for adding support to it?

The current API (V2) is unable to nor discover neither control the S12 device.


### Are you trying to fix a problem?

Yes, add support to a new device

### Any lead on how this feature can be implemented?

Probably reverse engineer the new API or try to find out the ports open by the new device. I can help if needed.

Discussion:
YogevBokobza: @thecode @TomerFi 
Finally, this is ready for your review.
YogevBokobza: @thecode 
I think we are going to have a little issue in HA with this,
Right now shutter position and direction are single vars and here this is an array, Also for light in HA this is an Array (because of S11), and here this is single var.

Theoretically, this library is well-coded with those use cases, but in HA there will be a split in those use cases.
What do you think?
thecode: > @thecode I think we are going to have a little issue in HA with this, Right now shutter position and direction are single vars and here this is an array, Also for light in HA this is an Array (because of S11), and here this is single var.
> 
> Theoretically, this library is well-coded with those use cases, but in HA there will be a split in those use cases. What do you think?

I don't understand the problem you are describing, these are well typed:
https://github.com/TomerFi/aioswitcher/blob/fca042f8a6f2eefff93561b530e80a31c220a1cb/src/aioswitcher/device/__init__.py#L348-L359

And in HA you check the type and add the correct number of entities:
https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/cover.py#L43-L47

https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/light.py#L41-L50

So you can just add another case similar to what we do in the switch platform:
https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/switch.py#L70-L73

Can you explain what is the problem we are going to face in HA?

YogevBokobza: > > @thecode I think we are going to have a little issue in HA with this, Right now shutter position and direction are single vars and here this is an array, Also for light in HA this is an Array (because of S11), and here this is single var.
> > Theoretically, this library is well-coded with those use cases, but in HA there will be a split in those use cases. What do you think?
> 
> I don't understand the problem you are describing, these are well typed:
> 
> https://github.com/TomerFi/aioswitcher/blob/fca042f8a6f2eefff93561b530e80a31c220a1cb/src/aioswitcher/device/__init__.py#L348-L359
> 
> And in HA you check the type and add the correct number of entities: https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/cover.py#L43-L47
> 
> https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/light.py#L41-L50
> 
> So you can just add another case similar to what we do in the switch platform: https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/switch.py#L70-L73
> 
> Can you explain what is the problem we are going to face in HA?

This for example:
https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/cover.py#L89
it takes data.position but in this use case ir should be something like data.position[Index]
thecode: > This for example: https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/cover.py#L89 it takes data.position but in this use case it should be something like data.position[Index] You are right for the code you showed.

I still don't understand what is the problem, you can subclass `SwitcherCoverEntity` in HA and add an index or you can check if the `data.position` is a list or not to know if you need to use an index.
YogevBokobza: > > This for example: https://github.com/home-assistant/core/blob/dcb6c9a13375d5db73211f250a52c5342d36307d/homeassistant/components/switcher_kis/cover.py#L89 it takes data.position but in this use case it should be something like data.position[Index] You are right for the code you showed.
> 
> I still don't understand what is the problem, you can subclass `SwitcherCoverEntity` in HA and add an index or you can check if the `data.position` is a list or not to know if you need to use an index.

Yeah I guess you are right.. it's not a real problem but a matter of adding conditions to support both single value an array of values
It's not that nice but it can work
YogevBokobza: @TomerFi
Please approve, merge and make a release.
Thanks :)

Patch:
diff --git a/README.md b/README.md
index e9436eb..98c5185 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,7 @@ asyncio.run(print_devices(60))
   asyncio.run(control_runner(DeviceType.RUNNER, "111.222.11.22", "ab1c2d", "00"))
   asyncio.run(control_runner(DeviceType.RUNNER_MINI, "111.222.11.22", "ab1c2d", "00"))
   asyncio.run(control_runner(DeviceType.RUNNER_S11, "111.222.11.22", "ab1c2d", "00", "zvVvd7JxtN7CgvkD1Psujw=="))
+  asyncio.run(control_runner(DeviceType.RUNNER_S12, "111.222.11.22", "ab1c2d", "00", "zvVvd7JxtN7CgvkD1Psujw=="))
   ```
 
 </details>
diff --git a/docs/scripts.md b/docs/scripts.md
index 07ea725..f13dfe6 100644
--- a/docs/scripts.md
+++ b/docs/scripts.md
@@ -21,7 +21,7 @@ You can change the delay by passing an int argument: discover_devices.py 30
 
 Switcher devices uses two protocol types:
     Protocol type 1 (UDP port 20002 or port 10002), used by: Switcher Mini, Switcher Power Plug, Switcher Touch, Switcher V2 (esp), Switcher V2 (qualcomm), Switcher V4
-    Protocol type 2 (UDP port 20003 or port 10003), used by: Switcher Breeze, Switcher Runner, Switcher Runner Mini, Switcher Runner S11
+    Protocol type 2 (UDP port 20003 or port 10003), used by: Switcher Breeze, Switcher Runner, Switcher Runner Mini, Switcher Runner S11, Switcher Runner S12
 You can change the scanned protocol type by passing an int argument: discover_devices.py -t 1
 
 Note:
@@ -117,26 +117,44 @@ python control_device.py get_shutter_state -c "Switcher Runner" -d f2239a -i "19
 
 python control_device.py get_shutter_state -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98"
 
+python control_device.py get_shutter_state -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 0
+
+python control_device.py get_shutter_state -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 1
+
 python control_device.py stop_shutter -c "Switcher Runner" -d f2239a -i "192.168.50.98"
 
 python control_device.py stop_shutter -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98"
 
+python control_device.py stop_shutter -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 0
+
+python control_device.py stop_shutter -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 1
+
 python control_device.py set_shutter_position -c "Switcher Runner" -d f2239a -i "192.168.50.98" -p 50
 
 python control_device.py set_shutter_position -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -p 50
 
+python control_device.py set_shutter_position -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -p 50 -x 0
+
+python control_device.py set_shutter_position -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -p 50 -x 1
+
 python control_device.py get_light_state -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 0
 
 python control_device.py get_light_state -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 1
 
+python control_device.py get_light_state -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22"
+
 python control_device.py turn_on_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 0
 
 python control_device.py turn_on_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 1
 
+python control_device.py turn_on_light -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22"
+
 python control_device.py turn_off_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 0
 
 python control_device.py turn_off_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 1
 
+python control_device.py turn_off_light -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22"
+
 python control_device.py get_thermostat_state -c "Switcher Breeze" -d 3a20b7 -i "192.168.50.77"
 
 python control_device.py control_thermostat -c "Switcher Breeze" -d 3a20b7 -i "192.168.50.77" -r ELEC7001 -s on
diff --git a/docs/usage.md b/docs/usage.md
index bf86fc4..405702d 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -122,6 +122,9 @@ asyncio.run(
 asyncio.run(
     control_runner(DeviceType.RUNNER_S11, "111.222.11.22", "ab1c2d", "00", "zvVvd7JxtN7CgvkD1Psujw==")
 )
+asyncio.run(
+    control_runner(DeviceType.RUNNER_S12, "111.222.11.22", "ab1c2d", "00", "zvVvd7JxtN7CgvkD1Psujw==")
+)
 ```
 
 1. [SwitcherShutterStateResponse](./codedocs.md#src.aioswitcher.api.messages.SwitcherShutterStateResponse)
diff --git a/scripts/control_device.py b/scripts/control_device.py
index 13bf8c6..5cc7295 100755
--- a/scripts/control_device.py
+++ b/scripts/control_device.py
@@ -53,17 +53,26 @@
 
 python control_device.py get_shutter_state -c "Switcher Runner" -d f2239a -i "192.168.50.98"\n
 python control_device.py get_shutter_state -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98"\n
+python control_device.py get_shutter_state -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 0\n
+python control_device.py get_shutter_state -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 1\n
 python control_device.py stop_shutter -c "Switcher Runner" -d f2239a -i "192.168.50.98"\n
 python control_device.py stop_shutter -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98"\n
+python control_device.py stop_shutter -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 0\n
+python control_device.py stop_shutter -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -x 1\n
 python control_device.py set_shutter_position -c "Switcher Runner" -d f2239a -i "192.168.50.98" -p 50\n
 python control_device.py set_shutter_position -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -p 50\n
+python control_device.py set_shutter_position -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -p 50 -x 0\n
+python control_device.py set_shutter_position -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d f2239a -i "192.168.50.98" -p 50 -x 1\n
 
 python control_device.py get_light_state -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 0\n
 python control_device.py get_light_state -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 1\n
+python control_device.py get_light_state -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22"\n
 python control_device.py turn_on_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 0\n
 python control_device.py turn_on_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 1\n
+python control_device.py turn_on_light -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22"\n
 python control_device.py turn_off_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 0\n
 python control_device.py turn_off_light -c "Switcher Runner S11" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22" -x 1\n
+python control_device.py turn_off_light -c "Switcher Runner S12" -k "zvVvd7JxtN7CgvkD1Psujw==" -d ab1c2d -i "111.222.11.22"\n
 
 python control_device.py get_thermostat_state -c "Switcher Breeze" -d 3a20b7 -i "192.168.50.77"\n
 
diff --git a/src/aioswitcher/api/__init__.py b/src/aioswitcher/api/__init__.py
index 57a8c27..3639c4b 100644
--- a/src/aioswitcher/api/__init__.py
+++ b/src/aioswitcher/api/__init__.py
@@ -68,6 +68,7 @@
     DeviceCategory.THERMOSTAT: SWITCHER_TCP_PORT_TYPE2,
     DeviceCategory.SHUTTER: SWITCHER_TCP_PORT_TYPE2,
     DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT: SWITCHER_TCP_PORT_TYPE2,
+    DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT: SWITCHER_TCP_PORT_TYPE2,
     DeviceCategory.WATER_HEATER: SWITCHER_TCP_PORT_TYPE1,
     DeviceCategory.POWER_PLUG: SWITCHER_TCP_PORT_TYPE1,
 }
diff --git a/src/aioswitcher/bridge.py b/src/aioswitcher/bridge.py
index 1676123..e170ff1 100644
--- a/src/aioswitcher/bridge.py
+++ b/src/aioswitcher/bridge.py
@@ -31,6 +31,7 @@
     DeviceType,
     ShutterDirection,
     SwitcherBase,
+    SwitcherDualShutterSingleLight,
     SwitcherPowerPlug,
     SwitcherShutter,
     SwitcherSingleShutterDualLight,
@@ -54,7 +55,7 @@
 # Protocol type 1 devices: V2, Touch, V4, Mini, Power Plug
 SWITCHER_UDP_PORT_TYPE1 = 20002
 SWITCHER_UDP_PORT_TYPE1_NEW_VERSION = 10002
-# Protocol type 2 devices: Breeze, Runner, Runner Mini, Runner S11
+# Protocol type 2 devices: Breeze, Runner, Runner Mini, Runner S11, Runner S12
 SWITCHER_UDP_PORT_TYPE2 = 20003
 SWITCHER_UDP_PORT_TYPE2_NEW_VERSION = 10003
 
@@ -64,6 +65,7 @@
     DeviceCategory.THERMOSTAT: SWITCHER_UDP_PORT_TYPE2,
     DeviceCategory.SHUTTER: SWITCHER_UDP_PORT_TYPE2,
     DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT: SWITCHER_UDP_PORT_TYPE2_NEW_VERSION,
+    DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT: SWITCHER_UDP_PORT_TYPE2_NEW_VERSION,
 }
 
 
@@ -188,6 +190,43 @@ def _parse_device_from_datagram(
                 )
             )
 
+        elif (
+            device_type
+            and device_type.category == DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT
+        ):
+            logger.debug("discovered a Runner S12 switcher device")
+            device_callback(
+                SwitcherDualShutterSingleLight(
+                    device_type,
+                    DeviceState.ON,
+                    parser.get_device_id(),
+                    parser.get_device_key(),
+                    parser.get_ip_type2(),
+                    parser.get_mac_type2(),
+                    parser.get_name(),
+                    device_type.token_needed,
+                    [
+                        parser.get_shutter_position(
+                            get_shutter_discovery_packet_index(device_type, 0)
+                        ),
+                        parser.get_shutter_position(
+                            get_shutter_discovery_packet_index(device_type, 1)
+                        ),
+                    ],
+                    [
+                        parser.get_shutter_direction(
+                            get_shutter_discovery_packet_index(device_type, 0)
+                        ),
+                        parser.get_shutter_direction(
+                            get_shutter_discovery_packet_index(device_type, 1)
+                        ),
+                    ],
+                    parser.get_light_state(
+                        get_light_discovery_packet_index(device_type, 0)
+                    ),
+                )
+            )
+
         elif device_type and device_type.category == DeviceCategory.THERMOSTAT:
             logger.debug("discovered a Breeze switcher device")
             device_callback(
@@ -336,7 +375,7 @@ def is_switcher_originator(self) -> bool:
             len(self.message) == 165
             or len(self.message) == 168  # Switcher Breeze
             or len(self.message) == 159  # Switcher Runner and RunnerMini
-            or len(self.message) == 203  # Switcher Runner S11
+            or len(self.message) == 203  # Switcher Runner S11 and Switcher Runner S12
         )
 
     def get_ip_type1(self) -> str:
diff --git a/src/aioswitcher/device/__init__.py b/src/aioswitcher/device/__init__.py
index 3299cbb..159fc57 100644
--- a/src/aioswitcher/device/__init__.py
+++ b/src/aioswitcher/device/__init__.py
@@ -30,6 +30,7 @@ class DeviceCategory(Enum):
     THERMOSTAT = auto()
     SHUTTER = auto()
     SINGLE_SHUTTER_DUAL_LIGHT = auto()
+    DUAL_SHUTTER_SINGLE_LIGHT = auto()
 
 
 @unique
@@ -52,6 +53,13 @@ class DeviceType(Enum):
         DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT,
         True,
     )
+    RUNNER_S12 = (
+        "Switcher Runner S12",
+        "0f02",
+        2,
+        DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT,
+        True,
+    )
 
     def __new__(
         cls,
@@ -336,6 +344,21 @@ class SwitcherSingleShutterDualLightBase(ABC):
     lights: List[DeviceState]
 
 
+@dataclass
+class SwitcherDualShutterSingleLightBase(ABC):
+    """Abstraction for all switcher devices controlling dual shutter with single light.
+
+    Args:
+        position: the current array of position of the shutter (integer percentage).
+        direction: the current array of direction of the shutter.
+        lights: the current lights state.
+    """
+
+    position: List[int]
+    direction: List[ShutterDirection]
+    lights: DeviceState
+
+
 @final
 @dataclass
 class SwitcherPowerPlug(SwitcherPowerBase, SwitcherBase):
@@ -406,3 +429,18 @@ def __post_init__(self) -> None:
         if self.device_type.category != DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT:
             raise ValueError("only shutters with dual lights are allowed")
         return super().__post_init__()
+
+
+@final
+@dataclass
+class SwitcherDualShutterSingleLight(SwitcherDualShutterSingleLightBase, SwitcherBase):
+    """Implementation of the Switcher dual Shutter with single light device."""
+
+    def __post_init__(self) -> None:
+        """Post initialization.
+
+        Validate device type category as DUAL_SHUTTER_SINGLE_LIGHT.
+        """
+        if self.device_type.category != DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT:
+            raise ValueError("only dual shutters with single lights are allowed")
+        return super().__post_init__()
diff --git a/src/aioswitcher/device/tools.py b/src/aioswitcher/device/tools.py
index 840be2d..23017ec 100644
--- a/src/aioswitcher/device/tools.py
+++ b/src/aioswitcher/device/tools.py
@@ -168,6 +168,8 @@ def convert_str_to_devicetype(device_type: str) -> DeviceType:
         return DeviceType.RUNNER_MINI
     elif device_type == DeviceType.RUNNER_S11.value:
         return DeviceType.RUNNER_S11
+    elif device_type == DeviceType.RUNNER_S12.value:
+        return DeviceType.RUNNER_S12
     return DeviceType.MINI
 
 
@@ -231,6 +233,8 @@ async def validate_token(username: str, token: str) -> bool:
 #   shutter circuit is 0, get_light_discovery_packet_index would raise an error.
 # Runner S11: has two lights circuits & one shutter circuits ->
 #   Lights circuits are numbered 0 & 1, shutter circuit is 2.
+# Runner S12: has one lights circuits & two shutter circuits ->
+#   Lights circuit is 0, shutter circuits are numbered 1 & 2.
 def get_shutter_discovery_packet_index(
     device_type: DeviceType, circuit_number: int
 ) -> int:
@@ -239,13 +243,17 @@ def get_shutter_discovery_packet_index(
     Used in retriving the shutter position/direction from the packet
     (based of device type and circuit number).
     """
-    if circuit_number != 0:
+    if device_type != DeviceType.RUNNER_S12 and circuit_number != 0:
+        raise ValueError("Invalid circuit number")
+    if device_type == DeviceType.RUNNER_S12 and circuit_number not in [0, 1]:
         raise ValueError("Invalid circuit number")
 
     if device_type in (DeviceType.RUNNER, DeviceType.RUNNER_MINI):
         return 0
     elif device_type == DeviceType.RUNNER_S11:
         return 2
+    elif device_type == DeviceType.RUNNER_S12:
+        return circuit_number + 1
 
     raise ValueError("only shutters are allowed")
 
@@ -259,10 +267,13 @@ def get_light_discovery_packet_index(
     (based of device type and circuit number).
     """
     if device_type == DeviceType.RUNNER_S11:
-        if circuit_number in [0, 1]:
-            return circuit_number
-        else:
+        if circuit_number not in [0, 1]:
+            raise ValueError("Invalid circuit number")
+        return circuit_number
+    if device_type == DeviceType.RUNNER_S12:
+        if circuit_number != 0:
             raise ValueError("Invalid circuit number")
+        return 0
 
     raise ValueError("only devices that has lights are allowed")
 


Test patch:
diff --git a/tests/test_device_enum_helpers.py b/tests/test_device_enum_helpers.py
index b2992ff..1af96c8 100644
--- a/tests/test_device_enum_helpers.py
+++ b/tests/test_device_enum_helpers.py
@@ -54,7 +54,8 @@
         (DeviceType.BREEZE, "Switcher Breeze", "0e01", 2, DeviceCategory.THERMOSTAT, False),
         (DeviceType.RUNNER, "Switcher Runner", "0c01", 2, DeviceCategory.SHUTTER, False),
         (DeviceType.RUNNER_MINI, "Switcher Runner Mini", "0c02", 2, DeviceCategory.SHUTTER, False),
-        (DeviceType.RUNNER_S11, "Switcher Runner S11", "0f01", 2, DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, True)
+        (DeviceType.RUNNER_S11, "Switcher Runner S11", "0f01", 2, DeviceCategory.SINGLE_SHUTTER_DUAL_LIGHT, True),
+        (DeviceType.RUNNER_S12, "Switcher Runner S12", "0f02", 2, DeviceCategory.DUAL_SHUTTER_SINGLE_LIGHT, True)
     ],
 )
 def test_the_given_type_custom_properties_are_returning_the_expected_data(
diff --git a/tests/test_device_parsing.py b/tests/test_device_parsing.py
index 92d33ab..7f51ec7 100644
--- a/tests/test_device_parsing.py
+++ b/tests/test_device_parsing.py
@@ -24,6 +24,7 @@
 from aioswitcher.bridge import DatagramParser, _parse_device_from_datagram
 from aioswitcher.device import (
     DeviceState,
+    SwitcherDualShutterSingleLight,
     SwitcherPowerPlug,
     SwitcherShutter,
     SwitcherSingleShutterDualLight,
@@ -102,3 +103,12 @@ def test_a_single_runner_dual_light_datagram_produces_device(mock_device_cls, mo
     sut_datagram = Path(f'{resource_path}.txt').read_text().replace('\n', '').encode()
     _parse_device_from_datagram(mock_callback, unhexlify(sut_datagram))
     mock_callback.assert_called_once_with(mock_device)
+
+
+@patch.object(SwitcherDualShutterSingleLight, "__new__")
+@patch.object(DatagramParser, "is_switcher_originator", lambda s: True)
+def test_a_dual_runner_single_light_datagram_produces_device(mock_device_cls, mock_device, resource_path, mock_callback):
+    mock_device_cls.return_value = mock_device
+    sut_datagram = Path(f'{resource_path}.txt').read_text().replace('\n', '').encode()
+    _parse_device_from_datagram(mock_callback, unhexlify(sut_datagram))
+    mock_callback.assert_called_once_with(mock_device)
diff --git a/tests/test_device_tools.py b/tests/test_device_tools.py
index caf708f..7691c7c 100644
--- a/tests/test_device_tools.py
+++ b/tests/test_device_tools.py
@@ -107,7 +107,8 @@ def test_watts_to_amps_with_parameterized_watts_should_procude_expected_amps(wat
     ("Switcher Breeze", DeviceType.BREEZE),
     ("Switcher Runner", DeviceType.RUNNER),
     ("Switcher Runner Mini", DeviceType.RUNNER_MINI),
-    ("Switcher Runner S11", DeviceType.RUNNER_S11)
+    ("Switcher Runner S11", DeviceType.RUNNER_S11),
+    ("Switcher Runner S12", DeviceType.RUNNER_S12)
     ])
 def test_convert_str_to_devicetype_should_return_expected_devicetype(str, type):
     assert_that(tools.convert_str_to_devicetype(str)).is_equal_to(type)
@@ -166,6 +167,8 @@ async def test_validate_token_should_return_token_invalid(mock_post, username, t
     (DeviceType.RUNNER, 0, 0),
     (DeviceType.RUNNER_MINI, 0, 0),
     (DeviceType.RUNNER_S11, 0, 2),
+    (DeviceType.RUNNER_S12, 0, 1),
+    (DeviceType.RUNNER_S12, 1, 2),
     ])
 def test_get_shutter_discovery_packet_index_should_return_expected_index(device_type, circuit_number, index):
     assert_that(tools.get_shutter_discovery_packet_index(device_type, circuit_number)).is_equal_to(index)
@@ -175,6 +178,7 @@ def test_get_shutter_discovery_packet_index_should_return_expected_index(device_
     (DeviceType.RUNNER, 1, ValueError, "Invalid circuit number"),
     (DeviceType.RUNNER_MINI, 1, ValueError, "Invalid circuit number"),
     (DeviceType.RUNNER_S11, 1, ValueError, "Invalid circuit number"),
+    (DeviceType.RUNNER_S12, 2, ValueError, "Invalid circuit number"),
     ])
 def test_get_shutter_discovery_packet_index_with_invalid_circuit_number_should_raise_error(device_type, circuit_number, error, error_msg):
     assert_that(tools.get_shutter_discovery_packet_index).raises(error).when_called_with(
@@ -196,13 +200,15 @@ def test_get_shutter_discovery_packet_index_with_different_device_should_raise_e
 @mark.parametrize("device_type, circuit_number, index", [
     (DeviceType.RUNNER_S11, 0, 0),
     (DeviceType.RUNNER_S11, 1, 1),
+    (DeviceType.RUNNER_S12, 0, 0),
     ])
 def test_get_light_discovery_packet_index_should_return_expected_index(device_type, circuit_number, index):
     assert_that(tools.get_light_discovery_packet_index(device_type, circuit_number)).is_equal_to(index)
 
 
 @mark.parametrize("device_type, circuit_number, error, error_msg", [
-    (DeviceType.RUNNER_S11, 2, ValueError, "Invalid circuit number")
+    (DeviceType.RUNNER_S11, 2, ValueError, "Invalid circuit number"),
+    (DeviceType.RUNNER_S12, 1, ValueError, "Invalid circuit number")
     ])
 def test_get_light_discovery_packet_index_with_invalid_circuit_number_should_raise_error(device_type, circuit_number, error, error_msg):
     assert_that(tools.get_light_discovery_packet_index).raises(error).when_called_with(
@@ -225,6 +231,8 @@ def test_get_light_discovery_packet_index_with_different_device_should_raise_err
     (DeviceType.RUNNER, 0, 1),
     (DeviceType.RUNNER_MINI, 0, 1),
     (DeviceType.RUNNER_S11, 0, 3),
+    (DeviceType.RUNNER_S12, 0, 2),
+    (DeviceType.RUNNER_S12, 1, 3),
     ])
 def test_get_shutter_api_packet_index_should_return_expected_index(device_type, circuit_number, index):
     assert_that(tools.get_shutter_api_packet_index(device_type, circuit_number)).is_equal_to(index)
@@ -234,6 +242,7 @@ def test_get_shutter_api_packet_index_should_return_expected_index(device_type,
     (DeviceType.RUNNER, 1, ValueError, "Invalid circuit number"),
     (DeviceType.RUNNER_MINI, 1, ValueError, "Invalid circuit number"),
     (DeviceType.RUNNER_S11, 1, ValueError, "Invalid circuit number"),
+    (DeviceType.RUNNER_S12, 2, ValueError, "Invalid circuit number"),
     ])
 def test_get_shutter_api_packet_index_with_invalid_circuit_number_should_raise_error(device_type, circuit_number, error, error_msg):
     assert_that(tools.get_shutter_api_packet_index).raises(error).when_called_with(
@@ -255,13 +264,15 @@ def test_get_shutter_api_packet_index_with_different_device_should_raise_error(d
 @mark.parametrize("device_type, circuit_number, index", [
     (DeviceType.RUNNER_S11, 0, 1),
     (DeviceType.RUNNER_S11, 1, 2),
+    (DeviceType.RUNNER_S12, 0, 1),
     ])
 def test_get_light_api_packet_index_should_return_expected_index(device_type, circuit_number, index):
     assert_that(tools.get_light_api_packet_index(device_type, circuit_number)).is_equal_to(index)
 
 
 @mark.parametrize("device_type, circuit_number, error, error_msg", [
-    (DeviceType.RUNNER_S11, 2, ValueError, "Invalid circuit number")
+    (DeviceType.RUNNER_S11, 2, ValueError, "Invalid circuit number"),
+    (DeviceType.RUNNER_S12, 1, ValueError, "Invalid circuit number")
     ])
 def test_get_light_api_packet_index_with_invalid_circuit_number_should_raise_error(device_type, circuit_number, error, error_msg):
     assert_that(tools.get_light_api_packet_index).raises(error).when_called_with(
diff --git a/tests/testresources/test_device_parsing/test_a_dual_runner_single_light_datagram_produces_device.txt b/tests/testresources/test_device_parsing/test_a_dual_runner_single_light_datagram_produces_device.txt
new file mode 100644
index 0000000..e253b48
--- /dev/null
+++ b/tests/testresources/test_device_parsing/test_a_dual_runner_single_light_datagram_produces_device.txt
@@ -0,0 +1,1 @@
+fef0cb000402020000000000bf0401000000fd26c700000092fafa6600000000000000000000f0fe020053776974636865722052756e20706c75735f41394245000000000000000000000f0200c0a801eb483fda26a9be03030c0c000000000253776974636865722052756e6e65725f413942450000000000000000000000000204000041000100000000000400000000000000010000000000000100000000000000000000006400000001000000000000000000000000000000000000000000000000000000c92bc371
\ No newline at end of file