Skip to content

Commit

Permalink
Implemented bthome bluetooth protocol
Browse files Browse the repository at this point in the history
  • Loading branch information
UnAfraid committed Dec 28, 2024
1 parent 6d0800c commit 8439fa5
Show file tree
Hide file tree
Showing 11 changed files with 384 additions and 0 deletions.
1 change: 1 addition & 0 deletions device/impl/impls.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ import (
_ "github.com/nikiforov-soft/yasp/device/impl/lywsd03mmc"
_ "github.com/nikiforov-soft/yasp/device/impl/p1p2"
_ "github.com/nikiforov-soft/yasp/device/impl/passthrough"
_ "github.com/nikiforov-soft/yasp/device/impl/shelly"
)
21 changes: 21 additions & 0 deletions device/impl/shelly/sbbt-002c.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shelly

import (
"context"

"github.com/nikiforov-soft/yasp/config"
"github.com/nikiforov-soft/yasp/device"
)

// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/button/
func init() {
err := device.RegisterDevice("SBBT-002C", func(ctx context.Context, config *config.Device) (device.Device, error) {
return &shellyBtDevice{
name: config.Name,
deviceType: config.Type,
}, nil
})
if err != nil {
panic(err)
}
}
21 changes: 21 additions & 0 deletions device/impl/shelly/sbbt-004ceu.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shelly

import (
"context"

"github.com/nikiforov-soft/yasp/config"
"github.com/nikiforov-soft/yasp/device"
)

// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/wall_eu
func init() {
err := device.RegisterDevice("SBBT-004CEU", func(ctx context.Context, config *config.Device) (device.Device, error) {
return &shellyBtDevice{
name: config.Name,
deviceType: config.Type,
}, nil
})
if err != nil {
panic(err)
}
}
21 changes: 21 additions & 0 deletions device/impl/shelly/sbbt-004cus.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shelly

import (
"context"

"github.com/nikiforov-soft/yasp/config"
"github.com/nikiforov-soft/yasp/device"
)

// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/wall_us
func init() {
err := device.RegisterDevice("SBBT-004CUS", func(ctx context.Context, config *config.Device) (device.Device, error) {
return &shellyBtDevice{
name: config.Name,
deviceType: config.Type,
}, nil
})
if err != nil {
panic(err)
}
}
21 changes: 21 additions & 0 deletions device/impl/shelly/sbdw-002c.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shelly

import (
"context"

"github.com/nikiforov-soft/yasp/config"
"github.com/nikiforov-soft/yasp/device"
)

// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/dw/
func init() {
err := device.RegisterDevice("SBDW-002C", func(ctx context.Context, config *config.Device) (device.Device, error) {
return &shellyBtDevice{
name: config.Name,
deviceType: config.Type,
}, nil
})
if err != nil {
panic(err)
}
}
21 changes: 21 additions & 0 deletions device/impl/shelly/sbht-003c.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shelly

import (
"context"

"github.com/nikiforov-soft/yasp/config"
"github.com/nikiforov-soft/yasp/device"
)

// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/ht/
func init() {
err := device.RegisterDevice("SBHT-003C", func(ctx context.Context, config *config.Device) (device.Device, error) {
return &shellyBtDevice{
name: config.Name,
deviceType: config.Type,
}, nil
})
if err != nil {
panic(err)
}
}
21 changes: 21 additions & 0 deletions device/impl/shelly/sbmo-003z.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package shelly

import (
"context"

"github.com/nikiforov-soft/yasp/config"
"github.com/nikiforov-soft/yasp/device"
)

// https://shelly-api-docs.shelly.cloud/docs-ble/Devices/motion
func init() {
err := device.RegisterDevice("SBMO-003Z", func(ctx context.Context, config *config.Device) (device.Device, error) {
return &shellyBtDevice{
name: config.Name,
deviceType: config.Type,
}, nil
})
if err != nil {
panic(err)
}
}
48 changes: 48 additions & 0 deletions device/impl/shelly/shelly_bt_device.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package shelly

import (
"context"
"encoding/hex"
"encoding/json"
"fmt"

"github.com/nikiforov-soft/yasp/device"
"github.com/nikiforov-soft/yasp/device/vendors/bthome"
)

type shellyBtDevice struct {
name string
deviceType string
}

func (sbd *shellyBtDevice) Decode(_ context.Context, data *device.Data) (*device.Data, error) {
sensorData, err := bthome.Parse(data.Data)
if err != nil {
return nil, fmt.Errorf("shelly: failed to parse sensor data: %s - %w", hex.EncodeToString(data.Data), err)
}

result, err := json.Marshal(sensorData)
if err != nil {
return nil, fmt.Errorf("shelly: failed to marshal sensor data: %w", err)
}

properties := make(map[string]interface{}, len(data.Properties)+8)
for k, v := range data.Properties {
properties[k] = v
}
properties["deviceName"] = sbd.name
properties["deviceType"] = sbd.deviceType
properties["batteryPercent"] = sensorData.BatteryPercent
properties["illuminanceLux"] = sensorData.IlluminanceLux
properties["motionState"] = sensorData.MotionState
properties["windowState"] = sensorData.WindowState
properties["humidityPercent"] = sensorData.HumidityPercent
properties["buttonEvent"] = sensorData.ButtonEvent
properties["rotationDegrees"] = sensorData.RotationDegrees
properties["temperatureCelsius"] = sensorData.TemperatureCelsius

return &device.Data{
Data: result,
Properties: properties,
}, nil
}
124 changes: 124 additions & 0 deletions device/vendors/bthome/parser.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package bthome

import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
)

// Parse - Parses bthome encoded data
// https://bthome.io/format/#sensor-data
func Parse(data []byte) (*SensorData, error) {
if len(data) < 3 {
return nil, fmt.Errorf("bthome: invalid data length: %d", len(data))
}

r := bytes.NewReader(data)
header, err := r.ReadByte()
if err != nil {
return nil, fmt.Errorf("bthome: failed to read header id: %w", err)
}
if header != 0x44 {
return nil, fmt.Errorf("bthome: invalid header type: %d", header)
}

var sensorData SensorData
for {
propertyType, err := r.ReadByte()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return nil, fmt.Errorf("bthome: failed to read property type: %d", propertyType)
}

switch propertyType {
case 0x00: // packet id
/**
https://bthome.io/format/#misc-data
* The packet id is optional and can be used to filter duplicate data.
* This allows you to send multiple advertisements that are exactly the same, to improve the chance that the advertisement arrives.
* BTHome receivers should only process the advertisement if the packet id is different compared to the previous one.
* The packet id is a value between 0 (0x00) and 255 (0xFF), and should be increased on every change in data.
* Note that most home automation software already have some other filtering for unchanged data.
* The use of a packet id is therefore often not needed.
**/
packetId, err := r.ReadByte()
if err != nil {
return nil, err
}
sensorData.PacketId = packetId
case 0x01: // battery %
battery, err := r.ReadByte()
if err != nil {
return nil, fmt.Errorf("bthome: failed to read battery: %w", err)
}
sensorData.BatteryPercent = battery
case 0x05: // illuminance lux
illuminance, err := readIlluminance(r)
if err != nil {
return nil, fmt.Errorf("bthome: failed to read illuminance: %w", err)
}
sensorData.IlluminanceLux = illuminance
case 0x21: // motion state
motion, err := r.ReadByte()
if err != nil {
return nil, fmt.Errorf("bthome: failed to read motion state: %w", err)
}
sensorData.MotionState = motion
case 0x2D: // window state
windowState, err := r.ReadByte()
if err != nil {
return nil, fmt.Errorf("bthome: failed to read window state: %w", err)
}
sensorData.WindowState = windowState
case 0x2E: // humidity %
humidity, err := r.ReadByte()
if err != nil {
return nil, fmt.Errorf("bthome: failed to read humidity: %w", err)
}
sensorData.HumidityPercent = humidity
case 0x3A: // button
var buttonEvent uint16
if err := binary.Read(r, binary.BigEndian, &buttonEvent); err != nil {
return nil, fmt.Errorf("bthome: failed to read button event: %w", err)
}
sensorData.ButtonEvent = buttonEvent
case 0x3F: // rotation °
var rotation int16
if err := binary.Read(r, binary.BigEndian, &rotation); err != nil {
return nil, fmt.Errorf("bthome: failed to read rotation: %w", err)
}
sensorData.RotationDegrees = float32(rotation) / 10.
case 0x45: // temperature °C
var temperature int16
if err := binary.Read(r, binary.BigEndian, &temperature); err != nil {
return nil, fmt.Errorf("bthome: failed to read temperature: %w", err)
}
sensorData.TemperatureCelsius = float32(temperature) / 10.
default:
return nil, fmt.Errorf("bthome: unknown property type: %d", propertyType)
}
}

return &sensorData, nil
}

func readIlluminance(r io.Reader) (float32, error) {
var component12 uint16
err := binary.Read(r, binary.BigEndian, &component12)
if err != nil {
return 0, err
}

var component3 byte
err = binary.Read(r, binary.BigEndian, &component3)
if err != nil {
return 0, err
}

illuminance := uint32(component12)<<8 | uint32(component3)
return float32(illuminance) / 100., nil
}
72 changes: 72 additions & 0 deletions device/vendors/bthome/parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package bthome

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestParse(t *testing.T) {
tests := []struct {
name string
data []byte
expected *SensorData
expectedError string
}{
{
name: "invalid length",
data: []byte{0x00},
expected: nil,
expectedError: "bthome: invalid data length: 1",
},
{
name: "invalid header",
data: []byte{0x00, 0x00, 0x00},
expected: nil,
expectedError: "bthome: invalid header type: 0",
},
{
name: "invalid property",
data: []byte{0x44, 0x00, 0x00, 0x98, 0x00},
expected: nil,
expectedError: "bthome: unknown property type: 152",
},
{
name: "packet id 43, battery 100%, illuminance 144192.00 lux, window 0, button event 0, rotation 0.0",
data: []byte{0x44, 0x00, 0x2b, 0x01, 0x64, 0x05, 0xdc, 0x05, 0x00, 0x2d, 0x00, 0x3f, 0x00, 0x00},
expected: &SensorData{
PacketId: 43,
BatteryPercent: 100,
IlluminanceLux: 144192.00,
WindowState: 0,
ButtonEvent: 0,
RotationDegrees: 0,
},
expectedError: "",
},
{
name: "packet id 44, battery 100%, illuminance 13120.00 lux, window 1, button event 0, rotation 0.0",
data: []byte{0x44, 0x00, 0x2c, 0x01, 0x64, 0x05, 0x14, 0x05, 0x00, 0x2d, 0x01, 0x3f, 0x00, 0x00},
expected: &SensorData{
PacketId: 44,
BatteryPercent: 100,
IlluminanceLux: 13120.00,
WindowState: 1,
ButtonEvent: 0,
RotationDegrees: 0,
},
expectedError: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
actual, err := Parse(test.data)
if test.expectedError != "" {
assert.EqualError(t, err, test.expectedError)
} else {
assert.NoError(t, err)
assert.Equal(t, test.expected, actual)
}
})
}
}
13 changes: 13 additions & 0 deletions device/vendors/bthome/sensor_data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package bthome

type SensorData struct {
PacketId byte `json:"packetId,omitempty"`
BatteryPercent byte `json:"batteryPercent,omitempty"`
IlluminanceLux float32 `json:"illuminanceLux,omitempty"`
MotionState byte `json:"motionState,omitempty"`
WindowState byte `json:"windowState,omitempty"`
HumidityPercent byte `json:"humidityPercent,omitempty"`
ButtonEvent uint16 `json:"buttonEvent,omitempty"`
RotationDegrees float32 `json:"rotationDegrees,omitempty"`
TemperatureCelsius float32 `json:"temperatureCelsius,omitempty"`
}

0 comments on commit 8439fa5

Please sign in to comment.