Skip to content

Commit 7028af1

Browse files
authored
Merge pull request #1497 from cetygamer/sonoff
Add Sonoff S-MATE and R5 switches
2 parents 3099543 + d00eb94 commit 7028af1

File tree

9 files changed

+318
-0
lines changed

9 files changed

+318
-0
lines changed

custom_components/ble_monitor/ble_parser/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from .sensorpush import parse_sensorpush
4343
from .senssun import parse_senssun
4444
from .smartdry import parse_smartdry
45+
from .sonoff import parse_sonoff
4546
from .switchbot import parse_switchbot
4647
from .teltonika import parse_teltonika
4748
from .thermobeacon import parse_thermobeacon
@@ -136,6 +137,11 @@ def parse_raw_data(self, data):
136137
elif adstuct_type == 0x03:
137138
# AD type 'Complete List of 16-bit Service Class UUIDs'
138139
service_class_uuid16 = (adstruct[2] << 8) | adstruct[3]
140+
elif adstuct_type == 0x05:
141+
# AD type 'Complete List of 32-bit Service Class UUIDs'
142+
if mac == b"\x66\x55\x44\x33\x22\x11":
143+
# Sonoff specific data
144+
man_spec_data_list.append(adstruct)
139145
elif adstuct_type == 0x06:
140146
# AD type '128-bit Service Class UUIDs'
141147
service_class_uuid128 = adstruct[2:]
@@ -429,6 +435,10 @@ def parse_advertisement(
429435
# Grundfos
430436
sensor_data = parse_grundfos(self, man_spec_data, mac)
431437
break
438+
elif comp_id == 0xFFFF and man_spec_data[4:6] == b"\xee\x1b" and mac == b"\x66\x55\x44\x33\x22\x11":
439+
# Sonoff
440+
sensor_data = parse_sonoff(self, man_spec_data, mac)
441+
break
432442
elif comp_id == 0xFFFF and data_len == 0x1E:
433443
# Kegtron
434444
sensor_data = parse_kegtron(self, man_spec_data, mac)
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Parser for Sonoff BLE advertisements"""
2+
import logging
3+
from typing import Any
4+
5+
from .helpers import to_mac, to_unformatted_mac
6+
7+
_LOGGER = logging.getLogger(__name__)
8+
9+
SONOFF_MODEL_MAP = {
10+
0x46: "S-MATE",
11+
0x47: "R5"
12+
}
13+
14+
SONOFF_BUTTON_MAP = {
15+
"S-MATE": {
16+
0x00: "three btn switch left",
17+
0x01: "three btn switch middle",
18+
0x02: "three btn switch right"
19+
},
20+
"R5": {
21+
0x00: "six btn switch top left",
22+
0x01: "six btn switch top middle",
23+
0x02: "six btn switch top right",
24+
0x03: "six btn switch bottom left",
25+
0x04: "six btn switch bottom middle",
26+
0x05: "six btn switch bottom right"
27+
}
28+
}
29+
30+
SONOFF_ACTION_MAP = {
31+
0x00: "single press",
32+
0x01: "double press",
33+
0x02: "long press"
34+
}
35+
36+
37+
def decrypt_sonoff(encrypted_data: bytes, seed: int) -> bytes:
38+
xor_table = [0x0f, 0x39, 0xbe, 0x5f, 0x27, 0x05, 0xbe, 0xf9, 0x66, 0xb5,
39+
0x74, 0x0d, 0x04, 0x86, 0xd2, 0x61, 0x55, 0xbb, 0xfc, 0x16,
40+
0x34, 0x40, 0x7e, 0x1d, 0x38, 0x6e, 0xe4, 0x06, 0xaa, 0x79,
41+
0x32, 0x95, 0x66, 0xb5, 0x74, 0x0d, 0xdb, 0x8c, 0xe9, 0x01,
42+
0x2a]
43+
xor_table_len = len(xor_table)
44+
45+
decrypted_data = bytearray()
46+
47+
for i, b in enumerate(encrypted_data):
48+
decrypted_data.append(b ^ seed ^ xor_table[i % xor_table_len])
49+
50+
return bytes(decrypted_data)
51+
52+
53+
def parse_sonoff(self, data: bytes, mac: bytes) -> dict[str, Any] | None:
54+
# Verify MAC address and data length
55+
if mac != b"\x66\x55\x44\x33\x22\x11" or len(data) < 10:
56+
return None
57+
58+
firmware = "Sonoff"
59+
60+
# data_uuid32 = data[2:6]
61+
# data_magic_1 = data[6:10]
62+
data_device_type = data[10]
63+
# data_magic_2 = data[11]
64+
# data_sequence_number = data[12]
65+
data_device_id = data[13:17]
66+
data_seed = data[17]
67+
data_encrypted = data[18:]
68+
69+
data_decrypted = decrypt_sonoff(data_encrypted, data_seed)
70+
71+
# data_padding_1 = data_decrypted[0]
72+
data_button_id = data_decrypted[1]
73+
data_press_type = data_decrypted[2]
74+
data_press_counter = data_decrypted[3:7]
75+
# data_padding_2 = data_decrypted[7]
76+
# data_crc = data_decrypted[8:10]
77+
78+
unique_mac = b"\x00\x00" + data_device_id
79+
80+
# Check for duplicate messages
81+
packet_id = int.from_bytes(data_press_counter, "big")
82+
try:
83+
prev_packet = self.lpacket_ids[unique_mac]
84+
except KeyError:
85+
# start with empty first packet
86+
prev_packet = None
87+
if prev_packet == packet_id:
88+
# only process new messages
89+
if self.filter_duplicates is True:
90+
return None
91+
self.lpacket_ids[unique_mac] = packet_id
92+
93+
try:
94+
device_type = SONOFF_MODEL_MAP[data_device_type]
95+
except KeyError:
96+
if self.report_unknown == "Sonoff":
97+
_LOGGER.info(
98+
"BLE ADV from UNKNOWN Sonoff DEVICE: MAC: %s, ADV: %s",
99+
to_mac(mac),
100+
data.hex()
101+
)
102+
return None
103+
104+
try:
105+
result = {
106+
SONOFF_BUTTON_MAP[device_type][data_button_id]: "toggle",
107+
"button switch": SONOFF_ACTION_MAP[data_press_type]
108+
}
109+
except KeyError:
110+
_LOGGER.error(
111+
"Unknown button id (%s) or press type (%s) from Sonoff DEVICE: MAC: %s, ADV: %s",
112+
hex(data_button_id),
113+
hex(data_press_type),
114+
to_mac(mac),
115+
data.hex()
116+
)
117+
return None
118+
119+
result.update({
120+
"mac": to_unformatted_mac(unique_mac),
121+
"type": device_type,
122+
"packet": packet_id,
123+
"firmware": firmware,
124+
"data": True
125+
})
126+
127+
return result

custom_components/ble_monitor/const.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1917,6 +1917,72 @@ class BLEMonitorBinarySensorEntityDescription(
19171917
device_class=None,
19181918
state_class=None,
19191919
),
1920+
BLEMonitorSensorEntityDescription(
1921+
key="six btn switch top left",
1922+
sensor_class="SwitchSensor",
1923+
update_behavior="Instantly",
1924+
name="ble six button switch top left",
1925+
unique_id="top_left_switch_",
1926+
icon="mdi:gesture-tap-button",
1927+
native_unit_of_measurement=None,
1928+
device_class=None,
1929+
state_class=None,
1930+
),
1931+
BLEMonitorSensorEntityDescription(
1932+
key="six btn switch top middle",
1933+
sensor_class="SwitchSensor",
1934+
update_behavior="Instantly",
1935+
name="ble six button switch top middle",
1936+
unique_id="top_middle_switch_",
1937+
icon="mdi:gesture-tap-button",
1938+
native_unit_of_measurement=None,
1939+
device_class=None,
1940+
state_class=None,
1941+
),
1942+
BLEMonitorSensorEntityDescription(
1943+
key="six btn switch top right",
1944+
sensor_class="SwitchSensor",
1945+
update_behavior="Instantly",
1946+
name="ble six button switch top right",
1947+
unique_id="top_right_switch_",
1948+
icon="mdi:gesture-tap-button",
1949+
native_unit_of_measurement=None,
1950+
device_class=None,
1951+
state_class=None,
1952+
),
1953+
BLEMonitorSensorEntityDescription(
1954+
key="six btn switch bottom left",
1955+
sensor_class="SwitchSensor",
1956+
update_behavior="Instantly",
1957+
name="ble six button switch bottom left",
1958+
unique_id="bottom_left_switch_",
1959+
icon="mdi:gesture-tap-button",
1960+
native_unit_of_measurement=None,
1961+
device_class=None,
1962+
state_class=None,
1963+
),
1964+
BLEMonitorSensorEntityDescription(
1965+
key="six btn switch bottom middle",
1966+
sensor_class="SwitchSensor",
1967+
update_behavior="Instantly",
1968+
name="ble six button switch bottom middle",
1969+
unique_id="bottom_middle_switch_",
1970+
icon="mdi:gesture-tap-button",
1971+
native_unit_of_measurement=None,
1972+
device_class=None,
1973+
state_class=None,
1974+
),
1975+
BLEMonitorSensorEntityDescription(
1976+
key="six btn switch bottom right",
1977+
sensor_class="SwitchSensor",
1978+
update_behavior="Instantly",
1979+
name="ble six button switch bottom right",
1980+
unique_id="bottom_right_switch_",
1981+
icon="mdi:gesture-tap-button",
1982+
native_unit_of_measurement=None,
1983+
device_class=None,
1984+
state_class=None,
1985+
),
19201986
BLEMonitorSensorEntityDescription(
19211987
key="remote",
19221988
sensor_class="BaseRemoteSensor",
@@ -2138,6 +2204,8 @@ class BLEMonitorBinarySensorEntityDescription(
21382204
'ST10' : [["temperature", "battery", "rssi"], [], []],
21392205
'MS1' : [["temperature", "battery", "rssi"], [], []],
21402206
'MS2' : [["temperature", "humidity", "battery", "rssi"], [], []],
2207+
'S-MATE' : [["rssi"], ["three btn switch left", "three btn switch middle", "three btn switch right"], []],
2208+
'R5' : [["rssi"], ["six btn switch top left", "six btn switch top middle", "six btn switch top right", "six btn switch bottom left", "six btn switch bottom middle", "six btn switch bottom right"], []],
21412209
}
21422210

21432211
# Sensor manufacturer dictionary
@@ -2286,6 +2354,8 @@ class BLEMonitorBinarySensorEntityDescription(
22862354
'ST10' : 'MOCREO',
22872355
'MS1' : 'MOCREO',
22882356
'MS2' : 'MOCREO',
2357+
'S-MATE' : 'Sonoff',
2358+
'R5' : 'Sonoff',
22892359
}
22902360

22912361

@@ -2527,6 +2597,7 @@ class BLEMonitorBinarySensorEntityDescription(
25272597
"Sensirion",
25282598
"SensorPush",
25292599
"SmartDry",
2600+
"Sonoff",
25302601
"Switchbot",
25312602
"Teltonika",
25322603
"Thermobeacon",

custom_components/ble_monitor/sensor.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,12 @@ class BaseSensor(RestoreSensor, SensorEntity):
405405
# | | |**four btn switch 2
406406
# | | |**four btn switch 3
407407
# | | |**four btn switch 4
408+
# | | |**six btn switch top left
409+
# | | |**six btn switch top middle
410+
# | | |**six btn switch top right
411+
# | | |**six btn switch bottom left
412+
# | | |**six btn switch bottom middle
413+
# | | |**six btn switch bottom right
408414
# | |--BaseRemoteSensor (Class)
409415
# | | |**remote
410416
# | | |**fan remote
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""The tests for the Sonoff ble_parser."""
2+
from ble_monitor.ble_parser import BleParser
3+
4+
5+
class TestSonoff:
6+
"""Tests for the HHCC parser"""
7+
def test_sonoff_s_mate(self):
8+
"""Test Sonoff BLE parser for S-MATE"""
9+
data_string = "043e2b020103011122334455661f0201021b05ffffee1bc878f64a4690dd5ad9e71f4e4177f011694babb7fe68ba"
10+
data = bytes(bytearray.fromhex(data_string))
11+
12+
# pylint: disable=unused-variable
13+
ble_parser = BleParser()
14+
sensor_msg, tracker_msg = ble_parser.parse_raw_data(data)
15+
16+
assert sensor_msg["firmware"] == "Sonoff"
17+
assert sensor_msg["type"] == "S-MATE"
18+
assert sensor_msg["mac"] == "00005AD9E71F"
19+
assert sensor_msg["packet"] == 91
20+
assert sensor_msg["data"]
21+
assert sensor_msg["three btn switch left"] == "toggle"
22+
assert sensor_msg["button switch"] == "single press"
23+
assert sensor_msg["rssi"] == -70
24+
25+
def test_sonoff_r5(self):
26+
"""Test Sonoff BLE parser for R5"""
27+
data_string = "043e2b020103011122334455661f0201021B05FFFFEE1BC878F64A4790365AD509227B7442C5245C7DE4828B98ae"
28+
data = bytes(bytearray.fromhex(data_string))
29+
30+
# pylint: disable=unused-variable
31+
ble_parser = BleParser()
32+
sensor_msg, tracker_msg = ble_parser.parse_raw_data(data)
33+
34+
assert sensor_msg["firmware"] == "Sonoff"
35+
assert sensor_msg["type"] == "R5"
36+
assert sensor_msg["mac"] == "00005AD50922"
37+
assert sensor_msg["packet"] == 801
38+
assert sensor_msg["data"]
39+
assert sensor_msg["six btn switch top left"] == "toggle"
40+
assert sensor_msg["button switch"] == "single press"
41+
assert sensor_msg["rssi"] == -82

docs/_devices/Sonoff_R5.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
manufacturer: Sonoff
3+
name: SwitchMan R5 Scene Controller
4+
model: R5 / R5W
5+
image: Sonoff_R5.png
6+
physical_description:
7+
broadcasted_properties:
8+
- six btn switch top left
9+
- six btn switch top middle
10+
- six btn switch top right
11+
- six btn switch bottom left
12+
- six btn switch bottom middle
13+
- six btn switch bottom right
14+
- button switch
15+
- rssi
16+
broadcasted_property_notes:
17+
- property: six btn switch top left
18+
note: returns 'short press', 'double press' or 'long press'
19+
- property: six btn switch top middle
20+
note: returns 'short press', 'double press' or 'long press'
21+
- property: six btn switch top right
22+
note: returns 'short press', 'double press' or 'long press'
23+
- property: six btn switch bottom left
24+
note: returns 'short press', 'double press' or 'long press'
25+
- property: six btn switch bottom middle
26+
note: returns 'short press', 'double press' or 'long press'
27+
- property: six btn switch bottom right
28+
note: returns 'short press', 'double press' or 'long press'
29+
broadcast_rate:
30+
active_scan:
31+
encryption_key: Yes
32+
custom_firmware:
33+
notes:
34+
- There are two versions of this switch - black and white.
35+
- The switch sensor state will return to `no press` after the time set with the [reset_timer](configuration_params#reset_timer) option. It is advised to change the reset time to 1 second (default = 35 seconds).
36+
---

docs/_devices/Sonoff_S-MATE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
manufacturer: Sonoff
3+
name: S-MATE Extreme Switch Mate | S-MATE2
4+
model: S-MATE / S-MATE2
5+
image: Sonoff_S-MATE.png
6+
physical_description:
7+
broadcasted_properties:
8+
- three btn switch left
9+
- three btn switch middle
10+
- three btn switch right
11+
- button switch
12+
- rssi
13+
broadcasted_property_notes:
14+
- property: three btn switch left
15+
note: returns 'short press', 'double press' or 'long press'
16+
- property: three btn switch middle
17+
note: returns 'short press', 'double press' or 'long press'
18+
- property: three btn switch right
19+
note: returns 'short press', 'double press' or 'long press'
20+
broadcast_rate:
21+
active_scan:
22+
encryption_key: Yes
23+
custom_firmware:
24+
notes:
25+
- There are two revisions of this switch - with and without power pass-through.
26+
- The switch sensor state will return to `no press` after the time set with the [reset_timer](configuration_params#reset_timer) option. It is advised to change the reset time to 1 second (default = 35 seconds).
27+
---

docs/assets/images/Sonoff_R5.png

7.25 KB
Loading
13.5 KB
Loading

0 commit comments

Comments
 (0)