Skip to content

Commit 85dc765

Browse files
committed
Added plug UDP support, and made it default.
1 parent 338ed98 commit 85dc765

6 files changed

Lines changed: 185 additions & 18 deletions

File tree

src/powersensor_local/__init__.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,26 @@
44
devices on the local network.
55
66
The recommended approach is to use mDNS to discover plugs via their service
7-
"_powersensor._tcp.local", and then instantiate a PlugApi to obtain the event
8-
stream from each plug.
7+
"_powersensor._udp.local" (or "_powersensor._tcp.local" for TCP transport), and
8+
then instantiate a PlugApi to obtain the event stream from each plug. Note
9+
that the plugs are only capable of handling a single TCP connection at a time,
10+
so UDP is the preferred transport. Up to 5 concurrent subscriptions are
11+
supported over UDP. The interfaces provided by PlugListenerUdp and
12+
PlugListenerTCP are identical; switching between them should be trivial.
913
1014
A legacy abstraction is also provided via PowersensorDevices, which uses
1115
an older way of discovering plugs.
1216
13-
Lower-level interfaces are available in the PlugListener and
14-
PowersensorListener classed, though they are not recommended for general use.
17+
Lower-level interfaces are available in the PlugListenerUdp, PlugListenerTcp and
18+
PowersensorListener classes, though they are not recommended for general use.
1519
1620
Additionally a convience abstraction for translating some of the events into
1721
a household view is available in VirtualHousehold.
1822
1923
Quick overview:
2024
• PlugApi is the recommended API layer
21-
• PlugListener is the lower-level abstraction used by PlugApi
25+
• PlugListenerUdp is the UDP lower-level abstraction used by PlugApi
26+
• PlugListenerTcp is the TCP lower-level abstraction used by PlugApi
2227
• PowersensorDevices is the legacy main API layer
2328
• PowersensorListener provides a (legacy) lower-level abstraction
2429
• VirtualHousehold can be used to translate events into a household view
@@ -28,9 +33,17 @@
2833
respectively. There are also the legacy 'events' and 'rawfirehose' debug aids
2934
which get installed under the names ps-events and ps-rawfirehose respectively.
3035
"""
31-
__all__ = [ 'devices', 'listener', 'plug_api', 'plug_listener', 'virtual_household' ]
36+
__all__ = [
37+
'devices',
38+
'listener',
39+
'plug_api',
40+
'plug_listener_tcp',
41+
'plug_listener_udp',
42+
'virtual_household'
43+
]
3244
from .devices import PowersensorDevices
3345
from .listener import PowersensorListener
3446
from .plug_api import PlugApi
35-
from .plug_listener import PlugListener
47+
from .plug_listener_tcp import PlugListenerTcp
48+
from .plug_listener_udp import PlugListenerUdp
3649
from .virtual_household import VirtualHousehold

src/powersensor_local/plug_api.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
sys.path.append(project_root)
66

77
from powersensor_local.async_event_emitter import AsyncEventEmitter
8-
from powersensor_local.plug_listener import PlugListener
8+
from powersensor_local.plug_listener_tcp import PlugListenerTcp
9+
from powersensor_local.plug_listener_udp import PlugListenerUdp
910
from powersensor_local.xlatemsg import translate_raw_message
1011

1112
class PlugApi(AsyncEventEmitter):
@@ -19,19 +20,25 @@ class PlugApi(AsyncEventEmitter):
1920
documented in xlatemsg.translate_raw_message.
2021
"""
2122

22-
def __init__(self, mac, ip, port=49476):
23+
def __init__(self, mac, ip, port=49476, proto='udp'):
2324
"""
2425
Instantiates a new PlugApi for the given plug.
2526
2627
Args:
2728
- mac: The MAC address of the plug (typically found in the "id" field
2829
in the mDNS/ZeroConf discovery).
2930
- ip: The IP address of the plug.
30-
- port: The TCP port number of the API service on the plug.
31+
- port: The port number of the API service on the plug.
32+
- proto: One of 'udp' or 'tcp'.
3133
"""
3234
super().__init__()
3335
self._mac = mac
34-
self._listener = PlugListener(ip, port)
36+
if proto == 'udp':
37+
self._listener = PlugListenerUdp(ip, port)
38+
elif proto == 'tcp':
39+
self._listener = PlugListenerTcp(ip, port)
40+
else:
41+
raise ValueError(f'Unsupported proto: {proto}')
3542
self._listener.subscribe('message', self._on_message)
3643
self._listener.subscribe('exception', self._on_exception)
3744
self._seen = set()

src/powersensor_local/plug_listener.py renamed to src/powersensor_local/plug_listener_tcp.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from powersensor_local.async_event_emitter import AsyncEventEmitter
1313

14-
class PlugListener(AsyncEventEmitter):
14+
class PlugListenerTcp(AsyncEventEmitter):
1515
"""An interface class for accessing the event stream from a single plug.
1616
The following events may be emitted:
1717
- ("connecting") Whenever a connection attempt is made.
@@ -27,7 +27,7 @@ class PlugListener(AsyncEventEmitter):
2727
"""
2828

2929
def __init__(self, ip, port=49476):
30-
"""Initialises a PlugListener object, bound to the given IP address.
30+
"""Initialises a PlugListenerTcp object, bound to the given IP address.
3131
The port number may be overridden if necessary."""
3232
super().__init__()
3333
self._ip = ip
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import asyncio
2+
import json
3+
import socket
4+
import time
5+
import sys
6+
7+
from pathlib import Path
8+
project_root = str(Path(__file__).parents[1])
9+
if project_root not in sys.path:
10+
sys.path.append(project_root)
11+
12+
from powersensor_local.async_event_emitter import AsyncEventEmitter
13+
14+
class PlugListenerUdp(AsyncEventEmitter, asyncio.DatagramProtocol):
15+
"""An interface class for accessing the event stream from a single plug.
16+
The following events may be emitted:
17+
- ("connecting") Whenever a connection attempt is made.
18+
- ("connected") When a connection is successful.
19+
- ("disconnected") When a connection is dropped, be it intentional or not.
20+
- ("message",{...}) For each event message received from the plug. The
21+
plug's JSON message is decoded into a dict which is passed as the second
22+
argument to the registered event handler(s).
23+
- ("malformed",line) If JSON decoding of a message fails. The raw line
24+
is included (as a byte string).
25+
26+
The event handlers must be async.
27+
"""
28+
29+
def __init__(self, ip, port=49476):
30+
"""Initialises a PlugListener object, bound to the given IP address.
31+
The port number may be overridden if necessary."""
32+
super().__init__()
33+
self._ip = ip
34+
self._port = port
35+
self._backoff = 0 # exponential backoff
36+
self._transport = None # UDP transport/socket
37+
self._reconnect = None # reconnect timer
38+
self._disconnecting = False # disconnecting flag
39+
self._was_connected = False # 'disconnected' event armed?
40+
41+
def connect(self):
42+
"""Initiates the connection to the plug. The object will automatically
43+
retry as necessary if/when it can't connect to the plug, until such
44+
a time disconnect() is called."""
45+
self._disconnecting = False
46+
self._backoff = 0
47+
if self._transport is None:
48+
asyncio.create_task(self._do_connection())
49+
50+
async def disconnect(self):
51+
"""Goes through the disconnection process towards a plug. No further
52+
automatic reconnects will take place, until connect() is called."""
53+
self._disconnecting = True
54+
55+
await self._close_connection()
56+
57+
async def _close_connection(self, unsub = True):
58+
if self._reconnect is not None:
59+
self._reconnect.cancel()
60+
self._reconnect = None
61+
62+
if self._transport is not None:
63+
if unsub:
64+
self._transport.sendto(b'subscribe(0)\n')
65+
self._transport.close()
66+
self._transport = None
67+
68+
if self._was_connected:
69+
await self.emit('disconnected')
70+
self._was_connected = False
71+
72+
if not self._disconnecting:
73+
await self._do_connection()
74+
75+
def _retry(self):
76+
self._reconnect = None
77+
asyncio.create_task(self._do_connection())
78+
79+
async def _do_connection(self):
80+
if self._disconnecting:
81+
return
82+
if self._backoff < 9:
83+
self._backoff += 1
84+
await self.emit('connecting')
85+
loop = asyncio.get_running_loop()
86+
await loop.create_datagram_endpoint(
87+
self.protocol_factory,
88+
family = socket.AF_INET,
89+
remote_addr = (self._ip, self._port))
90+
self._reconnect = loop.call_later(
91+
min(5*60, 2**self._backoff + 2), self._retry)
92+
93+
def _send_subscribe(self):
94+
if self._transport is not None:
95+
self._transport.sendto(b'subscribe(60)\n')
96+
97+
# DatagramProtocol support below
98+
99+
def protocol_factory(self):
100+
return self
101+
102+
def connection_made(self, transport):
103+
self._transport = transport
104+
self._send_subscribe()
105+
106+
def datagram_received(self, data, addr):
107+
if self._reconnect is not None:
108+
self._reconnect.cancel()
109+
self._reconnect = None
110+
self._backoff = 0
111+
asyncio.create_task(self.emit('connected'))
112+
113+
if not self._was_connected:
114+
self._was_connected = True
115+
116+
lines = data.decode('utf-8').splitlines()
117+
for line in lines:
118+
try:
119+
message = json.loads(line)
120+
typ = message['type']
121+
if typ == 'subscription':
122+
if message['subtype'] == 'warning':
123+
self._send_subscribe()
124+
elif typ == 'discovery':
125+
pass
126+
else:
127+
asyncio.create_task(self.emit('message', message))
128+
except (json.decoder.JSONDecodeError) as ex:
129+
asyncio.create_task(self.emit('malformed', data))
130+
131+
def error_received(self, exc):
132+
asyncio.create_task(self._close_connection(False))
133+
134+
def connection_lost(self, exc):
135+
if self._transport is not None:
136+
asyncio.create_task(self._close_connection(False))

src/powersensor_local/plugevents.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def on_evt_msg(evt, msg):
2525

2626
async def main():
2727
if len(sys.argv) < 3:
28-
print(f"Syntax: {sys.argv[0]} <id> <ip> [port]")
28+
print(f"Syntax: {sys.argv[0]} <id> <ip> [port] [proto]")
2929
sys.exit(1)
3030

3131
# Signal handler for Ctrl+C
@@ -36,7 +36,7 @@ def handle_sigint(signum, frame):
3636
signal.signal(signal.SIGINT, handle_sigint)
3737

3838
global plug
39-
plug = PlugApi(sys.argv[1], sys.argv[2], *sys.argv[3:3])
39+
plug = PlugApi(sys.argv[1], sys.argv[2], *sys.argv[3:5])
4040
known_evs = [
4141
'exception',
4242
'average_flow',

src/powersensor_local/rawplug.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
import os
88
import signal
99
import sys
10-
from plug_listener import PlugListener
10+
from plug_listener_tcp import PlugListenerTcp
11+
from plug_listener_udp import PlugListenerUdp
1112

1213
exiting = False
1314
plug = None
@@ -28,7 +29,7 @@ async def on_evt(evt):
2829

2930
async def main():
3031
if len(sys.argv) < 2:
31-
print(f"Syntax: {sys.argv[0]} <ip> [port]")
32+
print(f"Syntax: {sys.argv[0]} <ip> [port] [proto]")
3233
sys.exit(1)
3334

3435
# Signal handler for Ctrl+C
@@ -38,8 +39,18 @@ def handle_sigint(signum, frame):
3839

3940
signal.signal(signal.SIGINT, handle_sigint)
4041

42+
proto='udp'
43+
if len(sys.argv) >= 4:
44+
proto = sys.argv[3]
45+
4146
global plug
42-
plug = PlugListener(sys.argv[1], *sys.argv[2:2])
47+
if proto == 'udp':
48+
plug = PlugListenerUdp(sys.argv[1], *sys.argv[2:3])
49+
elif proto == 'tcp':
50+
plug = PlugListenerTcp(sys.argv[1], *sys.argv[2:3])
51+
else:
52+
print('Unsupported protocol:', proto)
53+
sys.exit(1)
4354
plug.subscribe('exception', on_evt_msg)
4455
plug.subscribe('message', on_evt_msg)
4556
plug.subscribe('connecting', on_evt)

0 commit comments

Comments
 (0)