subDesTagesMitExtraKaese 1 ano atrás
commit
56a50038f2
8 arquivos alterados com 426 adições e 0 exclusões
  1. 1 0
      .gitignore
  2. 9 0
      LICENSE
  3. 80 0
      README.md
  4. 178 0
      bleclient.py
  5. 13 0
      crc.py
  6. BIN
      images/hass.png
  7. 143 0
      main.py
  8. 2 0
      requirements.txt

+ 1 - 0
.gitignore

@@ -0,0 +1 @@
+*.pyc

+ 9 - 0
LICENSE

@@ -0,0 +1,9 @@
+MIT License
+
+Copyright (c) 2023, user
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 80 - 0
README.md

@@ -0,0 +1,80 @@
+# Solarlife MPPT BLE Client
+
+This Python application connects to an X45, 10 A, MPPT solar charge controller via Bluetooth Low Energy (BLE). It reads the data from the charge controller and publishes it to HomeAssistant using MQTT.
+
+The application establishes a connection to the MQTT broker and the BLE device, retrieves the details from the charge controller periodically, and publishes the data to MQTT topics. HomeAssistant can then subscribe to these topics to display the data in its user interface.
+
+![HomeAssistant Screenshot](/images/hass.png)
+
+## Requirements
+
+- Python 3.7+
+- [Bleak](https://github.com/hbldh/bleak) - A BLE library for Python
+- [Paho MQTT](https://github.com/eclipse/paho.mqtt.python) - A MQTT library for Python
+
+## Installation
+
+1. Clone the repository:
+
+   ```bash
+   git clone https://github.com/subDesTagesMitExtraKaese/solarlife-mppt-ble-client.git
+   ```
+
+2. Change into the project directory:
+
+   ```bash
+   cd solarlife-mppt-ble-client
+   ```
+
+3. Install the required Python packages using pip:
+
+   ```bash
+   pip install -r requirements.txt
+   ```
+
+## Usage
+
+1. Run the application by providing the required command-line arguments:
+
+   ```bash
+   python main.py <BLE device address> --host <MQTT broker host> --port <MQTT broker port> --username <MQTT username> --password <MQTT password>
+   ```
+
+   Replace `<BLE device address>` with the Bluetooth address of your MPPT solar charge controller. The other arguments are optional and can be used to customize the MQTT connection.
+
+2. The application will connect to the MQTT broker and the BLE device. It will periodically retrieve the data from the charge controller and publish it to MQTT topics.
+
+3. HomeAssistant can subscribe to the MQTT topics to display the published data in its user interface.
+
+## MQTT Topics
+
+The application publishes the data to MQTT topics in the following format:
+
+- Sensor state topic: `homeassistant/sensor/solarlife/<key>/state`
+- Sensor configuration topic (MQTT Discovery): `homeassistant/sensor/solarlife/<key>/config`
+
+The `<key>` represents the data field from the charge controller. For example, `battery_percentage`, `battery_voltage`, etc.
+
+## HomeAssistant Integration
+
+To integrate the published data into HomeAssistant, you can use the MQTT integration and configure it to subscribe to the MQTT topics published by the BLE client.
+
+Here's an example configuration in HomeAssistant's `configuration.yaml` file:
+
+```yaml
+sensor:
+  - platform: mqtt
+    name: "Solarlife Battery Percentage"
+    state_topic: "homeassistant/sensor/solarlife/battery_percentage/state"
+    unit_of_measurement: "%"
+```
+
+Replace `state_topic` with the appropriate MQTT topic for each sensor field you want to display.
+
+## Contributing
+
+Contributions are welcome! If you encounter any issues or have suggestions for improvements, please open an issue or submit a pull request.
+
+## License
+
+This project is licensed under the [MIT License](LICENSE).

+ 178 - 0
bleclient.py

@@ -0,0 +1,178 @@
+import asyncio
+import struct
+from bleak import BleakClient, BleakScanner
+from bleak.backends.characteristic import BleakGATTCharacteristic
+
+from crc import crc16
+
+class BleClient:
+    DEVICE_NAME_UUID = "00002a00-0000-1000-8000-00805f9b34fb"
+    NOTIFY_UUID = "0000ff01-0000-1000-8000-00805f9b34fb"
+    WRITE_UUID = "0000ff02-0000-1000-8000-00805f9b34fb"
+
+    buffer = bytearray()
+
+    def __init__(self, mac_address: str):
+        self.client = BleakClient(mac_address)
+        self.on_details_received = lambda v: print(v)  # Callback function to handle received details
+
+    async def __aenter__(self):
+        await self.client.connect()  # Connect to the BLE device
+        await self.client.start_notify(self.NOTIFY_UUID, self.notification_handler)  # Start receiving notifications
+        return self
+
+    async def __aexit__(self, exc_type, exc, tb):
+        await self.client.stop_notify(self.NOTIFY_UUID)  # Stop receiving notifications
+        await self.client.disconnect()  # Disconnect from the BLE device
+
+    async def write(self, cmd: int):
+        data = cmd.to_bytes(8, 'big')  # Convert the command to a byte array
+        crc = data[-2:]  # Extract the CRC from the data
+        values = data[:-2]  # Extract the values from the data
+        crc2 = crc16(values)  # Calculate the CRC of the values
+        if crc != crc2:
+            # If the calculated CRC doesn't match the extracted CRC, replace it with the calculated CRC
+            data = values + crc2
+        #print("write ", self.WRITE_UUID, data.hex())
+        await self.client.write_gatt_char(self.WRITE_UUID, data)  # Write the data to the BLE device
+
+    def notification_handler(self, characteristic: BleakGATTCharacteristic, data: bytearray):
+        if characteristic.uuid != self.NOTIFY_UUID:
+            return
+        self.buffer += data  # Append the received data to the buffer
+        if len(self.buffer) < 3:
+            return
+
+        crc = self.buffer[-2:]  # Extract the CRC from the buffer
+        values = data[:-2]  # Extract the values from the buffer
+        crc2 = crc16(values)  # Calculate the CRC of the values
+        if crc != crc2:
+            # If the calculated CRC doesn't match the extracted CRC, ignore the data
+            pass
+        if self.buffer[0] != 0x01:
+            print("invalid start byte", self.buffer.hex())
+            self.buffer = bytearray()
+            return
+        if len(self.buffer) == 91:
+            response = BleClient.parse_details_response(self.buffer)  # Parse the details response from the buffer
+            self.on_details_received(response)  # Call the callback function with the parsed response
+            self.buffer = bytearray()
+        if len(self.buffer) >= 91:
+            print(f"received too many bytes ({len(self.buffer)})")
+            self.buffer = bytearray()  # Clear the buffer
+
+    async def list_services(self):
+        for service in self.client.services:
+            print("[Service] %s", service)
+            for char in service.characteristics:
+                print("  [Characteristic] ", char, ",".join(char.properties))
+                for descriptor in char.descriptors:
+                    try:
+                        value = await self.client.read_gatt_descriptor(descriptor.handle)
+                        print("    [Descriptor] ", descriptor, value)
+                    except Exception as e:
+                        print("    [Descriptor] ", descriptor, e)
+
+    async def get_device_name(self):
+        device_name = await self.client.read_gatt_char(self.DEVICE_NAME_UUID)  # Read the device name from the BLE device
+        return "".join(map(chr, device_name))
+
+    async def request_details(self):
+        await self.write(0xFE043030002bbf1a)  # Send a request for details to the BLE device
+
+    @staticmethod
+    def solar_panel_charge_state(v: int):
+        if 0:
+            return "invalid"
+        elif 1:
+            return "float_charge"
+        elif 2: 
+            return "boost_charge"
+        elif 3:
+            return "equal_charge"
+        else:
+            return "fault"
+        
+    @staticmethod
+    def load_discharge_state(v: int):
+        fault = v & 2 == 1
+        if not fault and v & 1:
+            return "enabled"
+        elif not fault:
+            return "disabled"
+        elif (v >> 2) & 1:
+            return  "over_temperature"
+        elif (v >> 3) & 1:
+            return "open_circuit_protection"
+        elif (v >> 4) & 1:
+            return "hardware_protection"
+        elif (v >> 11) & 1:
+            return "short_circuit_protection"
+        else:
+            return str((v >> 12) & 0b11)
+
+    @staticmethod
+    def parse_details_response(data):
+        if len(data) != 91:
+            return None
+        subdata = data[3:-2]
+        return {
+            "equipment_id": struct.unpack_from(">H", subdata, 0)[0],
+            "run_days": struct.unpack_from(">H", subdata, 2)[0],
+            "battery_full_level": struct.unpack_from(">H", subdata, 4)[0] / 100,
+            "battery_state_1": struct.unpack_from(">H", subdata, 6)[0] & 0xF0 >> 4,
+            "battery_state_2": struct.unpack_from(">H", subdata, 6)[0] & 0x0F,
+            "solar_panel_is_charging": struct.unpack_from(">H", subdata, 8)[0] & 1 > 0,
+            "solar_panel_is_night": struct.unpack_from(">H", subdata, 8)[0] & 32 > 0,
+            "solar_panel_charge_state": BleClient.solar_panel_charge_state(struct.unpack_from(">H", subdata, 8)[0] & 0b1100 >> 2),
+            "solar_panel_state": struct.unpack_from(">H", subdata, 8)[0] & 16 > 0,
+            "load_is_enabled": struct.unpack_from(">H", subdata, 10)[0] & 1 > 0,
+            "load_state": BleClient.load_discharge_state(struct.unpack_from(">H", subdata, 10)[0]),
+            "temperature_1": struct.unpack_from(">H", subdata, 12)[0] / 100,
+            "temperature_2": struct.unpack_from(">H", subdata, 14)[0] / 100,
+            "battery_empty_times": struct.unpack_from(">H", subdata, 16)[0],
+            "battery_full_times": struct.unpack_from(">H", subdata, 18)[0],
+            "battery_percentage": struct.unpack_from(">H", subdata, 42)[0],
+            "battery_voltage": struct.unpack_from(">H", subdata, 44)[0] / 100,
+            "battery_current": struct.unpack_from(">h", subdata, 46)[0] / 100,
+            "battery_power": (struct.unpack_from(">H", subdata, 48)[0] + struct.unpack_from(">h", subdata, 50)[0]  * 0x100) / 100,
+            "load_voltage": struct.unpack_from(">H", subdata, 52)[0] / 100,
+            "load_current": struct.unpack_from(">H", subdata, 54)[0] / 100,
+            "load_power": (struct.unpack_from(">H", subdata, 56)[0] | struct.unpack_from(">H", subdata, 58)[0] << 16) / 100,
+            "solar_panel_voltage": struct.unpack_from(">H", subdata, 60)[0] / 100,
+            "solar_panel_current": struct.unpack_from(">H", subdata, 62)[0] / 100,
+            "solar_panel_power": (struct.unpack_from(">H", subdata, 64)[0] | struct.unpack_from(">H", subdata, 66)[0] << 16) / 100,
+            "solar_panel_daily_energy": struct.unpack_from(">H", subdata, 68)[0] / 100,
+            "solar_panel_total_energy": (struct.unpack_from(">H", subdata, 70)[0] | struct.unpack_from(">H", subdata, 72)[0] << 16) / 100,
+            "load_daily_energy": struct.unpack_from(">H", subdata, 74)[0] / 100,
+            "load_total_energy": struct.unpack_from(">I", subdata, 78)[0] / 100,
+        }
+
+    # Map the keys to their respective units of measurement
+    @staticmethod
+    def get_unit_of_measurement(key):
+        unit_mapping = {
+            "solar_panel_is_charging": None,
+            "solar_panel_is_night": None,
+            "solar_panel_charge_state": None,
+            "solar_panel_state": None,
+            "load_is_enabled": None,
+            "load_state": None,
+            "temperature_1": "°C",
+            "temperature_2": "°C",
+            "battery_percentage": "%",
+            "battery_voltage": "V",
+            "battery_current": "A",
+            "battery_power": "W",
+            "load_voltage": "V",
+            "load_current": "A",
+            "load_power": "W",
+            "solar_panel_voltage": "V",
+            "solar_panel_current": "A",
+            "solar_panel_power": "W",
+            "solar_panel_daily_energy": "kWh",
+            "solar_panel_total_energy": "kWh",
+            "load_daily_energy": "kWh",
+            "load_total_energy": "kWh",
+        }
+        return unit_mapping.get(key, None)

+ 13 - 0
crc.py

@@ -0,0 +1,13 @@
+def crc16(data):
+    bArr2 = [0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64, 0, -63, -127, 64, 1, -64, -128, 65, 0, -63, -127, 64, 1, -64, -128, 65, 1, -64, -128, 65, 0, -63, -127, 64]
+    bArr3 = [0, -64, -63, 1, -61, 3, 2, -62, -58, 6, 7, -57, 5, -59, -60, 4, -52, 12, 13, -51, 15, -49, -50, 14, 10, -54, -53, 11, -55, 9, 8, -56, -40, 24, 25, -39, 27, -37, -38, 26, 30, -34, -33, 31, -35, 29, 28, -36, 20, -44, -43, 21, -41, 23, 22, -42, -46, 18, 19, -45, 17, -47, -48, 16, -16, 48, 49, -15, 51, -13, -14, 50, 54, -10, -9, 55, -11, 53, 52, -12, 60, -4, -3, 61, -1, 63, 62, -2, -6, 58, 59, -5, 57, -7, -8, 56, 40, -24, -23, 41, -21, 43, 42, -22, -18, 46, 47, -17, 45, -19, -20, 44, -28, 36, 37, -27, 39, -25, -26, 38, 34, -30, -29, 35, -31, 33, 32, -32, -96, 96, 97, -95, 99, -93, -94, 98, 102, -90, -89, 103, -91, 101, 100, -92, 108, -84, -83, 109, -81, 111, 110, -82, -86, 106, 107, -85, 105, -87, -88, 104, 120, -72, -71, 121, -69, 123, 122, -70, -66, 126, 127, -65, 125, -67, -68, 124, -76, 116, 117, -75, 119, -73, -74, 118, 114, -78, -77, 115, -79, 113, 112, -80, 80, -112, -111, 81, -109, 83, 82, -110, -106, 86, 87, -105, 85, -107, -108, 84, -100, 92, 93, -99, 95, -97, -98, 94, 90, -102, -101, 91, -103, 89, 88, -104, -120, 72, 73, -119, 75, -117, -118, 74, 78, -114, -113, 79, -115, 77, 76, -116, 68, -124, -123, 69, -121, 71, 70, -122, -126, 66, 67, -125, 65, -127, -128, 64]
+    i = 0
+    b2 = 255
+    b3 = 255
+    for i in range(len(data)):
+        b4 = (b3 ^ data[i]) & 255
+        b5 = bArr3[b4] & 255
+        b3 = b2 ^ (bArr2[b4] & 255)
+        b2 = b5
+    b6 = ((b2 & 255) << 8) | (b3 & 255 & 65535)
+    return (((b6 & 255) << 8) | ((65280 & b6) >> 8)).to_bytes(2, 'big')

BIN
images/hass.png


+ 143 - 0
main.py

@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+
+import argparse
+import asyncio
+import signal
+import json
+
+import paho.mqtt.client as mqtt
+from bleak.exc import BleakError, BleakDeviceNotFoundError
+
+from bleclient import BleClient
+
+client = mqtt.Client()
+send_config = True
+
+def details_handler(details):
+    if details:
+        print(f"Battery: {details['battery_percentage']}% ({details['battery_voltage']}V)")
+        mqtt_publish(details, client)
+    else:
+        print("No values recieved")
+
+
+def mqtt_publish(details, client):
+    global send_config
+    # Define the base topic for MQTT Discovery
+    base_topic = "homeassistant"
+
+    # Define the device information
+    device_info = {
+        "identifiers": ["solarlife_mppt_ble"],
+        "name": "Solarlife MPPT",
+        "manufacturer": "Solarlife",
+    }
+
+    # Publish each item in the details dictionary to its own MQTT topic
+    for key, value in details.items():
+        state_topic = f"{base_topic}/sensor/solarlife/{key}/state"
+        topic = f"{base_topic}/sensor/solarlife/{key}/config"
+
+        # Create the MQTT Discovery payload
+        payload = {
+            "name": f"Solarlife {key.replace('_', ' ').title()}",
+            "device": device_info,
+            "unique_id": f"solarlife_{key}",
+            "state_topic": state_topic,
+            "unit_of_measurement": BleClient.get_unit_of_measurement(key)
+        }
+        if "daily_energy" in key:
+            payload['device_class'] = "energy"
+            payload['state_class'] = "total_increasing"
+        elif "total_energy" in key:
+            payload['device_class'] = "energy"
+            payload['state_class'] = "total"
+        elif "voltage" in key:
+            payload['device_class'] = "voltage"
+            payload['state_class'] = "measurement"
+        elif "current" in key:
+            payload['device_class'] = "current"
+            payload['state_class'] = "measurement"
+        elif "power" in key:
+            payload['device_class'] = "power"
+            payload['state_class'] = "measurement"
+        elif "temperature" in key:
+            payload['device_class'] = "temperature"
+            payload['state_class'] = "measurement"
+        elif key == "battery_percentage":
+            payload['device_class'] = "battery"
+            payload['state_class'] = "measurement"
+
+        # Publish the MQTT Discovery payload
+        if send_config:
+            client.publish(topic, payload=json.dumps(payload), retain=True)
+
+        # Publish the entity state
+        client.publish(state_topic, payload=str(value))
+    send_config = False
+    
+async def main(address, host, port, username, password):
+    client.username_pw_set(username, password)  # Set MQTT username and password
+
+    async def run_mppt():
+        while True:
+            try:
+                client.connect(host, port)  # Connect to the MQTT broker
+                break  # Connection successful, exit the loop
+
+            except asyncio.CancelledError:
+                raise  # Re-raise the CancelledError to stop the task
+            except Exception as e:
+                print(f"An error occurred while connecting to MQTT broker: {e}")
+                await asyncio.sleep(5)  # Wait for 5 seconds before retrying
+
+        while True:
+            try:
+                async with BleClient(address) as mppt:
+                    mppt.on_details_received = details_handler
+                    await mppt.request_details()
+
+                    while True:
+                        await asyncio.sleep(20.0)
+                        try:
+                            await mppt.request_details()
+                        except BleakError as e:
+                            print(f"BLE error occurred: {e}")
+                            # Handle the BLE error accordingly, e.g., reconnect or terminate the task
+                            break
+
+            except asyncio.CancelledError:
+                raise  # Re-raise the CancelledError to stop the task
+            except BleakDeviceNotFoundError:
+                print(f"BLE device with address {address} was not found")
+                await asyncio.sleep(5)  # Wait for 5 seconds before retrying
+            except Exception as e:
+                print(f"An error occurred during BLE communication: {e}")
+                await asyncio.sleep(5)  # Wait for 5 seconds before retrying
+
+    try:
+        loop = asyncio.get_running_loop()
+        task = loop.create_task(run_mppt())
+
+        # Setup signal handler to cancel the task on termination
+        for signame in {'SIGINT', 'SIGTERM'}:
+            loop.add_signal_handler(getattr(signal, signame),
+                                    task.cancel)
+
+        await task  # Wait for the task to complete
+
+    except asyncio.CancelledError:
+        pass  # Task was cancelled, no need for an error message
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description='Solarlife MPPT BLE Client')
+    parser.add_argument('address', help='BLE device address')
+    parser.add_argument('--host', help='MQTT broker host', default='localhost')
+    parser.add_argument('--port', help='MQTT broker port', default=1883, type=int)
+    parser.add_argument('--username', help='MQTT username')
+    parser.add_argument('--password', help='MQTT password')
+
+    args = parser.parse_args()
+
+    asyncio.run(main(args.address, args.host, args.port, args.username, args.password))

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+paho-mqtt==1.6.1
+bleak==0.20.2