| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- // BLE Scanner - scans for BLE advertisements via BlueZ D-Bus and publishes events via ZMQ
- package main
- import (
- "context"
- "encoding/binary"
- "encoding/hex"
- "encoding/json"
- "flag"
- "log"
- "os"
- "os/signal"
- "strings"
- "sync"
- "syscall"
- "time"
- "mybeacon/internal/protocol"
- "github.com/go-zeromq/zmq4"
- "github.com/godbus/dbus/v5"
- )
- const (
- defaultZMQAddr = "tcp://127.0.0.1:5555"
- // D-Bus constants
- bluezBus = "org.bluez"
- adapterInterface = "org.bluez.Adapter1"
- deviceInterface = "org.bluez.Device1"
- objectManager = "org.freedesktop.DBus.ObjectManager"
- propertiesIface = "org.freedesktop.DBus.Properties"
- )
- // DeviceCache stores device properties to avoid D-Bus calls
- type DeviceCache struct {
- mu sync.RWMutex
- devices map[dbus.ObjectPath]*DeviceProps
- }
- type DeviceProps struct {
- Address string
- RSSI int16
- ManufacturerData map[uint16][]byte
- LastSeen time.Time
- }
- func NewDeviceCache() *DeviceCache {
- return &DeviceCache{
- devices: make(map[dbus.ObjectPath]*DeviceProps),
- }
- }
- func (c *DeviceCache) Update(path dbus.ObjectPath, props map[string]dbus.Variant) *DeviceProps {
- c.mu.Lock()
- defer c.mu.Unlock()
- dev, exists := c.devices[path]
- if !exists {
- dev = &DeviceProps{
- ManufacturerData: make(map[uint16][]byte),
- }
- c.devices[path] = dev
- }
- // Update fields from props
- if v, ok := props["Address"]; ok {
- if addr, ok := v.Value().(string); ok {
- dev.Address = addr
- }
- }
- // Extract address from path if not in props (e.g. /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF)
- if dev.Address == "" {
- pathStr := string(path)
- if idx := strings.LastIndex(pathStr, "/dev_"); idx != -1 {
- mac := strings.ReplaceAll(pathStr[idx+5:], "_", ":")
- dev.Address = mac
- }
- }
- if v, ok := props["RSSI"]; ok {
- if rssi, ok := v.Value().(int16); ok {
- dev.RSSI = rssi
- }
- }
- if v, ok := props["ManufacturerData"]; ok {
- if mfgVariant, ok := v.Value().(map[uint16]dbus.Variant); ok {
- // Clear old data - device may send different advert types
- dev.ManufacturerData = make(map[uint16][]byte)
- for companyID, dataVariant := range mfgVariant {
- if data, ok := dataVariant.Value().([]byte); ok {
- dev.ManufacturerData[companyID] = data
- }
- }
- }
- }
- dev.LastSeen = time.Now()
- return dev
- }
- // Cleanup removes stale devices (not seen for 60 seconds)
- func (c *DeviceCache) Cleanup() {
- c.mu.Lock()
- defer c.mu.Unlock()
- threshold := time.Now().Add(-60 * time.Second)
- for path, dev := range c.devices {
- if dev.LastSeen.Before(threshold) {
- delete(c.devices, path)
- }
- }
- }
- var debugMode bool
- func main() {
- var (
- zmqAddr = flag.String("zmq", defaultZMQAddr, "ZMQ PUB address")
- adapter = flag.String("adapter", "hci0", "Bluetooth adapter")
- debug = flag.Bool("debug", false, "Enable debug logging")
- )
- flag.Parse()
- debugMode = *debug
- log.SetFlags(log.Ltime)
- log.Printf("BLE Scanner starting (adapter=%s, zmq=%s)", *adapter, *zmqAddr)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- // Create ZMQ publisher socket
- publisher := zmq4.NewPub(ctx)
- defer publisher.Close()
- if err := publisher.Listen(*zmqAddr); err != nil {
- log.Fatalf("ZMQ listen: %v", err)
- }
- log.Printf("ZMQ PUB listening on %s", *zmqAddr)
- time.Sleep(100 * time.Millisecond)
- // Connect to system D-Bus
- conn, err := dbus.SystemBus()
- if err != nil {
- log.Fatalf("D-Bus connection: %v", err)
- }
- defer conn.Close()
- adapterPath := dbus.ObjectPath("/org/bluez/" + *adapter)
- // Power on adapter
- adapterObj := conn.Object(bluezBus, adapterPath)
- if err := adapterObj.Call(propertiesIface+".Set", 0, adapterInterface, "Powered", dbus.MakeVariant(true)).Err; err != nil {
- log.Printf("Warning: power on adapter: %v", err)
- }
- // Set discovery filter for LE devices
- filter := map[string]interface{}{
- "Transport": "le",
- "DuplicateData": true,
- }
- if err := adapterObj.Call(adapterInterface+".SetDiscoveryFilter", 0, filter).Err; err != nil {
- log.Printf("Warning: set discovery filter: %v", err)
- }
- // Subscribe to InterfacesAdded and PropertiesChanged signals
- if err := conn.AddMatchSignal(
- dbus.WithMatchObjectPath("/"),
- dbus.WithMatchInterface(objectManager),
- dbus.WithMatchMember("InterfacesAdded"),
- ); err != nil {
- log.Fatalf("Add InterfacesAdded match: %v", err)
- }
- if err := conn.AddMatchSignal(
- dbus.WithMatchInterface(propertiesIface),
- dbus.WithMatchMember("PropertiesChanged"),
- ); err != nil {
- log.Fatalf("Add PropertiesChanged match: %v", err)
- }
- // Start discovery
- if err := adapterObj.Call(adapterInterface+".StartDiscovery", 0).Err; err != nil {
- log.Fatalf("Start discovery: %v", err)
- }
- log.Printf("BLE discovery started on %s", *adapter)
- // Handle shutdown
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
- go func() {
- <-sigChan
- log.Println("Shutting down...")
- adapterObj.Call(adapterInterface+".StopDiscovery", 0)
- cancel()
- os.Exit(0)
- }()
- // Device cache for fast property lookup
- cache := NewDeviceCache()
- // Periodic cache cleanup
- go func() {
- ticker := time.NewTicker(30 * time.Second)
- defer ticker.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- cache.Cleanup()
- }
- }
- }()
- // Process D-Bus signals - large buffer for high-traffic environments
- signals := make(chan *dbus.Signal, 10000)
- conn.Signal(signals)
- var eventCount uint64
- for sig := range signals {
- var dev *DeviceProps
- if debugMode {
- log.Printf("Signal: %s path=%s", sig.Name, sig.Path)
- }
- switch sig.Name {
- case objectManager + ".InterfacesAdded":
- if len(sig.Body) < 2 {
- continue
- }
- path, ok := sig.Body[0].(dbus.ObjectPath)
- if !ok {
- continue
- }
- ifaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant)
- if !ok {
- continue
- }
- props, ok := ifaces[deviceInterface]
- if !ok {
- continue
- }
- if debugMode {
- log.Printf("InterfacesAdded: path=%s props=%v", path, props)
- }
- dev = cache.Update(path, props)
- case propertiesIface + ".PropertiesChanged":
- if len(sig.Body) < 2 {
- continue
- }
- iface, ok := sig.Body[0].(string)
- if !ok || iface != deviceInterface {
- continue
- }
- props, ok := sig.Body[1].(map[string]dbus.Variant)
- if !ok {
- continue
- }
- if debugMode {
- log.Printf("PropertiesChanged: path=%s props=%v", sig.Path, props)
- }
- // Update cache with partial props - no D-Bus call needed!
- dev = cache.Update(sig.Path, props)
- }
- if dev == nil || dev.Address == "" || len(dev.ManufacturerData) == 0 {
- continue
- }
- ev := parseDeviceProps(dev)
- if ev == nil {
- continue
- }
- jsonData, err := json.Marshal(ev)
- if err != nil {
- continue
- }
- var topic string
- switch ev.(type) {
- case *protocol.IBeaconEvent:
- topic = "ble.ibeacon"
- case *protocol.AccelEvent:
- topic = "ble.acc"
- case *protocol.RelayEvent:
- topic = "ble.relay"
- default:
- topic = "ble.unknown"
- }
- msg := zmq4.NewMsgString(topic + " " + string(jsonData))
- if err := publisher.Send(msg); err != nil {
- log.Printf("ZMQ send error: %v", err)
- continue
- }
- eventCount++
- if debugMode {
- log.Printf("[%s] %s", topic, string(jsonData))
- } else if eventCount%1000 == 0 {
- log.Printf("[ble-scanner] %d events sent to daemon via ZMQ", eventCount)
- }
- }
- }
- func parseDeviceProps(dev *DeviceProps) interface{} {
- mac := strings.ToLower(strings.ReplaceAll(dev.Address, "-", ":"))
- ts := time.Now().UnixMilli()
- rssi := int(dev.RSSI)
- // Check for iBeacon (Apple company ID 0x004C)
- if data, ok := dev.ManufacturerData[0x004C]; ok {
- if debugMode {
- log.Printf(" Found Apple mfg data: %x", data)
- }
- if ev := parseIBeacon(mac, rssi, ts, data); ev != nil {
- return ev
- }
- }
- // Check for Nordic/custom (0x0059) - my-beacon_acc and rt_mybeacon
- if data, ok := dev.ManufacturerData[0x0059]; ok {
- if debugMode {
- log.Printf(" Found Nordic mfg data: %x", data)
- }
- // Check for acc (0x01 0x15) or relay (0x02 0x15)
- if len(data) >= 2 {
- if data[0] == 0x01 && data[1] == 0x15 {
- if ev := parseAccelBeacon(mac, rssi, ts, data); ev != nil {
- return ev
- }
- } else if data[0] == 0x02 && data[1] == 0x15 {
- if ev := parseRelayBeacon(mac, rssi, ts, data); ev != nil {
- return ev
- }
- }
- }
- }
- return nil
- }
- func parseIBeacon(mac string, rssi int, ts int64, data []byte) *protocol.IBeaconEvent {
- // iBeacon format: 0x02 0x15 [UUID 16 bytes] [Major 2 bytes] [Minor 2 bytes] [TX Power 1 byte]
- if len(data) < 23 || data[0] != 0x02 || data[1] != 0x15 {
- return nil
- }
- uuid := hex.EncodeToString(data[2:18])
- major := binary.BigEndian.Uint16(data[18:20])
- minor := binary.BigEndian.Uint16(data[20:22])
- return &protocol.IBeaconEvent{
- BLEEvent: protocol.BLEEvent{
- Type: protocol.EventIBeacon,
- MAC: mac,
- RSSI: int8(rssi),
- TsMs: ts,
- },
- UUID: uuid,
- Major: major,
- Minor: minor,
- }
- }
- func parseAccelBeacon(mac string, rssi int, ts int64, data []byte) *protocol.AccelEvent {
- // my-beacon_acc format: 0x01 0x15 [x s8] [y s8] [z s8] [bat u8] [temp s8] [ff u8]
- // Caller already verified data[0]=0x01, data[1]=0x15
- if len(data) < 8 {
- return nil
- }
- evType := protocol.EventAccel
- ff := data[7] == 0xff
- if ff {
- evType = protocol.EventAccelFF
- }
- return &protocol.AccelEvent{
- BLEEvent: protocol.BLEEvent{
- Type: evType,
- MAC: mac,
- RSSI: int8(rssi),
- TsMs: ts,
- },
- X: int8(data[2]),
- Y: int8(data[3]),
- Z: int8(data[4]),
- Bat: data[5],
- Temp: int8(data[6]),
- FF: ff,
- }
- }
- func parseRelayBeacon(mac string, rssi int, ts int64, data []byte) *protocol.RelayEvent {
- // rt_mybeacon format: 0x02 0x15 DE AD BE EF [mac 6] [maj 2] [min 2] [rssi 1] [bat 1] [ib_maj 2] [ib_min 2]
- // Caller already verified data[0]=0x02, data[1]=0x15
- // Total: 22 bytes
- if len(data) < 22 {
- return nil
- }
- // Verify DEADBEEF magic
- if data[2] != 0xDE || data[3] != 0xAD || data[4] != 0xBE || data[5] != 0xEF {
- return nil
- }
- origMAC := formatMAC(data[6:12])
- relayMaj := binary.BigEndian.Uint16(data[12:14])
- relayMin := binary.BigEndian.Uint16(data[14:16])
- relayRSSI := int8(data[16])
- relayBat := data[17]
- ibMajor := binary.BigEndian.Uint16(data[18:20])
- ibMinor := binary.BigEndian.Uint16(data[20:22])
- return &protocol.RelayEvent{
- BLEEvent: protocol.BLEEvent{
- Type: protocol.EventRelay,
- MAC: origMAC,
- RSSI: relayRSSI,
- TsMs: ts,
- },
- RelayMAC: mac,
- RelayMaj: relayMaj,
- RelayMin: relayMin,
- RelayRSSI: int8(rssi),
- RelayBat: relayBat,
- IBMajor: ibMajor,
- IBMinor: ibMinor,
- }
- }
- func formatMAC(b []byte) string {
- if len(b) < 6 {
- return ""
- }
- return strings.ToLower(hex.EncodeToString(b[0:1]) + ":" +
- hex.EncodeToString(b[1:2]) + ":" +
- hex.EncodeToString(b[2:3]) + ":" +
- hex.EncodeToString(b[3:4]) + ":" +
- hex.EncodeToString(b[4:5]) + ":" +
- hex.EncodeToString(b[5:6]))
- }
|