diff --git a/README.MD b/README.MD index e7e53a6..2ddcc5e 100644 --- a/README.MD +++ b/README.MD @@ -2,6 +2,8 @@ This tool will publish JuiceBox data by using a Man in the Middle UDP proxy to MQTT that is auto-discoverable by HomeAssistant. The `Enel X Way` app will continue to function. +It can also publish the IP of the proxy to the Juicebox directly to avoid using custom local DNS servers using the `update_udpc` and `juicebox_host` command line parameters (not yet supported in Docker). + Builds upon work by lovely folks in this issue: https://github.com/home-assistant/core/issues/86588 _Hopefully we won't need this if EnelX fixes their API!_ @@ -91,6 +93,7 @@ Variable | Required | Description & Default | 4. Launch by executing `python juicepassproxy.py --dst --host ` (params documented below) 5. Nothing happens! 6. Configure your DNS server running on your network (like Pi-hole or your router) to route all DNS requests from EnelX to the machine running this proxy. For me this was `juicenet-udp-prod3-usa.enelx.com`. See below for instructions to determine that. +7. Alternatively to #6, you can enable `update_udpc` on the command line and set `juicebox_host` and the application will force publish the IP in the `src` flag to the Juicebox and avoid the need to set DNS rules on your router or DNS server. ### CLI Options @@ -108,6 +111,9 @@ options: -D DISCOVERY_PREFIX, --discovery-prefix DISCOVERY_PREFIX Home Assistant MQTT topic prefix (default: homeassistant) --name DEVICE_NAME Home Assistant Device Name (default: Juicebox) + --update_udpc Update UDPC on the Juicebox. Requires --juicebox_host + --juicebox_host JUICEBOX_HOST + host or IP address of the Juicebox. required for --update_udpc ``` _For **DST**, you should only use the IP address of the EnelX Server and **not** the fully qualified domain name (FQDN) to avoid DNS lookup loops._ diff --git a/juicebox_telnet.py b/juicebox_telnet.py new file mode 100644 index 0000000..14e04f9 --- /dev/null +++ b/juicebox_telnet.py @@ -0,0 +1,89 @@ +from telnetlib import Telnet +import logging + +class JuiceboxTelnet(object): + def __init__(self, host, port=2000): + self.host = host + self.port = port + self.connection = None + + def __enter__(self): + self.connection = self.open() + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + if self.connection: + self.connection.close() + self.connection = None + + def open(self): + if not self.connection: + self.connection = Telnet(host=self.host, port=self.port) + self.connection.read_until(b">") + return self.connection + + def list(self): + tn = self.open() + tn.write(b"\n") + tn.read_until(b"> ") + tn.write(b"list\n") + tn.read_until(b"list\r\n! ") + res = tn.read_until(b">") + lines = str(res[:-3]).split("\\r\\n") + out = [] + for line in lines[1:]: + parts = line.split(" ") + if len(parts) >= 5: + out.append({ + 'id': parts[1], + 'type': parts[2], + 'dest': parts[4] + }) + return out + + def get(self, variable): + tn = self.open() + tn.write(b"\n") + tn.read_until(b"> ") + cmd = f"get {variable}\r\n".encode("ascii") + tn.write(cmd) + tn.read_until(cmd) + res = tn.read_until(b">") + return {variable: str(res[:-1].strip())} + + def get_all(self): + tn = self.open() + tn.write(b"\n") + tn.read_until(b">") + cmd = f"get all\r\n".encode("ascii") + tn.write(cmd) + tn.read_until(cmd) + res = tn.read_until(b">") + lines = str(res[:-1]).split("\\r\\n") + vars = {} + for line in lines: + parts = line.split(": ") + if len(parts) == 2: + vars[parts[0]] = parts[1] + return vars + + def stream_close(self, id): + tn = self.open() + tn.write(b"\n") + tn.read_until(b">") + tn.write(f"stream_close {id}\n".encode('ascii')) + tn.read_until(b">") + + def udpc(self, host, port): + tn = self.open() + tn.write(b"\n") + tn.read_until(b">") + tn.write(f"udpc {host} {port}\n".encode('ascii')) + tn.read_until(b">") + + def save(self): + tn = self.open() + tn.write(b"\n") + tn.read_until(b">") + tn.write(b"save\n") + tn.read_until(b">") \ No newline at end of file diff --git a/juicepassproxy.py b/juicepassproxy.py index aab4b95..d2b52f0 100644 --- a/juicepassproxy.py +++ b/juicepassproxy.py @@ -1,6 +1,8 @@ import argparse import logging - +import time +from threading import Thread +from juicebox_telnet import JuiceboxTelnet from ha_mqtt_discoverable import DeviceInfo, Settings from ha_mqtt_discoverable.sensors import Sensor, SensorInfo from pyproxy import pyproxy @@ -234,13 +236,53 @@ def local_data_handler(self, data): self.basic_message_publish(message) return data +class JuiceboxUDPCUpdater(object): + def __init__(self, juicebox_host, udpc_host, udpc_port = 8047): + self.juicebox_host = juicebox_host + self.udpc_host = udpc_host + self.udpc_port = udpc_port + self.interval = 30 + self.run_event = True + + def start(self): + while self.run_event: + try: + logging.debug("JuiceboxUDPCUpdater check...") + with JuiceboxTelnet(self.juicebox_host,2000) as tn: + connections = tn.list() + update_required = True + connection_to_update = None + id_to_update = None + + for connection in connections: + if connection['type'] == 'UDPC': + connection_to_update = connection + + if connection_to_update is None: + logging.debug('UDPC IP not found, updating...') + elif self.udpc_host not in connection['dest']: + logging.debug('UDPC IP incorrect, updating...') + id_to_update = connection['id'] + else: + logging.debug('UDPC IP correct') + update_required = False + + if update_required: + if id_to_update is not None: + tn.stream_close(id_to_update) + tn.udpc(self.udpc_host, self.udpc_port) + tn.save() + logging.debug('UDPC IP Saved') + except: + logging.exception('Error in JuiceboxUDPCUpdater') + time.sleep(self.interval) def main(): parser = argparse.ArgumentParser( formatter_class=argparse.RawDescriptionHelpFormatter, description=AP_DESCRIPTION ) - parser.add_argument( + arg_src = parser.add_argument( "-s", "--src", required=True, @@ -281,11 +323,26 @@ def main(): parser.add_argument( "--juicebox-id", type=str, help="JuiceBox ID", dest="juicebox_id" ) + parser.add_argument( + "--update_udpc", action="store_true", + help="Update UDPC on the Juicebox. Requires --juicebox_host" + ) + arg_juicebox_host = parser.add_argument( + "--juicebox_host", type=str, + help="host or IP address of the Juicebox. required for --update_udpc" + ) args = parser.parse_args() if args.debug: logging.getLogger().setLevel(logging.DEBUG) + if args.update_udpc and not args.juicebox_host: + raise argparse.ArgumentError(arg_juicebox_host, "juicebox_host is required") + + localhost_src = args.src.startswith("0.") or args.src.startswith("127") + if args.update_udpc and localhost_src: + raise argparse.ArgumentError(arg_src, "src must not be a local IP address for update_udpc to work") + mqttsettings = Settings.MQTT( host=args.host, port=args.port, @@ -302,8 +359,20 @@ def main(): pyproxy.LOCAL_DATA_HANDLER = handler.local_data_handler pyproxy.REMOTE_DATA_HANDLER = handler.remote_data_handler + udpc_updater_thread = None + udpc_updater = None + + if args.update_udpc: + address = args.src.split(':') + udpc_updater = JuiceboxUDPCUpdater(args.juicebox_host, address[0], address[1]) + udpc_updater_thread = Thread(target=udpc_updater.start) + udpc_updater_thread.start() + pyproxy.udp_proxy(args.src, args.dst) + if udpc_updater is not None and udpc_updater_thread is not None: + udpc_updater.run_event = False + udpc_updater_thread.join() if __name__ == "__main__": main()