Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added bluetooth dongle support for Rpi4 users #21

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
b43ec97
added explicit python version for local situation
Jan 25, 2025
6a21b55
feat: Improve error handling, restart logic, and health monitoring
Jan 25, 2025
a911e39
refactor: Improve type hints using TypeVar for device data transformers
Jan 25, 2025
dd25d52
fix: Move TypeVar definition after DeviceData import to resolve undef…
Jan 25, 2025
8b7119d
refactor: Update type hints for transformers dictionary in SignalKSca…
Jan 25, 2025
eed2b7b
feat: Add debug and info logs for BLE connection monitoring
Feb 4, 2025
60d784d
fix: Remove duplicate DeviceData import in plugin.py
Feb 4, 2025
9c50eab
refactor: Replace `Any` with `DeviceData` in type hint for accuracy
Feb 4, 2025
c62a146
refactor: Use TypeVar T in Callable type hint for transformers
Feb 4, 2025
3d7d7b4
refactor: Change debug logs to error logs in victron plugin
Feb 4, 2025
ee255ed
feat: Enhance BLE logging with packet size, timestamp, and RSSI
Feb 4, 2025
7f04337
refactor: Change logging level from debug/info to error in plugin.py
Feb 4, 2025
6b008c3
feat: enhance error logging for device identification and SignalK data
Feb 4, 2025
826fb5f
refactor: Use Bleak's adapter selection for Bluetooth interface
Feb 4, 2025
7094da4
refactor: Change default BLE adapter from hci0 to hci1
Feb 4, 2025
716c460
fix: Remove adapter parameter from SignalKScanner constructor
Feb 4, 2025
d9ba0a3
fix: Update BLE scanner to match bleak 0.20+ API changes
Feb 5, 2025
3e7aa1c
fix: Use environment variable for Bluetooth adapter selection
Feb 5, 2025
a7d0e48
feat: Add debug logging to inspect parent class initialization
Feb 5, 2025
6e32d04
refactor: Remove redundant error log and adjust log level in plugin.py
Feb 5, 2025
423ddb2
fix: Update callback to handle raw bytes from Victron BLE library
Feb 5, 2025
4dcba99
converted error messages to debug
Feb 5, 2025
e5dee0d
feat: Add device name to SignalK delta and battery data transformer
Feb 5, 2025
864e82c
feat: Add custom device name support and MAC address validation
Feb 5, 2025
9471a9d
feat: add configurable Bluetooth adapter selection in plugin UI
Feb 5, 2025
369733e
refactor: Remove optional name field and use BLE device name directly
Feb 5, 2025
007d6c8
feat: add BLE adapter selection, device naming, and stability improve…
Feb 5, 2025
99c93a4
Revert "added explicit python version for local situation"
Feb 5, 2025
25c528e
added PR doc
Feb 5, 2025
03e9dc0
docs: Update PR.md with changelog and fix schema.json duplicate entry
Feb 5, 2025
5923240
added PR doc
Feb 5, 2025
ea9018f
removed spurios PR description
Feb 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 38 additions & 14 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,46 +15,70 @@ module.exports = function (app) {
let args = ['plugin.py']
child = spawn('ve/bin/python', args, { cwd: __dirname })

const cleanup = () => {
if (child) {
child.removeAllListeners()
child = undefined
}
}

child.stdout.on('data', data => {
app.debug(data.toString())
try {
data.toString().split(/\r?\n/).forEach(line => {
// console.log(JSON.stringify(line))
if (line.length > 0) {
app.handleMessage(undefined, JSON.parse(line))
app.handleMessage(pkgData.name, {
updates: [{
values: [{
path: "plugins.victronBLE.status",
value: "active"
}]
}]
})
}
})
} catch (e) {
console.error(e.message)
console.error('Data processing error:', e.message)
}
})

child.stderr.on('data', fromChild => {
console.error(fromChild.toString())
console.error('Plugin stderr:', fromChild.toString())
})

child.on('error', err => {
console.error(err)
console.error('Subprocess error:', err)
cleanup()
setTimeout(() => run_python_plugin(options), 2000)
})

child.on('close', code => {
app.handleMessage(pkgData.name, {
updates: [{
values: [{
path: "plugins.victronBLE.status",
value: "inactive"
}]
}]
})
cleanup()
if (code !== 0) {
console.warn(`Plugin exited ${code}, restarting...`)
console.warn(`Plugin exited ${code}, restarting in 2s...`)
setTimeout(() => run_python_plugin(options), 2000)
}
child = undefined
})

child.stdin.write(JSON.stringify(options))
child.stdin.write(JSON.stringify({
adapter: options.adapter || 'hci0',
devices: options.devices
}))
child.stdin.write('\n')
};
return {
start: async (options) => {
while (true) {
if (child === undefined) {
run_python_plugin(options);
}
await sleep(1000);
}
start: (options) => {
run_python_plugin(options)
return () => {} // Return dummy stop for compatibility
},
stop: () => {
if (child) {
Expand Down
86 changes: 74 additions & 12 deletions plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import dataclasses
import json
import logging
import os
import sys
from typing import Any, Callable, Union
from typing import Any, Callable, TypeVar, Union

from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from victron_ble.devices import (
AuxMode,
BatteryMonitorData,
Expand All @@ -21,11 +23,18 @@
SolarChargerData,
VEBusData,
)

T = TypeVar('T', bound=DeviceData)
import inspect
from victron_ble.exceptions import AdvertisementKeyMissingError, UnknownDeviceError
from victron_ble.scanner import Scanner

logger = logging.getLogger("signalk-victron-ble")

logger.debug(
f"victron plugin starting up"
)

# 3.9 compatible TypeAliases
SignalKDelta = dict[str, list[dict[str, Any]]]
SignalKDeltaValues = list[dict[str, Union[int, float, str, None]]]
Expand All @@ -43,7 +52,13 @@ class SignalKScanner(Scanner):
_devices: dict[str, ConfiguredDevice]

def __init__(self, devices: dict[str, ConfiguredDevice]) -> None:
super().__init__()
# Add debug logging for parent class inspection
logger.debug(f"Parent __init__ signature: {inspect.signature(super().__init__)}")
try:
super().__init__()
except TypeError as e:
logger.debug(f"Parent __init__ args required: {e}")
raise
self._devices = devices

def load_key(self, address: str) -> str:
Expand All @@ -53,9 +68,17 @@ def load_key(self, address: str) -> str:
raise AdvertisementKeyMissingError(f"No key available for {address}")

def callback(self, bl_device: BLEDevice, raw_data: bytes) -> None:
rssi = getattr(bl_device, "rssi", None)
if rssi is None:
logger.debug(f"No RSSI for {bl_device.address.lower()}")
return

logger.debug(
f"Received data from {bl_device.address.lower()}: {raw_data.hex()}"
f"Received {len(raw_data)}B packet from {bl_device.address.lower()} "
f"(RSSI: {rssi}) @ {datetime.datetime.now().isoformat()}: "
f"Payload={raw_data.hex()}"
)

try:
device = self.get_device(bl_device, raw_data)
except AdvertisementKeyMissingError:
Expand All @@ -66,9 +89,10 @@ def callback(self, bl_device: BLEDevice, raw_data: bytes) -> None:
data = device.parse(raw_data)
configured_device = self._devices[bl_device.address.lower()]
id_ = configured_device.id
logger.debug(f"Processing device: ID={id_} MAC={bl_device.address.lower()}")
transformers: dict[
type[DeviceData],
Callable[[BLEDevice, ConfiguredDevice, Any, str], SignalKDeltaValues],
Callable[[BLEDevice, ConfiguredDevice, T, str], SignalKDeltaValues],
] = {
BatteryMonitorData: self.transform_battery_data,
BatterySenseData: self.transform_battery_sense_data,
Expand All @@ -84,16 +108,32 @@ def callback(self, bl_device: BLEDevice, raw_data: bytes) -> None:
if isinstance(data, data_type):
values = transformer(bl_device, configured_device, data, id_)
delta = self.prepare_signalk_delta(bl_device, values)
logger.info(delta)
logger.debug("Generated SignalK delta: %s", json.dumps(delta))
print(json.dumps(delta))
sys.stdout.flush()
return
else:
logger.debug("Unknown device", device)
logger.warn("Unknown device type %s from %s", type(device).__name__, bl_device.address.lower())

def prepare_signalk_delta(
self, bl_device: BLEDevice, values: SignalKDeltaValues
) -> SignalKDelta:
# Get the configured device for the MAC address
configured_device = self._devices[bl_device.address.lower()]
id_ = configured_device.id

# Add device name to all deltas
values.append({
"path": f"electrical.devices.{id_}.deviceName",
"value": bl_device.name
})

# Add device name to all deltas
values.append({
"path": f"electrical.devices.{id_}.deviceName",
"value": bl_device.name
})

return {
"updates": [
{
Expand Down Expand Up @@ -134,6 +174,10 @@ def transform_battery_data(
id_: str,
) -> SignalKDeltaValues:
values: SignalKDeltaValues = [
{
"path": f"electrical.deviceMetadata.{id_}.name",
"value": bl_device.name
},
{
"path": f"electrical.batteries.{id_}.voltage",
"value": data.get_voltage(),
Expand Down Expand Up @@ -431,10 +475,23 @@ def transform_ve_bus_data(
return values


async def monitor(devices: dict[str, ConfiguredDevice]) -> None:
scanner = SignalKScanner(devices)
await scanner.start()
await asyncio.Event().wait()
async def monitor(devices: dict[str, ConfiguredDevice], adapter: str) -> None:
os.environ["BLUETOOTH_DEVICE"] = adapter
logger.info(f"Starting Victron BLE monitor on adapter {adapter}")

while True:
try:
scanner = SignalKScanner(devices)
logger.debug(f"Initializing scanner with adapter {adapter}")
await scanner.start()
await asyncio.Event().wait()
except (Exception, asyncio.CancelledError) as e:
logger.error(f"Scanner failed: {e}", exc_info=True)
logger.info(f"Attempting restart in 5 seconds...")
await asyncio.sleep(5)
continue
else:
break


def main() -> None:
Expand All @@ -450,7 +507,11 @@ def main() -> None:

logging.debug("Waiting for config...")
config = json.loads(input())
logging.info("Configured: %s", json.dumps(config))
logging.debug("Configured: %s", json.dumps(config))

# Get adapter from config with fallback to hci0
adapter = config.get("adapter", "hci0")

devices: dict[str, ConfiguredDevice] = {}
for device in config["devices"]:
devices[device["mac"].lower()] = ConfiguredDevice(
Expand All @@ -460,7 +521,8 @@ def main() -> None:
secondary_battery=device.get("secondary_battery"),
)

asyncio.run(monitor(devices))
logging.info("Starting Victron BLE plugin on adapter %s", adapter)
asyncio.run(monitor(devices, adapter))


if __name__ == "__main__":
Expand Down
16 changes: 14 additions & 2 deletions schema.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
{
"type": "object",
"type": "object",
"properties": {
"adapter": {
"type": "string",
"title": "Bluetooth Adapter Interface",
"description": "Linux HCI interface (hci0, hci1 etc)",
"default": "hci0",
"enum": ["hci0", "hci1"],
"options": {
"enum_titles": ["Primary (hci0)", "Secondary (hci1)"]
}
},
"devices": {
"type": "array",
"title": "Victron Devices",
Expand All @@ -15,11 +25,13 @@
"id": {
"type": "string",
"title": "Device ID in SignalK",
"description": "Used to group device metrics under electrical.devices.[ID]",
"default": "0"
},
"mac": {
"type": "string",
"title": "MAC Address",
"pattern": "([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}",
"default": "00:00:00:00:00:00"
},
"key": {
Expand All @@ -30,7 +42,7 @@
"secondary_battery": {
"type": "string",
"title": "Secondary Battery Device ID (if relevant)",
"default": "starter"
"default": null
}
}
}
Expand Down