v0.7.0: pure C core + Zephyr module

- Extract all decode/decrypt into a dependency-free C99 core
 (include/victronble.h, src/victronble_core.c): victronble_decode(),
 is_product_adv/key_matches pre-filters, NAN sentinels, LE accessors.
- AES-128-CTR behind a hook: weak-symbol bundled tiny-AES default,
 runtime override (victronble_set_aes_ctr) for PSA/mbedTLS/hardware.
- Arduino VictronBLE class becomes a thin wrapper over the core
 (registry + nonce dedup + rate limit); public C++ API unchanged,
 NAN converted back to the legacy 0 convention.
- Host test vectors (tests/vectors): openssl-generated ciphertext,
 independent of the bundled AES; all five payload shapes + negatives.
- Zephyr module: zephyr/module.yml + Kconfig (CONFIG_VICTRONBLE) +
 observer backend (victronble_zephyr.{h,c}) — scan cb pre-filters and
 queues, dedicated decode thread, listener callbacks, slow passive
 scan defaults, stats counters. docs/ZEPHYR_PORT.md records the plan.
- library.properties: fix URL (gitea, not the nonexistent GitHub).
This commit is contained in:
2026-08-21 11:59:19 +10:00
parent 617c240e02
commit b390c0d579
20 changed files with 2145 additions and 284 deletions
+119 -272
View File
@@ -1,15 +1,23 @@
/**
* VictronBLE - portable library for Victron Energy BLE devices
* Common implementation (platform-independent: decoding + AES-128-CTR decrypt).
* BLE scanning lives in the per-platform backends under src/esp32 and src/nrf52.
*
* Thin Arduino wrapper over the pure C core (src/victronble_core.c): this
* file owns the device registry, nonce dedup and rate limiting; decryption
* and payload decoding live in victronble_decode(). BLE scanning lives in
* the per-platform backends under src/esp32 and src/nrf52.
*
* Copyright (c) 2025 Scott Penrose
* License: MIT
*/
#include "VictronBLE.h"
#include "crypto/vble_aes.h"
#include "victronble.h"
#include <string.h>
#include <math.h>
// The public API keeps the legacy "absent = 0" convention; the core reports
// absent fields as NAN.
static inline float nan_to_zero(float v) { return isnan(v) ? 0.0f : v; }
VictronBLE::VictronBLE()
: deviceCount(0), callback(nullptr), debugEnabled(false),
@@ -24,7 +32,6 @@ VictronBLE::VictronBLE()
bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey,
VictronDeviceType type) {
if (deviceCount >= VICTRON_MAX_DEVICES) return false;
if (!hexKey || strlen(hexKey) != 32) return false;
if (!mac || strlen(mac) == 0) return false;
char normalizedMAC[VICTRON_MAC_LEN];
@@ -35,6 +42,8 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
DeviceEntry* entry = &devices[deviceCount];
memset(entry, 0, sizeof(DeviceEntry));
if (!victronble_parse_key(hexKey, entry->key)) return false;
entry->active = true;
strncpy(entry->device.name, name ? name : "", VICTRON_NAME_LEN - 1);
@@ -43,8 +52,6 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
entry->device.deviceType = type;
entry->device.rssi = -100;
if (!hexToBytes(hexKey, entry->key, 16)) return false;
deviceCount++;
if (debugEnabled) Serial.printf("[VictronBLE] Added: %s (%s)\n", name, normalizedMAC);
@@ -56,17 +63,7 @@ bool VictronBLE::addDevice(const char* name, const char* mac, const char* hexKey
// result and feeds them here.
void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi) {
if (!mfgData || len < 10) return;
// Quick vendor ID check before any other work
uint16_t vendorID = mfgData[0] | ((uint16_t)mfgData[1] << 8);
if (vendorID != VICTRON_MANUFACTURER_ID) return;
// Copy into the wire-format struct
victronManufacturerData mfg;
memset(&mfg, 0, sizeof(mfg));
size_t copyLen = len > sizeof(mfg) ? sizeof(mfg) : len;
memcpy(&mfg, mfgData, copyLen);
if (!victronble_is_product_adv(mfgData, len)) return;
// Normalize MAC and find device
char normalizedMAC[VICTRON_MAC_LEN];
@@ -79,7 +76,8 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
}
// Skip if nonce unchanged (data hasn't changed on the device)
if (entry->device.dataValid && mfg.nonceDataCounter == entry->lastNonce) {
uint16_t nonce = mfgData[7] | ((uint16_t)mfgData[8] << 8);
if (entry->device.dataValid && nonce == entry->lastNonce) {
entry->device.rssi = rssi; // still refresh RSSI
return;
}
@@ -90,274 +88,123 @@ void VictronBLE::onAdvertisement(const uint8_t* mfgData, size_t len,
return;
}
victronble_record_t rec;
victronble_err_t err = victronble_decode(mfgData, len, entry->key, &rec);
if (err != VICTRONBLE_OK) {
if (debugEnabled) Serial.printf("[VictronBLE] Decode %s: %s\n",
entry->device.name, victronble_strerror(err));
return;
}
if (debugEnabled) Serial.printf("[VictronBLE] Processing: %s nonce:0x%04X\n",
entry->device.name, mfg.nonceDataCounter);
entry->device.name, rec.nonce);
if (parseAdvertisement(entry, mfg)) {
entry->lastNonce = mfg.nonceDataCounter;
entry->device.rssi = rssi;
entry->device.lastUpdate = now;
}
storeRecord(entry, rec);
entry->lastNonce = nonce;
entry->device.rssi = rssi;
entry->device.lastUpdate = now;
entry->device.dataValid = true;
if (callback) callback(&entry->device);
}
bool VictronBLE::parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg) {
if (debugEnabled) {
Serial.printf("[VictronBLE] Beacon:0x%02X Record:0x%02X Nonce:0x%04X\n",
mfg.beaconType, mfg.victronRecordType, mfg.nonceDataCounter);
}
// Quick key check before expensive decryption
if (mfg.encryptKeyMatch != entry->key[0]) {
if (debugEnabled) Serial.println("[VictronBLE] Key byte mismatch");
return false;
}
// Build IV from nonce (2 bytes little-endian + 14 zero bytes)
uint8_t iv[16] = {0};
iv[0] = mfg.nonceDataCounter & 0xFF;
iv[1] = (mfg.nonceDataCounter >> 8) & 0xFF;
// Decrypt
uint8_t decrypted[VICTRON_ENCRYPTED_LEN];
if (!decryptData(mfg.victronEncryptedData, VICTRON_ENCRYPTED_LEN,
entry->key, iv, decrypted)) {
if (debugEnabled) Serial.println("[VictronBLE] Decryption failed");
return false;
}
// Parse based on record type (auto-detects device type)
bool ok = false;
switch (mfg.victronRecordType) {
case DEVICE_TYPE_SOLAR_CHARGER:
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
ok = parseSolarCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.solar);
break;
case DEVICE_TYPE_BATTERY_MONITOR:
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
ok = parseBatteryMonitor(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.battery);
break;
case DEVICE_TYPE_INVERTER:
case DEVICE_TYPE_INVERTER_RS:
case DEVICE_TYPE_MULTI_RS:
case DEVICE_TYPE_VE_BUS:
entry->device.deviceType = DEVICE_TYPE_INVERTER;
ok = parseInverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.inverter);
break;
case DEVICE_TYPE_DCDC_CONVERTER:
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
ok = parseDCDCConverter(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.dcdc);
break;
case DEVICE_TYPE_AC_CHARGER:
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
ok = parseACCharger(decrypted, VICTRON_ENCRYPTED_LEN, entry->device.acCharger);
break;
default:
if (debugEnabled) Serial.printf("[VictronBLE] Unknown type: 0x%02X\n", mfg.victronRecordType);
return false;
}
if (ok) {
entry->device.dataValid = true;
if (callback) callback(&entry->device);
}
return ok;
}
bool VictronBLE::decryptData(const uint8_t* encrypted, size_t len,
const uint8_t* key, const uint8_t* iv,
uint8_t* decrypted) {
// AES-128-CTR via the bundled portable implementation (was mbedTLS on ESP32).
// CTR is symmetric and operates in place, so copy then XOR the keystream.
struct vble_aes_ctx ctx;
vble_aes_init_ctx_iv(&ctx, key, iv);
memcpy(decrypted, encrypted, len);
vble_aes_ctr_xcrypt(&ctx, decrypted, len);
return true;
}
bool VictronBLE::parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result) {
if (len < sizeof(victronSolarChargerPayload)) return false;
const auto* p = reinterpret_cast<const victronSolarChargerPayload*>(data);
result.chargeState = p->deviceState;
result.errorCode = p->errorCode;
result.batteryVoltage = p->batteryVoltage * 0.01f; // 0.01V units
result.batteryCurrent = p->batteryCurrent * 0.1f; // 0.1A units
result.yieldToday = p->yieldToday * 10;
result.panelPower = p->inputPower;
// Load current is a 9-bit field (0.1A units); 0x1FF = no load output
uint16_t loadRaw = p->loadCurrent & 0x1FF;
result.loadCurrent = (loadRaw != 0x1FF) ? loadRaw * 0.1f : 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
result.batteryVoltage, result.batteryCurrent,
(int)result.panelPower, result.chargeState);
}
return true;
}
bool VictronBLE::parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result) {
// Payload is bit-packed (10 fields, 104 bits ending in byte 12). Decode LSB-first.
if (len < 13) return false;
size_t bit = 0;
auto readBits = [&](uint8_t width) -> uint32_t {
uint32_t value = 0;
for (uint8_t i = 0; i < width; i++) {
size_t b = bit + i;
value |= (uint32_t)((data[b >> 3] >> (b & 7)) & 0x01) << i;
// Map a decoded core record into the legacy public structs (NAN -> 0).
void VictronBLE::storeRecord(DeviceEntry* entry, const victronble_record_t& rec) {
switch (rec.type) {
case VICTRONBLE_DEV_SOLAR_CHARGER: {
entry->device.deviceType = DEVICE_TYPE_SOLAR_CHARGER;
VictronSolarData& s = entry->device.solar;
s.chargeState = rec.u.solar.state;
s.errorCode = rec.u.solar.error;
s.batteryVoltage = rec.u.solar.battery_voltage;
s.batteryCurrent = rec.u.solar.battery_current;
s.panelPower = rec.u.solar.pv_power;
s.yieldToday = (uint16_t)rec.u.solar.yield_today_wh;
s.loadCurrent = nan_to_zero(rec.u.solar.load_current);
if (debugEnabled) {
Serial.printf("[VictronBLE] Solar: %.2fV %.2fA %dW State:%d\n",
s.batteryVoltage, s.batteryCurrent,
(int)s.panelPower, s.chargeState);
}
bit += width;
return value;
};
result.chargeState = (uint8_t)readBits(8);
result.errorCode = (uint8_t)readBits(8);
uint32_t v1 = readBits(13), i1 = readBits(11);
uint32_t v2 = readBits(13), i2 = readBits(11);
uint32_t v3 = readBits(13), i3 = readBits(11);
uint32_t temp = readBits(7);
uint32_t acCur = readBits(9);
result.voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : 0;
result.current1 = (i1 != 0x7FF) ? i1 * 0.1f : 0;
result.voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : 0;
result.current2 = (i2 != 0x7FF) ? i2 * 0.1f : 0;
result.voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : 0;
result.current3 = (i3 != 0x7FF) ? i3 * 0.1f : 0;
result.temperature = (temp != 0x7F) ? (float)temp - 40.0f : 0; // C offset by -40
result.acCurrent = (acCur != 0x1FF) ? acCur * 0.1f : 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
result.voltage1, result.current1, result.temperature, result.chargeState);
break;
}
return true;
}
bool VictronBLE::parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result) {
// The payload is bit-packed and not byte-aligned, so it is decoded by bit
// offset directly rather than via a struct. SOC ends at bit 117 (byte 14).
if (len < 15) return false;
// TTG (bits 0-15), unsigned minutes
result.remainingMinutes = data[0] | ((uint16_t)data[1] << 8);
// Voltage (bits 16-31), signed, 0.01V units
result.voltage = (int16_t)(data[2] | ((uint16_t)data[3] << 8)) * 0.01f;
// Alarm (bits 32-47), 16-bit bitmask
uint16_t alarm = data[4] | ((uint16_t)data[5] << 8);
result.alarmLowVoltage = (alarm & 0x0001) != 0;
result.alarmHighVoltage = (alarm & 0x0002) != 0;
result.alarmLowSOC = (alarm & 0x0004) != 0;
result.alarmLowTemperature = (alarm & 0x0010) != 0;
result.alarmHighTemperature = (alarm & 0x0020) != 0;
// Aux value (bits 48-63) interpreted per aux mode (bits 64-65)
uint16_t auxRaw = data[6] | ((uint16_t)data[7] << 8);
uint8_t auxMode = data[8] & 0x03; // 0=aux voltage, 1=midpoint, 2=temperature, 3=none
if (auxMode == 0) {
result.auxVoltage = auxRaw * 0.01f;
result.temperature = 0;
} else if (auxMode == 2) {
result.temperature = auxRaw * 0.01f - 273.15f; // 0.01K -> C
result.auxVoltage = 0;
} else {
result.auxVoltage = 0;
result.temperature = 0;
case VICTRONBLE_DEV_BATTERY_MONITOR: {
entry->device.deviceType = DEVICE_TYPE_BATTERY_MONITOR;
VictronBatteryData& b = entry->device.battery;
b.voltage = rec.u.batmon.voltage;
b.current = rec.u.batmon.current;
b.temperature = nan_to_zero(rec.u.batmon.temperature);
b.auxVoltage = nan_to_zero(rec.u.batmon.aux_voltage);
b.remainingMinutes = rec.u.batmon.remaining_minutes;
b.consumedAh = rec.u.batmon.consumed_ah;
b.soc = rec.u.batmon.soc;
b.alarmLowVoltage = (rec.u.batmon.alarm & 0x0001) != 0;
b.alarmHighVoltage = (rec.u.batmon.alarm & 0x0002) != 0;
b.alarmLowSOC = (rec.u.batmon.alarm & 0x0004) != 0;
b.alarmLowTemperature = (rec.u.batmon.alarm & 0x0010) != 0;
b.alarmHighTemperature = (rec.u.batmon.alarm & 0x0020) != 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
b.voltage, b.current, b.soc);
}
break;
}
// Battery current (bits 66-87), 22-bit signed, 0.001A units
int32_t current = ((uint32_t)(data[8] >> 2) & 0x3F)
| ((uint32_t)data[9] << 6)
| ((uint32_t)data[10] << 14);
if (current & 0x200000) current |= 0xFFC00000; // Sign extend 22-bit
result.current = current * 0.001f;
// Consumed Ah (bits 88-107), 20-bit, stored as a positive count, 0.1Ah units.
// Reported as a negative value (amp-hours consumed).
uint32_t consumed = (uint32_t)data[11]
| ((uint32_t)data[12] << 8)
| ((uint32_t)(data[13] & 0x0F) << 16);
result.consumedAh = -((float)consumed * 0.1f);
// SOC (bits 108-117), 10-bit, 0.1% units
uint16_t soc = ((uint16_t)(data[13] >> 4) | ((uint16_t)data[14] << 4)) & 0x3FF;
result.soc = soc * 0.1f;
if (debugEnabled) {
Serial.printf("[VictronBLE] Battery: %.2fV %.2fA SOC:%.1f%%\n",
result.voltage, result.current, result.soc);
case VICTRONBLE_DEV_INVERTER: {
entry->device.deviceType = DEVICE_TYPE_INVERTER;
VictronInverterData& inv = entry->device.inverter;
inv.batteryVoltage = rec.u.inverter.battery_voltage;
inv.batteryCurrent = rec.u.inverter.battery_current;
inv.acPower = rec.u.inverter.ac_power;
inv.state = rec.u.inverter.state;
inv.alarmLowVoltage = (rec.u.inverter.alarms & 0x01) != 0;
inv.alarmHighVoltage = (rec.u.inverter.alarms & 0x02) != 0;
inv.alarmHighTemperature = (rec.u.inverter.alarms & 0x04) != 0;
inv.alarmOverload = (rec.u.inverter.alarms & 0x08) != 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
inv.batteryVoltage, (int)inv.acPower, inv.state);
}
break;
}
return true;
}
bool VictronBLE::parseInverter(const uint8_t* data, size_t len, VictronInverterData& result) {
if (len < sizeof(victronInverterPayload)) return false;
const auto* p = reinterpret_cast<const victronInverterPayload*>(data);
result.state = p->deviceState;
result.batteryVoltage = p->batteryVoltage * 0.01f;
result.batteryCurrent = p->batteryCurrent * 0.01f;
// AC Power (signed 24-bit)
int32_t acPower = p->acPowerLow | (p->acPowerMid << 8) | (p->acPowerHigh << 16);
if (acPower & 0x800000) acPower |= 0xFF000000; // Sign extend
result.acPower = acPower;
// Alarm bits
result.alarmLowVoltage = (p->alarms & 0x01) != 0;
result.alarmHighVoltage = (p->alarms & 0x02) != 0;
result.alarmHighTemperature = (p->alarms & 0x04) != 0;
result.alarmOverload = (p->alarms & 0x08) != 0;
if (debugEnabled) {
Serial.printf("[VictronBLE] Inverter: %.2fV %dW State:%d\n",
result.batteryVoltage, (int)result.acPower, result.state);
case VICTRONBLE_DEV_DCDC_CONVERTER: {
entry->device.deviceType = DEVICE_TYPE_DCDC_CONVERTER;
VictronDCDCData& d = entry->device.dcdc;
d.chargeState = rec.u.dcdc.state;
d.errorCode = rec.u.dcdc.error;
d.inputVoltage = rec.u.dcdc.input_voltage;
d.outputVoltage = rec.u.dcdc.output_voltage;
d.outputCurrent = rec.u.dcdc.output_current;
if (debugEnabled) {
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
d.inputVoltage, d.outputVoltage, d.outputCurrent);
}
break;
}
return true;
}
bool VictronBLE::parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result) {
if (len < sizeof(victronDCDCConverterPayload)) return false;
const auto* p = reinterpret_cast<const victronDCDCConverterPayload*>(data);
result.chargeState = p->chargeState;
result.errorCode = p->errorCode;
result.inputVoltage = p->inputVoltage * 0.01f;
result.outputVoltage = p->outputVoltage * 0.01f;
result.outputCurrent = p->outputCurrent * 0.01f;
if (debugEnabled) {
Serial.printf("[VictronBLE] DC-DC: In=%.2fV Out=%.2fV %.2fA\n",
result.inputVoltage, result.outputVoltage, result.outputCurrent);
case VICTRONBLE_DEV_AC_CHARGER: {
entry->device.deviceType = DEVICE_TYPE_AC_CHARGER;
VictronACChargerData& a = entry->device.acCharger;
a.chargeState = rec.u.ac.state;
a.errorCode = rec.u.ac.error;
a.voltage1 = nan_to_zero(rec.u.ac.voltage1);
a.current1 = nan_to_zero(rec.u.ac.current1);
a.voltage2 = nan_to_zero(rec.u.ac.voltage2);
a.current2 = nan_to_zero(rec.u.ac.current2);
a.voltage3 = nan_to_zero(rec.u.ac.voltage3);
a.current3 = nan_to_zero(rec.u.ac.current3);
a.temperature = nan_to_zero(rec.u.ac.temperature);
a.acCurrent = nan_to_zero(rec.u.ac.ac_current);
if (debugEnabled) {
Serial.printf("[VictronBLE] AC Charger: %.2fV %.2fA Temp:%.0fC State:%d\n",
a.voltage1, a.current1, a.temperature, a.chargeState);
}
break;
}
default:
break;
}
return true;
}
// --- Helpers ---
bool VictronBLE::hexToBytes(const char* hex, uint8_t* out, size_t len) {
if (strlen(hex) != len * 2) return false;
for (size_t i = 0; i < len; i++) {
uint8_t hi = hex[i * 2], lo = hex[i * 2 + 1];
if (hi >= '0' && hi <= '9') hi -= '0';
else if (hi >= 'a' && hi <= 'f') hi = hi - 'a' + 10;
else if (hi >= 'A' && hi <= 'F') hi = hi - 'A' + 10;
else return false;
if (lo >= '0' && lo <= '9') lo -= '0';
else if (lo >= 'a' && lo <= 'f') lo = lo - 'a' + 10;
else if (lo >= 'A' && lo <= 'F') lo = lo - 'A' + 10;
else return false;
out[i] = (hi << 4) | lo;
}
return true;
}
void VictronBLE::normalizeMAC(const char* input, char* output) {
int j = 0;
for (int i = 0; input[i] && j < VICTRON_MAC_LEN - 1; i++) {
+5 -9
View File
@@ -16,6 +16,7 @@
#define VICTRON_BLE_H
#include <Arduino.h>
#include "victronble.h" // pure C core: decode + decrypt (src/victronble_core.c)
// --- Platform BLE backend selection ---
// The BLE scanning layer is the only platform-specific part of the library.
@@ -247,22 +248,17 @@ private:
uint32_t minIntervalMs;
bool initialized;
static bool hexToBytes(const char* hex, uint8_t* out, size_t len);
static void normalizeMAC(const char* input, char* output);
DeviceEntry* findDevice(const char* normalizedMAC);
bool decryptData(const uint8_t* encrypted, size_t len,
const uint8_t* key, const uint8_t* iv, uint8_t* decrypted);
// Common entry point fed by each platform BLE backend with one raw
// manufacturer-data record (vendor ID first), the device MAC and RSSI.
// Decryption and payload decoding are delegated to victronble_decode()
// in the pure C core; storeRecord() maps the result into the legacy
// public structs (core NAN sentinels become 0).
void onAdvertisement(const uint8_t* mfgData, size_t len,
const char* macStr, int8_t rssi);
bool parseAdvertisement(DeviceEntry* entry, const victronManufacturerData& mfg);
bool parseSolarCharger(const uint8_t* data, size_t len, VictronSolarData& result);
bool parseACCharger(const uint8_t* data, size_t len, VictronACChargerData& result);
bool parseBatteryMonitor(const uint8_t* data, size_t len, VictronBatteryData& result);
bool parseInverter(const uint8_t* data, size_t len, VictronInverterData& result);
bool parseDCDCConverter(const uint8_t* data, size_t len, VictronDCDCData& result);
void storeRecord(DeviceEntry* entry, const victronble_record_t& rec);
// --- Platform-specific BLE backend (see src/esp32 and src/nrf52) ---
#if defined(VICTRON_BACKEND_ESP32)
+5
View File
@@ -0,0 +1,5 @@
/* Arduino include-path shim: Arduino builds only add src/ to the include
* path, so route to the canonical core header in include/. Zephyr and host
* builds add include/ directly and never see this file first — both paths
* end up in the same header (it has an include guard). */
#include "../include/victronble.h"
+38
View File
@@ -0,0 +1,38 @@
/**
* victronble — bundled software AES-128-CTR backend.
*
* Weak symbol: an alternative backend (PSA Crypto, mbedTLS, hardware) defines
* victronble_aes_ctr_default strong and the linker drops this file's code —
* and with it the bundled AES tables — from the final image.
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#include "victronble.h"
#include "crypto/vble_aes.h"
#include <string.h>
#if defined(_MSC_VER)
#define VICTRONBLE_WEAK
#else
#define VICTRONBLE_WEAK __attribute__((weak))
#endif
VICTRONBLE_WEAK
int victronble_aes_ctr_default(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out,
size_t len, void *user)
{
(void)user;
struct vble_aes_ctx ctx;
vble_aes_init_ctx_iv(&ctx, key, iv);
if (out != in) {
memcpy(out, in, len);
}
vble_aes_ctr_xcrypt(&ctx, out, len);
return 0;
}
+382
View File
@@ -0,0 +1,382 @@
/**
* victronble core — decode + decrypt for Victron Instant Readout adverts.
* Pure C99: no Arduino, no BLE stack, no allocation, no I/O, reentrant.
*
* Byte offsets and bit layouts match the proven ESP32/nRF52 implementation
* in src/VictronBLE.cpp and Victron's "Extra Manufacturer Data" document.
*
* Copyright (c) 2025-2026 Scott Penrose
* License: MIT
*/
#include "victronble.h"
#include <string.h>
#include <math.h>
/* Manufacturer-data layout (offsets from the company ID):
* 0-1 company ID (LE, 0x02E1)
* 2 record type, 0x10 = product advertisement
* 3-4 model ID (LE)
* 5 read-out type
* 6 device record type (victronble_device_type_t)
* 7-8 nonce / data counter (LE)
* 9 key check byte (== key[0])
* 10- AES-128-CTR ciphertext, up to 21 bytes
*/
#define OFF_RECORD 2
#define OFF_MODEL 3
#define OFF_READOUT 5
#define OFF_DEVTYPE 6
#define OFF_NONCE 7
#define OFF_KEYCHECK 9
#define OFF_CIPHER 10
#define PRODUCT_ADV 0x10
static uint16_t get_le16(const uint8_t *p)
{
return (uint16_t)(p[0] | ((uint16_t)p[1] << 8));
}
/* --- AES backend selection ------------------------------------------- */
static victronble_aes_ctr_fn aes_fn;
static void *aes_user;
void victronble_set_aes_ctr(victronble_aes_ctr_fn fn, void *user)
{
aes_fn = fn;
aes_user = user;
}
static int aes_ctr(const uint8_t key[16], const uint8_t iv[16],
const uint8_t *in, uint8_t *out, size_t len)
{
if (aes_fn != NULL) {
return aes_fn(key, iv, in, out, len, aes_user);
}
return victronble_aes_ctr_default(key, iv, in, out, len, NULL);
}
/* --- Pre-filters ------------------------------------------------------ */
bool victronble_is_product_adv(const uint8_t *mfg, size_t len)
{
return mfg != NULL && len >= VICTRONBLE_MIN_MFG_LEN &&
get_le16(mfg) == VICTRONBLE_COMPANY_ID &&
mfg[OFF_RECORD] == PRODUCT_ADV;
}
bool victronble_key_matches(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN])
{
return victronble_is_product_adv(mfg, len) && mfg[OFF_KEYCHECK] == key[0];
}
/* --- Per-type payload decoders ---------------------------------------
* All operate on the decrypted payload, zero-padded to
* VICTRONBLE_MAX_CIPHER_LEN bytes, so length checks always pass at the
* decode() call site; they remain for direct-call safety. */
static bool parse_solar_charger(const uint8_t *d, size_t len,
victronble_solar_charger_t *r)
{
if (len < 12) {
return false;
}
r->state = d[0];
r->error = d[1];
r->battery_voltage = (int16_t)get_le16(d + 2) * 0.01f; /* 0.01 V */
r->battery_current = (int16_t)get_le16(d + 4) * 0.1f; /* 0.1 A */
r->yield_today_wh = (uint32_t)get_le16(d + 6) * 10u; /* 0.01 kWh */
r->pv_power = get_le16(d + 8); /* 1 W */
/* Load current is a 9-bit field (0.1 A units); 0x1FF = no load output */
uint16_t load_raw = get_le16(d + 10) & 0x1FF;
r->load_current = (load_raw != 0x1FF) ? load_raw * 0.1f : NAN;
return true;
}
static bool parse_battery_monitor(const uint8_t *d, size_t len,
victronble_battery_monitor_t *r)
{
/* Bit-packed, not byte-aligned; decoded by bit offset. SOC ends at
* bit 117 (byte 14). */
if (len < 15) {
return false;
}
r->remaining_minutes = get_le16(d); /* bits 0-15 */
r->voltage = (int16_t)get_le16(d + 2) * 0.01f; /* bits 16-31 */
r->alarm = get_le16(d + 4); /* bits 32-47 */
/* Aux value (bits 48-63) interpreted per aux mode (bits 64-65) */
uint16_t aux_raw = get_le16(d + 6);
r->aux_mode = d[8] & 0x03;
r->aux_voltage = (r->aux_mode == 0) ? aux_raw * 0.01f : NAN;
r->temperature = (r->aux_mode == 2) ? aux_raw * 0.01f - 273.15f : NAN;
/* Battery current (bits 66-87), 22-bit signed, 0.001 A units */
int32_t current = (int32_t)(((uint32_t)(d[8] >> 2) & 0x3F) |
((uint32_t)d[9] << 6) |
((uint32_t)d[10] << 14));
if (current & 0x200000) {
current |= (int32_t)0xFFC00000; /* sign extend */
}
r->current = current * 0.001f;
/* Consumed Ah (bits 88-107), 20-bit positive count, 0.1 Ah units,
* reported negative (amp-hours consumed). */
uint32_t consumed = (uint32_t)d[11] | ((uint32_t)d[12] << 8) |
((uint32_t)(d[13] & 0x0F) << 16);
r->consumed_ah = -((float)consumed * 0.1f);
/* SOC (bits 108-117), 10-bit, 0.1 % units */
uint16_t soc = (uint16_t)(((d[13] >> 4) | ((uint16_t)d[14] << 4)) & 0x3FF);
r->soc = soc * 0.1f;
return true;
}
static bool parse_inverter(const uint8_t *d, size_t len,
victronble_inverter_t *r)
{
if (len < 9) {
return false;
}
r->state = d[0];
/* d[1] is the error code on the wire; kept out of the struct for parity
* with the proven implementation, which only surfaced the alarm bits. */
r->battery_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
r->battery_current = (int16_t)get_le16(d + 4) * 0.01f; /* 10 mA */
int32_t ac_power = (int32_t)((uint32_t)d[6] | ((uint32_t)d[7] << 8) |
((uint32_t)d[8] << 16));
if (ac_power & 0x800000) {
ac_power |= (int32_t)0xFF000000; /* sign extend */
}
r->ac_power = (float)ac_power;
r->alarms = (len > 9) ? d[9] : 0;
return true;
}
static bool parse_dcdc(const uint8_t *d, size_t len, victronble_dcdc_t *r)
{
if (len < 8) {
return false;
}
r->state = d[0];
r->error = d[1];
r->input_voltage = get_le16(d + 2) * 0.01f; /* 10 mV */
r->output_voltage = get_le16(d + 4) * 0.01f; /* 10 mV */
r->output_current = get_le16(d + 6) * 0.01f; /* 10 mA */
return true;
}
static uint32_t read_bits(const uint8_t *d, size_t *bit, uint8_t width)
{
uint32_t value = 0;
for (uint8_t i = 0; i < width; i++) {
size_t b = *bit + i;
value |= (uint32_t)((d[b >> 3] >> (b & 7)) & 0x01) << i;
}
*bit += width;
return value;
}
static bool parse_ac_charger(const uint8_t *d, size_t len,
victronble_ac_charger_t *r)
{
/* Bit-packed: 10 fields, 104 bits ending in byte 12, LSB-first. */
if (len < 13) {
return false;
}
size_t bit = 0;
r->state = (uint8_t)read_bits(d, &bit, 8);
r->error = (uint8_t)read_bits(d, &bit, 8);
uint32_t v1 = read_bits(d, &bit, 13), i1 = read_bits(d, &bit, 11);
uint32_t v2 = read_bits(d, &bit, 13), i2 = read_bits(d, &bit, 11);
uint32_t v3 = read_bits(d, &bit, 13), i3 = read_bits(d, &bit, 11);
uint32_t temp = read_bits(d, &bit, 7);
uint32_t ac_cur = read_bits(d, &bit, 9);
r->voltage1 = (v1 != 0x1FFF) ? v1 * 0.01f : NAN;
r->current1 = (i1 != 0x7FF) ? i1 * 0.1f : NAN;
r->voltage2 = (v2 != 0x1FFF) ? v2 * 0.01f : NAN;
r->current2 = (i2 != 0x7FF) ? i2 * 0.1f : NAN;
r->voltage3 = (v3 != 0x1FFF) ? v3 * 0.01f : NAN;
r->current3 = (i3 != 0x7FF) ? i3 * 0.1f : NAN;
r->temperature = (temp != 0x7F) ? (float)temp - 40.0f : NAN;
r->ac_current = (ac_cur != 0x1FF) ? ac_cur * 0.1f : NAN;
return true;
}
/* --- Decode entry point ----------------------------------------------- */
victronble_err_t victronble_decode(const uint8_t *mfg, size_t len,
const uint8_t key[VICTRONBLE_KEY_LEN],
victronble_record_t *out)
{
if (mfg == NULL || len < VICTRONBLE_MIN_MFG_LEN) {
return VICTRONBLE_ERR_SHORT;
}
if (get_le16(mfg) != VICTRONBLE_COMPANY_ID) {
return VICTRONBLE_ERR_NOT_VICTRON;
}
if (mfg[OFF_RECORD] != PRODUCT_ADV) {
return VICTRONBLE_ERR_NOT_PRODUCT;
}
if (mfg[OFF_KEYCHECK] != key[0]) {
return VICTRONBLE_ERR_KEY_MISMATCH;
}
uint16_t nonce = get_le16(mfg + OFF_NONCE);
/* IV: nonce in the two low bytes (LE), remaining 14 bytes zero. */
uint8_t iv[16] = {0};
iv[0] = (uint8_t)(nonce & 0xFF);
iv[1] = (uint8_t)(nonce >> 8);
/* Decrypt what's on the wire; zero-pad to the full payload size so the
* per-type decoders see a fixed-length buffer (matches the proven
* implementation, which zero-filled the wire struct before copy-in). */
uint8_t plain[VICTRONBLE_MAX_CIPHER_LEN] = {0};
size_t cipher_len = len - OFF_CIPHER;
if (cipher_len > VICTRONBLE_MAX_CIPHER_LEN) {
cipher_len = VICTRONBLE_MAX_CIPHER_LEN;
}
if (aes_ctr(key, iv, mfg + OFF_CIPHER, plain, cipher_len) != 0) {
return VICTRONBLE_ERR_CRYPTO;
}
victronble_record_t rec;
memset(&rec, 0, sizeof(rec));
rec.record_type = mfg[OFF_DEVTYPE];
rec.model_id = get_le16(mfg + OFF_MODEL);
rec.readout_type = mfg[OFF_READOUT];
rec.nonce = nonce;
bool ok = false;
switch (mfg[OFF_DEVTYPE]) {
case VICTRONBLE_DEV_SOLAR_CHARGER:
rec.type = VICTRONBLE_DEV_SOLAR_CHARGER;
ok = parse_solar_charger(plain, sizeof(plain), &rec.u.solar);
break;
case VICTRONBLE_DEV_BATTERY_MONITOR:
rec.type = VICTRONBLE_DEV_BATTERY_MONITOR;
ok = parse_battery_monitor(plain, sizeof(plain), &rec.u.batmon);
break;
case VICTRONBLE_DEV_INVERTER:
case VICTRONBLE_DEV_INVERTER_RS:
case VICTRONBLE_DEV_MULTI_RS:
case VICTRONBLE_DEV_VE_BUS:
rec.type = VICTRONBLE_DEV_INVERTER;
ok = parse_inverter(plain, sizeof(plain), &rec.u.inverter);
break;
case VICTRONBLE_DEV_DCDC_CONVERTER:
rec.type = VICTRONBLE_DEV_DCDC_CONVERTER;
ok = parse_dcdc(plain, sizeof(plain), &rec.u.dcdc);
break;
case VICTRONBLE_DEV_AC_CHARGER:
rec.type = VICTRONBLE_DEV_AC_CHARGER;
ok = parse_ac_charger(plain, sizeof(plain), &rec.u.ac);
break;
default:
return VICTRONBLE_ERR_UNSUPPORTED;
}
if (!ok) {
return VICTRONBLE_ERR_SHORT;
}
*out = rec;
return VICTRONBLE_OK;
}
/* --- Helpers ----------------------------------------------------------- */
static int hex_nibble(char c)
{
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'f') {
return c - 'a' + 10;
}
if (c >= 'A' && c <= 'F') {
return c - 'A' + 10;
}
return -1;
}
bool victronble_parse_key(const char *hex, uint8_t key[VICTRONBLE_KEY_LEN])
{
if (hex == NULL || strlen(hex) != VICTRONBLE_KEY_LEN * 2) {
return false;
}
for (size_t i = 0; i < VICTRONBLE_KEY_LEN; i++) {
int hi = hex_nibble(hex[i * 2]);
int lo = hex_nibble(hex[i * 2 + 1]);
if (hi < 0 || lo < 0) {
return false;
}
key[i] = (uint8_t)((hi << 4) | lo);
}
return true;
}
const char *victronble_strerror(victronble_err_t err)
{
switch (err) {
case VICTRONBLE_OK: return "ok";
case VICTRONBLE_ERR_NOT_VICTRON: return "not victron";
case VICTRONBLE_ERR_SHORT: return "truncated";
case VICTRONBLE_ERR_NOT_PRODUCT: return "not product adv";
case VICTRONBLE_ERR_KEY_MISMATCH: return "key mismatch";
case VICTRONBLE_ERR_UNSUPPORTED: return "unsupported type";
case VICTRONBLE_ERR_CRYPTO: return "crypto error";
default: return "unknown error";
}
}
const char *victronble_device_type_str(victronble_device_type_t type)
{
switch (type) {
case VICTRONBLE_DEV_SOLAR_CHARGER: return "solar charger";
case VICTRONBLE_DEV_BATTERY_MONITOR: return "battery monitor";
case VICTRONBLE_DEV_INVERTER: return "inverter";
case VICTRONBLE_DEV_DCDC_CONVERTER: return "dc-dc converter";
case VICTRONBLE_DEV_SMART_LITHIUM: return "smart lithium";
case VICTRONBLE_DEV_INVERTER_RS: return "inverter rs";
case VICTRONBLE_DEV_GX_DEVICE: return "gx device";
case VICTRONBLE_DEV_AC_CHARGER: return "ac charger";
case VICTRONBLE_DEV_BATTERY_PROTECT: return "battery protect";
case VICTRONBLE_DEV_LYNX_SMART_BMS: return "lynx smart bms";
case VICTRONBLE_DEV_MULTI_RS: return "multi rs";
case VICTRONBLE_DEV_VE_BUS: return "ve.bus";
case VICTRONBLE_DEV_DC_ENERGY_METER: return "dc energy meter";
case VICTRONBLE_DEV_ORION_XS: return "orion xs";
default: return "unknown";
}
}
const char *victronble_state_str(uint8_t state)
{
switch (state) {
case VICTRONBLE_STATE_OFF: return "off";
case VICTRONBLE_STATE_LOW_POWER: return "low";
case VICTRONBLE_STATE_FAULT: return "fault";
case VICTRONBLE_STATE_BULK: return "bulk";
case VICTRONBLE_STATE_ABSORPTION: return "abs";
case VICTRONBLE_STATE_FLOAT: return "float";
case VICTRONBLE_STATE_STORAGE: return "store";
case VICTRONBLE_STATE_EQUALIZE: return "eq";
case VICTRONBLE_STATE_INVERTING: return "invert";
case VICTRONBLE_STATE_POWER_SUPPLY: return "psu";
case VICTRONBLE_STATE_EXTERNAL_CONTROL: return "ext";
default: return "?";
}
}
+301
View File
@@ -0,0 +1,301 @@
/**
* victronble — Zephyr BLE observer backend.
*
* Scan callback (BT RX context) does the cheap work only: AD walk, product
* pre-filter, registry match, copy into a message queue. A dedicated thread
* decrypts, decodes, dedups and fans out to registered listeners.
*
* Copyright (c) 2026 Scott Penrose
* License: MIT
*/
/* Arduino/PlatformIO builds compile every file under src/ — this backend
* only exists under Zephyr (the Zephyr CMake build lists sources
* explicitly, so the reverse problem doesn't arise). */
#ifdef __ZEPHYR__
#include <zephyr/kernel.h>
#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/bluetooth/gap.h>
#include <zephyr/logging/log.h>
#include "victronble_zephyr.h"
LOG_MODULE_REGISTER(victronble, CONFIG_VICTRONBLE_LOG_LEVEL);
#define MAX_MFG_LEN (VICTRONBLE_MIN_MFG_LEN + VICTRONBLE_MAX_CIPHER_LEN)
struct vb_frame {
bt_addr_le_t addr;
int8_t rssi;
uint8_t len;
uint8_t data[MAX_MFG_LEN];
};
struct vb_device {
bt_addr_le_t addr;
uint8_t key[VICTRONBLE_KEY_LEN];
uint16_t last_nonce;
bool have_nonce;
bool used;
};
K_MSGQ_DEFINE(vb_msgq, sizeof(struct vb_frame),
CONFIG_VICTRONBLE_QUEUE_DEPTH, 4);
static struct vb_device devices[CONFIG_VICTRONBLE_MAX_DEVICES];
static struct k_mutex dev_mtx;
static sys_slist_t callbacks = SYS_SLIST_STATIC_INIT(&callbacks);
static struct victronble_stats stats;
static bool scanning;
static struct vb_device *find_device(const bt_addr_le_t *addr)
{
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
if (devices[i].used &&
bt_addr_le_cmp(&devices[i].addr, addr) == 0) {
return &devices[i];
}
}
return NULL;
}
/* --- Scan path (BT RX context) --------------------------------------- */
struct ad_ctx {
const bt_addr_le_t *addr;
int8_t rssi;
};
static bool ad_cb(struct bt_data *data, void *user_data)
{
struct ad_ctx *ctx = user_data;
if (data->type != BT_DATA_MANUFACTURER_DATA) {
return true; /* keep walking the AD structures */
}
if (!victronble_is_product_adv(data->data, data->data_len)) {
return true;
}
stats.adverts++;
/* Registry check is a handful of compares — cheap enough here, and
* it keeps other people's Victrons out of the queue. */
if (find_device(ctx->addr) == NULL) {
return false;
}
struct vb_frame frame;
bt_addr_le_copy(&frame.addr, ctx->addr);
frame.rssi = ctx->rssi;
frame.len = MIN(data->data_len, sizeof(frame.data));
memcpy(frame.data, data->data, frame.len);
if (k_msgq_put(&vb_msgq, &frame, K_NO_WAIT) == 0) {
stats.queued++;
} else {
stats.dropped++;
}
return false; /* found the record — stop walking */
}
static void scan_recv(const bt_addr_le_t *addr, int8_t rssi,
uint8_t adv_type, struct net_buf_simple *ad)
{
ARG_UNUSED(adv_type);
struct ad_ctx ctx = { .addr = addr, .rssi = rssi };
bt_data_parse(ad, ad_cb, &ctx);
}
/* --- Decode thread ----------------------------------------------------- */
static void decode_frame(const struct vb_frame *frame)
{
uint8_t key[VICTRONBLE_KEY_LEN];
uint16_t last_nonce;
bool have_nonce;
k_mutex_lock(&dev_mtx, K_FOREVER);
struct vb_device *dev = find_device(&frame->addr);
if (dev == NULL) { /* removed while queued */
k_mutex_unlock(&dev_mtx);
return;
}
memcpy(key, dev->key, sizeof(key));
last_nonce = dev->last_nonce;
have_nonce = dev->have_nonce;
k_mutex_unlock(&dev_mtx);
victronble_record_t rec;
victronble_err_t err = victronble_decode(frame->data, frame->len,
key, &rec);
struct victronble_cb *cb;
if (err != VICTRONBLE_OK) {
stats.errors++;
LOG_DBG("decode failed: %s", victronble_strerror(err));
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
if (cb->decode_error != NULL) {
cb->decode_error(&frame->addr, err);
}
}
return;
}
if (IS_ENABLED(CONFIG_VICTRONBLE_DEDUP) &&
have_nonce && rec.nonce == last_nonce) {
stats.duplicates++;
return;
}
k_mutex_lock(&dev_mtx, K_FOREVER);
dev = find_device(&frame->addr);
if (dev != NULL) {
dev->last_nonce = rec.nonce;
dev->have_nonce = true;
}
k_mutex_unlock(&dev_mtx);
stats.decoded++;
LOG_DBG("%s record, nonce 0x%04x, rssi %d",
victronble_device_type_str(rec.type), rec.nonce, frame->rssi);
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, cb, node) {
if (cb->record != NULL) {
cb->record(&frame->addr, frame->rssi, &rec);
}
}
}
static void vb_thread_fn(void *a, void *b, void *c)
{
ARG_UNUSED(a);
ARG_UNUSED(b);
ARG_UNUSED(c);
struct vb_frame frame;
while (true) {
k_msgq_get(&vb_msgq, &frame, K_FOREVER);
decode_frame(&frame);
}
}
K_THREAD_DEFINE(vb_thread, CONFIG_VICTRONBLE_THREAD_STACK_SIZE,
vb_thread_fn, NULL, NULL, NULL,
CONFIG_VICTRONBLE_THREAD_PRIORITY, 0, 0);
/* --- Public API -------------------------------------------------------- */
int victronble_cb_register(struct victronble_cb *cb)
{
struct victronble_cb *it;
SYS_SLIST_FOR_EACH_CONTAINER(&callbacks, it, node) {
if (it == cb) {
return -EALREADY;
}
}
sys_slist_append(&callbacks, &cb->node);
return 0;
}
int victronble_device_add(const bt_addr_le_t *addr,
const uint8_t key[VICTRONBLE_KEY_LEN])
{
int ret = -ENOMEM;
k_mutex_lock(&dev_mtx, K_FOREVER);
if (find_device(addr) != NULL) {
ret = -EALREADY;
} else {
for (int i = 0; i < CONFIG_VICTRONBLE_MAX_DEVICES; i++) {
if (!devices[i].used) {
bt_addr_le_copy(&devices[i].addr, addr);
memcpy(devices[i].key, key,
VICTRONBLE_KEY_LEN);
devices[i].have_nonce = false;
devices[i].used = true;
ret = 0;
break;
}
}
}
k_mutex_unlock(&dev_mtx);
return ret;
}
int victronble_device_remove(const bt_addr_le_t *addr)
{
int ret = -ENOENT;
k_mutex_lock(&dev_mtx, K_FOREVER);
struct vb_device *dev = find_device(addr);
if (dev != NULL) {
memset(dev, 0, sizeof(*dev));
ret = 0;
}
k_mutex_unlock(&dev_mtx);
return ret;
}
int victronble_start(void)
{
/* Passive scan at a low duty cycle: Victron devices advertise about
* once per second, so slow-scan parameters catch every record for a
* fraction of the radio-on time. */
static const struct bt_le_scan_param param = {
.type = BT_LE_SCAN_TYPE_PASSIVE,
.options = BT_LE_SCAN_OPT_NONE,
.interval = CONFIG_VICTRONBLE_SCAN_INTERVAL,
.window = CONFIG_VICTRONBLE_SCAN_WINDOW,
};
int err;
if (scanning) {
return -EALREADY;
}
err = bt_le_scan_start(&param, scan_recv);
if (err != 0) {
LOG_ERR("scan start failed (%d)", err);
return err;
}
scanning = true;
LOG_INF("observing (interval %u window %u)",
CONFIG_VICTRONBLE_SCAN_INTERVAL, CONFIG_VICTRONBLE_SCAN_WINDOW);
return 0;
}
int victronble_stop(void)
{
int err;
if (!scanning) {
return -EALREADY;
}
err = bt_le_scan_stop();
if (err == 0) {
scanning = false;
}
return err;
}
void victronble_get_stats(struct victronble_stats *out)
{
*out = stats;
}
static int vb_init(void)
{
k_mutex_init(&dev_mtx);
return 0;
}
SYS_INIT(vb_init, APPLICATION, CONFIG_APPLICATION_INIT_PRIORITY);
#endif /* __ZEPHYR__ */