qvest-digital / go-nuki

A go library to control nuki devices - such as Smart Locks - via bluetooth.
MIT License
7 stars 2 forks source link

How to implement callbacks? #5

Open rovo89 opened 1 year ago

rovo89 commented 1 year ago

I'm trying to implement a MQTT service based on this library so I can monitor and control my smart lock via ioBroker.

One key requirement for this is to get informed by the lock that the state has changed, so it would be worth to call ReadStates(). This can be checked via the advertisements (explanation). Based on this example, I was able to implement the following

func main() {
    ...
    chkErr(ble.Scan(ctx, *dup, advHandler, advFilter))
}

var PREFIX, _ = hex.DecodeString("4C000215A92EE200550111E4916C0800200C9A66")
func advFilter(a ble.Advertisement) bool {
    return a.Connectable() && bytes.HasPrefix(a.ManufacturerData(), PREFIX)
}

func advHandler(a ble.Advertisement) {
    md := a.ManufacturerData()
    id := hex.EncodeToString(md[20:24])
    txPower := int8(md[len(md)-1])
    updated := txPower & 1 == 1
    fmt.Printf("%s [%s], id: %s, txPower: %d, updated %t\n", a.Addr(), a.LocalName(), id, txPower, updated)
}

Now the big question is: What could be a good way to add a new library function to subscribe to "state of device x has changed", i,e. updated has changed from false to true? It would involve setting up the scan and running it in the background, providing a callback function, the ability to cancel the scan (which might be necessary to connect, not sure?!?), and probably other things.

Any ideas where to start, which patterns or libraries to use? Keep in mind that waiting for an update shouldn't block the whole process, because at any time there could be an MQTT command to unlock the door.

By the way, this library currently assumes that you already know the device's MAC address. Maybe some functions to discover available devices (using code similar to the above) would be a nice addition.

sschum commented 1 year ago

Hey! I have not yet thought this through in depth. But I think a good approach could be to implements a new component which is responsible for scanning in the background and filter nuki devices (as like your prototype code above). But the only thing this component should do is to inform about state-changes and not reading the state after detecting a change! Why? Possibly a user of this library is only interested in the "it changed something" notification. And if someone is interested in "what changed", they could do an ReadState own his own after informing about the change.

What you would have to check is if you can scan and send/receive commands at the same time. If not you have to save the ble-actions - for example via sync.mutex - (so only one go-routine can use ble-device at the same time).

Here a fix coding "prototype":

import (
    "context"
    "github.com/go-ble/ble"
    "time"
)

type observer struct {
    detected []ble.Advertisement
}

func (o *observer) Start(ctx context.Context, clb func(ble.Advertisement)) {
    for ctx.Err() == nil {
        o.detected = []ble.Advertisement{}

        err := ble.Scan(ctx, false, o.advHandler, o.advFilter)
        if err != nil {
            return
        }

        for _, advertisement := range o.detected {
            clb(advertisement)
        }

        select {
        case <-ctx.Done():
            return
        case <-time.After(1 * time.Second): // sleep some time to prevent permanently scanning
        }
    }
}

func (o *observer) advHandler(a ble.Advertisement) {
    detected := true //do magic if status has changed ...

    if detected {
        o.detected = append(o.detected, a)
    }
}

func (o *observer) advFilter(a ble.Advertisement) bool {
    //check if this is a nuki device
    return true
}

For better usage you can think about to "register" Clients:

o := observer

nukiClient1 := nuki.NewClient(device)
nukiClient1.EstablishConnection(context.Background(), ble.NewAddr("54:D2:AA:BB:CC:DD"))

nukiClient2 := nuki.NewClient(device)
nukiClient2.EstablishConnection(context.Background(), ble.NewAddr("54:D2:EE:FF:00:00"))

o.Observe(nukiClient1, nukiClient2)

In that case the observer can check which type the device is and which address the device has.

PS: Yeah we know that we assume that you know the MAC because in our project at this point we know these MAC and therefore a feature for discovering was not necessary. But for the lib it would be a great idea! :)

rovo89 commented 1 year ago

Thanks for your suggestions! :-)

But the only thing this component should do is to inform about state-changes and not reading the state after detecting a change!

Yes, that was my thought as well. Maybe at a later point there could be a helper for the standard case where a script reacts on the "state changed" event by reading the latest state and doing something with it.

What you would have to check is if you can scan and send/receive commands at the same time.

Just did a test. Empirical observation, which might just be true for my device (Intel NUC 11 Pro Kit with integrated AX201): I can run start a scan with long timeout in background (by putting the ble.Scan() in a function and executing it using go scan()), then sleep for 3 seconds and establish a connection to read the state (or the log stream). Scan results from other devices keep coming in while the read action is still running. I didn't see advertisements from the Nuki while I was connected (most obvious if I start the scan after the nukiClient.EstablishConnection(), but they come in immediately when I can call nukiClient.Close(). I read somewhere that it the newer locks (which can handle multiple connections) should keep sending advertisements, but maybe the receiver ignores them while being connected.

So your example wouldn't work exactly like that, but it's anyway not desired to keep the connection established all the time or it would drain the battery. Instead the connection should be established in the callback and it should be closed once the state has been read. It would be a bit easier if the device address was passed to nuki.NewClient() rather than nukiClient.EstablishConnection()... but have to check further on that.

rovo89 commented 1 year ago

Still WIP, but this is simple to use and working fine: https://github.com/rovo89/go-nuki/commits/monitor

In my test callback, I just establish a connection (authentication details saved before starting to monitor), read the states and close. I could also read the latest log entry to get more information, but that won't reset the flag in the advertisement.