From 85434230d0f0d6dbec0c1b6363f2a60c4fc85860 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 27 Apr 2024 10:38:07 +0100 Subject: [PATCH] add victron decoder --- ESPBMS.ino | 7 ++ victron.ino | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 victron.ino diff --git a/ESPBMS.ino b/ESPBMS.ino index 664a781..854a306 100644 --- a/ESPBMS.ino +++ b/ESPBMS.ino @@ -40,6 +40,13 @@ void loop() { BLEAdvertisedDevice advertisedDevice = foundDevices.getDevice(i); Serial.println("\nFound Device: " + String(advertisedDevice.toString().c_str())); + std::string targetAddress = "d0:65:de:e5:89:76"; + if (advertisedDevice.getAddress().toString() == targetAddress) { + Serial.println("Victron device found!"); + decodeVictron(advertisedDevice); + break; + } + if(!advertisedDevice.isAdvertisingService(serviceUUID)) { Serial.println("Device does not advertise the specified service UUID."); continue; diff --git a/victron.ino b/victron.ino new file mode 100644 index 0000000..e1f8406 --- /dev/null +++ b/victron.ino @@ -0,0 +1,195 @@ +// credit to: https://github.com/hoberman/Victron_BLE_Advertising_example + +#include // AES library for decrypting the Victron manufacturer data. + +uint8_t key[16]={0xd7,0x93,0xc8,0xb1,0x79,0x18,0x4c,0x97,0x97,0x6d,0x12,0x27,0xf2,0x23,0x48,0x6b}; + +int keyBits=128; // Number of bits for AES-CTR decrypt. + +// Victron docs on the manufacturer data in advertisement packets can be found at: +// https://community.victronenergy.com/storage/attachments/48745-extra-manufacturer-data-2022-12-14.pdf +// + +// Usage/style note: I use uint16_t in places where I need to force 16-bit unsigned integers +// instead of whatever the compiler/architecture might decide to use. I might not need to do +// the same with byte variables, but I'll do it anyway just to be at least a little consistent. + +// Must use the "packed" attribute to make sure the compiler doesn't add any padding to deal with +// word alignment. +typedef struct { + uint16_t vendorID; // vendor ID + uint8_t beaconType; // Should be 0x10 (Product Advertisement) for the packets we want + uint8_t unknownData1[3]; // Unknown data + uint8_t victronRecordType; // Should be 0x01 (Solar Charger) for the packets we want + uint16_t nonceDataCounter; // Nonce + uint8_t encryptKeyMatch; // Should match pre-shared encryption key byte 0 + uint8_t victronEncryptedData[21]; // (31 bytes max per BLE spec - size of previous elements) + uint8_t nullPad; // extra byte because toCharArray() adds a \0 byte. +} __attribute__((packed)) victronManufacturerData; + + +// Must use the "packed" attribute to make sure the compiler doesn't add any padding to deal with +// word alignment. +typedef struct { + uint8_t deviceState; + uint8_t errorCode; + int16_t batteryVoltage; + int16_t batteryCurrent; + uint16_t todayYield; + uint16_t inputPower; + uint8_t outputCurrentLo; // Low 8 bits of output current (in 0.1 Amp increments) + uint8_t outputCurrentHi; // High 1 bit of output current (must mask off unused bits) + uint8_t unused[4]; // Not currently used by Vistron, but it could be in the future. +} __attribute__((packed)) victronPanelData; + +// FYI, here are Device State values. I haven't seen ones with '???' so I don't know +// if they exist or not or what they might mean: +// 0 = no charge from solar +// 1 = ??? +// 2 = ??? +// 3 = bulk charge +// 4 = absorption charge +// 5 = float +// 6 = ??? +// 7 = equalization +// I've also seen a value '245' for about a second when my solar panel (simulated by a +// benchtop power supply) transitions from off/low voltage to on/higher voltage. There +// be others, but I haven't seen them. + +void decodeVictron(BLEAdvertisedDevice advertisedDevice) { + + #define manDataSizeMax 31 // BLE specs say no more than 31 bytes, but see comments below! + + // See if we have manufacturer data and then look to see if it's coming from a Victron device. + if (advertisedDevice.haveManufacturerData() == true) { + + // Note: This comment (and maybe some code?) needs to be adjusted so it's not so + // specific to String-vs-std:string. I'll leave it as-is for now so you at least + // understand why I have an extra byte added to the manCharBuf array. + // + // Here's the thing: BLE specs say our manufacturer data can be a max of 31 bytes. + // But: The library code puts this data into a String, which we will then copy to + // a character (i.e., byte) buffer using String.toCharArray(). Assuming we have the + // full 31 bytes of manufacturer data allowed by the BLE spec, we'll need to size our + // buffer with an extra byte for a null terminator. Our toCharArray() call will need + // to specify *32* bytes so it will copy 31 bytes of data with a null terminator + // at the end. + uint8_t manCharBuf[manDataSizeMax+1]; + + #ifdef USE_String + String manData = advertisedDevice.getManufacturerData(); // lib code returns String. + #else + std::string manData = advertisedDevice.getManufacturerData(); // lib code returns std::string + #endif + int manDataSize=manData.length(); // This does not count the null at the end. + + // Copy the data from the String to a byte array. Must have the +1 so we + // don't lose the last character to the null terminator. + #ifdef USE_String + manData.toCharArray((char *)manCharBuf,manDataSize+1); + #else + manData.copy((char *)manCharBuf, manDataSize+1); + #endif + + // Now let's setup a pointer to a struct to get to the data more cleanly. + victronManufacturerData * vicData=(victronManufacturerData *)manCharBuf; + + // ignore this packet if the Vendor ID isn't Victron. + if (vicData->vendorID!=0x02e1) { + return; + } + + // ignore this packet if it isn't type 0x01 (Solar Charger). + if (vicData->victronRecordType != 0x01) { + return; + } + + // Not all packets contain a device name, so if we get one we'll save it and use it from now on. + if (advertisedDevice.haveName()) { + // This works the same whether getName() returns String or std::string. + strcpy(currentName,advertisedDevice.getName().c_str()); + } + + if (vicData->encryptKeyMatch != key[0]) { + Serial.printf("Packet encryption key byte 0x%2.2x doesn't match configured key[0] byte 0x%2.2x\n", + vicData->encryptKeyMatch, key[0]); + return; + } + + uint8_t inputData[16]; + uint8_t outputData[16]={0}; // i don't really need to initialize the output. + + // The number of encrypted bytes is given by the number of bytes in the manufacturer + // data as a whole minus the number of bytes (10) in the header part of the data. + int encrDataSize=manDataSize-10; + for (int i=0; ivictronEncryptedData[i]; // copy for our decrypt below while I figure this out. + } + + esp_aes_context ctx; + esp_aes_init(&ctx); + + auto status = esp_aes_setkey(&ctx, key, keyBits); + if (status != 0) { + Serial.printf(" Error during esp_aes_setkey operation (%i).\n",status); + esp_aes_free(&ctx); + return; + } + + // construct the 16-byte nonce counter array by piecing it together byte-by-byte. + uint8_t data_counter_lsb=(vicData->nonceDataCounter) & 0xff; + uint8_t data_counter_msb=((vicData->nonceDataCounter) >> 8) & 0xff; + u_int8_t nonce_counter[16] = {data_counter_lsb, data_counter_msb, 0}; + + u_int8_t stream_block[16] = {0}; + + size_t nonce_offset=0; + status = esp_aes_crypt_ctr(&ctx, encrDataSize, &nonce_offset, nonce_counter, stream_block, inputData, outputData); + if (status != 0) { + Serial.printf("Error during esp_aes_crypt_ctr operation (%i).",status); + esp_aes_free(&ctx); + return; + } + esp_aes_free(&ctx); + + // Now do our same struct magic so we can get to the data more easily. + victronPanelData * victronData = (victronPanelData *) outputData; + + // Getting to these elements is easier using the struct instead of + // hacking around with outputData[x] references. + uint8_t deviceState=victronData->deviceState; + uint8_t errorCode=victronData->errorCode; + float batteryVoltage=float(victronData->batteryVoltage)*0.01; + float batteryCurrent=float(victronData->batteryCurrent)*0.1; + float todayYield=float(victronData->todayYield)*0.01*1000; + uint16_t inputPower=victronData->inputPower; // this is in watts; no conversion needed + + // Getting the output current takes some magic because of the way they have the + // 9-bit value packed into two bytes. The first byte has the low 8 bits of the count + // and the second byte has the upper (most significant) bit of the 9-bit value plus some + // There's some other junk in the remaining 7 bits - i'm not sure if it's useful for + // anything else but we can't use it here! - so we will mask them off. Then combine the + // two bye components to get an integer value in 0.1 Amp increments. + int integerOutputCurrent=((victronData->outputCurrentHi & 0x01)<<9) | victronData->outputCurrentLo; + float outputCurrent=float(integerOutputCurrent)*0.1; + + // I don't know why, but every so often we'll get half-corrupted data from the Victron. As + // far as I can tell it's not a decryption issue because we (usually) get voltage data that + // agrees with non-corrupted records. + // + // Towards the goal of filtering out this noise, I've found that I've rarely (or never) seen + // corrupted data when the 'unused' bits of the outputCurrent MSB equal 0xfe. We'll use this + // as a litmus test here. + uint8_t unusedBits=victronData->outputCurrentHi & 0xfe; + if (unusedBits != 0xfe) { + return; + } + + Serial.printf("%-31s Battery: %6.2f Volts %6.2f Amps Solar: %6d Watts Yield: %6.0f Wh Load: %6.1f Amps State: %3d\n", + currentName, + batteryVoltage, batteryCurrent, + inputPower, todayYield, + outputCurrent, deviceState + ); + } +}