diff --git a/NOTICE b/NOTICE index 349567c..0e01124 100644 --- a/NOTICE +++ b/NOTICE @@ -2,3 +2,31 @@ Juice Pass Proxy Copyright 2024 Juice Rescue This product includes software developed by Juice Rescue. + + +# +# Some parts of the code based on https://github.com/philipkocanda/juicebox-protocol +# + +MIT License + +Copyright (c) 2024 Philip Kocanda + +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. + diff --git a/README.MD b/README.MD index 736fc83..562c66e 100644 --- a/README.MD +++ b/README.MD @@ -109,6 +109,7 @@ Variable | Required | Description & Default | **MQTT_USER** | No | **MQTT_PASS** | No | **MQTT_DISCOVERY_PREFIX** | No | homeassistant +**LOG_LOC** | No | /log (use **none** to disable log to file)

Less Common Docker Environment Variables

@@ -118,9 +119,10 @@ Variable | Required | Description & Default | **DEVICE_NAME** | No | JuiceBox **DEBUG** | No | false **EXPERIMENTAL** | No | Default: false. Enables additional entities in Home Assistant that are in in development or can be used toward developing the ability to send commands to a JuiceBox -**IGNORE_ENELX** | No | Default: false. If true, will not send commands received from EnelX to the JuiceBox nor send outgoing information from the JuiceBox to EnelX +**IGNORE_ENELX** | No | Default: false. If true, will not send commands received from EnelX to the JuiceBox nor send outgoing information from the JuiceBox to +EnelX, to use local control this option should be true **TELNET_TIMEOUT** | No | Default: 30. Timeout in seconds for telnet operations. -**JUICEBOX_ID** | No | If not defined, will attempt to get the JuiceBox ID using telnet. +**JUICEBOX_ID** | No | If not defined, will attempt to get the JuiceBox ID using telnet, don't use this if you are testing multiple devices. **LOCAL_IP**

_Deprecated Variable: SRC_ | No | If not defined, will attempt to get the Local Docker IP. Can optionally define port (ex. 127.0.0.1:8047). If unsuccessful, will default to 127.0.0.1. **LOCAL_PORT** | No | Local port for JuicePass Proxy to listen on. If not defined, will use the EnelX Port. **ENELX_IP**

_Deprecated Variable: DST_ | No | If not defined, will attempt to get the IP of the EnelX Server. If unsuccessful, will default to 54.161.185.130. Can optionally define port (ex. 54.161.185.130:8047). If defined, only use the IP address of the EnelX Server and not the fully qualified domain name to avoid DNS lookup loops. @@ -169,6 +171,8 @@ options: --ignore_enelx If set, will not send commands received from EnelX to the JuiceBox nor send outgoing information from the JuiceBox to EnelX + --tp PORT, --telnet_port PORT + Telnet PORT (default: 2000) --telnet_timeout SECONDS Timeout in seconds for Telnet operations (default: 30) --juicebox_id ID JuiceBox ID. If not defined, will obtain it @@ -191,7 +195,7 @@ _For `--enelx_ip`, only use the IP address of the EnelX Server and **not** the f ## Getting EnelX Server IPs -To get the destination IP:Port of the EnelX server, telnet into your JuiceBox device: +To get the destination IP:Port of the EnelX server, telnet into your JuiceBox device and use the [list](https://docs.silabs.com/gecko-os/4/standard/4.2/cmd/commands#stream-list) command: `$ telnet 192.168.x.x 2000` and type the `list` command: @@ -202,6 +206,91 @@ list # 1 UDPC juicenet-udp-prod3-usa.enelx.com:8047 (26674) ``` -The address is in the `UDPC` line. Run, `ping`, `nslookup`, or similar command to determine the IP. +The address is in the `UDPC` line. Run, `ping`, `nslookup`, or similar command to determine the IP. The following [network_lookup](https://docs.silabs.com/gecko-os/4/standard/4.2/cmd/commands#network-lookup) command can be run in JuiceBox telnet to look it up while still connected: +``` +network_lookup juicenet-udp-prod3-usa.enelx.com +54.161.185.130 +network_lookup jvb1.emotorwerks.com +158.47.1.128 +``` As of November, 2023: `juicenet-udp-prod3-usa.enelx.com` = `54.161.185.130`. + +## Important information +- This proxy is made using effort from owners that found information and made packet capture to reverse enginner the protocol used by the devices +- There are many different firmware versions found + - some accept telnet, some others not +- Different protocol versions are found +- We cannot assure that this will work will all versions +- If this does not work with your device you must provide : + - logs (and if possible packet captures) with messages that are send to/from your device + - docker enviroment configuration used or juicepassproxy command line parameters + - if your device still works with ENELX servers but not with juicepass : + - a packet capture will provide usefull information to understand what are the differences that are not being considered yet +- Sometimes it takes a while to stabilize, if you are changing between ENEL X and JPP let it running for some minutes before testing + +## juicepassproxy important behaviours to understand +- For devices that uses the protocol version v07 the juicepassproxy will only start talking with device after 6 minutes to make sure it gets the correct offline current in the device. +- when defining the MQTT entities that are show on homeassistant juicepassproxy will define a max_current value, on the first time it starts it will use 48A for this value, after receiving the device rating the value will be stored on configuration and at next start will be used as maximum to allow the correct range on homeassistant + +## Controlling Charging current + +- **Max Current (Offline/Wanted)** + - Control maximum current that device will charge when offline (not connected to juicebox or Enel X) + - 2024-06 tested on device which send protocol v09u it changes **Max Charging Current** to this value around 5 minutes after not receiving messages from proxy + - Stored on EEPROM - https://github.com/snicker/juicepassproxy/issues/39#issuecomment-2002312548 + - Because of this **don't change that value many times**, as any EEPROM has a lifespan based on writes and the *Max Charging Current* will make possible to control the Current for Charging + +- **Max Current (Offline/Device)** + - The value that are sent from the juicebox device indicating what will the offline charge current + +- **Max Current (Online/Wanted)** + - Control the Current that Juicebox provides to the vehicle when connected to server + - Can be used for example to control charging based on Solar Power generation + - As suggestion check for changes at 3-5 minutes intervals + - this give time for stabilizations on charging and energy generation + - this interval was tested with old ENEL X API integration and now with juicepassproxy responding to juicebox + - Putting 0 pauses the charging + - Pausing will reset the session energy value + - This may affect the lifespan of internal contactor if paused/restarted too many times + - Some cars can have different behaviours + - Bolt 2022 (Brazil) checks the Current at connection + - if the value is less equal than 10 A it will consider that is using a portable charger and cannot accept Current changes greater than 10A later + - if the value is 0 A (Pause), it will show a charging error on dashboard but will start charging when value goes to 6A or over + +- **Max Current (Online/Device)** + - The value that are sent from the juicebox device indicating what is the online charge current + + +- Warning about offline / online + - Apparently some devices consider offline as a maximum that can be used on device, and even if you put online over that it will consider the minimum value + - https://github.com/JuiceRescue/juicepassproxy/pull/69#issuecomment-2408423204 + - Tests on one JB 2.x / v09u indicates that the online value can be over the offline value for charging + - This will allow safe usage of load-balancing logic from a server and use lower values for safety + - If you have one of this devices and have limited circuit you must respect your circuit limits when changing the online value + +## Energy +- **Energy Session** + - the Juicebox device reset this value when car changes from **Charging** to **Plugged In** State + +## Multiple JuiceBoxes +- Multiple instances of JPP must be executed, one per JuiceBox. + - Each JPP instance should specify the following parameters in addition to the basic parameters. + - **--name** - each should use a different name, because this is the identifier of MQTT topic + - **--juicebox_id** - defining this disable the telnet and will start faster, must be the correct serial of each device + - **--local_port** - each needs to use their own port but make sure the UDP 8042 redirection rule matches the destination port + - **--config_loc** - each needs their own directory + - Future versions can be able to work with multiple devices : https://github.com/JuiceRescue/juicepassproxy/issues/102 + + +## Configuration file +- You can configure initial state of mqtt entities : + - **ENTITY_initial_state** or **SERIAL_ENTITY_initial_state** + - **current_max_offline_set_initial_state** can be used for device that does not send current_max_offline value on status messages (v07 protocol) and do faster startup + +## Upgrading from older versions if you have any problem with wrong entities on Homeassistant +- Stop juicepassproxy +- Remove old configuration on MQTT, using mosquitto_sub or any other MQTT client + - **mosquitto_sub -t "homeassistant/+/JuiceBox/+/config" -v --remove-retained** +- Remove Juicebox device on Homeassistant +- Start juicepassproxy diff --git a/const.py b/const.py index 59a107c..9fe27b5 100644 --- a/const.py +++ b/const.py @@ -1,7 +1,7 @@ import logging # Will auto-update based on GitHub release tag -VERSION = "v0.3.1" +VERSION = "v0.5.0" CONF_YAML = "juicepassproxy.yaml" @@ -20,7 +20,8 @@ DEFAULT_MQTT_PORT = "1883" DEFAULT_MQTT_DISCOVERY_PREFIX = "homeassistant" DEFAULT_DEVICE_NAME = "JuiceBox" -DEFAULT_TELNET_TIMEOUT = 30 +DEFAULT_TELNET_PORT = "2000" +DEFAULT_TELNET_TIMEOUT = "30" # How many times to fully restart JPP before exiting MAX_JPP_LOOP = 10 diff --git a/docker_entrypoint.sh b/docker_entrypoint.sh index d524d5e..56a800d 100644 --- a/docker_entrypoint.sh +++ b/docker_entrypoint.sh @@ -73,7 +73,12 @@ if [[ ! -z "${TELNET_TIMEOUT}" ]]; then JPP_STRING+=" --telnet_timeout ${TELNET_TIMEOUT}" fi JPP_STRING+=" --config_loc /config" -JPP_STRING+=" --log_loc /log" +if [[ -v LOG_LOC ]]; then + logger INFO "LOG_LOC: ${LOG_LOC}" + JPP_STRING+=" --log_loc ${LOG_LOC}" +else + JPP_STRING+=" --log_loc /log" +fi logger INFO "DEBUG: ${DEBUG}" if $DEBUG; then JPP_STRING+=" --debug" diff --git a/juicebox_config.py b/juicebox_config.py new file mode 100644 index 0000000..07502ce --- /dev/null +++ b/juicebox_config.py @@ -0,0 +1,89 @@ +import yaml +from pathlib import Path +import logging + +from const import ( + CONF_YAML, +) + +_LOGGER = logging.getLogger(__name__) + +class JuiceboxConfig: + + + def __init__(self, config_loc, filename=CONF_YAML): + self.config_loc = Path(config_loc) + self.config_loc.mkdir(parents=True, exist_ok=True) + self.config_loc = self.config_loc.joinpath(filename) + self.config_loc.touch(exist_ok=True) + _LOGGER.info(f"config_loc: {self.config_loc}") + self._config = {} + self._changed = False + + + async def load(self): + config = {} + try: + _LOGGER.info(f"Reading config from {self.config_loc}") + with open(self.config_loc, "r") as file: + config = yaml.safe_load(file) + except Exception as e: + _LOGGER.warning(f"Can't load {self.config_loc}. ({e.__class__.__qualname__}: {e})") + if not config: + config = {} + self._config = config + + async def write(self): + try: + _LOGGER.info(f"Writing config to {self.config_loc}") + with open(self.config_loc, "w") as file: + yaml.dump(self._config, file) + self._changed = False + return True + except Exception as e: + _LOGGER.warning( + f"Can't write to {self.config_loc}. ({e.__class__.__qualname__}: {e})" + ) + return False + + + async def write_if_changed(self): + if self._changed: + return await self.write() + return True + + def get(self, key, default): + return self._config.get(key, default) + + # Get device specific configuration, if not found try to use global parameter + def get_device(self, device, key, default): + return self._config.get(device +"_" + key, self._config.get(key, default)) + + def update(self, data): + # TODO detect changes + return self._config.update(data) + + def update_value(self, key, value): + if self._config.get(key, None) != value: + self.update({ key : value }) + self._changed = True + + def update_device_value(self, device, key, value): + self.update_value(device + "_" + key, value) + + + def pop(self, key): + if key in self._config: + self._config.pop(key, None) + self._changed = True + + def is_changed(self): + return self._changed + + + + + + + + \ No newline at end of file diff --git a/juicebox_crc.py b/juicebox_crc.py new file mode 100644 index 0000000..61f57dc --- /dev/null +++ b/juicebox_crc.py @@ -0,0 +1,52 @@ +# +# Original code : https://github.com/philipkocanda/juicebox-protocol +# +class JuiceboxCRC: + ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + def __init__(self, payload: str) -> None: + self.payload = payload + pass + + def integer(self) -> int: + return self.crc(self.payload) + + + def base35(self) -> str: + return self.base35encode(self.integer()) + + + def inspect(self) -> dict: + return { + "payload": self.payload, + "base35": self.base35(), + "integer": self.integer(), + } + + def base35encode(self, number: int) -> str: + base35 = "" + + # Sometimes it ends with 0 and the juicebox CRC should have 3 characters + while (number > 1) or (len(base35) < 3): + number, i = divmod(number, 35) + if i == 24: + i = 35 + base35 = base35 + self.ALPHABET[i] + + return base35 + + + def base35decode(self, number: str) -> int: + decimal = 0 + for i, s in enumerate(reversed(number)): + decimal += self.ALPHABET.index(s) * (35**i) + return decimal + + + def crc(self, data: str) -> int: + h = 0 + for s in data: + h ^= (h << 5) + (h >> 2) + ord(s) + h &= 0xFFFF + return h + diff --git a/juicebox_exceptions.py b/juicebox_exceptions.py new file mode 100644 index 0000000..901a6ac --- /dev/null +++ b/juicebox_exceptions.py @@ -0,0 +1,13 @@ +# +# Original code : https://github.com/philipkocanda/juicebox-protocol +# +class JuiceboxException(Exception): + "Generic exception class for this library" + pass + +class JuiceboxInvalidMessageFormat(JuiceboxException): + pass + + +class JuiceboxCRCError(JuiceboxException): + pass diff --git a/juicebox_message.py b/juicebox_message.py new file mode 100644 index 0000000..f969de5 --- /dev/null +++ b/juicebox_message.py @@ -0,0 +1,559 @@ +# +# Inspired on : https://github.com/philipkocanda/juicebox-protocol +# +from juicebox_crc import JuiceboxCRC +from juicebox_exceptions import JuiceboxInvalidMessageFormat +import datetime +import logging +import re + +_LOGGER = logging.getLogger(__name__) + +STATUS_CHARGING = "Charging" +STATUS_ERROR = "Error" +STATUS_PLUGGED_IN = "Plugged In" +STATUS_UNPLUGGED = "Unplugged" + + +STATUS_DEFS = { + 0: STATUS_UNPLUGGED, + 1: STATUS_PLUGGED_IN, + 2: STATUS_CHARGING, + 5: STATUS_ERROR + } + + +FIELD_SERIAL = "serial" +FIELD_CURRENT = "current" +FIELD_VOLTAGE = "voltage" +FIELD_POWER = "power" + + +def process_status(message, value): + if value and value.isnumeric() and (int(value) in STATUS_DEFS): + return STATUS_DEFS[int(value)] + + # Old Protocol does not send status in all messages + # try to detect state based on current + if (value is None) and message.has_value(FIELD_CURRENT) and message.get_value(FIELD_CURRENT).isnumeric(): + current = int(message.get_value(FIELD_CURRENT)) + # must test more to know if they send messages like this when Unplugged + if current == 0: + return STATUS_PLUGGED_IN + + if current > 0: + return STATUS_CHARGING + + return f"unknown {value}" + +def process_voltage(message, value): + # Older messages came with less digits + if len(value) < 4: + return float(value) + + return round(float(value) * 0.1, 1) + +def process_int(message, value): + + return int(value) + +def process_float(message, value): + + return float(value) + +# For older devices that does not send the current_max value use the current_rating value +def process_current_max(message, value): + if value: + return int(value) +# else: +# return message.get_processed_value("current_rating"); + return None +# TODO add process to other messages to convert values + +# This keeps old behaviour, temperature comes in message as Celsius and are converted to Farenheit +def process_temperature(message, value): + return round(float(value) * 1.8 + 32, 2) + +def process_frequency(message, value): + return round(float(value) * 0.01, 2) + +def process_current(message, value): + if value is None: + return 0 + + return round(float(value) * 0.1, 1) + + +def process_power_factor(message, value): + + return round(float(value) * 0.001, 3) + + + +def process_power(message, value): + if message.has_value(FIELD_VOLTAGE): + return round(message.get_processed_value(FIELD_VOLTAGE) * message.get_processed_value(FIELD_CURRENT)) + + + + + +FROM_JUICEBOX_FIELD_DEFS = { + # many of definitions came from https://github.com/snicker/juicepassproxy/issues/52 + "A" : { "alias" : "current", "process" : process_current }, + # b ? + # B ? + # max current to be used when offline + "C" : { "alias" : "current_max_offline", "process" : process_current_max }, + # e ? variable positive/negative/zero values + "E" : { "alias" : "energy_session", "process" : process_int }, + "f" : { "alias" : "frequency", "process" : process_frequency }, + # F ? + # i = Interval number. It contains a 96-slot interval memory (15-minute x 24-hour cycle) and + # this tells you how much energy was consumed in the rolling window as it reports one past + # (or current, if it's reporting the "right-now" interval) interval per message. + # The letter after "i" = the energy in that interval (usually 0 if you're not charging basically 24/7) + "i" : { "alias" : "interval", "process" : process_int }, + # indicates the hardware limit + "m" : { "alias" : "current_rating", "process" : process_int }, + # the max current to be used when connected to server + "M" : { "alias" : "current_max_online", "process" : process_int }, + "L" : { "alias" : "energy_lifetime", "process" : process_int }, + # power factor most of the times are over 0980 which appear to be ok + # but when car stops charging goes very low which is strange + # https://github.com/JuiceRescue/juicepassproxy/issues/84 + "p" : { "alias" : "power_factor", "process" : process_power_factor }, + # P ? v09u - does not came when car is unplugged and appear to be allways 0 + # r ? v09u - appear to be fixed to 995 in one device, but found other values + # https://github.com/JuiceRescue/juicepassproxy/issues/84#issuecomment-2424907089 + "s" : { "alias" : "counter" }, + "S" : { "alias" : "status", "process" : process_status }, + # t - probably the report time in seconds - "every 9 seconds" (or may end up being 10). + # It can change its reporting interval if the bit mask in the reply command indicates that it should send reports faster (yet to be determined). + "t" : { "alias" : "report_time" }, + "T" : { "alias" : "temperature", "process" : process_temperature }, + "u" : { "alias" : "loop_counter" }, + "v" : { "alias" : "protocol_version" }, + "V" : { "alias" : "voltage", "process" : process_voltage }, + # X ? + # Y ? + # Calculated parameters + "power" : { "process" : process_power, "calculated" : True }, + } + + +# +# try to detect message format and use correct decoding process +# +def juicebox_message_from_bytes(data : bytes): + ## TODO: try to detect here if message is encrypted or not + # Currently all non encrypted messages that we have capture can be converted to string + try: + string = data.decode("utf-8") + return juicebox_message_from_string(string) + except UnicodeDecodeError: + # Probably is a encrypted messsage, try to use the specific class + try: + return JuiceboxEncryptedMessage().from_bytes(data) + except UnicodeDecodeError: + raise JuiceboxInvalidMessageFormat(f"Unable to parse message: '{data}'") + + + +# +# Groups used on regex patterns +# +PATTERN_GROUP_SERIAL = "serial" +PATTERN_GROUP_VERSION = "version" +PATTERN_GROUP_VALUE = "value" +PATTERN_GROUP_TYPE = "type" +# all payload from message except crc +PATTERN_GROUP_PAYLOAD = "payload" +# data from payload (excluding serial number) +PATTERN_GROUP_DATA_PAYLOAD = "data_payload" +PATTERN_GROUP_CRC = "crc" + +# ID:version +# Some versions came with e - encripted some with u - unencripted and some dont came with letter +BASE_MESSAGE_PATTERN = r'^(?P<' + PATTERN_GROUP_SERIAL + '>[0-9]+):(?P<' + PATTERN_GROUP_VERSION + '>v[0-9]+[eu]?)' +BASE_MESSAGE_PATTERN_NO_VERSION = r'^(?P<' + PATTERN_GROUP_SERIAL + '>[0-9]+):(?P<' + PATTERN_GROUP_DATA_PAYLOAD + '>.*)' +PAYLOAD_CRC_PATTERN = r'((?P<' + PATTERN_GROUP_PAYLOAD + '>[^!]*)(!(?P<' + PATTERN_GROUP_CRC + r'>[A-Z0-9]{3}))?(?:\$|:))' + +# For version there is an ending 'u' for this unencrypted messages, the 'e' encrypted messages are not supported here +# all other values are numeric +# Serial appear only on messages that came from juicebox device +PAYLOAD_PARTS_PATTERN = r'((?P<' + FIELD_SERIAL + '>[0-9]+):)?[,]?(?P<' + PATTERN_GROUP_TYPE + '>[A-Za-z]+)(?P<' + PATTERN_GROUP_VALUE + '>[-]?[0-9]+[u]?)' + + +def is_encrypted_version(version : str): + # https://github.com/snicker/juicepassproxy/issues/73 + # https://github.com/snicker/juicepassproxy/issues/116 + return (version == 'v09e') or (version == 'v08') + +def juicebox_message_from_string(string : str): + if string[0:3] == "CMD": + return JuiceboxCommand().from_string(string) + + msg = re.search(BASE_MESSAGE_PATTERN, string) + + if msg: + if is_encrypted_version(msg.group(PATTERN_GROUP_VERSION)): + return JuiceboxEncryptedMessage(str.encode(string)) + + return JuiceboxStatusMessage().from_string(string) + + msg = re.search(BASE_MESSAGE_PATTERN_NO_VERSION, string) + if msg: + if msg.group(PATTERN_GROUP_DATA_PAYLOAD)[:3] == 'DBG': + return JuiceboxDebugMessage().from_string(string) + else: + return JuiceboxStatusMessage(False).from_string(string) + + raise JuiceboxInvalidMessageFormat(f"Unable to parse message: '{string}'") + + + +class JuiceboxMessage: + + def __init__(self, has_crc=True, defs={}) -> None: + self.has_crc = has_crc + self.payload_str = None + self.crc_str = None + self.values = None + self.end_char = ':' + self.defs = defs + self.aliases = {} + # to make easier to use get_values + for k in self.defs: + if "alias" in self.defs[k]: + self.aliases[self.defs[k]["alias"]] = k + + pass + + + def parse_values(self): + # Nothing to do here now + _LOGGER.debug(f"No values conversion on base JuiceboxMessage : {self.values}") + + def store_value(self, values, type, value): + if not type in values: + values[type] = value + else: + #TODO think better option after understanding of this case whe same type repeats in message + ok = False + for idx in range(1,2): + if not (type + ":" + str(idx)) in values: + values[type + ":" + str(idx)] = value + ok = True + break + if not ok: + _LOGGER.error(f"Unable to store duplicate type {type}={value} other_values={values}") + + def from_string(self, string: str) -> 'JuiceboxMessage': + _LOGGER.info(f"from_string {string}") + msg = re.search(PAYLOAD_CRC_PATTERN, string) + + if msg is None: + raise JuiceboxInvalidMessageFormat(f"Unable to parse message: '{string}'") + + self.payload_str = msg.group(PATTERN_GROUP_PAYLOAD) + self.crc_str = msg.group(PATTERN_GROUP_CRC) + + if not self.has_crc: + if self.crc_str: + raise JuiceboxInvalidMessageFormat(f"Found CRC in message that are supposed to don't have CRC '{string}'") + else: + if not self.crc_str: + raise JuiceboxInvalidMessageFormat(f"CRC not found in message that are supposed to have CRC '{string}'") + + if self.crc_str != self.crc_computed(): + raise JuiceboxInvalidMessageFormat(f"Expected CRC {self.crc_computed()} detected_crc={self.crc_str} '{string}'") + + values = {} + tmp = self.payload_str + while len(tmp) > 0: + data = re.search(PAYLOAD_PARTS_PATTERN, tmp) + if data: + if data.group(FIELD_SERIAL): + values[FIELD_SERIAL] = data.group(FIELD_SERIAL) + self.store_value(values, data.group(PATTERN_GROUP_TYPE), data.group(PATTERN_GROUP_VALUE)) + tmp = tmp[len(data.group(0)):] + else: + _LOGGER.error(f"unable to parse value from message tmp='{tmp}', string='{string}'") + break + + self.values = values + self.parse_values() + + return self + + + def has_value(self, type): + if type in self.aliases: + return self.aliases[type] in self.values + else: + return type in self.values + + + def get_value(self, type): + + if self.has_value(type): + if type in self.aliases: + return self.values[self.aliases[type]] + else: + return self.values[type] + + return None + + + def get_processed_value(self, type): + + if type in self.aliases: + return self.get_processed_value(self.aliases[type]) + + if (type in self.defs) and ("process" in self.defs[type]): + return self.defs[type]["process"](self, self.get_value(type)) + + return self.get_value(type) + + + def crc(self) -> JuiceboxCRC: + return JuiceboxCRC(self.payload_str) + + + def crc_computed(self) -> str: + if self.has_crc: + return self.crc().base35() + else: + return None + + + def build_payload(self) -> None: + if self.payload_str: + return + + _LOGGER.error("this base class cannot build payload") + + + def build(self) -> str: + self.build_payload() + if self.has_crc: + return f"{(self.payload_str)}!{self.crc_str}{self.end_char}" + else: + return f"{(self.payload_str)}{self.end_char}" + + + def inspect(self) -> dict: + data = { + "payload_str": self.payload_str, + } + if self.has_crc: + data.update({ + "crc_str": self.crc_str, + "crc_computed": self.crc_computed(), + }) + + # Generic base class does not know specific fields, then put all split values + if self.values: + for k in self.values: + if k in self.defs: + data[self.defs[k]["alias"]] = self.values[k] + else: + data[k] = self.values[k] + + return data + + + def __str__(self): + return self.build() + + + +class JuiceboxStatusMessage(JuiceboxMessage): + + def __init__(self, has_crc=True, defs=FROM_JUICEBOX_FIELD_DEFS) -> None: + super().__init__(has_crc=has_crc, defs=defs) + + # Generate data like old processing + def to_simple_format(self): + # Default values that should be in all status messages + data = { "type" : "basic", "current": 0, "energy_session": 0} + + for k in self.values: + if k in self.defs: + data[self.defs[k]["alias"]] = self.get_processed_value(k) + else: + data[k] = self.values[k] + + for k in self.defs: + if ("calculated" in self.defs[k]) and self.defs[k]["calculated"]: + if not k in data: + value = self.get_processed_value(k) + if not k is None: + data[k] = value + + # On original code the energy_session is chaged to zero when not charging + # here we will keep sending the value that came from device + + return data + + +class JuiceboxEncryptedMessage(JuiceboxStatusMessage): + + + def from_bytes(self, data : bytes): + # get only serial and version + + string = data[0:(33 if chr(data[32]) in ['e','u'] else 32)].decode("utf-8") + msg = re.search(BASE_MESSAGE_PATTERN, string) + + if msg: + version = msg.group(PATTERN_GROUP_VERSION) + if is_encrypted_version(version): + _LOGGER.warning(f"TODO: encrypted '{version}' - '{data}'") + # TODO unencrypt when we know how to do + return self + else: + raise JuiceboxInvalidMessageFormat(f"Unsupported encrypted message version: '{version}' - '{data}'") + + else: + raise JuiceboxInvalidMessageFormat(f"Unsupported message format: '{data}'") + + +class JuiceboxCommand(JuiceboxMessage): + + def __init__(self, previous=None, new_version=False) -> None: + super().__init__() + self.new_version = new_version + self.command = 6 # Alternates between C242, C244, C008, C006. Meaning unclear. + self.end_char = "$" + + # increments by one for every message until 999 then it loops back to 1 + if previous: + self.counter = previous.counter + 1 + if (self.counter > 999): + self.counter = 1 + self.offline_amperage = previous.offline_amperage + self.instant_amperage = previous.instant_amperage + else: + self.counter = 1 + self.offline_amperage = 0 + self.instant_amperage = 0 + + self.time = datetime.datetime.today() + + def inspect(self) -> dict: + data = { + "command": self.command, + "offline_amperage": self.offline_amperage, + "instant_amperage": self.instant_amperage, + "counter": self.counter, + "payload_str": self.payload_str, + "crc_str": self.crc_str, + "crc_computed": self.crc_computed(), + } + + # add any extra received value + if self.values: + data.update(self.values) + + return data + + def build_payload(self) -> None: + if self.payload_str: + return + + weekday = self.time.strftime('%w') # 0 = Sunday, 6 = Saturday + + self.payload_str = f"CMD{weekday}{self.time.strftime('%H%M')}" + + # Original comment : + # Instant amperage may need to be represented using 4 digits (e.g. 0040) on newer Juicebox versions. + # mine which send data using version 09u works with 4 digits on offline and 3 digit on instant + # sizes got from original packet dump when communicating with enel x server + # + # https://github.com/snicker/juicepassproxy/issues/39#issuecomment-2002312548 + # @FalconFour definition of currents + # Not sending undefined values + if self.new_version: + self.payload_str += f"A{self.instant_amperage:04d}M{self.offline_amperage:03d}" + else: + self.payload_str += f"A{self.instant_amperage:02d}M{self.offline_amperage:02d}" + self.payload_str += f"C{self.command:03d}S{self.counter:03d}" + self.crc_str = self.crc_computed() + + def parse_values(self): + if "CMD" in self.values: + self.values["DOW"] = self.values["CMD"][0] + self.values["HHMM"] = self.values["CMD"][1:] + self.values.pop("CMD") + if "C" in self.values: + self.command = int(self.values["C"]) + self.values.pop("C") + if "A" in self.values: + self.offline_amperage = int(self.values["A"]) + self.values.pop("A") + if "M" in self.values: + self.instant_amperage = int(self.values["M"]) + self.values.pop("M") + if "S" in self.values: + self.counter = int(self.values["S"]) + self.values.pop("S") + + _LOGGER.info(f"parse_values values {self.values}") + + +# +# Juicebox send this debug messages during reboot or in some cases when it does like command received +# if server send a command message to a juicebox that expect crc without the crc it will send a debug message complaining "Miss CRC" +# + + +class JuiceboxDebugMessage(JuiceboxMessage): + + def __init__(self) -> None: + super().__init__(has_crc=False) + + def from_string(self, string: str) -> 'JuiceboxMessage': + _LOGGER.info(f"from_string {string}") + #TODO use better regex to remove this end_char on pattern match + if string.endswith(self.end_char): + string = string[:-1] + + self.payload_str = string + + msg = re.search(BASE_MESSAGE_PATTERN_NO_VERSION, string) + self.values = { "type" : "debug" } + self.values[FIELD_SERIAL] = msg.group(PATTERN_GROUP_SERIAL) + + debug_msg = re.sub('^DBG,','',msg.group(PATTERN_GROUP_DATA_PAYLOAD)) + + # Change abbreviated to full log level like old code + dbg_level_abbr = debug_msg[:4] + if dbg_level_abbr == "NFO:": + dbg_level = "INFO" + elif dbg_level_abbr == "WRN:": + dbg_level = "WARNING" + elif dbg_level_abbr == "ERR:": + dbg_level = "ERROR" + else: + dbg_level = dbg_level_abbr + debug_msg_text = debug_msg[4:] + self.values["debug_message"] = dbg_level + ": " + debug_msg_text + self.values["boot"] = debug_msg_text.startswith("BOT:") + + return self + + def is_boot(self): + return ("boot" in self.values) and (self.values["boot"]) + + def build_payload(self) -> None: + if self.payload_str: + return + + self.payload_str = self.values[FIELD_SERIAL] + ':' + 'DBG,' + self.values["debug_message"] + + + # Generate data like old processing + def to_simple_format(self): + return self.values diff --git a/juicebox_mitm.py b/juicebox_mitm.py index d4351f9..295a4d2 100644 --- a/juicebox_mitm.py +++ b/juicebox_mitm.py @@ -12,6 +12,7 @@ MITM_RECV_TIMEOUT, MITM_SEND_DATA_TIMEOUT, ) +from juicebox_message import JuiceboxCommand, JuiceboxStatusMessage, JuiceboxEncryptedMessage, JuiceboxDebugMessage, juicebox_message_from_bytes # Began with https://github.com/rsc-dev/pyproxy and rewrote when moving to async. @@ -29,6 +30,7 @@ def __init__( remote_mitm_handler=None, mqtt_handler=None, loglevel=None, + reuse_port=True, ): if loglevel is not None: _LOGGER.setLevel(loglevel) @@ -39,16 +41,22 @@ def __init__( self._local_mitm_handler = local_mitm_handler self._remote_mitm_handler = remote_mitm_handler self._mqtt_handler = mqtt_handler + self._reuse_port = reuse_port self._loop = asyncio.get_running_loop() self._mitm_loop_task: asyncio.Task = None self._sending_lock = asyncio.Lock() self._dgram = None self._error_count = 0 self._error_timestamp_list = [] + # Last command sent to juicebox device + self._last_command = None + # Last message received from juicebox device + self._last_status_message = None + self._first_status_message_timestamp = None + self._boot_timestamp = None async def start(self) -> None: - _LOGGER.info("Starting JuiceboxMITM") - _LOGGER.debug(f"JPP: {self._jpp_addr[0]}:{self._jpp_addr[1]}") + _LOGGER.info(f"Starting JuiceboxMITM at {self._jpp_addr[0]}:{self._jpp_addr[1]} reuse_port={self._reuse_port}") _LOGGER.debug(f"EnelX: {self._enelx_addr[0]}:{self._enelx_addr[1]}") await self._connect() @@ -75,12 +83,12 @@ async def _connect(self): try: if self._sending_lock.locked(): self._dgram = await asyncio_dgram.bind( - self._jpp_addr, reuse_port=True + self._jpp_addr, reuse_port=self._reuse_port ) else: async with self._sending_lock: self._dgram = await asyncio_dgram.bind( - self._jpp_addr, reuse_port=True + self._jpp_addr, reuse_port=self._reuse_port ) except OSError as e: _LOGGER.warning( @@ -138,6 +146,63 @@ async def _mitm_loop(self) -> None: f"{ERROR_LOOKBACK_MIN} min." ) + + def _booted_in_less_than(self, seconds): + return self._boot_timestamp and ((time.time() - self._boot_timestamp) < seconds) + + async def _message_decode(self, data : bytes): + decoded_message = None + try: + decoded_message = juicebox_message_from_bytes(data) + if isinstance(decoded_message, JuiceboxEncryptedMessage): + # encrypted are not supported now + # directory server can set the encripted mode, to disable the JuiceBox must be blocked to access the Directory Server + _LOGGER.error("Encrypted messages are not supported yet, please restart yout Juicebox device without internet connection to be able to use unencrypted messages") + elif isinstance(decoded_message, JuiceboxStatusMessage): + self._last_status_message = decoded_message + if self._first_status_message_timestamp is None: + self._first_status_message_timestamp = time.time() + elapsed = int(time.time() - self._first_status_message_timestamp) + + # Try to initialize the set entities with safe values from the juicebox device + # This is not the best way to do, but can be made without need to store somewhere the data as config is not available here + # TODO: better/safer way + if not self.is_mqtt_numeric_entity_defined("current_max_online_set"): + if decoded_message.has_value("current_max_online"): + _LOGGER.info("setting current_max_online_set with current_max_online") + await self._mqtt_handler.get_entity("current_max_online_set").set_state(self._last_status_message.get_processed_value("current_max_online")) + + # Apparently all messages came with current_max_online then, this code will never be executed + elif ((elapsed > 600) or self._booted_in_less_than(30)) and decoded_message.has_value("current_rating"): + _LOGGER.info("setting current_max_online_set with current_rating") + await self._mqtt_handler.get_entity("current_max_online_set").set_state(self._last_status_message.get_processed_value("current_rating")) + + #TODO now the MQTT is storing previous data on config, this can be used to get initialize theses values from previous JPP execution + if not self.is_mqtt_numeric_entity_defined("current_max_offline_set"): + if decoded_message.has_value("current_max_offline"): + _LOGGER.info("setting current_max_offline_set with current_max_offline") + await self._mqtt_handler.get_entity("current_max_offline_set").set_state(self._last_status_message.get_processed_value("current_max_offline")) + # After a reboot of device, the device that does not send offline will start with online value defined with offline setting + # as the device will start to use the offline current after 5 minutes without responses from server, we can consider that after this time + # we got the offline value from the online parameter, use the parameter after 6 minutes from first status message + elif (self._booted_in_less_than(30) or (elapsed > 6*60) ) and decoded_message.has_value("current_max_online"): + _LOGGER.info(f"setting current_max_offline_set with current_max_online after reboot or more than 5 minutes (elapsed={elapsed})") + await self._mqtt_handler.get_entity("current_max_offline_set").set_state(self._last_status_message.get_processed_value("current_max_online")) + + #TODO we still have a problem on v07 protocol that does not send the current_max_offline + # the entity will not be updated + + elif isinstance(decoded_message, JuiceboxDebugMessage): + if decoded_message.is_boot(): + self._boot_timestamp = time.time() + else: + _LOGGER.exception(f"Unexpected juicebox message type {decoded_message}") + + except Exception as e: + _LOGGER.exception(f"Not a valid juicebox message |{data}| {e}") + + return decoded_message + async def _main_mitm_handler(self, data: bytes, from_addr: tuple[str, int]): if data is None or from_addr is None: return @@ -147,8 +212,18 @@ async def _main_mitm_handler(self, data: bytes, from_addr: tuple[str, int]): self._juicebox_addr = from_addr if from_addr == self._juicebox_addr: - data = await self._local_mitm_handler(data) - if not self._ignore_enelx: + # Must decode message to give correct command response based on version + # Also this decoded message can will passed to the mqtt handler to skip a new decoding + decoded_message = await self._message_decode(data) + + data = await self._local_mitm_handler(data, decoded_message) + + if self._ignore_enelx: + # Keep sending responses to local juicebox like the enelx servers using last values + # the responses should be send only to valid JuiceboxStatusMessages + if isinstance(decoded_message, JuiceboxStatusMessage): + await self.send_cmd_message_to_juicebox(new_values=False) + else: try: await self.send_data(data, self._enelx_addr) except OSError as e: @@ -226,6 +301,59 @@ async def send_data( async def send_data_to_juicebox(self, data: bytes): await self.send_data(data, self._juicebox_addr) + + def is_mqtt_numeric_entity_defined(self, entity_name): + entity = self._mqtt_handler.get_entity(entity_name) + + # TODO: not clear why sometimes "0" came at this point as string instead of numeric + # Using same way on HA dashboard sometimes came 0.0 float and sometimes "0" str + # _LOGGER.debug(f"is_mqtt_entity_defined {entity_name} {entity} {entity.state}") + defined = entity and (isinstance(entity.state, int | float) or (isinstance(entity.state, str) and entity.state.isnumeric())) + + return defined + + async def __build_cmd_message(self, new_values): + + if type(self._last_status_message) is JuiceboxEncryptedMessage: + _LOGGER.info("Responses for encrypted protocol not supported yet") + return None + + # TODO: check which other versions can be considered as new_version of protocol + # packet captures indicate that v07 uses old version + new_version = self._last_status_message and (self._last_status_message.get_value("v") == "09u") + if self._last_command: + message = JuiceboxCommand(previous=self._last_command, new_version=new_version) + else: + message = JuiceboxCommand(new_version=new_version) + # Should start with values + new_values = True + + if new_values: + if (not self.is_mqtt_numeric_entity_defined("current_max_offline_set")) or (not self.is_mqtt_numeric_entity_defined("current_max_online_set")): + _LOGGER.error("Must have both current_max(online|offline) defined to send command message") + + return None + + message.offline_amperage = int(self._mqtt_handler.get_entity("current_max_offline_set").state) + message.instant_amperage = int(self._mqtt_handler.get_entity("current_max_online_set").state) + + _LOGGER.info(f"command message = {message} new_values={new_values} new_version={new_version}") + + self._last_command = message; + return message.build() + + # Send a new message using values on mqtt entities + async def send_cmd_message_to_juicebox(self, new_values): + if not self._ignore_enelx: + _LOGGER.warning("To send commands to juicebox you have to ignore ENEL X servers, please set ignore_enelx option") + + elif self._mqtt_handler.get_entity("act_as_server").is_on(): + + cmd_message = await self.__build_cmd_message(new_values) + if cmd_message: + _LOGGER.info(f"Sending command to juicebox {cmd_message} new_values={new_values}") + await self.send_data(cmd_message.encode('utf-8'), self._juicebox_addr) + async def set_mqtt_handler(self, mqtt_handler): self._mqtt_handler = mqtt_handler diff --git a/juicebox_mqtthandler.py b/juicebox_mqtthandler.py index 81b6bb1..e5e472a 100644 --- a/juicebox_mqtthandler.py +++ b/juicebox_mqtthandler.py @@ -1,12 +1,12 @@ import asyncio import logging -import re import time import ha_mqtt_discoverable.sensors as ha_mqtt from const import ERROR_LOOKBACK_MIN, VERSION # MAX_ERROR_COUNT, from ha_mqtt_discoverable import DeviceInfo, Settings from paho.mqtt.client import Client, MQTTMessage +from juicebox_message import JuiceboxStatusMessage, JuiceboxDebugMessage, JuiceboxEncryptedMessage _LOGGER = logging.getLogger(__name__) MQTT_SENDING_ENTITIES = ["text", "number", "switch", "button"] @@ -79,12 +79,25 @@ async def set_state(self, state): async def set(self, state=None): self._state = state try: - getattr(self._mqtt, self._set_func)(state) + if self.entity_type == 'number': + # float to be used by any number, JuiceboxMessage will use int + getattr(self._mqtt, self._set_func)(float(state)) + elif self.entity_type == 'switch': + # This works with ha-mqtt-discoverable after v0.14.0 + # but for ARM we need older version + # getattr(self._mqtt, self._set_func)(state.lower() == 'on') + # workaround calling the on/off methods + if (state.lower() == 'on'): + self._mqtt.on() + else: + self._mqtt.off() + else: + getattr(self._mqtt, self._set_func)(state) except AttributeError as e: if self._add_error is not None: await self._add_error() _LOGGER.warning( - f"Can't update attribtutes for {self.name} " + f"Can't update attributes for {self.name} " "as MQTT isn't connected/started. " f"({e.__class__.__qualname__}: {e})" ) @@ -97,7 +110,7 @@ async def set_attributes(self, attr={}): if self._add_error is not None: await self._add_error() _LOGGER.warning( - f"Can't update attribtutes for {self.name} " + f"Can't update attributes for {self.name} " "as MQTT isn't connected/started. " f"({e.__class__.__qualname__}: {e})" ) @@ -111,6 +124,7 @@ def __init__( ): # _LOGGER.debug(f"SendingEntity Init: {name}") super().__init__(name, **kwargs) + self.command_timestamp = None async def start(self): entity_info_keys = getattr( @@ -133,6 +147,9 @@ async def start(self): if self._kwargs.get("initial_state", None) is not None: await self.set(self._kwargs.get("initial_state", None)) + elif self.entity_type == 'number': + # The state will came on juicebox messages + _LOGGER.warning(f"{self.name} has no initial_state") else: await self.set(self.name) @@ -151,8 +168,14 @@ async def _callback_async(self, client: Client, user_data, message: MQTTMessage) f"{state}. User Data: {user_data}" ) if self._mitm_handler: - _LOGGER.debug(f"Sending to MITM: {state}") - await self._mitm_handler.send_data_to_juicebox(state.encode("utf-8")) + if user_data == 'RAW': + _LOGGER.debug(f"Sending to MITM: {state}") + await self._mitm_handler.send_data_to_juicebox(state.encode("utf-8")) + else: + # Internal state must be set before sending message to juicebox + await self.set(state) + self.command_timestamp = time.time() + await self._mitm_handler.send_cmd_message_to_juicebox(new_values=True) else: if self._add_error is not None: await self._add_error() @@ -174,6 +197,39 @@ def __init__( super().__init__(name, **kwargs) +class JuiceboxMQTTNumber(JuiceboxMQTTSendingEntity): + def __init__( + self, + name, + **kwargs, + ): + # _LOGGER.debug(f"Number Init: {name}") + self.entity_type = "number" + self._set_func = "set_value" + super().__init__(name, **kwargs) + + + +class JuiceboxMQTTSwitch(JuiceboxMQTTSendingEntity): + def __init__( + self, + name, + **kwargs, + ): + # _LOGGER.debug(f"Switch Init: {name}") + self.entity_type = "switch" + self._set_func = "update_state" + super().__init__(name, **kwargs) + + + def is_on(self): + + if type(self.state) is str: + return self.state.lower() == 'on' + + return self.state + + class JuiceboxMQTTText(JuiceboxMQTTSendingEntity): def __init__( self, @@ -195,6 +251,7 @@ def __init__( device_name, mqtt_settings, experimental, + config, juicebox_id=None, mitm_handler=None, loglevel=None, @@ -205,7 +262,11 @@ def __init__( self._device_name = device_name self._juicebox_id = juicebox_id self._experimental = experimental + self._config = config self._mitm_handler = mitm_handler + # Try to use first the MAX_CURRENT as maximum, if not found use the previous run current_rating or default of 48 which is safe and not so big + self._max_current = config.get_device(self._juicebox_id, "MAX_CURRENT", config.get_device(self._juicebox_id, "current_rating", 48)) + _LOGGER.info(f"max_current: {self._max_current}") self._error_count = 0 self._error_timestamp_list = [] @@ -228,85 +289,156 @@ def __init__( sw_version=VERSION, via_device="JuicePass Proxy", ) + self._entities = { "status": JuiceboxMQTTSensor( name="Status", icon="mdi:ev-station", + expire_after=7200, ), "current": JuiceboxMQTTSensor( name="Current", state_class="measurement", device_class="current", unit_of_measurement="A", + expire_after=7200, ), + # Maximum supported by device "current_rating": JuiceboxMQTTSensor( name="Current Rating", + device_class="current", + unit_of_measurement="A", + expire_after=7200, + ), + # Offline max + "current_max_offline": JuiceboxMQTTSensor( + name="Max Current(Offline/Device)", state_class="measurement", device_class="current", unit_of_measurement="A", + expire_after=7200, + ), + "current_max_offline_set": JuiceboxMQTTNumber( + name="Max Current(Offline/Wanted)", + device_class="current", + unit_of_measurement="A", + min=0, + max=self._max_current, + # no initial state, to use the value that will be received from juicebox or from config + # because of this, the entity will only show later (first time) on homeassistant when value is set + # and can change the homeassistant value + ), + # Instant / Charging current + "current_max_online": JuiceboxMQTTSensor( + name="Max Current(Online/Device)", + state_class="measurement", + device_class="current", + unit_of_measurement="A", + expire_after=7200, + ), + "current_max_online_set": JuiceboxMQTTNumber( + name="Max Current(Online/Wanted)", + device_class="current", + unit_of_measurement="A", + min=0, + max=self._max_current, + # no initial state, to use the value that will be received from juicebox or from config + # because of this, the entity will only show later (first time) on homeassistant when value is set + # and can change the homeassistant value ), "frequency": JuiceboxMQTTSensor( name="Frequency", state_class="measurement", device_class="frequency", unit_of_measurement="Hz", + expire_after=7200, ), "energy_lifetime": JuiceboxMQTTSensor( name="Energy (Lifetime)", state_class="total_increasing", device_class="energy", unit_of_measurement="Wh", + expire_after=7200, ), "energy_session": JuiceboxMQTTSensor( name="Energy (Session)", state_class="total_increasing", device_class="energy", unit_of_measurement="Wh", + expire_after=7200, ), "temperature": JuiceboxMQTTSensor( name="Temperature", state_class="measurement", device_class="temperature", unit_of_measurement="°F", + expire_after=7200, ), "voltage": JuiceboxMQTTSensor( name="Voltage", state_class="measurement", device_class="voltage", unit_of_measurement="V", + expire_after=7200, ), "power": JuiceboxMQTTSensor( name="Power", state_class="measurement", device_class="power", unit_of_measurement="W", + expire_after=7200, + ), + "power_factor": JuiceboxMQTTSensor( + name="Power Factor", + state_class="measurement", + device_class="power_factor", + expire_after=7200, + ), + # Make possible to control from HA when juicepassproxy will act as ENEL X server for the juicebox + # Will only work when ignoring ENEL X server + "act_as_server": JuiceboxMQTTSwitch( + name="Act as Server", + enabled_by_default=False, + # As will only work when ignoring ENEL X server, True appear to be good for initial state + initial_state="ON", ), "debug_message": JuiceboxMQTTSensor( name="Last Debug Message", - # expire_after=60, enabled_by_default=False, icon="mdi:bug", entity_category="diagnostic", initial_state=f"INFO: Starting JuicePass Proxy {VERSION}", + expire_after=0, # Keep last message available ), "data_from_juicebox": JuiceboxMQTTSensor( name="Data from JuiceBox", experimental=True, enabled_by_default=False, entity_category="diagnostic", + expire_after=0, # Keep last message available ), "data_from_enelx": JuiceboxMQTTSensor( name="Data from EnelX", experimental=True, enabled_by_default=False, entity_category="diagnostic", + expire_after=0, # Keep last message available ), "send_to_juicebox": JuiceboxMQTTText( name="Send Command to JuiceBox", + user_data="RAW", experimental=True, enabled_by_default=False, ), } + + _LOGGER.info("Checking for initial_states on config") + for key in self._entities.keys(): + initial_state = self._config.get_device(self._juicebox_id, key + "_initial_state", None) + if initial_state: + _LOGGER.info(f"got initial_state on config : {key} -> {initial_state}") + self._entities[key].add_kwargs(initial_state=initial_state) + for entity in self._entities.values(): entity.add_kwargs( juicebox_id=self._juicebox_id, @@ -317,6 +449,9 @@ def __init__( if entity.entity_type in MQTT_SENDING_ENTITIES: entity.add_kwargs(mitm_handler=self._mitm_handler) + def get_entity(self, name): + return self._entities[name] + async def start(self): _LOGGER.info("Starting JuiceboxMQTTHandler") @@ -339,76 +474,6 @@ async def set_mitm_handler(self, mitm_handler): if entity.entity_type in MQTT_SENDING_ENTITIES: entity.add_kwargs(mitm_handler=mitm_handler) - async def _basic_message_parse(self, data: bytes): - message = {"type": "basic", "current": 0, "energy_session": 0} - active = True - parts = re.split(r",|!|:", data.decode("utf-8")) - parts.pop(0) # JuiceBox ID - parts.pop(-1) # Ending blank - parts.pop(-1) # Checksum - - # Undefined parts: F, e, r, b, B, P, p - # https://github.com/snicker/juicepassproxy/issues/52 - # s = Counter - # v = version of protocol - # i = Interval number. It contains a 96-slot interval memory (15-minute x 24-hour cycle) and - # this tells you how much energy was consumed in the rolling window as it reports one past - # (or current, if it's reporting the "right-now" interval) interval per message. - # The letter after "i" = the energy in that interval (usually 0 if you're not charging basically 24/7) - # t - probably the report time in seconds - "every 9 seconds" (or may end up being 10). - # It can change its reporting interval if the bit mask in the reply command indicates that it should send reports faster (yet to be determined). - # u - loop counter - for part in parts: - if part[0] == "S": - message["status"] = { - "S0": "Unplugged", - "S1": "Plugged In", - "S2": "Charging", - "S5": "Error", - "S00": "Unplugged", - "S01": "Plugged In", - "S02": "Charging", - "S05": "Error", - }.get(part) - if message["status"] is None: - message["status"] = f"unknown {part}" - active = message["status"].lower() == "charging" - elif part[0] == "A": - message["current"] = ( - round(float(part.split("A")[1]) * 0.1, 2) if active else 0 - ) - elif part[0] == "m": - message["current_rating"] = float(part.split("m")[1]) - elif part[0] == "M": - message["current_setting"] = float(part.split("M")[1]) - elif part[0] == "f": - message["frequency"] = round(float(part.split("f")[1]) * 0.01, 2) - elif part[0] == "L": - message["energy_lifetime"] = float(part.split("L")[1]) - elif part[0] == "v": - message["protocol_version"] = part.split("v")[1] - elif part[0] == "E": - message["energy_session"] = float(part.split("E")[1]) if active else 0 - elif part[0] == "t": - message["report_time"] = part.split("t")[1] - elif part[0] == "v": - message["protocol_version"] = part.split("v")[1] - elif part[0] == "i": - message["interval"] = part.split("i")[1] - elif part[0] == "u": - message["loop_counter"] = part.split("u")[1] - elif part[0] == "T": - message["temperature"] = round(float(part.split("T")[1]) * 1.8 + 32, 2) - elif part[0] == "V": - message["voltage"] = round(float(part.split("V")[1]) * 0.1, 2) - else: - message["unknown_" + part[0]] = part[1:] - message["power"] = round( - message.get("voltage", 0) * message.get("current", 0), 2 - ) - message["data_from_juicebox"] = data.decode("utf-8") - return message - async def _udp_mitm_oserror_message_parse(self, data): message = {"type": "udp_mitm_oserror"} err_data = str(data).split("|") @@ -419,38 +484,27 @@ async def _udp_mitm_oserror_message_parse(self, data): ) return message - async def _debug_message_parse(self, data): - message = {"type": "debug"} - - dbg_data = ( - data.decode("utf-8") - .replace("https://", "https//") - .replace("http://", "http//") - ) - dbg_level_abbr = dbg_data.split(":")[1].split(",")[1] - if dbg_level_abbr == "NFO": - dbg_level = "INFO" - elif dbg_level_abbr == "WRN": - dbg_level = "WARNING" - elif dbg_level_abbr == "ERR": - dbg_level = "ERROR" - else: - dbg_level = dbg_level_abbr - dbg_data = dbg_data[dbg_data.find(":", dbg_data.find(":") + 1) + 1: -1] - dbg_msg = dbg_data.replace("https//", "https://").replace("http//", "http://") - - message["debug_message"] = f"{dbg_level}: {dbg_msg}" - return message - + async def _store_if_on_message(self, message, key): + if key in message: + self._config.update_device_value(self._juicebox_id, key, message[key]) + await self._config.write_if_changed() + async def _basic_message_publish(self, message): _LOGGER.debug(f"Publish {message.get('type').title()} Message: {message}") # try: attributes = {} + + # This values are usefull when JPP starts again to start fast + await self._store_if_on_message(message, "current_rating") + await self._store_if_on_message(message, "current_max_offline") + for k in message: entity = self._entities.get(k, None) if entity and (entity.experimental is False or self._experimental is True): + await entity.set_state(message.get(k, None)) + attributes[k] = message.get(k, None) if ( self._experimental @@ -494,18 +548,35 @@ async def remote_mitm_handler(self, data): # f"Exception handling remote data. ({e.__class__.__qualname__}: {e})" # ) - async def local_mitm_handler(self, data): + async def local_mitm_handler(self, data, decoded_message): message = None try: - _LOGGER.debug(f"From JuiceBox: {data}") + _LOGGER.debug(f"From JuiceBox: {data} decoded={decoded_message}") if "JuiceboxMITM_OSERROR" in str(data): message = await self._udp_mitm_oserror_message_parse(data) - elif ":DBG," in str(data): - message = await self._debug_message_parse(data) + + # Now using the classes as priority over older code + elif isinstance(decoded_message, JuiceboxEncryptedMessage): + _LOGGER.warning(f"Encrypted messages will not be sent to mqtt - {decoded_message}") + elif isinstance(decoded_message, JuiceboxStatusMessage): + message = decoded_message.to_simple_format() + elif isinstance(decoded_message, JuiceboxDebugMessage): + message = decoded_message.to_simple_format() else: - message = await self._basic_message_parse(data) + _LOGGER.error(f"should never arrive here, message is unsupported {data} {decoded_message}") + + _LOGGER.debug(f"decode/parsed message = {message}") + if message: + # Something is wrong if device is changed + # as the entities use the juicebox_id as unique_id this should not happen + if "serial" in message: + if message["serial"] != self._juicebox_id: + _LOGGER.error(f"serial {message['serial']} on received message does not match juicebox_id {self._juicebox_id}") + # For now just give the error, but will be better to dont send values on entities and return + await self._basic_message_publish(message) + return data except IndexError as e: await self._add_error() diff --git a/juicebox_telnet.py b/juicebox_telnet.py index 271ea4b..553b53b 100644 --- a/juicebox_telnet.py +++ b/juicebox_telnet.py @@ -7,7 +7,7 @@ class JuiceboxTelnet: - def __init__(self, host, port=2000, timeout=None, loglevel=None): + def __init__(self, host, port, timeout=None, loglevel=None): if loglevel is not None: _LOGGER.setLevel(loglevel) self.host = host diff --git a/juicebox_udpcupdater.py b/juicebox_udpcupdater.py index c8e3b18..7cc83ed 100644 --- a/juicebox_udpcupdater.py +++ b/juicebox_udpcupdater.py @@ -18,6 +18,7 @@ def __init__( self, juicebox_host, jpp_host, + telnet_port, udpc_port=8047, telnet_timeout=None, loglevel=None, @@ -27,6 +28,7 @@ def __init__( self._juicebox_host = juicebox_host self._jpp_host = jpp_host self._udpc_port = udpc_port + self._telnet_port = telnet_port self._telnet_timeout = telnet_timeout self._default_sleep_interval = 30 self._udpc_update_loop_task = None @@ -57,6 +59,7 @@ async def _connect(self): connect_attempt += 1 self._telnet = JuiceboxTelnet( self._juicebox_host, + self._telnet_port, loglevel=_LOGGER.getEffectiveLevel(), timeout=self._telnet_timeout, ) @@ -147,6 +150,7 @@ async def _udpc_update_handler(self, default_sleep_interval): not in connections[udpc_streams_to_close[udpc_stream_to_update]]["dest"] ): _LOGGER.info("UDPC IP incorrect, updating") + _LOGGER.debug(f"connections: {connections}") elif len(udpc_streams_to_close) == 1: _LOGGER.info("UDPC IP correct") update_required = False @@ -156,8 +160,9 @@ async def _udpc_update_handler(self, default_sleep_interval): _LOGGER.debug(f"Closing UDPC stream: {id}") await self._telnet.close_udpc_stream(id) await self._telnet.write_udpc_stream(self._jpp_host, self._udpc_port) - await self._telnet.save_udpc() - _LOGGER.info("UDPC IP Saved") + # Save is not recommended https://github.com/snicker/juicepassproxy/issues/96 + # await self._telnet.save_udpc() + _LOGGER.info("UDPC IP Changed") except ConnectionResetError as e: _LOGGER.warning( "Telnet connection to JuiceBox lost. " diff --git a/juicepassproxy.py b/juicepassproxy.py index 069aa96..11ed811 100644 --- a/juicepassproxy.py +++ b/juicepassproxy.py @@ -10,10 +10,8 @@ from pathlib import Path import dns -import yaml from aiorun import run from const import ( - CONF_YAML, DAYS_TO_KEEP_LOGS, DEFAULT_DEVICE_NAME, DEFAULT_ENELX_IP, @@ -24,6 +22,7 @@ DEFAULT_MQTT_DISCOVERY_PREFIX, DEFAULT_MQTT_HOST, DEFAULT_MQTT_PORT, + DEFAULT_TELNET_PORT, DEFAULT_TELNET_TIMEOUT, EXTERNAL_DNS, LOG_DATE_FORMAT, @@ -37,6 +36,7 @@ from juicebox_mqtthandler import JuiceboxMQTTHandler from juicebox_telnet import JuiceboxTelnet from juicebox_udpcupdater import JuiceboxUDPCUpdater +from juicebox_config import JuiceboxConfig logging.basicConfig( format=LOG_FORMAT, @@ -122,10 +122,11 @@ async def is_valid_ip(test_ip): return True -async def get_enelx_server_port(juicebox_host, telnet_timeout=None): +async def get_enelx_server_port(juicebox_host, telnet_port, telnet_timeout=None): try: async with JuiceboxTelnet( juicebox_host, + telnet_port, loglevel=_LOGGER.getEffectiveLevel(), timeout=telnet_timeout, ) as tn: @@ -151,10 +152,11 @@ async def get_enelx_server_port(juicebox_host, telnet_timeout=None): return None -async def get_juicebox_id(juicebox_host, telnet_timeout=None): +async def get_juicebox_id(juicebox_host, telnet_port, telnet_timeout=None): try: async with JuiceboxTelnet( juicebox_host, + telnet_port, loglevel=_LOGGER.getEffectiveLevel(), timeout=telnet_timeout, ) as tn: @@ -175,28 +177,6 @@ async def get_juicebox_id(juicebox_host, telnet_timeout=None): return None -async def load_config(config_loc): - config = {} - try: - with open(config_loc, "r") as file: - config = yaml.safe_load(file) - except Exception as e: - _LOGGER.warning(f"Can't load {config_loc}. ({e.__class__.__qualname__}: {e})") - if not config: - config = {} - return config - - -async def write_config(config, config_loc): - try: - with open(config_loc, "w") as file: - yaml.dump(config, file) - return True - except Exception as e: - _LOGGER.warning( - f"Can't write to {config_loc}. ({e.__class__.__qualname__}: {e})" - ) - return False def ip_to_tuple(ip): @@ -218,11 +198,13 @@ async def parse_args(): metavar="HOST", help="Host or IP address of the JuiceBox. Required for --update_udpc or if --enelx_ip not defined.", ) + parser.add_argument( "--update_udpc", action="store_true", help="Update UDPC on the JuiceBox. Requires --juicebox_host", ) + parser.add_argument( "--jpp_host", "--juicepass_proxy_host", @@ -233,6 +215,7 @@ async def parse_args(): "Proxy. Optional: only necessary when using --update_udpc and " "it will be inferred from the address in --local_ip if omitted.", ) + parser.add_argument( "-H", "--mqtt_host", @@ -241,6 +224,7 @@ async def parse_args(): default=DEFAULT_MQTT_HOST, help="MQTT Hostname to connect to (default: %(default)s)", ) + parser.add_argument( "-p", "--mqtt_port", @@ -249,12 +233,15 @@ async def parse_args(): default=DEFAULT_MQTT_PORT, help="MQTT Port (default: %(default)s)", ) + parser.add_argument( "-u", "--mqtt_user", type=str, help="MQTT Username", metavar="USER" ) + parser.add_argument( "-P", "--mqtt_password", type=str, help="MQTT Password", metavar="PASSWORD" ) + parser.add_argument( "-D", "--mqtt_discovery_prefix", @@ -264,6 +251,7 @@ async def parse_args(): default=DEFAULT_MQTT_DISCOVERY_PREFIX, help="Home Assistant MQTT topic prefix (default: %(default)s)", ) + parser.add_argument( "--config_loc", type=str, @@ -271,13 +259,15 @@ async def parse_args(): default=Path.home().joinpath(".juicepassproxy"), help="The location to store the config file (default: %(default)s)", ) + parser.add_argument( "--log_loc", type=str, metavar="LOC", - default=Path.home(), + default=str(Path.home()), help="The location to store the log files (default: %(default)s)", ) + parser.add_argument( "--name", type=str, @@ -285,19 +275,38 @@ async def parse_args(): help="Home Assistant Device Name (default: %(default)s)", dest="device_name", ) + parser.add_argument( "--debug", action="store_true", help="Show Debug level logging. (default: Info)" ) + + parser.add_argument( + "--disable_reuse_port", action="store_true", help="Disable port reuse for server socket (default: reuse_port)" + ) + parser.add_argument( "--experimental", action="store_true", help="Enables additional entities in Home Assistant that are in in development or can be used toward developing the ability to send commands to a JuiceBox.", ) + parser.add_argument( "--ignore_enelx", action="store_true", help="If set, will not send commands received from EnelX to the JuiceBox nor send outgoing information from the JuiceBox to EnelX", ) + + parser.add_argument( + "--tp", + "--telnet_port", + dest="telnet_port", + required=False, + type=int, + metavar="PORT", + default=DEFAULT_TELNET_PORT, + help="Telnet PORT (default: %(default)s)", + ) + parser.add_argument( "--telnet_timeout", type=int, @@ -305,6 +314,7 @@ async def parse_args(): default=DEFAULT_TELNET_TIMEOUT, help="Timeout in seconds for Telnet operations (default: %(default)s)", ) + parser.add_argument( "--juicebox_id", type=str, @@ -312,6 +322,7 @@ async def parse_args(): help="JuiceBox ID. If not defined, will obtain it automatically.", dest="juicebox_id", ) + parser.add_argument( "--local_ip", "-s", @@ -322,6 +333,7 @@ async def parse_args(): metavar="IP", help="Local IP (and optional port). If not defined, will obtain it automatically. (Ex. 127.0.0.1:8047) [Deprecated: -s --src]", ) + parser.add_argument( "--local_port", dest="local_port", @@ -330,6 +342,7 @@ async def parse_args(): metavar="PORT", help="Local Port for JPP to listen on.", ) + parser.add_argument( "--enelx_ip", "-d", @@ -346,29 +359,33 @@ async def parse_args(): async def main(): args = await parse_args() + log_handlers = [ logging.StreamHandler() ] + enable_file_log = (len(args.log_loc) > 0) and (args.log_loc != 'none') log_loc = Path(args.log_loc) - log_loc.mkdir(parents=True, exist_ok=True) - log_loc = log_loc.joinpath(LOGFILE) - log_loc.touch(exist_ok=True) + if enable_file_log: + log_loc.mkdir(parents=True, exist_ok=True) + log_loc = log_loc.joinpath(LOGFILE) + log_loc.touch(exist_ok=True) + log_handlers.append(TimedRotatingFileHandler( + log_loc, when="midnight", backupCount=DAYS_TO_KEEP_LOGS + )) logging.basicConfig( format=LOG_FORMAT, datefmt=LOG_DATE_FORMAT, level=DEFAULT_LOGLEVEL, - handlers=[ - logging.StreamHandler(), - TimedRotatingFileHandler( - log_loc, when="midnight", backupCount=DAYS_TO_KEEP_LOGS - ), - ], + handlers=log_handlers, force=True, ) if args.debug: _LOGGER.setLevel(logging.DEBUG) _LOGGER.warning( f"Starting JuicePass Proxy {VERSION} " - f"(Log Level: {logging.getLevelName(_LOGGER.getEffectiveLevel())})" + f"(Log Level: {logging.getLevelName(_LOGGER.getEffectiveLevel())}, log_handlers={len(log_handlers)})" ) - _LOGGER.info(f"log_loc: {log_loc}") + if enable_file_log: + _LOGGER.info(f"log_loc: {log_loc}") + else: + _LOGGER.info("not logging to file") if len(sys.argv) == 1: _LOGGER.error( "Exiting: no command-line arguments given. Run with --help to see options." @@ -387,12 +404,13 @@ async def main(): ) sys.exit(1) - config_loc = Path(args.config_loc) - config_loc.mkdir(parents=True, exist_ok=True) - config_loc = config_loc.joinpath(CONF_YAML) - config_loc.touch(exist_ok=True) - _LOGGER.info(f"config_loc: {config_loc}") - config = await load_config(config_loc) + config = JuiceboxConfig(args.config_loc) + await config.load() + + telnet_port = int(args.telnet_port) + _LOGGER.info(f"telnet port: {telnet_port}") + if telnet_port == 0: + telnet_port = 2000 telnet_timeout = int(args.telnet_timeout) _LOGGER.info(f"telnet timeout: {telnet_timeout}") @@ -405,7 +423,7 @@ async def main(): enelx_server_port = None if not ignore_enelx: enelx_server_port = await get_enelx_server_port( - args.juicebox_host, telnet_timeout=telnet_timeout + args.juicebox_host, args.telnet_port, telnet_timeout=telnet_timeout ) if enelx_server_port: @@ -415,8 +433,8 @@ async def main(): else: enelx_server = config.get("ENELX_SERVER", DEFAULT_ENELX_SERVER) enelx_port = config.get("ENELX_PORT", DEFAULT_ENELX_PORT) - config.update({"ENELX_SERVER": enelx_server}) - config.update({"ENELX_PORT": enelx_port}) + config.update_value("ENELX_SERVER", enelx_server) + config.update_value("ENELX_PORT", enelx_port) _LOGGER.info(f"enelx_server: {enelx_server}") _LOGGER.info(f"enelx_port: {enelx_port}") @@ -448,7 +466,7 @@ async def main(): f"{config.get('LOCAL_IP', config.get('SRC', DEFAULT_LOCAL_IP))}:" f"{local_port}" ) - config.update({"LOCAL_IP": local_addr[0]}) + config.update_value("LOCAL_IP", local_addr[0]) _LOGGER.info(f"local_addr: {local_addr[0]}:{local_addr[1]}") localhost_check = ( @@ -475,19 +493,20 @@ async def main(): f"{config.get('ENELX_IP', config.get('DST', DEFAULT_ENELX_IP))}:" f"{enelx_port}" ) - config.update({"ENELX_IP": enelx_addr[0]}) + config.update_value("ENELX_IP", enelx_addr[0]) _LOGGER.info(f"enelx_addr: {enelx_addr[0]}:{enelx_addr[1]}") + _LOGGER.info(f"telnet_addr: {args.juicebox_host}:{args.telnet_port}") if juicebox_id := args.juicebox_id: pass elif juicebox_id := await get_juicebox_id( - args.juicebox_host, telnet_timeout=telnet_timeout + args.juicebox_host, args.telnet_port, telnet_timeout=telnet_timeout ): pass else: - juicebox_id = config.get("JUICEBOX_ID") + juicebox_id = config.get("JUICEBOX_ID", None) if juicebox_id: - config.update({"JUICEBOX_ID": juicebox_id}) + config.update_value("JUICEBOX_ID", juicebox_id) _LOGGER.info(f"juicebox_id: {juicebox_id}") else: _LOGGER.error( @@ -498,10 +517,10 @@ async def main(): _LOGGER.info(f"experimental: {experimental}") # Remove DST and SRC from Config as they have been replaced by ENELX_IP and LOCAL_IP respectively - config.pop("DST", None) - config.pop("SRC", None) + config.pop("DST") + config.pop("SRC") - await write_config(config, config_loc) + await config.write_if_changed() mqtt_settings = Settings.MQTT( host=args.mqtt_host, @@ -522,6 +541,7 @@ async def main(): mqtt_settings=mqtt_settings, device_name=args.device_name, juicebox_id=juicebox_id, + config=config, experimental=experimental, loglevel=_LOGGER.getEffectiveLevel(), ) @@ -534,6 +554,9 @@ async def main(): enelx_addr=enelx_addr, # EnelX IP ignore_enelx=ignore_enelx, loglevel=_LOGGER.getEffectiveLevel(), + # windows users are having trouble with reuse_port=True + # TODO find a safe way to detect windows and change the default value + reuse_port=config.get("reuse_port", not args.disable_reuse_port), ) await mitm_handler.set_local_mitm_handler(mqtt_handler.local_mitm_handler) await mitm_handler.set_remote_mitm_handler(mqtt_handler.remote_mitm_handler) @@ -549,6 +572,7 @@ async def main(): udpc_updater = JuiceboxUDPCUpdater( juicebox_host=args.juicebox_host, jpp_host=jpp_host, + telnet_port=telnet_port, udpc_port=local_addr[1], telnet_timeout=telnet_timeout, loglevel=_LOGGER.getEffectiveLevel(), @@ -562,7 +586,7 @@ async def main(): *jpp_task_list, ) except Exception as e: - _LOGGER.error( + _LOGGER.exception( f"A JuicePass Proxy task failed: {e.__class__.__qualname__}: {e}" ) await mqtt_handler.close() diff --git a/test_config.py b/test_config.py new file mode 100644 index 0000000..e37da53 --- /dev/null +++ b/test_config.py @@ -0,0 +1,57 @@ +import unittest +import random + +from juicebox_config import JuiceboxConfig +from test_message import FAKE_SERIAL + +class TestMessage(unittest.IsolatedAsyncioTestCase): + + def test_basic_config(self): + """ + Test Config + """ + config = JuiceboxConfig("/tmp/juicepass-test/") + self.assertEqual("default", config.get("not_defined", "default")) + value = random.seed(10000) + + config.update({ "ANY" : value }) + self.assertEqual(value, config.get("ANY", None)) + config.pop("ANY") + self.assertEqual(None, config.get("ANY", None)) + + config.update_value("ANY", value ) + self.assertEqual(value, config.get("ANY", None)) + config.pop("ANY") + self.assertEqual(None, config.get("ANY", None)) + + self.assertTrue(config.is_changed()) + + + self.assertEqual(None, config.get_device(FAKE_SERIAL, "ANY", None)) + config.update_device_value(FAKE_SERIAL, "ANY", value) + self.assertEqual(value, config.get_device(FAKE_SERIAL, "ANY", None)) + + # TODO more basic tests + + async def test_sample_config(self): + """ + Test Sample Config + """ + config = JuiceboxConfig("./", filename="test_config.yaml") + await config.load() + + self.assertEqual(1, config.get("MAX_CURRENT", None)) + self.assertEqual(2, config.get_device("0000", "MAX_CURRENT", None)) + self.assertEqual(1, config.get_device("0001", "MAX_CURRENT", None)) + + value = random.seed(10000) + self.assertEqual(value, config.get("x", value)) + self.assertEqual(value, config.get_device("0000", "x", value)) + self.assertEqual(value, config.get_device("0001", "x", value)) + + self.assertFalse(config.is_changed()) + # TODO more tests + +if __name__ == '__main__': + unittest.main() + \ No newline at end of file diff --git a/test_config.yaml b/test_config.yaml new file mode 100644 index 0000000..dbcea71 --- /dev/null +++ b/test_config.yaml @@ -0,0 +1,2 @@ +MAX_CURRENT: 1 +0000_MAX_CURRENT: 2 diff --git a/test_message.py b/test_message.py new file mode 100644 index 0000000..547bc60 --- /dev/null +++ b/test_message.py @@ -0,0 +1,376 @@ +import unittest +from juicebox_message import juicebox_message_from_string, juicebox_message_from_bytes, JuiceboxMessage, JuiceboxDebugMessage, JuiceboxEncryptedMessage, JuiceboxCommand +from juicebox_exceptions import JuiceboxInvalidMessageFormat +import codecs +import datetime + + +FAKE_SERIAL = "0910000000000000000000000000" + +# +# Some messages here are not the real one captured, the serial number of device was changed after doing tests with real values and crc corrected +# +class TestMessage(unittest.TestCase): + + def setUp(self): + self.maxDiff = None + + def do_test_message_building(self, new_version, offline_amperage, instant_amperage, full_command): + m = JuiceboxCommand(new_version=new_version) + m.time = datetime.datetime(2012, 3, 23, 23, 24, 55, 173504) + m.offline_amperage = offline_amperage + m.instant_amperage = instant_amperage + self.assertEqual(m.build(), full_command) + + def test_message_building(self): + self.do_test_message_building(False, 0, 0, "CMD52324A00M00C006S001!SHP$") + self.do_test_message_building(False, 16, 20, "CMD52324A20M16C006S001!5RE$") + + + def test_message_building_new(self): + self.do_test_message_building(True, 0, 0, "CMD52324A0000M000C006S001!ETK$") + self.do_test_message_building(True, 16, 20, "CMD52324A0020M016C006S001!YUK$") + + + def test_encrypted_message(self): + messages = [ + # https://github.com/snicker/juicepassproxy/issues/73#issuecomment-2149670058 + "303931303034323030313238303636303432373332333632303533353a7630396512b10a000000716b1493270404a809cbcbb7995fd86b391e4b5e606fd5153a81ecd6251eb2bf87da82db9ceaefb268caa8f0c01b538af48e45d1ef3ad28ca72a4fdf05261780fd753b361906368634821bf6cada5624bae11feb7dc975cfe14e2c305eb01adcc7b39687ddc130d66cc39bc2ccac7f903cb9b50adb9a77b95b77bd364b82dcbe8599dc9a8a880cc44eb0f04e8a1d9f4a6305978a7f3e3c58d5" + "303931303034323030313238303636303432373332333632303533353a7630396512b10a00000073480d38833df8ebed8add322332c5c9f0501b32e9b35b71d1d8d3e389f5b9002b42ee953b5d9f712ddd36ebcb9f0a8973eba739f388583429d3fcd4cd135f9e4d437ad6ad21c11ad8e89369252ada194b52436beeb67a15b4a24f85eae07ebeeb6270588c94e390fa6da00c831e290a8552bd49ce014db1aa70843ebb5db2b0dea0fa20d0ed00714ae3001c895bf54779d5d1449ee15bf486" + # TODO try to find a message that maybe can be decoded as string if possible + ] + for message in messages: + print("enc") + m = juicebox_message_from_bytes(codecs.decode(message,'hex')) + self.assertEqual(JuiceboxEncryptedMessage, type(m)) + + def test_encrypted_message_v08(self): + messages = [ + # https://github.com/JuiceRescue/juicepassproxy/issues/116 + b"0910000000000000000000000000:v08\x9a\xa0\x1d\x00\x00\x00\x00\x94" + ] + for message in messages: + m = juicebox_message_from_bytes(message) + self.assertEqual(JuiceboxEncryptedMessage, type(m)) + + def test_message_validation(self): + messages = [ + "g4rbl3d", + ] + for message in messages: + with self.assertRaises(JuiceboxInvalidMessageFormat): + print(f"bad : {message}") + juicebox_message_from_string(message) + + + def test_command_message_parsing(self): + """ + Command messages are typically sent by the Cloud to the Juicebox + """ + raw_msg = "CMD41325A0040M040C006S638!5N5$" + m = juicebox_message_from_string(raw_msg) + self.assertEqual(m.payload_str, "CMD41325A0040M040C006S638") + self.assertEqual(m.crc_str, "5N5") + self.assertEqual(m.crc_str, m.crc_computed()) + self.assertEqual(m.build(), raw_msg) + + self.assertEqual(m.get_value("DOW"), "4") + self.assertEqual(m.get_value("HHMM"), "1325") + + + + def test_status_message_parsing(self): + """ + Status messages are sent by the Juicebox + """ + raw_msg = "0910000000000000000000000000:v09u,s627,F10,u01254993,V2414,L00004555804,S01,T08,M0040,C0040,m0040,t29,i75,e00000,f5999,r61,b000,B0000000!S1H:" + + m = juicebox_message_from_string(raw_msg) + self.assertEqual(m.payload_str, "0910000000000000000000000000:v09u,s627,F10,u01254993,V2414,L00004555804,S01,T08,M0040,C0040,m0040,t29,i75,e00000,f5999,r61,b000,B0000000") + self.assertEqual(m.crc_str, "S1H") + self.assertEqual(m.crc_str, m.crc_computed()) + self.assertEqual(True, m.has_value("C")) + self.assertEqual(m.get_value("C"), "0040") + self.assertEqual(m.get_value("v"), "09u") + self.assertEqual(m.get_value("V"), "2414") + self.assertEqual(m.get_value("voltage"), "2414") + self.assertEqual(m.get_processed_value("status"), "Plugged In") + self.assertEqual(m.get_processed_value("voltage"), 241.4) + self.assertEqual(m.get_value("temperature"), "08") + self.assertEqual(m.get_processed_value("temperature"), 46.4) + self.assertEqual(m.get_value("serial"), "0910000000000000000000000000") + self.assertEqual(True, m.has_value("L")) + self.assertEqual(True, m.has_value("M")) + self.assertEqual(m.build(), raw_msg) + + # https://github.com/snicker/juicepassproxy/issues/80 + OLD_MESSAGE = '0910000000000000000000000000:V247,L11097,S0,T34,E14,i84,e1,t30:' + OLD_MESSAGE_2 = '0910000000000000000000000000:V247,L11156,E13322,A138,T28,t10,E14,i41,e1:' + OLD_CHARGING = '0910000000000000000000000000:V247,L11097,E60,A137,T20,t10,E14,i94,e2:' + OLD_PLUGGED_IN = '0910000000000000000000000000:V247,L11097,E67,A0,T20,t10,E14,i49,e1:' + + + def test_old_message(self): + """ + Test old message + """ + + m = juicebox_message_from_string(self.OLD_MESSAGE) + self.assertEqual(m.payload_str, self.OLD_MESSAGE[:-1]) + self.assertEqual(m.crc_str, None) + self.assertEqual(m.get_value("serial"), FAKE_SERIAL) + self.assertEqual(m.get_processed_value("status"), "Unplugged") + self.assertEqual(m.get_processed_value("voltage"), 247) + self.assertEqual(m.get_value("temperature"), "34") + self.assertEqual(m.get_value("current"), None) + self.assertEqual(m.get_processed_value("current"), 0) + self.assertEqual(m.get_processed_value("temperature"), 93.2) + # TODO complete other tests with this kind of assert + self.assertDictEqual(m.to_simple_format(), { "type" : "basic", "current" : 0, "serial" : FAKE_SERIAL, "status" : "Unplugged", "voltage": 247.0, + "temperature" : 93.2, "energy_lifetime": 11097, "energy_session": 14, "interval": 84, "report_time": "30", "e" : "1", + "power" : 0}) + + def test_old_message_2(self): + """ + Test old message + """ + + m = juicebox_message_from_string(self.OLD_MESSAGE_2) + self.assertEqual(m.payload_str, self.OLD_MESSAGE_2[:-1]) + self.assertEqual(m.crc_str, None) + self.assertEqual(m.get_value("serial"), FAKE_SERIAL) + self.assertEqual(m.get_processed_value("status"), "Charging") + self.assertEqual(m.get_value("temperature"), "28") + self.assertEqual(m.get_processed_value("temperature"), 82.4) + # the duplicate value is saved but what they mean ??? + self.assertEqual(m.get_value("E"), "13322") + self.assertEqual(m.get_value("E:1"), "14") + self.assertEqual(m.get_value("A"), "138") + + def test_old_charging(self): + """ + Test old charging message + """ + + m = juicebox_message_from_string(self.OLD_CHARGING) + self.assertEqual(m.payload_str, self.OLD_CHARGING[:-1]) + self.assertEqual(m.crc_str, None) + self.assertEqual(m.get_value("serial"), FAKE_SERIAL) + self.assertEqual(m.get_processed_value("status"), "Charging") + self.assertEqual(m.get_processed_value("voltage"), 247) + self.assertEqual(m.get_value("temperature"), "20") + self.assertEqual(m.get_processed_value("temperature"), 68.0) + # the duplicate value is saved but what they mean ??? + self.assertEqual(m.get_value("E"), "60") + self.assertEqual(m.get_value("E:1"), "14") + + def test_old_pluggedin(self): + """ + Test old PluggedIn message + """ + + m = juicebox_message_from_string(self.OLD_PLUGGED_IN) + self.assertEqual(m.payload_str, self.OLD_PLUGGED_IN[:-1]) + self.assertEqual(m.crc_str, None) + self.assertEqual(m.get_value("serial"), FAKE_SERIAL) + self.assertEqual(m.get_processed_value("status"), "Plugged In") + self.assertEqual(m.get_processed_value("voltage"), 247) + self.assertEqual(m.get_value("temperature"), "20") + self.assertEqual(m.get_processed_value("temperature"), 68.0) + # the duplicate value is saved but what they mean ??? + self.assertEqual(m.get_value("E"), "67") + self.assertEqual(m.get_value("E:1"), "14") + + + # Original messages changed to remove real serial number + V09U_SAMPLE = '0910000000000000000000000000:v09u,s001,F31,u00412974,V1366,L00004262804,S02,T28,M0024,C0024,m0032,t09,i23,e-0001,f5990,r99,b000,B0000000,P0,E0004501,A00161,p0996!ZW5:'; + # from https://github.com/snicker/juicepassproxy/issues/90 + V07_SAMPLE = '0910000000000000000000000000:v07,s0001,u30048,V2400,L0024880114,S2,T62,M40,m40,t09,i78,e-001,f6001,X0,Y0,E006804,A0394,p0992!KKD:'; + # from discord channel + V07_SAMPLE_2 = '0910000000000000000000000000:v07,s0177,u16708,V2422,L0024957914,S2,T61,M40,m40,t09,i51,e-001,f6001,X0,Y0,E019146,A0393,p0992!QBJ:' + + def test_v09(self): + """ + Test v09 sample message + """ + + m = juicebox_message_from_string(self.V09U_SAMPLE) + chkidx = self.V09U_SAMPLE.index('!') + self.assertEqual(m.payload_str, self.V09U_SAMPLE[:chkidx]) + self.assertEqual(m.crc_str, self.V09U_SAMPLE[(chkidx+1):(chkidx+4)]) + self.assertEqual(m.get_processed_value("status"), "Charging") + self.assertEqual(m.get_processed_value("voltage"), 136.6) + self.assertEqual(m.get_processed_value("current"), 16.1) + self.assertEqual(m.get_processed_value("power"), 2199) + self.assertEqual(m.get_processed_value("current_rating"), 32) + self.assertEqual(m.get_processed_value("current_max_online"), 24) + self.assertEqual(m.get_processed_value("current_max_offline"), 24) + self.assertEqual(m.get_processed_value("energy_session"), 4501) + self.assertEqual(m.get_processed_value("energy_lifetime"), 4262804) + self.assertEqual(m.get_processed_value("interval"), 23) + self.assertEqual(m.get_value("temperature"), "28") + self.assertEqual(m.get_processed_value("temperature"), 82.4) + + def test_v07(self): + """ + Test v07 sample message + """ + + m = juicebox_message_from_string(self.V07_SAMPLE) + chkidx = self.V07_SAMPLE.index('!') + self.assertEqual(m.payload_str, self.V07_SAMPLE[:chkidx]) + self.assertEqual(m.crc_str, self.V07_SAMPLE[(chkidx+1):(chkidx+4)]) + self.assertEqual(m.get_processed_value("status"), "Charging") + self.assertEqual(m.get_processed_value("voltage"), 240.0) + self.assertEqual(m.get_processed_value("frequency"), 60.01) + self.assertEqual(m.get_processed_value("current"), 39.4) + self.assertEqual(m.get_processed_value("power"), 9456) + self.assertEqual(m.get_processed_value("current_rating"), 40) + self.assertEqual(m.get_processed_value("current_max_online"), 40) + # The process will return value for this parameter that are not comming on the message + self.assertEqual(m.get_processed_value("current_max_offline"), None) + self.assertEqual(m.get_processed_value("energy_session"), 6804) + self.assertEqual(m.get_processed_value("energy_lifetime"), 24880114) + self.assertEqual(m.get_processed_value("interval"), 78) + self.assertEqual(m.get_value("temperature"), "62") + self.assertEqual(m.get_processed_value("temperature"), 143.6) + self.assertDictEqual(m.to_simple_format(), { "type" : "basic", "current" : 39.4, "serial" : FAKE_SERIAL, "status" : "Charging", "voltage": 240.0, + "temperature" : 143.6, "energy_lifetime": 24880114, "energy_session": 6804, "interval": 78, + "report_time": "09", "e" : "-001", "frequency" : 60.01, "loop_counter": "30048", + "protocol_version" : "07", "power_factor" : 0.992, "current_max_online": 40, "current_rating": 40, + "power" : 9456, + "X" : "0", "Y" : "0", "counter" : "0001" }) + + def test_v07_2(self): + """ + Test v07_2 sample message + """ + + m = juicebox_message_from_string(self.V07_SAMPLE_2) + chkidx = self.V07_SAMPLE_2.index('!') + self.assertEqual(m.payload_str, self.V07_SAMPLE_2[:chkidx]) + self.assertEqual(m.crc_str, self.V07_SAMPLE_2[(chkidx+1):(chkidx+4)]) + self.assertEqual(m.get_processed_value("status"), "Charging") + self.assertEqual(m.get_processed_value("voltage"), 242.2) + self.assertEqual(m.get_processed_value("frequency"), 60.01) + self.assertEqual(m.get_processed_value("current"), 39.3) + self.assertEqual(m.get_processed_value("power"), 9518) + self.assertEqual(m.get_processed_value("current_rating"), 40) + self.assertEqual(m.get_processed_value("current_max_online"), 40) + # The process will return value for this parameter that are not comming on the message + self.assertEqual(m.get_processed_value("current_max_offline"), None) + self.assertEqual(m.get_processed_value("energy_session"), 19146) + self.assertEqual(m.get_processed_value("energy_lifetime"), 24957914) + self.assertEqual(m.get_processed_value("interval"), 51) + self.assertEqual(m.get_value("temperature"), "61") + self.assertEqual(m.get_processed_value("temperature"), 141.8) + self.assertDictEqual(m.to_simple_format(), { "type" : "basic", "current" : 39.3, "serial" : FAKE_SERIAL, "status" : "Charging", "voltage": 242.2, + "temperature" : 141.8, "energy_lifetime": 24957914, "energy_session": 19146, "interval": 51, + "report_time": "09", "e" : "-001", "frequency" : 60.01, "loop_counter": "16708", + "protocol_version" : "07", "power_factor" : 0.992, "current_max_online": 40, "current_rating": 40, + "power" : 9518, + "X" : "0", "Y" : "0", "counter" : "0177" }) + + + DEBUG_BOT_VERSION = "0000000000000000000000000000:DBG,NFO:BOT:EMWERK-JB_1_1-1.4.0.28, 2021-04-27T20:39:50Z, ZentriOS-WZ-3.6.4.0:" + + def test_debug_BOT_VERSION(self): + m = juicebox_message_from_string(self.DEBUG_BOT_VERSION) + self.assertTrue(isinstance(m, JuiceboxDebugMessage)) + self.assertEqual(m.get_value("debug_message"),"INFO: BOT:EMWERK-JB_1_1-1.4.0.28, 2021-04-27T20:39:50Z, ZentriOS-WZ-3.6.4.0") + self.assertTrue(m.is_boot()) + + ISSUE_84_SAMPLE_MESSAGE = "0000000000000000000000000000:v09u,s997,F11,u00083333,V2475,L00006302485,S02,T20,M0040,C0040,m0040,t09,i13,e00000,f6002,r38,b000,B0000000,P0,E0015458,A00013,p0009!ACI:"; + + def test_issue_84(self): + m = juicebox_message_from_string(self.ISSUE_84_SAMPLE_MESSAGE) + self.assertEqual(1.3, m.get_processed_value("current")); + self.assertEqual(247.5, m.get_processed_value("voltage")); + self.assertEqual(322, m.get_processed_value("power")); + self.assertEqual(60.02, m.get_processed_value("frequency")); + self.assertEqual("Charging", m.get_processed_value("status")); + self.assertEqual(40, m.get_processed_value("current_max_offline")); + + # Serial was changed + # testing indicates that the '\xc3' is garbage on the received message, the fields appear to be correct but CRC was wrong + ISSUE_111_SAMPLE_MESSAGE_WRONG = b'0\xc300000000000000000000000000:v07,s0,u6623,V2479,L20710648,S1,T25,M24,m32,t30,i50,e0,f6002,X0,Y0!JLA:\n' + ISSUE_111_SAMPLE_MESSAGE = b'000000000000000000000000000:v07,s0,u6623,V2479,L20710648,S1,T25,M24,m32,t30,i50,e0,f6002,X0,Y0!JLA:\n' + + def test_issue_111_wrong(self): + with self.assertRaises(JuiceboxInvalidMessageFormat): + juicebox_message_from_bytes(self.ISSUE_111_SAMPLE_MESSAGE_WRONG) + + def test_issue_111(self): + m = juicebox_message_from_bytes(self.ISSUE_111_SAMPLE_MESSAGE) + self.assertEqual(247.9, m.get_processed_value("voltage")) + self.assertEqual(24, m.get_processed_value("current_max_online")) + self.assertEqual("07", m.get_processed_value("protocol_version")) + self.assertEqual(77, m.get_processed_value("temperature")) + + + def test_message_crcs(self): + cmd_messages = [ + 'CMD41325A0040M040C006S638!5N5$', # @MrDrew514 (v09u) + 'CMD62210A20M18C006S006!31Y$', + 'CMD62228A20M15C008S048!IR4$', + 'CMD62207A20M20C244S997!R5Y$', + 'CMD62207A20M20C008S996!ZI4$', + 'CMD62201A20M20C244S981!ECD$', + 'CMD62201A20M20C006S982!QT8$', + 'CMD31353A0000M010C244S741!2B3$', # (v09u) + # https://github.com/snicker/juicepassproxy/issues/90 + 'CMD41301A40M40C006S074!F0P$', + 'CMD41301A29M40C242S075!TJ5$', + 'CMD41301A40M40C008S076!YCA$', + 'CMD41301A40M40C244S077!B72$', + 'CMD41301A40M40C006S078!J0P$', + 'CMD41301A40M40C242S079!RQ7$', + 'CMD41302A40M40C008S080!S9E$', + 'CMD41302A40M40C244S081!T2P$', + 'CMD41302A40M40C006S082!KQL$' + ] + + crc_messages = [ + self.V09U_SAMPLE, + self.V07_SAMPLE, + self.V07_SAMPLE_2, + self.ISSUE_84_SAMPLE_MESSAGE, + ] + + debug_messages = [ + self.DEBUG_BOT_VERSION, + "0000000000000000000000000000:DBG,NFO:BOT:FW Init.ENC.Y/ECDAYS.90/EVT.Y/ECHTTP.Y:", + "0000000000000000000000000000:DBG,NFO:BOT:UUID 0000000000000000000000000000000000000000:", + "0000000000000000000000000000:DBG,NFO:BOT:BT:BootLoader(0), OTA(3), crc(ffffffff):", + "0000000000000000000000000000:DBG,ERR:Miss CRC 'CMD01216A27M30C006S23':", + "0000000000000000000000000000:DBG,WRN:Events_03_04e22Z-01-01 Open Err 7034:", + "0000000000000000000000000000:DBG,NFO:ELife [-1,-1,5340542], 2, w 5340542, r 5340542,5340543:", + ] + + old_messages = [ + self.OLD_MESSAGE, + self.OLD_MESSAGE_2, + self.OLD_PLUGGED_IN, + self.OLD_CHARGING + ] + + for message in (cmd_messages + crc_messages + old_messages + debug_messages): + m = juicebox_message_from_string(message) + + self.assertEqual(m.build(), message) + self.assertEqual(m.crc_str, m.crc_computed()) +# print(m.inspect()) + + + for message in crc_messages: + # expect for error when trying to parse a crc message ignoring crc + with self.assertRaises(JuiceboxInvalidMessageFormat): + m = JuiceboxMessage(False).from_string(message) + + for message in old_messages: + # expect for error when trying to parse old message without checking considering crc + with self.assertRaises(JuiceboxInvalidMessageFormat): + m = JuiceboxMessage().from_string(message) + +if __name__ == '__main__': + unittest.main()