// 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])) }