powersensor-local 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ __pycache__
2
+ .*.swp
@@ -0,0 +1,10 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright © 2024 DiUS Computing Pty Ltd
4
+
5
+ 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:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ 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.
10
+
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.3
2
+ Name: powersensor-local
3
+ Version: 1.0.2
4
+ Summary: Network-local (non-cloud) interface for Powersensor devices
5
+ Project-URL: Repository, https://github.com/DiUS/python-powersensor_local.git
6
+ Project-URL: Homepage, https://github.com/DiUS/python-powersensor_local
7
+ Project-URL: Issues, https://github.com/DiUS/python-powersensor_local/issues
8
+ Author-email: Jade Mattsson <jmattsson@dius.com.au>
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Home Automation
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Powersensor (local)
18
+
19
+ A small package to interface with the network-local event streams available on
20
+ Powersensor devices. Specifically, this package abstracts away the connections
21
+ to all Powersensor gateway devices (plugs) on the network, and provides a
22
+ uniform event stream from all devices (including sensors relaying their data
23
+ via the gateways).
24
+
25
+ The main API is in `powersensor_local.devices' via the PowersensorDevices
26
+ class, which provides an abstracted view of the discovered Powersensor devices
27
+ on the local network.
28
+
29
+ There are also two small utilities included, `ps-events` and `ps-rawfirehose`.
30
+ The former is effectively a consumer of the the PowersensorDevices event
31
+ stream which dumps all events to standard out. The latter, `ps-rawfirehose`
32
+ is a debugging aid which dumps the lower-level event streams from each
33
+ Powersensor gateway.
@@ -0,0 +1,17 @@
1
+ # Powersensor (local)
2
+
3
+ A small package to interface with the network-local event streams available on
4
+ Powersensor devices. Specifically, this package abstracts away the connections
5
+ to all Powersensor gateway devices (plugs) on the network, and provides a
6
+ uniform event stream from all devices (including sensors relaying their data
7
+ via the gateways).
8
+
9
+ The main API is in `powersensor_local.devices' via the PowersensorDevices
10
+ class, which provides an abstracted view of the discovered Powersensor devices
11
+ on the local network.
12
+
13
+ There are also two small utilities included, `ps-events` and `ps-rawfirehose`.
14
+ The former is effectively a consumer of the the PowersensorDevices event
15
+ stream which dumps all events to standard out. The latter, `ps-rawfirehose`
16
+ is a debugging aid which dumps the lower-level event streams from each
17
+ Powersensor gateway.
@@ -0,0 +1,29 @@
1
+ [project]
2
+ name = "powersensor-local"
3
+ version = "1.0.2"
4
+ description = "Network-local (non-cloud) interface for Powersensor devices"
5
+ authors = [
6
+ { name = "Jade Mattsson", email = "jmattsson@dius.com.au" },
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.11"
10
+ classifiers = [
11
+ "Programming Language :: Python :: 3",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Topic :: Home Automation",
16
+ ]
17
+
18
+ [project.urls]
19
+ Repository = "https://github.com/DiUS/python-powersensor_local.git"
20
+ Homepage = "https://github.com/DiUS/python-powersensor_local"
21
+ Issues = "https://github.com/DiUS/python-powersensor_local/issues"
22
+
23
+ [project.scripts]
24
+ ps-events = "powersensor_local.events:app"
25
+ ps-rawfirehose = "powersensor_local.rawfirehose:app"
26
+
27
+ [build-system]
28
+ requires = [ "hatchling" ]
29
+ build-backend = "hatchling.build"
@@ -0,0 +1,17 @@
1
+ """Direct (non-cloud) interface to Powersensor devices
2
+
3
+ This package contains two abstraction layers:
4
+
5
+ • PowersensorDevices is the main API layer
6
+ • PowersensorListener provides a lower-level abstraction
7
+
8
+ These are both available within this namespace, or specifically as
9
+ devices.PowersensorDevices and listener.PowersensorListener
10
+
11
+ The 'events' and 'rawfirehose' modules are helper utilities provided as
12
+ debug aids, which get installed under the names ps-events and ps-rawfirehose
13
+ respectively.
14
+ """
15
+ __all__ = [ 'devices', 'listener' ]
16
+ from .devices import PowersensorDevices
17
+ from .listener import PowersensorListener
@@ -0,0 +1,321 @@
1
+ import asyncio
2
+ import json
3
+
4
+ from datetime import datetime, timezone
5
+
6
+ from .listener import PowersensorListener
7
+
8
+ EXPIRY_CHECK_INTERVAL_S = 30
9
+ EXPIRY_TIMEOUT_S = 5 * 60
10
+
11
+ class PowersensorDevices:
12
+ """Abstraction interface for the unified event stream from all Powersensor
13
+ devices on the local network.
14
+ """
15
+
16
+ def __init__(self, bcast_addr='<broadcast>'):
17
+ """Creates a fresh instance, without scanning for devices."""
18
+ self._event_cb = None
19
+ self._ps = PowersensorListener(bcast_addr)
20
+ self._devices = dict()
21
+ self._timer = None
22
+
23
+ async def start(self, async_event_cb):
24
+ """Registers the async event callback function and starts the scan
25
+ of the local network to discover present devices. The callback is
26
+ of the form
27
+
28
+ async def yourcallback(event: dict)
29
+
30
+ Known events:
31
+
32
+ scan_complete:
33
+ Indicates the discovery of Powersensor devices has completed.
34
+ Emitted in response to start() and rescan() calls.
35
+ The number of found gateways (plugs) is reported.
36
+
37
+ { event: "scan_complete", gateway_count: N }
38
+
39
+ device_found:
40
+ A new device found on the network.
41
+ The order found devices are announced is not fixed.
42
+
43
+ { event: "device_found",
44
+ device_type: "plug" or "sensor",
45
+ mac: "...",
46
+ }
47
+
48
+ An optional field named "via" is present for sensor devices, and
49
+ shows the MAC address of the gateway the sensor is communicating
50
+ via.
51
+
52
+ device_lost:
53
+ A device appears to no longer be present on the network.
54
+
55
+ { event: "device_lost", mac: "..." }
56
+
57
+
58
+
59
+ The events below all have the following common fields:
60
+
61
+ { mac: "...", starttime_utc: X }
62
+
63
+ and where applicable, also:
64
+
65
+ { via: "..." }
66
+
67
+ For brevity's sake they are not shown in the examples below, other
68
+ then simply as ...
69
+
70
+
71
+ battery_level:
72
+ The battery level of a sensor.
73
+
74
+ { ..., event: "battery_level", volts: X.Y }
75
+
76
+ voltage:
77
+ The mains voltage as detected by a plug.
78
+
79
+ { ..., event: "voltage", volts: X.Y }
80
+
81
+ average_power:
82
+ Reports the average power observed over the reporting duration.
83
+ May be negative for e.g. solar sensors and house sensors when
84
+ exporting solar to the grid.
85
+
86
+ The summation_joules field is a summation style register which
87
+ reports accumulated energy. This field is only useful for
88
+ calculating the delta of energy between two events. The counter
89
+ will reset to zero if the device is restarted, and is technically
90
+ subject to overflow, though that is unlikely to be reached.
91
+ The summation may be negative if solar export is present. The
92
+ summation may increment or decrement depending on whether energy
93
+ is being imported from or exported to the grid.
94
+
95
+ { ..., event: "average_power",
96
+ watts: X.Y,
97
+ durations_s: N.M,
98
+ summation_joules: J.K,
99
+ }
100
+
101
+ For reports from plugs, the following fields will also be present:
102
+
103
+ {
104
+ ...,
105
+ volts: X.Y,
106
+ current: C.D,
107
+ active_current: E.F,
108
+ reactive_current: G.H,
109
+ }
110
+
111
+ The (apparent) current, active_current and reactive_current fields
112
+ are all reported in a unit of Amperes.
113
+
114
+ uncalibrated_power:
115
+ Powersensors require calibrations of their readings before they
116
+ are able to be converted into a proper power reading. This event
117
+ is issued for sensor readings prior to such calibration completing.
118
+ The reported value has no inherent meaning beyond being an
119
+ indication of the strength of the signal seen by the sensor. It
120
+ is most definitely NOT in Watts. For most purposes, this event
121
+ can (and should be) ignored.
122
+
123
+ { ..., event: "uncalibrated_power",
124
+ value: Y.Z,
125
+ durations_s: N.M,
126
+ }
127
+
128
+ The start function returns the number of found gateway plugs.
129
+ Powersensor devices aren't found directly as they are typically not
130
+ on the network, but are instead detected when they relay data through
131
+ a plug via long-range radio.
132
+ """
133
+ self._event_cb = async_event_cb
134
+ await self._on_scanned(await self._ps.scan())
135
+ self._timer = self._Timer(EXPIRY_CHECK_INTERVAL_S, self._on_timer)
136
+ return len(self._ips)
137
+
138
+ async def rescan(self):
139
+ """Performs a fresh scan of the network to discover added devices,
140
+ or devices which have changed their IP address for some reason."""
141
+ await self._on_scanned(await self._ps.scan())
142
+
143
+ async def stop(self):
144
+ """Stops the event streaming and disconnects from the devices.
145
+ To restart the event streaming, call start() again."""
146
+ await self._ps.unsubscribe()
147
+ await self._ps.stop()
148
+ self._event_cb = None
149
+ if self._timer:
150
+ self._timer.terminate()
151
+ self._timer = None
152
+
153
+ def subscribe(self, mac):
154
+ """Subscribes to events from the device with the given MAC address."""
155
+ device = self._devices.get(mac)
156
+ if device:
157
+ device.subscribed = True
158
+
159
+ def unsubscribe(self, mac):
160
+ """Unsubscribes from events from the given MAC address."""
161
+ device = self._devices.get(mac)
162
+ if device:
163
+ device.subscribed = False
164
+
165
+ async def _on_scanned(self, ips):
166
+ self._ips = ips
167
+ if self._event_cb:
168
+ ev = {
169
+ 'event': 'scan_complete',
170
+ 'gateway_count': len(ips),
171
+ }
172
+ await self._event_cb(ev)
173
+
174
+ await self._ps.subscribe(self._on_msg)
175
+
176
+ async def _on_msg(self, obj):
177
+ mac = obj.get('mac')
178
+ if mac and not self._devices.get(mac):
179
+ typ = obj.get('device')
180
+ via = obj.get('via')
181
+ await self._add_device(mac, typ, via)
182
+
183
+ device = self._devices[mac]
184
+ device.mark_active()
185
+
186
+ if self._event_cb and device.subscribed:
187
+ evs = self._mk_events(obj)
188
+ if len(evs) > 0:
189
+ for ev in evs:
190
+ await self._event_cb(ev)
191
+
192
+ async def _on_timer(self):
193
+ devices = list(self._devices.values())
194
+ for device in devices:
195
+ if device.has_expired():
196
+ await self._remove_device(device.mac)
197
+
198
+ async def _add_device(self, mac, typ, via):
199
+ self._devices[mac] = self._Device(mac, typ, via)
200
+ if self._event_cb:
201
+ ev = {
202
+ 'event': 'device_found',
203
+ 'device_type': typ,
204
+ 'mac': mac,
205
+ }
206
+ if via:
207
+ ev['via'] = via
208
+ await self._event_cb(ev)
209
+
210
+ async def _remove_device(self, mac):
211
+ if self._devices.get(mac):
212
+ self._devices.pop(mac)
213
+ if self._event_cb:
214
+ ev = {
215
+ 'event': 'device_lost',
216
+ 'mac': mac
217
+ }
218
+ await self._event_cb(ev)
219
+
220
+ ### Event formatting ###
221
+
222
+ def _mk_events(self, obj):
223
+ evs = []
224
+ typ = obj.get('type')
225
+ if typ == 'instant_power':
226
+ unit = obj.get('unit')
227
+ if unit == 'w' or unit == 'W':
228
+ evs.append(self._mk_average_power_event(obj))
229
+ elif unit == 'l' or unit == 'L':
230
+ evs.append(self.mk_average_water_event(obj))
231
+ pass # TODO, cl/min?
232
+ elif unit == 'U':
233
+ evs.append(self._mk_uncalib_power_event(obj))
234
+ elif unit == 'I':
235
+ pass # invalid data / sample failed
236
+
237
+ if obj.get('voltage') is not None:
238
+ evs.append(self._mk_voltage_event(obj))
239
+
240
+ if obj.get('batteryMicrovolt') is not None:
241
+ evs.append(self._mk_battery_event(obj))
242
+ else:
243
+ print(obj)
244
+
245
+ for ev in evs:
246
+ ev['mac'] = obj.get('mac')
247
+ if obj.get('starttime'):
248
+ ev['starttime_utc'] = obj.get('starttime')
249
+ if obj.get('via'):
250
+ ev['via'] = obj.get('via')
251
+
252
+ return evs
253
+
254
+ def _mk_average_power_event(self, obj):
255
+ ev = {
256
+ 'event': 'average_power',
257
+ 'watts': obj.get('power'),
258
+ 'duration_s': obj.get('duration'),
259
+ 'summation_joules': obj.get('summation'),
260
+ }
261
+ if obj.get('device') == 'plug':
262
+ ev['volts'] = obj.get('voltage')
263
+ ev['current'] = obj.get('current')
264
+ ev['active_current'] = obj.get('active_current')
265
+ ev['reactive_current'] = obj.get('reactive_current')
266
+ return ev
267
+
268
+ def _mk_uncalib_power_event(self, obj):
269
+ ev = {
270
+ 'event': 'uncalibrated_power',
271
+ 'value': obj.get('power'),
272
+ 'duration_s': obj.get('duration'),
273
+ }
274
+ return ev
275
+
276
+ def _mk_voltage_event(self, obj):
277
+ return {
278
+ 'event': 'voltage',
279
+ 'volts': obj.get('voltage'),
280
+ }
281
+
282
+ def _mk_battery_event(self, obj):
283
+ return {
284
+ 'event': 'battery_level',
285
+ 'volts': float(obj.get('batteryMicrovolt'))/1000000.0,
286
+ }
287
+
288
+
289
+ ### Supporting classes ###
290
+
291
+ class _Device:
292
+ def __init__(self, mac, typ, via):
293
+ self.mac = mac
294
+ self.type = typ
295
+ self.via = via
296
+ self.subscribed = False
297
+ self._last_active = datetime.now(timezone.utc)
298
+
299
+ def mark_active(self):
300
+ self._last_active = datetime.now(timezone.utc)
301
+
302
+ def has_expired(self):
303
+ now = datetime.now(timezone.utc)
304
+ delta = now - self._last_active
305
+ return delta.total_seconds() > EXPIRY_TIMEOUT_S
306
+
307
+ class _Timer:
308
+ def __init__(self, interval_s, callback):
309
+ self._terminate = False
310
+ self._interval = interval_s
311
+ self._callback = callback
312
+ self._task = asyncio.create_task(self._run())
313
+
314
+ def terminate(self):
315
+ self._terminate = True
316
+ self._task.cancel()
317
+
318
+ async def _run(self):
319
+ while not self._terminate:
320
+ await asyncio.sleep(self._interval)
321
+ await self._callback()
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Utility script for accessing the full event stream from all network-local
4
+ Powersensor devices. Intended for debugging use only. Please use the proper
5
+ interface in devices.py rather than parsing the output from this script."""
6
+
7
+ import asyncio
8
+ import os
9
+ import signal
10
+ import sys
11
+
12
+ if __name__ == "__main__":
13
+ # Make CLI runnable from source tree
14
+ package_source_path = os.path.dirname(os.path.dirname(__file__))
15
+ sys.path.insert(0, package_source_path)
16
+ __package__ = "powersensor_local"
17
+
18
+ from .devices import PowersensorDevices
19
+
20
+
21
+ exiting = False
22
+ devices = None
23
+
24
+ async def do_exit():
25
+ global exiting
26
+ global devices
27
+ if devices != None:
28
+ await devices.stop()
29
+ exiting = True
30
+
31
+ async def on_msg(obj):
32
+ print(obj)
33
+ global devices
34
+ if obj['event'] == 'device_found':
35
+ devices.subscribe(obj['mac'])
36
+
37
+ async def main():
38
+ global devices
39
+ devices = PowersensorDevices()
40
+
41
+ # Signal handler for Ctrl+C
42
+ def handle_sigint(signum, frame):
43
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
44
+ asyncio.create_task(do_exit())
45
+
46
+ signal.signal(signal.SIGINT, handle_sigint)
47
+
48
+ await devices.start(on_msg)
49
+
50
+ # Keep the event loop running until Ctrl+C is pressed
51
+ while not exiting:
52
+ await asyncio.sleep(1)
53
+
54
+ def app():
55
+ loop = asyncio.new_event_loop()
56
+ loop.run_until_complete(main())
57
+ loop.stop()
58
+
59
+ if __name__ == "__main__":
60
+ app()
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import json
3
+ import socket
4
+ import time
5
+
6
+ PORT = 49476
7
+ DISCOVERY_TIMEOUT_S = 2
8
+
9
+ class PowersensorListener(asyncio.DatagramProtocol):
10
+ def __init__(self, bcast_addr='<broadcast>'):
11
+ """Initialises a listener object.
12
+ Optionally takes the broadcast address to use.
13
+ """
14
+ self._known_addresses = dict()
15
+ self._connections = {}
16
+ self._tasks = dict()
17
+ self._callback = None
18
+ self._exiting = False
19
+ self._bcast = bcast_addr
20
+
21
+ async def scan(self):
22
+ """Scans the local network for discoverable devices with a timeout.
23
+ Returns the list of IP addresses of the discovered gateways (plugs).
24
+ """
25
+ self._known_addresses.clear()
26
+ loop = asyncio.get_running_loop()
27
+ transport, _ = await loop.create_datagram_endpoint(
28
+ self.protocol_factory,
29
+ family=socket.AF_INET,
30
+ local_addr=('0.0.0.0', 0))
31
+ sock = transport.get_extra_info('socket')
32
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
33
+ message = 'discover()\n'.encode('utf-8')
34
+
35
+ timeout = DISCOVERY_TIMEOUT_S
36
+ while timeout > 0:
37
+ transport.sendto(message, (self._bcast, PORT))
38
+ await asyncio.sleep(0.5)
39
+ timeout -= 0.5
40
+
41
+ transport.close()
42
+ return self._known_addresses.keys()
43
+
44
+ def protocol_factory(self):
45
+ return self
46
+
47
+ def datagram_received(self, data, addr):
48
+ try:
49
+ response = json.loads(data.decode('utf-8'))
50
+ ip = response['ip']
51
+ self._known_addresses[ip] = response['mac']
52
+ except (json.JSONDecodeError, KeyError):
53
+ pass
54
+
55
+ async def subscribe(self, callback):
56
+ """Subscribes to updates from known devices."""
57
+ self._callback = callback
58
+
59
+ for ip, mac in self._known_addresses.items():
60
+ if not self._tasks.get(ip):
61
+ self._tasks[ip] = asyncio.create_task(
62
+ self._connect_to_device(ip, mac))
63
+
64
+ async def _send_subscribe(self, writer):
65
+ writer.write(b'subscribe(60)\n')
66
+ await writer.drain()
67
+
68
+ async def _processline(self, ip, mac, reader, writer):
69
+ data = await reader.readline()
70
+ if data != b'' and data != b'\n':
71
+ try:
72
+ message = json.loads(data.decode('utf-8'))
73
+ typ = message['type']
74
+ if typ == 'subscription':
75
+ if message['subtype'] == 'warning':
76
+ await self._send_subscribe(writer)
77
+ elif typ == 'discovery':
78
+ pass
79
+ else:
80
+ if message.get('device') == 'sensor':
81
+ message['via'] = mac
82
+ await self._callback(message)
83
+ except (json.decoder.JSONDecodeError) as ex:
84
+ print(f"JSON error {ex} from {data}")
85
+
86
+ async def _connect_to_device(self, ip, mac, backoff=0):
87
+ """Connects to a single device and handles reconnections."""
88
+ backoff += 1
89
+ try:
90
+ reader, writer = await asyncio.open_connection(ip, PORT)
91
+ self._connections[ip] = (reader, writer)
92
+
93
+ await self._send_subscribe(writer)
94
+ backoff = 1
95
+
96
+ while not self._exiting:
97
+ await self._processline(ip, mac, reader, writer)
98
+
99
+ except (ConnectionResetError, asyncio.TimeoutError):
100
+ # Handle disconnection and retry with exponential backoff
101
+ del self._connections[ip]
102
+ await asyncio.sleep(min(5 * 60, 2**backoff * 1))
103
+ return await self._connect_to_device(ip, mac, backoff)
104
+
105
+ async def unsubscribe(self):
106
+ """Unsubscribes from all devices."""
107
+ for (reader, writer) in self._connections.values():
108
+ writer.close()
109
+
110
+ self._connections = {}
111
+
112
+ async def stop(self):
113
+ """Disconnects from all devices."""
114
+ self._exiting = True
115
+ for task in self._tasks.values():
116
+ await task
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Utility script for accessing the raw plug subscription data from all
4
+ network-local Powersensor devices. Intended for advanced debugging use only.
5
+ For all other uses, please see the API in devices.py"""
6
+
7
+ import asyncio
8
+ import os
9
+ import signal
10
+ import sys
11
+
12
+ if __name__ == "__main__":
13
+ # Make CLI runnable from source tree
14
+ package_source_path = os.path.dirname(os.path.dirname(__file__))
15
+ sys.path.insert(0, package_source_path)
16
+ __package__ = "powersensor_local"
17
+
18
+ from .listener import PowersensorListener
19
+
20
+
21
+ exiting = False
22
+ ps = None
23
+
24
+ async def do_exit():
25
+ global exiting
26
+ global ps
27
+ if ps != None:
28
+ await ps.unsubscribe()
29
+ await ps.stop()
30
+ exiting = True
31
+
32
+ async def on_msg(data):
33
+ print(data)
34
+
35
+ async def main():
36
+ global ps
37
+ ps = PowersensorListener()
38
+
39
+ # Signal handler for Ctrl+C
40
+ def handle_sigint(signum, frame):
41
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
42
+ asyncio.create_task(do_exit())
43
+
44
+ signal.signal(signal.SIGINT, handle_sigint)
45
+
46
+ # Scan for devices and subscribe upon completion
47
+ await ps.scan()
48
+ await ps.subscribe(on_msg)
49
+
50
+ # Keep the event loop running until Ctrl+C is pressed
51
+ while not exiting:
52
+ await asyncio.sleep(1)
53
+
54
+ def app():
55
+ loop = asyncio.new_event_loop()
56
+ loop.run_until_complete(main())
57
+ loop.stop()
58
+
59
+ if __name__ == "__main__":
60
+ app()