rhyst / linak-controller

A Python script to control Linak standing desks.
MIT License
353 stars 51 forks source link

DPG1C Support #32

Closed linusbierhoff closed 10 months ago

linusbierhoff commented 2 years ago

Hi! I am not able to move my desk. When I try to run e.g. idasen-controller --sit my raspberry pi connects and gets the right height but nothing happens:

Connected D7:78:43:EE:CF:34 Height: 885mm Timed out while waiting for desk Final height: 885mm (Target: 683mm) Disconnected

Is there anything I need to be aware of when using the script on a raspberry pi or with a linak desk?

LuisDiazUgena commented 1 year ago

@mensfeld I have donwloaded the nordic nrf connect (any other ble scanner would work) and with that it's easy to get all the services for bluetooth. Maybe that is something that we can use?

mensfeld commented 1 year ago

@LuisDiazUgena I have no idea how that works. I tried understanding the dumps from wireshark and I know, that there is an update that needs to be sent to the desk once in a while to "wake it" or unlock etc.

At the moment I gave up but I would love to have it working. It drives me crazy that I cannot automate desk journey through the day.

LuisDiazUgena commented 1 year ago

connecting to the desk in linux also output the whole list of characteristics. Those are here: https://pastebin.com/mYWYu0ub

Also, I;m trying to make a workaround trying to reset the docker container every half hour or something like that. Maybe that works. will keep this updated as I found out things.

mensfeld commented 1 year ago

@LuisDiazUgena For me, the problem was not the characteristics but the usage of some of them. I mean one that is responsible for writing a name update/anything else that would just "wake" it up. The rest of the commands are pretty well-defined.

rhyst commented 1 year ago

I had another scan through the decompiled app to see if I could find anything to do with initialisation.

I found this function in com.linak.deskcontrol/sources/com/linak/sdk/connect/RxConnectionManager.java:

    /* access modifiers changed from: private */
    public final Completable setUpDevice(Device device, RxBleConnection rxBleConnection, DpgClient dpgClient) {
        Iterable<DpgCommand.ControlCommand> listOf = CollectionsKt.listOf(DpgCommand.ControlCommand.GET_CAPABILITIES,
                DpgCommand.ControlCommand.USER_ID, DpgCommand.ControlCommand.DESK_OFFSET,
                DpgCommand.ControlCommand.REMINDER_SETTING, DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_1,
                DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_2,
                DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_3,
                DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_4);
        Collection arrayList = new ArrayList(CollectionsKt.collectionSizeOrDefault(listOf, 10));
        for (DpgCommand.ControlCommand readCommand : listOf) {
            arrayList.add(DpgCommand.readCommand(readCommand));
        }
        List list = (List) arrayList;
        Completable doOnComplete = Completable
                .mergeArray(Observable.fromIterable(list)
                        .flatMapSingle(new RxConnectionManager$setUpDevice$dpgSetupCompletable$1(dpgClient))
                        .take((long) list.size()).ignoreElements()
                        .doOnComplete(RxConnectionManager$setUpDevice$dpgSetupCompletable$2.INSTANCE),
                        rxBleConnection
                                .readCharacteristic(LinakServices.Characteristic.GenericAccess.DEVICE_NAME.uuid())
                                .doOnSuccess(new RxConnectionManager$setUpDevice$nameReadCompletable$1(device))
                                .ignoreElement().retry(3),
                        rxBleConnection.readCharacteristic(LinakServices.Characteristic.ReferenceOutput.ONE.uuid())
                                .doOnSuccess(new RxConnectionManager$setUpDevice$referenceZeroReadCompletable$1(device))
                                .ignoreElement().retry(3).onErrorComplete())
                .doOnComplete(new RxConnectionManager$setUpDevice$1(device));
        Intrinsics.checkExpressionValueIsNotNull(doOnComplete, "Completable.mergeArray(d…anged()\n                }");
        return doOnComplete;
    }

which I guess is taking the list of commands and running them:

DpgCommand.ControlCommand.GET_CAPABILITIES,
DpgCommand.ControlCommand.USER_ID, 
DpgCommand.ControlCommand.DESK_OFFSET,
DpgCommand.ControlCommand.REMINDER_SETTING,
DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_1,
DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_2,
DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_3,
DpgCommand.ControlCommand.GET_SET_MEMORY_POSITION_4

This matches with what I found from that other repo earlier in the thread.

Another guess is that then the response is picked up in com.linak.deskcontrol/sources/com/linak/sdk/models/device/Dpg.java by a funciton called handleChangeDPG but as far as I can see this is just updating the app state and does not send further commands.

So it really seems like the only thing that the android app does is send that list of commands. Perhaps we just haven't found the correct byte encoding or something like that?

Trying to follow the code above it seems like it takes each command constant and runs:

DpgCommand.readCommand(readCommand));

This looks like it simply wraps the command constant in some other bytes:

    public static DpgCommand readCommand(ControlCommand controlCommand) {
        return new DpgCommand(new byte[]{ByteCompanionObject.MAX_VALUE, controlCommand.code, 0});
    }

It looks like ByteCompanionObject.MAX_VALUE is just java.lang.Byte.MAX_VALUE which is 127.

So for examples GET_CAPABILITIES has a value of 128 which I think wraps round to -128 then it seems like this creates a byte object new byte[]{127, -128,0} as the actual command. In Python that becomes:

>>> bytearray([127,128,0])
bytearray(b'\x7f\x80\x00')

(Python seems to want 128 and not -128).

This matches the command I asked someone to send earlier.

Long winded way to say I think the commands are correct BUT looking at this now I think they should be sent to UUID_DPG. Can someone modify the wakeup function to be:

async def wakeUp(client):
    await client.write_gatt_char(UUID_DPG, b"\x7F\x80\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x86\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x81\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x88\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x89\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x8a\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x8b\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x8c\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x8A\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x8B\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x8C\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x86\x80\x10\xe5\xc0\xca\xd8\xbe\xc4\x48\xe1\xa0\x80\x3e\x56\xf8\xd4\xcf\xca")
    await client.write_gatt_char(UUID_COMMAND, COMMAND_WAKEUP)

And try again? Note that COMMAND_WAKEUP I think does need to be on UUID_COMMAND as in the decompiled app it is a LinCommand not a DpgCommand. Also it may not be needed at all.

mensfeld commented 1 year ago

@rhyst nope :( still does not wake up with the suggested wakeup

LuisDiazUgena commented 1 year ago

Hi guys,

I have been working on a different via to be able to use the desk with HA. Basically I have used a existing project that wraps the idasen controller on a docker enviroment in nodejs and added some logic and new mqtt topics in order to be able to control it using HA. I have created a PR to original repo and the code is in my fork meanwhile.

Basically I have been able to run flawleslly the server for a few days now without any issue.

Moving the desk from HA works, but for me it feel weird. I always move from stored heigth 1 to stored heigth 2 and viceversa, so I double tap the controller and I'm ready. Also, triggering the movement from the HA buttons have always been weird, because the movement has stopped several times before reaching the desired heigth.

mensfeld commented 1 year ago

I always move from stored heigth 1 to stored heigth 2 and vice-versa

Maybe this is something we should pursue here.

kaml123 commented 10 months ago

Hi all,

Please check if this will work for you:

async def wakeUp(client):
    await client.write_gatt_char(UUID_DPG, b"\x7F\x86\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x86\x80\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11")
    await client.write_gatt_char(UUID_COMMAND, COMMAND_WAKEUP)
beckerhe commented 10 months ago

Hi all,

Please check if this will work for you:

async def wakeUp(client):
    await client.write_gatt_char(UUID_DPG, b"\x7F\x86\x00")
    await client.write_gatt_char(UUID_DPG, b"\x7F\x86\x80\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0A\x0B\x0C\x0D\x0E\x0F\x10\x11")
    await client.write_gatt_char(UUID_COMMAND, COMMAND_WAKEUP)

Yes! That works for me. Thank you for sharing!

rhyst commented 10 months ago

That's great! Thank you @kaml123 , do you have an explanation for what it's doing? I will confirm it works with idasen desk and add it in (unless you want to make a PR 🙂)

kaml123 commented 10 months ago

Hi @rhyst, I also had a problem with DGC1C and started experimenting based on logs. From that experiments I noticed that write 0x7F 0x86 0x00 to DPG characteristic will result an response with current USER_ID set in DPG1C. In my case I got: \x01\x11\x00\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\0x0d\x0e\x0f\x10\x11 Analyzing response I got:

- byte[0] - status, 1 - OK, else ERROR
- byte[1] - length in bytes
- byte[2-18] - USER_ID

Please look at byte[2] which is the only one that differs between the one read and the one that is written. I think byte[2] determine if controller will handle movement or not. Please remember that this USER_ID will probably be written to the EEPROM inside DPG1C controller, so often write will not be a good idea. The best idea will be to read USER_ID and check if byte[2] = 1 if not then write a proper USER_ID. This change should also work with the Idasen desk. Please prepare a fix.

mensfeld commented 10 months ago

@kaml123 works for me as well so far. Amazing! :pray:

rhyst commented 10 months ago

I have published 2.1.0.dev which attempts to set a user ID after connecting which is what @kaml123 suggestion does I think. You can specify the user id in your config file or as a CLI option but I don't think there's a reason to change it from the default.

Can someone let me know if this version works on their DPG1C?

kaml123 commented 10 months ago

Hi @rhyst, Great job. I think we don't need to store user_id in config file. Please check #69 with my slightly modification. I also added error handling for DPG and ability to get base height from controller when it is set to 0 in config file.

voruti commented 10 months ago

Hi, I'm getting

Traceback (most recent call last):
  File "idasen_controller/main.py", line 10, in <module>
    from .config import config, Commands
ImportError: attempted relative import with no known parent package

when running poetry run idasen_controller/main.py --config config.yaml --server from the project root and know Python too little to understand what's wrong.

rhyst commented 10 months ago

@voruti ah because of the relative imports you need to run it like:

poetry run python -m idasen_controller.main --config config.yaml --server 

I also know Python too little to know why this is needed 😆

voruti commented 10 months ago

It looks like Python 3.8 isn't supported: https://peps.python.org/pep-0604/ in line https://github.com/rhyst/idasen-controller/blob/master/idasen_controller/gatt.py#L80 which you wrote in the https://github.com/rhyst/idasen-controller/blob/master/CHANGELOG.md?plain=1#L13.

I switched to Python 3.10

voruti commented 10 months ago
Traceback (most recent call last):
  File "/app/idasen_controller/main.py", line 210, in main
    await run_command(client)
  File "/app/idasen_controller/main.py", line 97, in run_command
    await Desk.move_to(client, target)
  File "/app/idasen_controller/desk.py", line 47, in move_to
    await ControlService.wakeup(client)
AttributeError: type object 'ControlService' has no attribute 'wakeup'

~https://github.com/rhyst/idasen-controller/blob/master/idasen_controller/desk.py#L47 but there is no https://github.com/rhyst/idasen-controller/blob/master/idasen_controller/gatt.py#L148~

~What am I missing?~

Fixed by https://github.com/rhyst/idasen-controller/pull/69

voruti commented 10 months ago

Can someone let me know if this version works on their DPG1C?

As I stated above https://github.com/rhyst/idasen-controller/issues/32#issuecomment-1033941195 I have the DPG1M. It's working for me quite well now.

rhyst commented 10 months ago

Released 2.1.0 with these changes so closing this issue 🥳

mensfeld commented 10 months ago

Works like a charm! :)