Skip to content

Commit 110b4a7

Browse files
committed
Implemented bthome bluetooth protocol
1 parent 6d0800c commit 110b4a7

5 files changed

Lines changed: 267 additions & 0 deletions

File tree

device/impl/impls.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ import (
44
_ "github.com/nikiforov-soft/yasp/device/impl/lywsd03mmc"
55
_ "github.com/nikiforov-soft/yasp/device/impl/p1p2"
66
_ "github.com/nikiforov-soft/yasp/device/impl/passthrough"
7+
_ "github.com/nikiforov-soft/yasp/device/impl/shelly"
78
)

device/impl/shelly/sbdw-002c.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package lywsd03mmc
2+
3+
import (
4+
"context"
5+
"encoding/hex"
6+
"encoding/json"
7+
"fmt"
8+
9+
"github.com/nikiforov-soft/yasp/config"
10+
"github.com/nikiforov-soft/yasp/device"
11+
"github.com/nikiforov-soft/yasp/device/vendors/bthome"
12+
)
13+
14+
// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/dw/
15+
const (
16+
deviceType = "SBDW-002C"
17+
)
18+
19+
type bluDoorWindow struct {
20+
name string
21+
}
22+
23+
func (bdw *bluDoorWindow) Decode(_ context.Context, data *device.Data) (*device.Data, error) {
24+
sensorData, err := bthome.Parse(data.Data)
25+
if err != nil {
26+
return nil, fmt.Errorf("shelly: failed to parse sensor data: %s - %w", hex.EncodeToString(data.Data), err)
27+
}
28+
29+
result, err := json.Marshal(sensorData)
30+
if err != nil {
31+
return nil, fmt.Errorf("shelly: failed to marshal sensor data: %w", err)
32+
}
33+
34+
properties := make(map[string]interface{}, len(data.Properties)+6)
35+
for k, v := range data.Properties {
36+
properties[k] = v
37+
}
38+
properties["deviceName"] = bdw.name
39+
properties["deviceType"] = deviceType
40+
properties["batteryPercent"] = sensorData.BatteryPercent
41+
properties["illuminanceLux"] = sensorData.IlluminanceLux
42+
properties["windowState"] = sensorData.WindowState
43+
properties["buttonEvent"] = sensorData.ButtonEvent
44+
properties["rotationDegrees"] = sensorData.RotationDegrees
45+
46+
return &device.Data{
47+
Data: result,
48+
Properties: properties,
49+
}, nil
50+
}
51+
52+
func init() {
53+
err := device.RegisterDevice(deviceType, func(ctx context.Context, config *config.Device) (device.Device, error) {
54+
return &bluDoorWindow{
55+
name: config.Name,
56+
}, nil
57+
})
58+
if err != nil {
59+
panic(err)
60+
}
61+
}

device/vendors/bthome/parser.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package bthome
2+
3+
import (
4+
"bytes"
5+
"encoding/binary"
6+
"errors"
7+
"fmt"
8+
"io"
9+
)
10+
11+
/*
12+
*
13+
01 02 03 04 05 06 07 08 09 10 11 12 13 14
14+
44 00 2c 01 64 05 14 05 00 2d 01 3f 00 00
15+
0x01-0x01 44 = header
16+
0x02-0x03 00 = property type (packet id)
17+
0x03-0x04 2c = 44 packet id
18+
0x04-0x05 01 = property type (battery)
19+
0x05-0x06 64 = battery level 100%
20+
0x06-0x07 05 = property type (illuminance)
21+
0x07-0x0A 14 05 00 = the illuminance value
22+
0x0A-0x0B 2d = property type (window state)
23+
0x0B-0x0C 01 = 1 = Open 0 = Closed
24+
0x0C-0x0D 3F = property type (rotation)
25+
0x0D-0x0F 00 00 = rotation value 0
26+
*/
27+
28+
// Parse - Parses bthome encoded data
29+
// https://bthome.io/format/#sensor-data
30+
func Parse(data []byte) (*SensorData, error) {
31+
if len(data) < 3 {
32+
return nil, fmt.Errorf("bthome: invalid data length: %d", len(data))
33+
}
34+
35+
r := bytes.NewReader(data)
36+
header, err := r.ReadByte()
37+
if err != nil {
38+
return nil, fmt.Errorf("bthome: failed to read header id: %w", err)
39+
}
40+
if header != 0x44 {
41+
return nil, fmt.Errorf("bthome: invalid header type: %d", header)
42+
}
43+
44+
var sensorData SensorData
45+
for {
46+
propertyType, err := r.ReadByte()
47+
if err != nil {
48+
if errors.Is(err, io.EOF) {
49+
break
50+
}
51+
return nil, fmt.Errorf("bthome: failed to read property type: %d", propertyType)
52+
}
53+
54+
switch propertyType {
55+
case 0x00: // packet id
56+
/**
57+
https://bthome.io/format/#misc-data
58+
* The packet id is optional and can be used to filter duplicate data.
59+
* This allows you to send multiple advertisements that are exactly the same, to improve the chance that the advertisement arrives.
60+
* BTHome receivers should only process the advertisement if the packet id is different compared to the previous one.
61+
* The packet id is a value between 0 (0x00) and 255 (0xFF), and should be increased on every change in data.
62+
* Note that most home automation software already have some other filtering for unchanged data.
63+
* The use of a packet id is therefore often not needed.
64+
**/
65+
packetId, err := r.ReadByte()
66+
if err != nil {
67+
return nil, err
68+
}
69+
sensorData.PacketId = packetId
70+
case 0x01: // battery %
71+
battery, err := r.ReadByte()
72+
if err != nil {
73+
return nil, fmt.Errorf("bthome: failed to read battery: %w", err)
74+
}
75+
sensorData.BatteryPercent = battery
76+
case 0x05: // illuminance lux
77+
illuminance, err := readIlluminance(r)
78+
if err != nil {
79+
return nil, fmt.Errorf("bthome: failed to read illuminance: %w", err)
80+
}
81+
sensorData.IlluminanceLux = illuminance
82+
case 0x2D: // window bool
83+
windowState, err := r.ReadByte()
84+
if err != nil {
85+
return nil, fmt.Errorf("bthome: failed to read window state: %w", err)
86+
}
87+
sensorData.WindowState = windowState
88+
case 0x3A: // button
89+
var buttonEvent uint16
90+
if err := binary.Read(r, binary.BigEndian, &buttonEvent); err != nil {
91+
return nil, fmt.Errorf("bthome: failed to read button event: %w", err)
92+
}
93+
sensorData.ButtonEvent = buttonEvent
94+
case 0x3F: // rotation °
95+
var rotation int16
96+
if err := binary.Read(r, binary.BigEndian, &rotation); err != nil {
97+
return nil, fmt.Errorf("bthome: failed to read rotation: %w", err)
98+
}
99+
sensorData.RotationDegrees = float32(rotation) / 10.
100+
default:
101+
return nil, fmt.Errorf("bthome: unknown property type: %d", propertyType)
102+
}
103+
}
104+
105+
return &sensorData, nil
106+
}
107+
108+
func readIlluminance(r io.Reader) (float32, error) {
109+
var component12 uint16
110+
err := binary.Read(r, binary.BigEndian, &component12)
111+
if err != nil {
112+
return 0, err
113+
}
114+
115+
var component3 byte
116+
err = binary.Read(r, binary.BigEndian, &component3)
117+
if err != nil {
118+
return 0, err
119+
}
120+
121+
illuminance := uint32(component12)<<8 | uint32(component3)
122+
return float32(illuminance) / 100., nil
123+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package bthome
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestParse(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
data []byte
13+
expected *SensorData
14+
expectedError string
15+
}{
16+
{
17+
name: "invalid length",
18+
data: []byte{0x00},
19+
expected: nil,
20+
expectedError: "bthome: invalid data length: 1",
21+
},
22+
{
23+
name: "invalid header",
24+
data: []byte{0x00, 0x00, 0x00},
25+
expected: nil,
26+
expectedError: "bthome: invalid header type: 0",
27+
},
28+
{
29+
name: "invalid property",
30+
data: []byte{0x44, 0x00, 0x00, 0x98, 0x00},
31+
expected: nil,
32+
expectedError: "bthome: unknown property type: 152",
33+
},
34+
{
35+
name: "packet id 43, battery 100%, illuminance 144192.00 lux, window 0, button event 0, rotation 0.0",
36+
data: []byte{0x44, 0x00, 0x2b, 0x01, 0x64, 0x05, 0xdc, 0x05, 0x00, 0x2d, 0x00, 0x3f, 0x00, 0x00},
37+
expected: &SensorData{
38+
PacketId: 43,
39+
BatteryPercent: 100,
40+
IlluminanceLux: 144192.00,
41+
WindowState: 0,
42+
ButtonEvent: 0,
43+
RotationDegrees: 0,
44+
},
45+
expectedError: "",
46+
},
47+
{
48+
name: "packet id 44, battery 100%, illuminance 13120.00 lux, window 1, button event 0, rotation 0.0",
49+
data: []byte{0x44, 0x00, 0x2c, 0x01, 0x64, 0x05, 0x14, 0x05, 0x00, 0x2d, 0x01, 0x3f, 0x00, 0x00},
50+
expected: &SensorData{
51+
PacketId: 44,
52+
BatteryPercent: 100,
53+
IlluminanceLux: 13120.00,
54+
WindowState: 1,
55+
ButtonEvent: 0,
56+
RotationDegrees: 0,
57+
},
58+
expectedError: "",
59+
},
60+
}
61+
for _, test := range tests {
62+
t.Run(test.name, func(t *testing.T) {
63+
actual, err := Parse(test.data)
64+
if test.expectedError != "" {
65+
assert.EqualError(t, err, test.expectedError)
66+
} else {
67+
assert.NoError(t, err)
68+
assert.Equal(t, test.expected, actual)
69+
}
70+
})
71+
}
72+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package bthome
2+
3+
type SensorData struct {
4+
PacketId byte `json:"packetId,omitempty"`
5+
BatteryPercent byte `json:"batteryPercent,omitempty"`
6+
IlluminanceLux float32 `json:"illuminanceLux,omitempty"`
7+
WindowState byte `json:"windowState,omitempty"`
8+
ButtonEvent uint16 `json:"buttonEvent,omitempty"`
9+
RotationDegrees float32 `json:"rotationDegrees,omitempty"`
10+
}

0 commit comments

Comments
 (0)