Skip to content

Commit 7bcb7ac

Browse files
committed
A first attempt at maybe a terrible idea?
1 parent 77427dc commit 7bcb7ac

File tree

6 files changed

+490
-0
lines changed

6 files changed

+490
-0
lines changed

mock/ElectricitySensor.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import random
2+
3+
from MockSensor import MockSensor
4+
5+
6+
class ElectricitySensor(MockSensor):
7+
"""Simulates a magnetic sensor"""
8+
9+
def __init__(self, mac: str, role=None, update_interval: float = 30.0):
10+
super().__init__(mac, role, update_interval)
11+
12+
def get_unit(self):
13+
return 'w'

mock/MockPlug.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import asyncio
2+
import datetime
3+
import json
4+
import random
5+
from typing import Optional, List
6+
7+
import numpy as np
8+
9+
from MockSensor import MockSensor
10+
import logging
11+
logger = logging.getLogger(__name__)
12+
13+
14+
def random_sample_duration():
15+
if np.random.rand() < 0.5:
16+
return np.random.normal(0.96, 0.00025)
17+
else:
18+
return np.random.normal(1.04, 0.00025)
19+
20+
def random_sample_power():
21+
if np.random.rand() < 0.25:
22+
return np.random.normal(58.15, 0.26)
23+
else:
24+
return np.random.normal(59.8, 0.4)
25+
26+
def random_sample_reactive_current():
27+
if np.random.rand() < 0.25:
28+
return np.random.normal(.305, 0.0002)
29+
else:
30+
return np.random.normal(.318, 0.002)
31+
32+
33+
class MockPlug(asyncio.DatagramProtocol):
34+
"""
35+
Simulated Plug!
36+
Generates both plug messages and relayed sensor messages.
37+
"""
38+
39+
def __init__(self, mac: str, gateway_id: str, sensors: Optional[List[MockSensor]] = None):
40+
self.mac = mac
41+
self.gateway_id = gateway_id
42+
self.sensors = sensors or []
43+
self.transport = None
44+
self.sensor_tasks = []
45+
self.subscribers = set() # Track subscribed clients
46+
self.broadcast_lock = asyncio.Lock() # Prevent concurrent broadcasts
47+
48+
def connection_made(self, transport):
49+
self.transport = transport
50+
sock = transport.get_extra_info('socket')
51+
logger.info(f"Mock Plug {self.gateway_id} UDP server started on {sock.getsockname()}")
52+
53+
# Start all sensor tasks
54+
for sensor in self.sensors:
55+
task = asyncio.create_task(sensor.run(self._handle_sensor_reading))
56+
self.sensor_tasks.append(task)
57+
58+
59+
asyncio.create_task(self._send_plug_data())
60+
61+
def datagram_received(self, data, addr):
62+
"""Handle incoming UDP packets"""
63+
logger.info(f"Received {len(data)} bytes from {addr}")
64+
65+
# Try to decode as plain text first (for subscribe commands)
66+
try:
67+
message_str = data.decode('utf-8').strip()
68+
69+
# Handle subscribe command
70+
if message_str.startswith('subscribe('):
71+
self.handle_subscribe(message_str, addr)
72+
return
73+
74+
# Try parsing as JSON
75+
try:
76+
# We need to handle this possibility...we could add better message handling if it matters...
77+
message = json.loads(message_str)
78+
logger.info(f"Parsed JSON message: {message}")
79+
logger.info(f"Gateway {self.gateway_id} received: {message}")
80+
except json.JSONDecodeError:
81+
logger.warning(f"Received non-JSON text from {addr}: {message_str}")
82+
83+
except UnicodeDecodeError:
84+
logger.warning(f"Could not decode message from {addr}: {data}")
85+
except Exception as e:
86+
logger.error(f"Error handling message: {e}")
87+
88+
def handle_subscribe(self, message_str: str, addr: tuple):
89+
"""
90+
Handle subscription requests from clients.
91+
Format: subscribe(num) where num is subscriber ID
92+
subscribe(0) = disconnect/unsubscribe
93+
"""
94+
logger.info(f"Client {addr} sent: {message_str}")
95+
96+
# Check if this is a disconnect (subscribe(0))
97+
if 'subscribe(0)' in message_str:
98+
if addr in self.subscribers:
99+
self.subscribers.discard(addr)
100+
logger.info(f"Client {addr} unsubscribed. Total subscribers: {len(self.subscribers)}")
101+
else:
102+
logger.warning(f"Client {addr} tried to unsubscribe but was not subscribed")
103+
else:
104+
# Add to subscribers list
105+
self.subscribers.add(addr)
106+
logger.info(f"Client {addr} subscribed. Total subscribers: {len(self.subscribers)}")
107+
108+
109+
async def _handle_sensor_reading(self, reading: dict):
110+
"""
111+
Internal callback for when sensors generate readings.
112+
For now, it just replays it back, we could do more...
113+
"""
114+
async with self.broadcast_lock:
115+
self.broadcast_message(reading)
116+
logger.debug(f"Relayed sensor reading: {reading['mac']}")
117+
118+
async def _send_plug_data(self):
119+
"""Send periodic plug status messages"""
120+
while True:
121+
duration = random_sample_duration()
122+
await asyncio.sleep(duration)
123+
t = datetime.datetime.now(datetime.timezone.utc).timestamp()
124+
v = np.random.normal(240, 1.2)
125+
p = random_sample_power()
126+
active_current = p / v - random.random() / 1000
127+
reactive_current = random_sample_reactive_current()
128+
current = np.sqrt(active_current ** 2 + reactive_current ** 2) + 0.016 + (random.random() - 0.5) / 1000
129+
message = {
130+
'reactive_current': reactive_current,
131+
'type': 'instant_power',
132+
'summation_start': 1760615946.173936,
133+
'count': random.randint(12, 13),
134+
'duration': duration,
135+
'role': 'appliance',
136+
'power': p,
137+
'unit': 'W',
138+
'device': 'plug',
139+
'source': 'BLE',
140+
'active_current': active_current,
141+
'mac': self.mac,
142+
'voltage': v,
143+
'starttime': t,
144+
'current': current,
145+
'borrowed_summation': 0,
146+
'summation': 59.5649065 * t - 1.04883909e+11
147+
}
148+
async with self.broadcast_lock:
149+
self.broadcast_message(message)
150+
logger.info(f"Plug {self.mac} sent data!")
151+
152+
def send_message(self, message: dict, addr: tuple):
153+
"""Send a message to a specific address"""
154+
if self.transport:
155+
data = json.dumps(message).encode('utf-8')
156+
self.transport.sendto(data, addr)
157+
logger.debug(f"Sent message to {addr}: {message}")
158+
159+
def broadcast_message(self, message: dict, port: int = 49476):
160+
"""Broadcast a message to all subscribed clients (or network if no subscribers)"""
161+
logger.debug(f"broadcast_message ENTRY - subscribers: {len(self.subscribers)}")
162+
if self.transport:
163+
data = json.dumps(message).encode('utf-8')
164+
165+
if self.subscribers:
166+
# Send to each subscriber
167+
dead_subscribers = set()
168+
for addr in self.subscribers:
169+
try:
170+
logger.debug(f"Sending to {addr}...")
171+
self.transport.sendto(data, addr)
172+
logger.debug(f"Sent to {addr} OK")
173+
except Exception as e:
174+
logger.error(f"Failed to send to {addr}: {e}")
175+
dead_subscribers.add(addr)
176+
177+
# Clean up dead subscribers
178+
if dead_subscribers:
179+
self.subscribers -= dead_subscribers
180+
logger.warning(f"Removed {len(dead_subscribers)} dead subscribers")
181+
182+
logger.debug(f"Sent message to {len(self.subscribers)} subscribers")
183+
else:
184+
# No subscribers yet, broadcast to network
185+
logger.debug("Broadcasting to network (no subscribers)")
186+
self.transport.sendto(data, ('<broadcast>', port))
187+
logger.debug("Broadcast to network complete")
188+
else:
189+
logger.error("broadcast_message called but transport is None!")
190+
logger.debug("broadcast_message EXIT")
191+
192+
def stop(self):
193+
"""Clean up sensor tasks"""
194+
for task in self.sensor_tasks:
195+
task.cancel()

mock/MockPlugUDPService.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import asyncio
2+
import logging
3+
import socket
4+
from typing import Optional, List
5+
6+
from zeroconf import Zeroconf, ServiceInfo
7+
8+
from MockPlug import MockPlug
9+
from MockSensor import MockSensor
10+
11+
logger = logging.getLogger(__name__)
12+
13+
class MockPlugUDPService:
14+
"""
15+
Main service that manages zeroconf advertisement and UDP protocol.
16+
"""
17+
18+
def __init__(
19+
self,
20+
mac: str = "mock0plug123",
21+
gateway_id: str = "Powersensor-gateway-mock0plug123-civet",
22+
port: int = 49476,
23+
sensors: Optional[List[MockSensor]] = None,
24+
protocol_class: type = MockPlug,
25+
properties: Optional[dict] = None
26+
):
27+
self.mac = mac
28+
self.gateway_id = gateway_id
29+
self.port = port
30+
self.sensors = sensors or []
31+
self.protocol_class = protocol_class
32+
self.properties = properties or {}
33+
34+
self.zeroconf = None
35+
self.service_info = None
36+
self.transport = None
37+
self.protocol = None
38+
39+
def _get_local_ip(self) -> str:
40+
"""Get the local IP address"""
41+
try:
42+
# Create a socket to determine local IP
43+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
44+
s.connect(("8.8.8.8", 80))
45+
ip = s.getsockname()[0]
46+
s.close()
47+
return ip
48+
except Exception:
49+
return "127.0.0.1"
50+
51+
async def start(self):
52+
"""Start the mock gateway service"""
53+
logger.info(f"Starting Mock Gateway Service: {self.gateway_id}")
54+
logger.info(f"Configured with {len(self.sensors)} sensors")
55+
56+
# Start UDP server with sensors
57+
loop = asyncio.get_running_loop()
58+
self.transport, self.protocol = await loop.create_datagram_endpoint(
59+
lambda: self.protocol_class(self.mac,self.gateway_id, self.sensors),
60+
local_addr=('0.0.0.0', self.port)
61+
)
62+
63+
# Set up Zeroconf advertisement
64+
await self._setup_zeroconf()
65+
66+
logger.info(f"Mock Gateway {self.gateway_id} is now running and discoverable")
67+
68+
async def _setup_zeroconf(self):
69+
"""Set up zeroconf service advertisement (IPv4 only)"""
70+
local_ip = self._get_local_ip()
71+
72+
# Prepare properties
73+
props = {
74+
'gateway_id': self.gateway_id,
75+
'version': '1.0',
76+
'sensor_count': str(len(self.sensors)),
77+
**self.properties
78+
}
79+
80+
# Create service info
81+
service_name = f"{self.gateway_id}._powersensor._udp.local."
82+
83+
self.service_info = ServiceInfo(
84+
"_powersensor._udp.local.",
85+
service_name,
86+
addresses=[socket.inet_aton(local_ip)],
87+
port=self.port,
88+
properties=props,
89+
server=f"{self.gateway_id}.local."
90+
)
91+
92+
# Register service in a thread (zeroconf is blocking) - IPv4 only
93+
self.zeroconf = Zeroconf(interfaces=[local_ip])
94+
await asyncio.get_running_loop().run_in_executor(
95+
None,
96+
self.zeroconf.register_service,
97+
self.service_info
98+
)
99+
100+
logger.info(f"Zeroconf service registered (IPv4): {service_name} at {local_ip}:{self.port}")
101+
102+
async def stop(self):
103+
"""Stop the mock gateway service"""
104+
logger.info(f"Stopping Mock Gateway {self.gateway_id}")
105+
106+
# Stop protocol (cancels sensor tasks)
107+
if self.protocol:
108+
self.protocol.stop()
109+
110+
# Unregister zeroconf with proper timeout
111+
if self.zeroconf and self.service_info:
112+
try:
113+
await asyncio.wait_for(
114+
asyncio.get_running_loop().run_in_executor(
115+
None,
116+
self._unregister_zeroconf
117+
),
118+
timeout=2.0
119+
)
120+
except asyncio.TimeoutError:
121+
logger.warning("Zeroconf unregister timed out")
122+
except Exception as e:
123+
logger.warning(f"Error unregistering zeroconf: {e}")
124+
125+
# Close UDP transport
126+
if self.transport:
127+
self.transport.close()
128+
129+
logger.info(f"Mock Gateway {self.gateway_id} stopped")
130+
131+
def _unregister_zeroconf(self):
132+
"""Synchronous helper to unregister zeroconf"""
133+
try:
134+
self.zeroconf.unregister_service(self.service_info)
135+
self.zeroconf.close()
136+
except Exception as e:
137+
logger.warning(f"Error in zeroconf cleanup: {e}")

0 commit comments

Comments
 (0)