Skip to content

Commit e84e456

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

12 files changed

Lines changed: 424 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+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package bthome
2+
3+
type CapabilityFlags struct {
4+
Encryption bool `json:"encryption,omitempty"`
5+
TriggerBasedDevice bool `json:"triggerBasedDevice,omitempty"`
6+
Version byte `json:"version,omitempty"`
7+
}

device/vendors/bthome/parser.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package bthome
2+
3+
import (
4+
"bytes"
5+
"encoding/binary"
6+
"errors"
7+
"fmt"
8+
"io"
9+
)
10+
11+
const (
12+
encryptionFlag = 1 << 0
13+
triggerBasedDeviceFlag = 1 << 2
14+
versionFlag = (1 << 3) - 1
15+
)
16+
17+
// Parse - Parses bthome encoded data
18+
// https://bthome.io/format/#sensor-data
19+
func Parse(data []byte) (*SensorData, error) {
20+
if len(data) < 3 {
21+
return nil, fmt.Errorf("bthome: invalid data length: %d", len(data))
22+
}
23+
24+
r := bytes.NewReader(data)
25+
header, err := r.ReadByte()
26+
if err != nil {
27+
return nil, fmt.Errorf("bthome: failed to read header id: %w", err)
28+
}
29+
capabilityFlags := readCapabilities(header)
30+
if capabilityFlags.Version != 2 {
31+
return nil, fmt.Errorf("bthome: unsupported bthome version: %d", capabilityFlags.Version)
32+
}
33+
34+
var sensorData SensorData
35+
sensorData.CapabilityFlags = capabilityFlags
36+
for {
37+
propertyType, err := r.ReadByte()
38+
if err != nil {
39+
if errors.Is(err, io.EOF) {
40+
break
41+
}
42+
return nil, fmt.Errorf("bthome: failed to read property type: %d", propertyType)
43+
}
44+
45+
switch propertyType {
46+
case 0x00: // packet id
47+
/**
48+
* https://bthome.io/format/#misc-data
49+
* The packet id is optional and can be used to filter duplicate data.
50+
* This allows you to send multiple advertisements that are exactly the same, to improve the chance that the advertisement arrives.
51+
* BTHome receivers should only process the advertisement if the packet id is different compared to the previous one.
52+
* The packet id is a value between 0 (0x00) and 255 (0xFF), and should be increased on every change in data.
53+
* Note that most home automation software already have some other filtering for unchanged data.
54+
* The use of a packet id is therefore often not needed.
55+
**/
56+
packetId, err := r.ReadByte()
57+
if err != nil {
58+
return nil, err
59+
}
60+
sensorData.PacketId = packetId
61+
case 0x01: // battery %
62+
battery, err := r.ReadByte()
63+
if err != nil {
64+
return nil, fmt.Errorf("bthome: failed to read battery: %w", err)
65+
}
66+
sensorData.BatteryPercent = battery
67+
case 0x05: // illuminance lux
68+
illuminance, err := readIlluminance(r)
69+
if err != nil {
70+
return nil, fmt.Errorf("bthome: failed to read illuminance: %w", err)
71+
}
72+
sensorData.IlluminanceLux = illuminance
73+
case 0x21: // motion state
74+
motion, err := r.ReadByte()
75+
if err != nil {
76+
return nil, fmt.Errorf("bthome: failed to read motion state: %w", err)
77+
}
78+
sensorData.MotionState = motion
79+
case 0x2D: // window state
80+
windowState, err := r.ReadByte()
81+
if err != nil {
82+
return nil, fmt.Errorf("bthome: failed to read window state: %w", err)
83+
}
84+
sensorData.WindowState = windowState
85+
case 0x2E: // humidity %
86+
humidity, err := r.ReadByte()
87+
if err != nil {
88+
return nil, fmt.Errorf("bthome: failed to read humidity: %w", err)
89+
}
90+
sensorData.HumidityPercent = humidity
91+
case 0x3A: // button
92+
var buttonEvent uint16
93+
if err := binary.Read(r, binary.BigEndian, &buttonEvent); err != nil {
94+
return nil, fmt.Errorf("bthome: failed to read button event: %w", err)
95+
}
96+
sensorData.ButtonEvent = buttonEvent
97+
case 0x3F: // rotation °
98+
var rotation int16
99+
if err := binary.Read(r, binary.BigEndian, &rotation); err != nil {
100+
return nil, fmt.Errorf("bthome: failed to read rotation: %w", err)
101+
}
102+
sensorData.RotationDegrees = float32(rotation) / 10.
103+
case 0x45: // temperature °C
104+
var temperature int16
105+
if err := binary.Read(r, binary.BigEndian, &temperature); err != nil {
106+
return nil, fmt.Errorf("bthome: failed to read temperature: %w", err)
107+
}
108+
sensorData.TemperatureCelsius = float32(temperature) / 10.
109+
default:
110+
return nil, fmt.Errorf("bthome: unknown property type: %d", propertyType)
111+
}
112+
}
113+
114+
return &sensorData, nil
115+
}
116+
117+
func readCapabilities(capabilityFlags byte) CapabilityFlags {
118+
encryption := (capabilityFlags & encryptionFlag) != 0
119+
triggerBasedDevice := (capabilityFlags & triggerBasedDeviceFlag) != 0
120+
version := (capabilityFlags >> 5) & versionFlag
121+
return CapabilityFlags{
122+
Encryption: encryption,
123+
TriggerBasedDevice: triggerBasedDevice,
124+
Version: version,
125+
}
126+
}
127+
128+
func readIlluminance(r io.Reader) (float32, error) {
129+
var component12 uint16
130+
err := binary.Read(r, binary.BigEndian, &component12)
131+
if err != nil {
132+
return 0, err
133+
}
134+
135+
var component3 byte
136+
err = binary.Read(r, binary.BigEndian, &component3)
137+
if err != nil {
138+
return 0, err
139+
}
140+
141+
illuminance := uint32(component12)<<8 | uint32(component3)
142+
return float32(illuminance) / 100., nil
143+
}

0 commit comments

Comments
 (0)