diff --git a/index.js b/index.js index e21473e..35097a2 100644 --- a/index.js +++ b/index.js @@ -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) { diff --git a/plugin.py b/plugin.py index 13745e3..3db1e5d 100644 --- a/plugin.py +++ b/plugin.py @@ -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, @@ -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]]] @@ -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: @@ -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: @@ -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, @@ -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": [ { @@ -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(), @@ -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: @@ -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( @@ -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__": diff --git a/schema.json b/schema.json index 268cbdd..f36124e 100644 --- a/schema.json +++ b/schema.json @@ -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", @@ -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": { @@ -30,7 +42,7 @@ "secondary_battery": { "type": "string", "title": "Secondary Battery Device ID (if relevant)", - "default": "starter" + "default": null } } }