Skip to content

Commit

Permalink
Add python telnet client for Juicebox to avoid DNS requirements (#20)
Browse files Browse the repository at this point in the history
* add basic telnet client to get/set data on Juicebox
* add thread to update UDPC on the device in the main application
  • Loading branch information
snicker committed Nov 14, 2023
1 parent 1bcda78 commit 501c354
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 2 deletions.
6 changes: 6 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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!_
Expand Down Expand Up @@ -91,6 +93,7 @@ Variable | Required | Description & Default |
4. Launch by executing `python juicepassproxy.py --dst <enelx IP:port> --host <mqtthost>` (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

Expand All @@ -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._
Expand Down
89 changes: 89 additions & 0 deletions juicebox_telnet.py
Original file line number Diff line number Diff line change
@@ -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">")
73 changes: 71 additions & 2 deletions juicepassproxy.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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()

0 comments on commit 501c354

Please sign in to comment.