diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..f238bf7 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile = black diff --git a/Readme.md b/Readme.md index f39ad8e..c35d7dc 100644 --- a/Readme.md +++ b/Readme.md @@ -8,8 +8,11 @@ Python library for interracting with the rather generic MPPT solar charge contro The Android app suggested for the bluetooth interface is [SolarApp](https://play.google.com/store/apps/details?id=com.shuori.gfv2.guangfu) by srne -(I'm not currently able to find the bluetooth bridge on Biltema's website? +([Biltema 25-5079](https://www.biltema.no/bil---mc/elektrisk-anlegg/solcellspaneler/fjernstyringsenhet-2000046542) It's got BT-1 printed on the front, and is basically just a RS-232 to BTLE UART GATT) [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://github.com/pre-commit/pre-commit) ![example workflow](https://github.com/oddstr13/SolarMPPT/actions/workflows/pre-commit/badge.svg) + +- [SRNE ML2420](https://www.srnesolar.com/product/mppt-solar-charge-controller-ml2420-2) +- [SRNE-BT-1](https://www.strømløs.no/tilbeh%c3%b8r/srne-bt-1/srne-bt-bluetooth-adapter) diff --git a/draw_memory_map.py b/misc/draw_memory_map.py similarity index 98% rename from draw_memory_map.py rename to misc/draw_memory_map.py index a15811c..37a3513 100644 --- a/draw_memory_map.py +++ b/misc/draw_memory_map.py @@ -2,9 +2,10 @@ from ast import literal_eval from typing import Iterable, List -from solar_ble import parse_packet from table_drawing import table +from srnemqtt.protocol import parse_packet + def memory_table( data: Iterable[int], diff --git a/memory_dump.txt b/misc/memory_dump.txt similarity index 100% rename from memory_dump.txt rename to misc/memory_dump.txt diff --git a/render_rrd.py b/misc/render_rrd.py similarity index 99% rename from render_rrd.py rename to misc/render_rrd.py index e5d97e8..2ca196e 100644 --- a/render_rrd.py +++ b/misc/render_rrd.py @@ -7,7 +7,7 @@ from typing import Any, Dict import rrdtool -from solar_types import DataName +from srnemqtt.solar_types import DataName DT_FORMAT = "%Y-%m-%d %H:%M:%S.%f" diff --git a/table_drawing.py b/misc/table_drawing.py similarity index 100% rename from table_drawing.py rename to misc/table_drawing.py diff --git a/test_bleuart.py b/misc/test_bleuart.py similarity index 68% rename from test_bleuart.py rename to misc/test_bleuart.py index 0f9fb21..1f28414 100644 --- a/test_bleuart.py +++ b/misc/test_bleuart.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -from feasycom_ble import BTLEUart -from solar_ble import MAC, construct_request, write +from srnemqtt.constants import MAC +from srnemqtt.lib.feasycom_ble import BTLEUart +from srnemqtt.protocol import construct_request, write with BTLEUart(MAC, timeout=1) as x: diff --git a/solar_ble.py b/solar_ble.py deleted file mode 100755 index a0518b1..0000000 --- a/solar_ble.py +++ /dev/null @@ -1,458 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -import datetime -import struct -import sys -import time -from decimal import Decimal -from io import RawIOBase -from typing import Callable, Collection, Optional, cast - -from bluepy import btle -from libscrc import modbus - -from feasycom_ble import BTLEUart -from solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem, DataName -from test_config import get_config, get_consumers - -MAC = "DC:0D:30:9C:61:BA" - -# write_service = "0000ffd0-0000-1000-8000-00805f9b34fb" -# read_service = "0000fff0-0000-1000-8000-00805f9b34fb" - -ACTION_READ = 0x03 -ACTION_WRITE = 0x03 - -POSSIBLE_MARKER = (0x01, 0xFD, 0xFE, 0xFF) - -# get(255, 12, 2) -# "ff 03 00 0c 00 02" -CMD_GET_1 = b"\xff\x03\x00\x0c\x00\x02" -# > ff 03 04 20 20 20 20 - -# get(255, 12, 8) -# ff 03 00 0c 00 08 -CMD_GET_MODEL = b"\xff\x03\x00\x0c\x00\x08" -# > ff 03 10 20 20 20 20 4d 4c 32 34 32 30 20 20 20 20 20 20 -# Device SKU: ML2420 - -# get(255, 20, 4) -# ff 03 00 14 00 04 -CMD_GET_VERSION = b"\xff\x03\x00\x14\x00\x04" -# > ff 03 08 00 04 02 00 02 00 00 03 -# CC ?? 11 22 33 ?? 44 55 66 -# Version: 4.2.0 - -# get(255, 24, 3) -# ff 03 00 18 00 03 -CMD_GET_SERIAL = b"\xff\x03\x00\x18\x00\x03" -# > ff 03 06 3c 13 02 67 00 01 -# CC 11 22 33 33 ?? ?? -# SN: 60-19-0615 - -# get(255, 256, 7) -# ff 03 01 00 00 07 -CMD_GET_BATTERY_STATE = b"\xff\x03\x01\x00\x00\x07" -# > ff 03 0e 00 48 00 7e 00 1d 0e 0d 00 7e 00 1c 00 03 -# CC 11 11 22 22 33 33 44 55 66 66 77 77 88 88 -# 1: Battery charge: 72 % -# 2: Battery voltage: 12.6 V -# 3: Battery current: 0.29 A -# 4: Internal temperature? -# 5: External temperature probe for battery signet 8bit: 13 degC -# 6: Load voltage: 12.6 V -# 7: Load current: 0.28 A -# 8: Load power: 3 W - -# get(255, 263, 4) -# ff 03 01 07 00 04 -CMD_GET_PANEL_STATUS = b"\xff\x03\x01\x07\x00\x04" -# > ff 03 08 00 c8 00 14 00 04 00 01 -# CC 11 11 22 22 33 33 ?? ?? -# 1: Panel voltage: 20.0 V -# 2: Panel current: 0.20 A -# 3: Panel power: 4 W -# Charging status? - -# set(255, 266, 1 or 0) -# ff 06 01 0a 00 01 -CMD_ENABLE_LOAD = b"\xff\x06\x01\x0a\x00\x01" -CMD_DISABLE_LOAD = b"\xff\x06\x01\x0a\x00\x00" -REG_LOAD_ENABLE = 0x010A - -# get(255, 267, 21) -# ff 03 01 0b 00 15 -CMD_GET_LOAD_PARAMETERS = b"\xff\x03\x01\x0b\x00\x15" -# > ff 03 2a 00 7c 00 7f 00 51 00 20 00 0a 00 03 00 00 00 00 00 -# > 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 -# > 00 00 00 00 00 - -# get(255, 288, 3) -# ff 03 01 20 00 03 -CMD_GET_2 = b"\xff\x03\x01\x20\x00\x03" -# > ff 03 06 80 02 00 00 00 00 -# CC 11 22 33 33 33 33 -# 1: boolean flag?: 1 -# 2: ?: 2 -# 3: ?: 0 - -# get(255, 57345, 33) -# ff 03 e0 01 00 21 -CMD_GET_BATTERY_PARAMETERS = b"\xff\x03\xe0\x01\x00\x21" -# > ff 03 42 07 d0 00 c8 ff 0c 00 02 00 a0 00 9b 00 92 00 90 00 -# > 8a 00 84 00 7e 00 78 00 6f 00 6a 64 32 00 05 00 78 00 78 00 -# > 1e 00 03 00 41 00 a3 00 4b 00 a3 00 00 00 00 00 00 00 00 00 -# > 0f 00 05 00 05 00 04 01 00 -# 33 * uint16 - -# get(1, 61440, 10) -# 01 03 f0 00 00 0a -CMD_GET_HISTORICAL_TODAY = b"\x01\x03\xf0\x00\x00\x0a" -CMD_GET_HISTORICAL_YESTERDAY = b"\x01\x03\xf0\x01\x00\x0a" -CMD_GET_HISTORICAL_D2 = b"\x01\x03\xf0\x02\x00\x0a" -CMD_GET_HISTORICAL_D3 = b"\x01\x03\xf0\x03\x00\x0a" - -# ,- battery_min_voltage -# | ,- battery_max_voltage -# | | ,- ?1 max charge %? -# | | | ,- ?2 -# | | | | ,- charge_max_power -# | | | | | ,- discharge_max_power -# | | | | | | ,- charge_amp_hour -# | | | | | | | ,- discharge_amp_hour -# | | | | | | | | ,- production_power -# | | | | | | | | | ,- consumption_power -# _|___ _|___ _|___ _|___ _|___ _|___ _|___ _|___ _|___ _|___ -# > 01 03 14 00 7c 00 7f 00 51 00 20 00 0a 00 03 00 00 00 00 00 00 00 00 -# > 01 03 14 00 7c 00 7f 00 53 00 20 00 0a 00 03 00 00 00 00 00 00 00 00 -# battery_min_voltage = 12.4 V -# battery_max_voltage = 12.7 V -# ?1 = 83 % ? -# ?2 = -# charge_max_power = 10 W -# discharge_max_power = 3 W -# charge_amp_hour = 0 Ah -# discharge_amp_hour = 0 Ah -# production_power = 0 Wh -# consumption_power = 0 Wh - -# ff 78 00 00 00 01 -CMD_ = b"\xff\x78\x00\x00\x00\x01" - -# CMD_GET_BATTERY_STATE = b'\xff\x03\x01\x00\x00\x07' -# > ff 03 0e 00 48 00 7e 00 1d 0e 0d 00 7e 00 1c 00 03 -# CC 11 11 22 22 33 33 44 55 66 66 77 77 88 88 -# 1: Battery charge: 72 % -# 2: Battery voltage: 12.6 V -# 3: Battery current: 0.29 A -# 4: Internal temperature? -# 5: External temperature probe for battery signed 8bit: 13 degC -# 6: Load voltage: 12.6 V -# 7: Load current: 0.28 A -# 8: Load power: 3 W - -# CMD_GET_PANEL_STATUS = b'\xff\x03\x01\x07\x00\x04' -# > ff 03 08 00 c8 00 14 00 04 00 01 -# CC 11 11 22 22 33 33 ?? ?? -# > ff 03 08 00 00 00 00 00 00 00 00 -# 1: Panel voltage: 20.0 V -# 2: Panel current: 0.20 A -# 3: Panel power: 4 W -# ?: load_enabled - - -# Only factor of 1000 -SI_PREFIXES_LARGE = "kMGTPEZY" -SI_PREFIXES_SMALL = "mµnpfazy" - - -def humanize_number(data, unit: str = ""): - counter = 0 - - while data >= 1000: - data /= 1000 - counter += 1 - if counter >= len(SI_PREFIXES_LARGE): - break - - while data < 1: - data *= 1000 - counter -= 1 - if abs(counter) >= len(SI_PREFIXES_SMALL): - break - - if not counter: - prefix = "" - elif counter > 0: - prefix = SI_PREFIXES_LARGE[counter - 1] - elif counter < 0: - prefix = SI_PREFIXES_SMALL[abs(counter) - 1] - - return f"{data:.3g} {prefix}{unit}" - - -def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict: - pos = offset - res = {} - - for i in items: - res[i.name] = i.transform(struct.unpack_from(i.st_format, data, offset=pos)[0]) - pos += i.st_size - - return res - - -# GET_BATTERY_STATE -def parse_battery_state(data: bytes) -> dict: - return parse(data, DATA_BATTERY_STATE) - - -def parse_historical_entry(data: bytes) -> dict: - res = parse(data, HISTORICAL_DATA[:10]) - - res_datalen = sum([x.st_size for x in HISTORICAL_DATA[:10]]) - - if len(data) > res_datalen: - res.update(parse(data, HISTORICAL_DATA[10:], offset=res_datalen)) - - return res - - -def write(fh, data): - bdata = bytes(data) - crc = modbus(bdata) - bcrc = bytes([crc & 0xFF, (crc & 0xFF00) >> 8]) - fh.write(data + bcrc) - - -def construct_request(address, words=1, action=ACTION_READ, marker=0xFF): - assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" - return struct.pack("!BBHH", marker, action, address, words) - - -def log(*message: object, **kwargs): - print(datetime.datetime.utcnow().isoformat(" "), *message, **kwargs) - sys.stdout.flush() - - -def parse_packet(data): - tag, operation, size = struct.unpack_from("BBB", data) - _unpacked = struct.unpack_from(f"<{size}BH", data, offset=3) - crc = _unpacked[-1] - payload = _unpacked[:-1] - calculated_crc = modbus(bytes([tag, operation, size, *payload])) - - if crc != calculated_crc: - e = ValueError(f"CRC missmatch: expected {crc:04X}, got {calculated_crc:04X}.") - e.tag = tag - e.operation = operation - e.size = size - e.payload = payload - e.crc = crc - e.calculated_crc = calculated_crc - raise e - - return payload - - -def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: - assert byte >= 0 and byte < 256, f"byte: Expected 8bit unsigned int, got {byte}" - - def expand(b: Optional[bytes]): - if b is None: - return b - return b[0] - - start = time.time() - discarded = 0 - read_byte = expand(fh.read(1)) - while read_byte != byte: - - if read_byte is not None: - if not discarded: - log("Discarding", end="") - discarded += 1 - print(f" {read_byte:02X}", end="") - sys.stdout.flush() - - if time.time() - start > timeout: - read_byte = None - break - - read_byte = expand(fh.read(1)) - - if discarded: - print() - sys.stdout.flush() - - return read_byte - - -def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: - # log(f"Reading {words} words from 0x{address:04X}") - request = construct_request(address, words=words) - # log("Request:", request) - write(fh, request) - - tag = discardUntil(fh, 0xFF) - if tag is None: - return None - - header = fh.read(2) - if header and len(header) == 2: - operation, size = header - data = fh.read(size) - _crc = fh.read(2) - if data and _crc: - try: - crc = struct.unpack_from(" bool: - if now is None: - now = time.time() - - if (now - self.prev) >= self.interval: - skipped, overshoot = divmod(now - self.prev, self.interval) - skipped -= 1 - if skipped: - log("Skipped:", skipped, overshoot, now - self.prev, self.interval) - self.prev = now - overshoot - return True - - return False - - -def try_read_parse( - dev: BTLEUart, - address: int, - words: int = 1, - parser: Callable = None, - attempts=5, -) -> Optional[dict]: - while attempts: - attempts -= 1 - res = readMemory(dev, address, words) - if res: - try: - if parser: - return parser(res) - except struct.error as e: - log(e) - log("0x0100 Unpack error:", len(res), res) - log("Flushed from read buffer; ", dev.read(timeout=0.5)) - else: - log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") - return None - - -if __name__ == "__main__": - conf = get_config() - consumers = get_consumers(conf) - - per_voltages = Periodical(interval=15) - per_current_hist = Periodical(interval=60) - - try: - while True: - try: - log("Connecting...") - with BTLEUart(MAC, timeout=5) as dev: - log("Connected.") - - # write(dev, construct_request(0, 32)) - - # Memory dump - # for address in range(0, 0x10000, 16): - # log(f"Reading 0x{address:04X}...") - # write(wd, construct_request(address, 16)) - days = 7 - res = try_read_parse(dev, 0x010B, 21, parse_historical_entry) - if res: - log(res) - for consumer in consumers: - consumer.write(res) - days = cast(int, res.get("run_days", 7)) - - for i in range(days): - res = try_read_parse( - dev, 0xF000 + i, 10, parse_historical_entry - ) - if res: - log({i: res}) - for consumer in consumers: - consumer.write({str(i): res}) - - while True: - now = time.time() - - if per_voltages(now): - data = try_read_parse(dev, 0x0100, 11, parse_battery_state) - if data: - data[DataName.CALCULATED_BATTERY_POWER] = float( - Decimal(str(data.get(DataName.BATTERY_VOLTAGE, 0))) - * Decimal( - str(data.get(DataName.BATTERY_CURRENT, 0)) - ) - ) - data[DataName.CALCULATED_PANEL_POWER] = float( - Decimal(str(data.get(DataName.PANEL_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.PANEL_CURRENT, 0))) - ) - data[DataName.CALCULATED_LOAD_POWER] = float( - Decimal(str(data.get(DataName.LOAD_VOLTAGE, 0))) - * Decimal(str(data.get(DataName.LOAD_CURRENT, 0))) - ) - log(data) - for consumer in consumers: - consumer.write(data) - - if per_current_hist(now): - data = try_read_parse( - dev, 0x010B, 21, parse_historical_entry - ) - if data: - log(data) - for consumer in consumers: - consumer.write(data) - - # print(".") - for consumer in consumers: - consumer.poll() - - time.sleep(max(0, 1 - time.time() - now)) - - # if STATUS.get('load_enabled'): - # write(wd, CMD_DISABLE_LOAD) - # else: - # write(wd, CMD_ENABLE_LOAD) - - except btle.BTLEDisconnectError: - log("ERROR: Disconnected") - time.sleep(1) - - except (KeyboardInterrupt, SystemExit, Exception) as e: - for consumer in consumers: - consumer.exit() - - if type(e) is not KeyboardInterrupt: - raise diff --git a/srnemqtt/__init__.py b/srnemqtt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/srnemqtt/__main__.py b/srnemqtt/__main__.py new file mode 100755 index 0000000..70cb669 --- /dev/null +++ b/srnemqtt/__main__.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import time +from decimal import Decimal +from typing import cast + +from bluepy import btle + +from .config import get_config, get_consumers +from .constants import MAC +from .lib.feasycom_ble import BTLEUart +from .protocol import parse_battery_state, parse_historical_entry, try_read_parse +from .solar_types import DataName +from .util import Periodical, log + + +def main(): + conf = get_config() + consumers = get_consumers(conf) + + per_voltages = Periodical(interval=15) + per_current_hist = Periodical(interval=60) + + try: + while True: + try: + log("Connecting...") + with BTLEUart(MAC, timeout=5) as dev: + log("Connected.") + + # write(dev, construct_request(0, 32)) + + # Memory dump + # for address in range(0, 0x10000, 16): + # log(f"Reading 0x{address:04X}...") + # write(wd, construct_request(address, 16)) + days = 7 + res = try_read_parse(dev, 0x010B, 21, parse_historical_entry) + if res: + log(res) + for consumer in consumers: + consumer.write(res) + days = cast(int, res.get("run_days", 7)) + + for i in range(days): + res = try_read_parse( + dev, 0xF000 + i, 10, parse_historical_entry + ) + if res: + log({i: res}) + for consumer in consumers: + consumer.write({str(i): res}) + + while True: + now = time.time() + + if per_voltages(now): + data = try_read_parse(dev, 0x0100, 11, parse_battery_state) + if data: + data[DataName.CALCULATED_BATTERY_POWER] = float( + Decimal(str(data.get(DataName.BATTERY_VOLTAGE, 0))) + * Decimal( + str(data.get(DataName.BATTERY_CURRENT, 0)) + ) + ) + data[DataName.CALCULATED_PANEL_POWER] = float( + Decimal(str(data.get(DataName.PANEL_VOLTAGE, 0))) + * Decimal(str(data.get(DataName.PANEL_CURRENT, 0))) + ) + data[DataName.CALCULATED_LOAD_POWER] = float( + Decimal(str(data.get(DataName.LOAD_VOLTAGE, 0))) + * Decimal(str(data.get(DataName.LOAD_CURRENT, 0))) + ) + log(data) + for consumer in consumers: + consumer.write(data) + + if per_current_hist(now): + data = try_read_parse( + dev, 0x010B, 21, parse_historical_entry + ) + if data: + log(data) + for consumer in consumers: + consumer.write(data) + + # print(".") + for consumer in consumers: + consumer.poll() + + time.sleep(max(0, 1 - time.time() - now)) + + # if STATUS.get('load_enabled'): + # write(wd, CMD_DISABLE_LOAD) + # else: + # write(wd, CMD_ENABLE_LOAD) + + except btle.BTLEDisconnectError: + log("ERROR: Disconnected") + time.sleep(1) + + except (KeyboardInterrupt, SystemExit, Exception) as e: + for consumer in consumers: + consumer.exit() + + if type(e) is not KeyboardInterrupt: + raise + + +if __name__ == "__main__": + main() diff --git a/test_config.py b/srnemqtt/config.py similarity index 93% rename from test_config.py rename to srnemqtt/config.py index e555bb7..de435eb 100644 --- a/test_config.py +++ b/srnemqtt/config.py @@ -6,13 +6,13 @@ from typing import Any, Dict, List, Optional, Type import yaml -from consumers import BaseConsumer +from .consumers import BaseConsumer def get_consumer(name: str) -> Optional[Type[BaseConsumer]]: mod_name, cls_name = name.rsplit(".", 1) - mod = importlib.import_module(f"consumers.{mod_name}") + mod = importlib.import_module(f".consumers.{mod_name}", package=__package__) # print(mod) # print(dir(mod)) diff --git a/srnemqtt/constants.py b/srnemqtt/constants.py new file mode 100644 index 0000000..2a13eac --- /dev/null +++ b/srnemqtt/constants.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +MAC = "DC:0D:30:9C:61:BA" + +# write_service = "0000ffd0-0000-1000-8000-00805f9b34fb" +# read_service = "0000fff0-0000-1000-8000-00805f9b34fb" + +ACTION_READ = 0x03 +ACTION_WRITE = 0x03 + +POSSIBLE_MARKER = (0x01, 0xFD, 0xFE, 0xFF) + +# get(255, 12, 2) +# "ff 03 00 0c 00 02" +CMD_GET_1 = b"\xff\x03\x00\x0c\x00\x02" +# > ff 03 04 20 20 20 20 + +# get(255, 12, 8) +# ff 03 00 0c 00 08 +CMD_GET_MODEL = b"\xff\x03\x00\x0c\x00\x08" +# > ff 03 10 20 20 20 20 4d 4c 32 34 32 30 20 20 20 20 20 20 +# Device SKU: ML2420 + +# get(255, 20, 4) +# ff 03 00 14 00 04 +CMD_GET_VERSION = b"\xff\x03\x00\x14\x00\x04" +# > ff 03 08 00 04 02 00 02 00 00 03 +# CC ?? 11 22 33 ?? 44 55 66 +# Version: 4.2.0 + +# get(255, 24, 3) +# ff 03 00 18 00 03 +CMD_GET_SERIAL = b"\xff\x03\x00\x18\x00\x03" +# > ff 03 06 3c 13 02 67 00 01 +# CC 11 22 33 33 ?? ?? +# SN: 60-19-0615 + +# get(255, 256, 7) +# ff 03 01 00 00 07 +CMD_GET_BATTERY_STATE = b"\xff\x03\x01\x00\x00\x07" +# > ff 03 0e 00 48 00 7e 00 1d 0e 0d 00 7e 00 1c 00 03 +# CC 11 11 22 22 33 33 44 55 66 66 77 77 88 88 +# 1: Battery charge: 72 % +# 2: Battery voltage: 12.6 V +# 3: Battery current: 0.29 A +# 4: Internal temperature? +# 5: External temperature probe for battery signet 8bit: 13 degC +# 6: Load voltage: 12.6 V +# 7: Load current: 0.28 A +# 8: Load power: 3 W + +# get(255, 263, 4) +# ff 03 01 07 00 04 +CMD_GET_PANEL_STATUS = b"\xff\x03\x01\x07\x00\x04" +# > ff 03 08 00 c8 00 14 00 04 00 01 +# CC 11 11 22 22 33 33 ?? ?? +# 1: Panel voltage: 20.0 V +# 2: Panel current: 0.20 A +# 3: Panel power: 4 W +# Charging status? + +# set(255, 266, 1 or 0) +# ff 06 01 0a 00 01 +CMD_ENABLE_LOAD = b"\xff\x06\x01\x0a\x00\x01" +CMD_DISABLE_LOAD = b"\xff\x06\x01\x0a\x00\x00" +REG_LOAD_ENABLE = 0x010A + +# get(255, 267, 21) +# ff 03 01 0b 00 15 +CMD_GET_LOAD_PARAMETERS = b"\xff\x03\x01\x0b\x00\x15" +# > ff 03 2a 00 7c 00 7f 00 51 00 20 00 0a 00 03 00 00 00 00 00 +# > 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 +# > 00 00 00 00 00 + +# get(255, 288, 3) +# ff 03 01 20 00 03 +CMD_GET_2 = b"\xff\x03\x01\x20\x00\x03" +# > ff 03 06 80 02 00 00 00 00 +# CC 11 22 33 33 33 33 +# 1: boolean flag?: 1 +# 2: ?: 2 +# 3: ?: 0 + +# get(255, 57345, 33) +# ff 03 e0 01 00 21 +CMD_GET_BATTERY_PARAMETERS = b"\xff\x03\xe0\x01\x00\x21" +# > ff 03 42 07 d0 00 c8 ff 0c 00 02 00 a0 00 9b 00 92 00 90 00 +# > 8a 00 84 00 7e 00 78 00 6f 00 6a 64 32 00 05 00 78 00 78 00 +# > 1e 00 03 00 41 00 a3 00 4b 00 a3 00 00 00 00 00 00 00 00 00 +# > 0f 00 05 00 05 00 04 01 00 +# 33 * uint16 + +# get(1, 61440, 10) +# 01 03 f0 00 00 0a +CMD_GET_HISTORICAL_TODAY = b"\x01\x03\xf0\x00\x00\x0a" +CMD_GET_HISTORICAL_YESTERDAY = b"\x01\x03\xf0\x01\x00\x0a" +CMD_GET_HISTORICAL_D2 = b"\x01\x03\xf0\x02\x00\x0a" +CMD_GET_HISTORICAL_D3 = b"\x01\x03\xf0\x03\x00\x0a" + +# ,- battery_min_voltage +# | ,- battery_max_voltage +# | | ,- ?1 max charge %? +# | | | ,- ?2 +# | | | | ,- charge_max_power +# | | | | | ,- discharge_max_power +# | | | | | | ,- charge_amp_hour +# | | | | | | | ,- discharge_amp_hour +# | | | | | | | | ,- production_power +# | | | | | | | | | ,- consumption_power +# _|___ _|___ _|___ _|___ _|___ _|___ _|___ _|___ _|___ _|___ +# > 01 03 14 00 7c 00 7f 00 51 00 20 00 0a 00 03 00 00 00 00 00 00 00 00 +# > 01 03 14 00 7c 00 7f 00 53 00 20 00 0a 00 03 00 00 00 00 00 00 00 00 +# battery_min_voltage = 12.4 V +# battery_max_voltage = 12.7 V +# ?1 = 83 % ? +# ?2 = +# charge_max_power = 10 W +# discharge_max_power = 3 W +# charge_amp_hour = 0 Ah +# discharge_amp_hour = 0 Ah +# production_power = 0 Wh +# consumption_power = 0 Wh + +# ff 78 00 00 00 01 +CMD_ = b"\xff\x78\x00\x00\x00\x01" + +# CMD_GET_BATTERY_STATE = b'\xff\x03\x01\x00\x00\x07' +# > ff 03 0e 00 48 00 7e 00 1d 0e 0d 00 7e 00 1c 00 03 +# CC 11 11 22 22 33 33 44 55 66 66 77 77 88 88 +# 1: Battery charge: 72 % +# 2: Battery voltage: 12.6 V +# 3: Battery current: 0.29 A +# 4: Internal temperature? +# 5: External temperature probe for battery signed 8bit: 13 degC +# 6: Load voltage: 12.6 V +# 7: Load current: 0.28 A +# 8: Load power: 3 W + +# CMD_GET_PANEL_STATUS = b'\xff\x03\x01\x07\x00\x04' +# > ff 03 08 00 c8 00 14 00 04 00 01 +# CC 11 11 22 22 33 33 ?? ?? +# > ff 03 08 00 00 00 00 00 00 00 00 +# 1: Panel voltage: 20.0 V +# 2: Panel current: 0.20 A +# 3: Panel power: 4 W +# ?: load_enabled diff --git a/consumers/__init__.py b/srnemqtt/consumers/__init__.py similarity index 100% rename from consumers/__init__.py rename to srnemqtt/consumers/__init__.py diff --git a/consumers/mqtt.py b/srnemqtt/consumers/mqtt.py similarity index 99% rename from consumers/mqtt.py rename to srnemqtt/consumers/mqtt.py index 5d471fc..6cd7497 100644 --- a/consumers/mqtt.py +++ b/srnemqtt/consumers/mqtt.py @@ -6,8 +6,7 @@ from uuid import uuid4 import paho.mqtt.client as mqtt -from solar_types import DataName - +from ..solar_types import DataName from . import BaseConsumer MAP_VALUES: Dict[DataName, Dict[str, Any]] = { diff --git a/srnemqtt/consumers/stdio.py b/srnemqtt/consumers/stdio.py new file mode 100644 index 0000000..df63e70 --- /dev/null +++ b/srnemqtt/consumers/stdio.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +import json +from typing import Any, Dict + +from . import BaseConsumer + + +class StdoutConsumer(BaseConsumer): + def __init__(self, settings: Dict[str, Any]) -> None: + super().__init__(settings) + + def poll(self): + return super().poll() + + def write(self, data: Dict[str, Any]): + print(json.dumps(data)) diff --git a/srnemqtt/lib/__init__.py b/srnemqtt/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/feasycom_ble.py b/srnemqtt/lib/feasycom_ble.py similarity index 100% rename from feasycom_ble.py rename to srnemqtt/lib/feasycom_ble.py diff --git a/srnemqtt/protocol.py b/srnemqtt/protocol.py new file mode 100644 index 0000000..160da0b --- /dev/null +++ b/srnemqtt/protocol.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +import struct +import sys +import time +from io import RawIOBase +from typing import Callable, Collection, Optional + +from libscrc import modbus + +from .constants import ACTION_READ, POSSIBLE_MARKER +from .lib.feasycom_ble import BTLEUart +from .solar_types import DATA_BATTERY_STATE, HISTORICAL_DATA, DataItem +from .util import log + + +def write(fh, data): + bdata = bytes(data) + crc = modbus(bdata) + bcrc = bytes([crc & 0xFF, (crc & 0xFF00) >> 8]) + fh.write(data + bcrc) + + +def construct_request(address, words=1, action=ACTION_READ, marker=0xFF): + assert marker in POSSIBLE_MARKER, f"marker should be one of {POSSIBLE_MARKER}" + return struct.pack("!BBHH", marker, action, address, words) + + +def parse(data: bytes, items: Collection[DataItem], offset: int = 0) -> dict: + pos = offset + res = {} + + for i in items: + res[i.name] = i.transform(struct.unpack_from(i.st_format, data, offset=pos)[0]) + pos += i.st_size + + return res + + +# GET_BATTERY_STATE +def parse_battery_state(data: bytes) -> dict: + return parse(data, DATA_BATTERY_STATE) + + +def parse_historical_entry(data: bytes) -> dict: + res = parse(data, HISTORICAL_DATA[:10]) + + res_datalen = sum([x.st_size for x in HISTORICAL_DATA[:10]]) + + if len(data) > res_datalen: + res.update(parse(data, HISTORICAL_DATA[10:], offset=res_datalen)) + + return res + + +def parse_packet(data): + tag, operation, size = struct.unpack_from("BBB", data) + _unpacked = struct.unpack_from(f"<{size}BH", data, offset=3) + crc = _unpacked[-1] + payload = _unpacked[:-1] + calculated_crc = modbus(bytes([tag, operation, size, *payload])) + + if crc != calculated_crc: + e = ValueError(f"CRC missmatch: expected {crc:04X}, got {calculated_crc:04X}.") + e.tag = tag + e.operation = operation + e.size = size + e.payload = payload + e.crc = crc + e.calculated_crc = calculated_crc + raise e + + return payload + + +def discardUntil(fh: RawIOBase, byte: int, timeout=10) -> Optional[int]: + assert byte >= 0 and byte < 256, f"byte: Expected 8bit unsigned int, got {byte}" + + def expand(b: Optional[bytes]): + if b is None: + return b + return b[0] + + start = time.time() + discarded = 0 + read_byte = expand(fh.read(1)) + while read_byte != byte: + + if read_byte is not None: + if not discarded: + log("Discarding", end="") + discarded += 1 + print(f" {read_byte:02X}", end="") + sys.stdout.flush() + + if time.time() - start > timeout: + read_byte = None + break + + read_byte = expand(fh.read(1)) + + if discarded: + print() + sys.stdout.flush() + + return read_byte + + +def readMemory(fh: RawIOBase, address: int, words: int = 1) -> Optional[bytes]: + # log(f"Reading {words} words from 0x{address:04X}") + request = construct_request(address, words=words) + # log("Request:", request) + write(fh, request) + + tag = discardUntil(fh, 0xFF) + if tag is None: + return None + + header = fh.read(2) + if header and len(header) == 2: + operation, size = header + data = fh.read(size) + _crc = fh.read(2) + if data and _crc: + try: + crc = struct.unpack_from(" Optional[dict]: + while attempts: + attempts -= 1 + res = readMemory(dev, address, words) + if res: + try: + if parser: + return parser(res) + except struct.error as e: + log(e) + log("0x0100 Unpack error:", len(res), res) + log("Flushed from read buffer; ", dev.read(timeout=0.5)) + else: + log(f"No data read, expected {words*2} bytes (attempts left: {attempts})") + return None diff --git a/solar_types.py b/srnemqtt/solar_types.py similarity index 100% rename from solar_types.py rename to srnemqtt/solar_types.py diff --git a/srnemqtt/util.py b/srnemqtt/util.py new file mode 100644 index 0000000..b641e70 --- /dev/null +++ b/srnemqtt/util.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- + +import datetime +import sys +import time +from typing import Optional + +# Only factor of 1000 +SI_PREFIXES_LARGE = "kMGTPEZY" +SI_PREFIXES_SMALL = "mµnpfazy" + + +def humanize_number(data, unit: str = ""): + counter = 0 + + while data >= 1000: + data /= 1000 + counter += 1 + if counter >= len(SI_PREFIXES_LARGE): + break + + while data < 1: + data *= 1000 + counter -= 1 + if abs(counter) >= len(SI_PREFIXES_SMALL): + break + + if not counter: + prefix = "" + elif counter > 0: + prefix = SI_PREFIXES_LARGE[counter - 1] + elif counter < 0: + prefix = SI_PREFIXES_SMALL[abs(counter) - 1] + + return f"{data:.3g} {prefix}{unit}" + + +def log(*message: object, **kwargs): + print(datetime.datetime.utcnow().isoformat(" "), *message, **kwargs) + sys.stdout.flush() + + +class Periodical: + prev: float + interval: float + + def __init__(self, interval: float, start: Optional[float] = None): + self.prev = time.time() - interval if start is None else start + self.interval = interval + + def __call__(self, now: Optional[float] = None) -> bool: + if now is None: + now = time.time() + + if (now - self.prev) >= self.interval: + skipped, overshoot = divmod(now - self.prev, self.interval) + skipped -= 1 + if skipped: + log("Skipped:", skipped, overshoot, now - self.prev, self.interval) + self.prev = now - overshoot + return True + + return False diff --git a/tox.ini b/tox.ini index 7772898..edfab12 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,3 @@ [flake8] max-line-length = 88 extend-ignore = E203, I201, I101 - -[tool.isort] -profile = "black"