Skip to content

Commit b65bd74

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

11 files changed

Lines changed: 401 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/sbbt-002c.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package shelly
2+
3+
import (
4+
"context"
5+
6+
"github.com/nikiforov-soft/yasp/config"
7+
"github.com/nikiforov-soft/yasp/device"
8+
)
9+
10+
// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/button/
11+
func init() {
12+
err := device.RegisterDevice("SBBT-002C", func(ctx context.Context, config *config.Device) (device.Device, error) {
13+
return &shellyBtDevice{
14+
name: config.Name,
15+
deviceType: config.Type,
16+
}, nil
17+
})
18+
if err != nil {
19+
panic(err)
20+
}
21+
}

device/impl/shelly/sbbt-004ceu.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package shelly
2+
3+
import (
4+
"context"
5+
6+
"github.com/nikiforov-soft/yasp/config"
7+
"github.com/nikiforov-soft/yasp/device"
8+
)
9+
10+
// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/wall_eu
11+
func init() {
12+
err := device.RegisterDevice("SBBT-004CEU", func(ctx context.Context, config *config.Device) (device.Device, error) {
13+
return &shellyBtDevice{
14+
name: config.Name,
15+
deviceType: config.Type,
16+
}, nil
17+
})
18+
if err != nil {
19+
panic(err)
20+
}
21+
}

device/impl/shelly/sbbt-004cus.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package shelly
2+
3+
import (
4+
"context"
5+
6+
"github.com/nikiforov-soft/yasp/config"
7+
"github.com/nikiforov-soft/yasp/device"
8+
)
9+
10+
// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/wall_us
11+
func init() {
12+
err := device.RegisterDevice("SBBT-004CUS", func(ctx context.Context, config *config.Device) (device.Device, error) {
13+
return &shellyBtDevice{
14+
name: config.Name,
15+
deviceType: config.Type,
16+
}, nil
17+
})
18+
if err != nil {
19+
panic(err)
20+
}
21+
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package shelly
2+
3+
import (
4+
"context"
5+
6+
"github.com/nikiforov-soft/yasp/config"
7+
"github.com/nikiforov-soft/yasp/device"
8+
)
9+
10+
// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/dw/
11+
func init() {
12+
err := device.RegisterDevice("SBDW-002C", func(ctx context.Context, config *config.Device) (device.Device, error) {
13+
return &shellyBtDevice{
14+
name: config.Name,
15+
deviceType: config.Type,
16+
}, nil
17+
})
18+
if err != nil {
19+
panic(err)
20+
}
21+
}

device/impl/shelly/sbht-003c.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package shelly
2+
3+
import (
4+
"context"
5+
6+
"github.com/nikiforov-soft/yasp/config"
7+
"github.com/nikiforov-soft/yasp/device"
8+
)
9+
10+
// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/ht/
11+
func init() {
12+
err := device.RegisterDevice("SBHT-003C", func(ctx context.Context, config *config.Device) (device.Device, error) {
13+
return &shellyBtDevice{
14+
name: config.Name,
15+
deviceType: config.Type,
16+
}, nil
17+
})
18+
if err != nil {
19+
panic(err)
20+
}
21+
}

device/impl/shelly/sbmo-003z.go

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

device/vendors/bthome/parser.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 0x21: // motion state
83+
motion, err := r.ReadByte()
84+
if err != nil {
85+
return nil, fmt.Errorf("bthome: failed to read motion state: %w", err)
86+
}
87+
sensorData.MotionState = motion
88+
case 0x2D: // window state
89+
windowState, err := r.ReadByte()
90+
if err != nil {
91+
return nil, fmt.Errorf("bthome: failed to read window state: %w", err)
92+
}
93+
sensorData.WindowState = windowState
94+
case 0x2E: // humidity %
95+
humidity, err := r.ReadByte()
96+
if err != nil {
97+
return nil, fmt.Errorf("bthome: failed to read humidity: %w", err)
98+
}
99+
sensorData.HumidityPercent = humidity
100+
case 0x3A: // button
101+
var buttonEvent uint16
102+
if err := binary.Read(r, binary.BigEndian, &buttonEvent); err != nil {
103+
return nil, fmt.Errorf("bthome: failed to read button event: %w", err)
104+
}
105+
sensorData.ButtonEvent = buttonEvent
106+
case 0x3F: // rotation °
107+
var rotation int16
108+
if err := binary.Read(r, binary.BigEndian, &rotation); err != nil {
109+
return nil, fmt.Errorf("bthome: failed to read rotation: %w", err)
110+
}
111+
sensorData.RotationDegrees = float32(rotation) / 10.
112+
case 0x45: // temperature °C
113+
var temperature int16
114+
if err := binary.Read(r, binary.BigEndian, &temperature); err != nil {
115+
return nil, fmt.Errorf("bthome: failed to read temperature: %w", err)
116+
}
117+
sensorData.TemperatureCelsius = float32(temperature) / 10.
118+
default:
119+
return nil, fmt.Errorf("bthome: unknown property type: %d", propertyType)
120+
}
121+
}
122+
123+
return &sensorData, nil
124+
}
125+
126+
func readIlluminance(r io.Reader) (float32, error) {
127+
var component12 uint16
128+
err := binary.Read(r, binary.BigEndian, &component12)
129+
if err != nil {
130+
return 0, err
131+
}
132+
133+
var component3 byte
134+
err = binary.Read(r, binary.BigEndian, &component3)
135+
if err != nil {
136+
return 0, err
137+
}
138+
139+
illuminance := uint32(component12)<<8 | uint32(component3)
140+
return float32(illuminance) / 100., nil
141+
}

0 commit comments

Comments
 (0)