easyhttp-python 0.3.2__py3-none-any.whl
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.
- easyhttp/__init__.py +9 -0
- easyhttp/core.py +517 -0
- easyhttp/wrapper.py +153 -0
- easyhttp_python-0.3.2.dist-info/METADATA +152 -0
- easyhttp_python-0.3.2.dist-info/RECORD +8 -0
- easyhttp_python-0.3.2.dist-info/WHEEL +5 -0
- easyhttp_python-0.3.2.dist-info/licenses/LICENSE +21 -0
- easyhttp_python-0.3.2.dist-info/top_level.txt +1 -0
easyhttp/__init__.py
ADDED
easyhttp/core.py
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""EasyHTTP - Simple HTTP-based P2P framework for IoT."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from enum import Enum, auto
|
|
10
|
+
from typing import Optional, Union, Dict, Any, Callable
|
|
11
|
+
|
|
12
|
+
# API libraries
|
|
13
|
+
import aiohttp
|
|
14
|
+
import asyncio
|
|
15
|
+
import socket
|
|
16
|
+
import uvicorn
|
|
17
|
+
from fastapi import FastAPI, Request
|
|
18
|
+
from fastapi.responses import JSONResponse
|
|
19
|
+
|
|
20
|
+
__version__ = "0.3.2"
|
|
21
|
+
|
|
22
|
+
class EasyHTTPAsync:
|
|
23
|
+
"""Simple asynchronous HTTP-based core of P2P framework for IoT."""
|
|
24
|
+
|
|
25
|
+
class commands(Enum):
|
|
26
|
+
"""Enumeration of available command types."""
|
|
27
|
+
|
|
28
|
+
PING = auto() # Ping another device
|
|
29
|
+
PONG = auto() # Anwser for PING
|
|
30
|
+
FETCH = auto() # Request data from another device
|
|
31
|
+
DATA = auto() # Response containing data
|
|
32
|
+
PUSH = auto() # Send data to another device
|
|
33
|
+
ACK = auto() # Acknowledge successful command
|
|
34
|
+
NACK = auto() # Indicate an error occurred
|
|
35
|
+
|
|
36
|
+
def __init__(self, debug: bool = False, port: int = 5000, config_file=None):
|
|
37
|
+
"""Initialize the EasyHTTPAsync instance.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
debug: Enable debug output. Defaults to False.
|
|
41
|
+
port: Port to run the HTTP server on. Defaults to 5000.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
self.debug = debug
|
|
45
|
+
self.port = port
|
|
46
|
+
|
|
47
|
+
if config_file:
|
|
48
|
+
self.config_file = config_file
|
|
49
|
+
else:
|
|
50
|
+
import sys
|
|
51
|
+
if getattr(sys, 'frozen', False):
|
|
52
|
+
base_dir = os.path.dirname(sys.executable)
|
|
53
|
+
else:
|
|
54
|
+
base_dir = os.path.dirname(os.path.abspath(sys.argv[0]))
|
|
55
|
+
self.config_file = os.path.join(base_dir, "easyhttp_device.json")
|
|
56
|
+
|
|
57
|
+
self.id = None
|
|
58
|
+
self.callbacks = {
|
|
59
|
+
'on_ping': None,
|
|
60
|
+
'on_pong': None,
|
|
61
|
+
'on_fetch': None,
|
|
62
|
+
'on_data': None,
|
|
63
|
+
'on_push': None
|
|
64
|
+
}
|
|
65
|
+
self.devices = {}
|
|
66
|
+
self.app = FastAPI(title="EasyHTTP API", docs_url=None, redoc_url=None)
|
|
67
|
+
self.app.post('/easyhttp/api')(self.api_handler)
|
|
68
|
+
self.server_task = None
|
|
69
|
+
self._load_config()
|
|
70
|
+
|
|
71
|
+
async def __aenter__(self):
|
|
72
|
+
"""Enter the async context manager."""
|
|
73
|
+
await self.start()
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
77
|
+
"""Exit the async context manager."""
|
|
78
|
+
await self.stop()
|
|
79
|
+
|
|
80
|
+
def _load_config(self):
|
|
81
|
+
try:
|
|
82
|
+
if os.path.exists(self.config_file):
|
|
83
|
+
with open(self.config_file, 'r') as f:
|
|
84
|
+
data = json.load(f)
|
|
85
|
+
self.id = data.get('device_id')
|
|
86
|
+
|
|
87
|
+
if self.debug and self.id:
|
|
88
|
+
print(f"\033[32mINFO\033[0m:\t Loaded ID: {self.id} from {self.config_file}")
|
|
89
|
+
except Exception as e:
|
|
90
|
+
if self.debug:
|
|
91
|
+
print(f"\033[31mERROR\033[0m:\t Error loading config: {e}")
|
|
92
|
+
|
|
93
|
+
def _save_config(self):
|
|
94
|
+
try:
|
|
95
|
+
config = {
|
|
96
|
+
'device_id': self.id,
|
|
97
|
+
'port': self.port,
|
|
98
|
+
'version': __version__
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
with open(self.config_file, 'w') as f:
|
|
102
|
+
json.dump(config, f, indent=2)
|
|
103
|
+
|
|
104
|
+
if self.debug:
|
|
105
|
+
print(f"\033[32mINFO\033[0m:\t Saved ID to {self.config_file}")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
if self.debug:
|
|
108
|
+
print(f"\033[31mERROR\033[0m:\t Error saving config: {e}")
|
|
109
|
+
|
|
110
|
+
def _get_local_ip(self):
|
|
111
|
+
try:
|
|
112
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
113
|
+
s.connect(('8.8.8.8', 80))
|
|
114
|
+
local_ip = s.getsockname()[0]
|
|
115
|
+
s.close()
|
|
116
|
+
return local_ip
|
|
117
|
+
except:
|
|
118
|
+
try:
|
|
119
|
+
return socket.gethostbyname(socket.gethostname())
|
|
120
|
+
except:
|
|
121
|
+
return "127.0.0.1"
|
|
122
|
+
|
|
123
|
+
def _generate_id(self, length: int = 6) -> str:
|
|
124
|
+
"""Generate a unique device ID with a custom alphabet.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
length: Length of the generated ID.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
The generated unique ID.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
|
134
|
+
self.id = ''.join(secrets.choice(alphabet) for _ in range(length))
|
|
135
|
+
self._save_config()
|
|
136
|
+
|
|
137
|
+
def on(self, event: str, callback_func: Callable) -> None:
|
|
138
|
+
"""Register a callback function for a specific event.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
event: Event name ('on_ping', 'on_fetch', etc.).
|
|
142
|
+
callback_func: Function to call when the event occurs.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If the event is unknown.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
if event in self.callbacks:
|
|
149
|
+
self.callbacks[event] = callback_func
|
|
150
|
+
else:
|
|
151
|
+
raise ValueError(f"Unknown event: {event}")
|
|
152
|
+
|
|
153
|
+
def add(self, device_id: str, device_ip: str, device_port: int) -> None:
|
|
154
|
+
"""Manually add a device to the local devices cache.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
device_id: 6-character device identifier.
|
|
158
|
+
device_ip: IP address of the device.
|
|
159
|
+
device_port: Port number of the device.
|
|
160
|
+
|
|
161
|
+
Raises:
|
|
162
|
+
ValueError: If device_id is not 6 characters.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
if len(device_id) != 6:
|
|
166
|
+
raise ValueError("Device ID must be 6 characters")
|
|
167
|
+
|
|
168
|
+
if device_id not in self.devices:
|
|
169
|
+
self.devices[device_id] = {
|
|
170
|
+
'ip': device_ip,
|
|
171
|
+
'port': int(device_port),
|
|
172
|
+
'last_seen': time.time(),
|
|
173
|
+
'added_manually': True
|
|
174
|
+
}
|
|
175
|
+
if self.debug:
|
|
176
|
+
print(f"DEBUG:\t Added device {device_id}: {device_ip}:{device_port}")
|
|
177
|
+
else:
|
|
178
|
+
print(f"DEBUG:\t Device already exists")
|
|
179
|
+
|
|
180
|
+
async def start(self) -> None:
|
|
181
|
+
"""Start the HTTP server and generate a device ID if not set."""
|
|
182
|
+
|
|
183
|
+
if not self.id:
|
|
184
|
+
self._generate_id()
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
config = uvicorn.Config(
|
|
188
|
+
self.app,
|
|
189
|
+
host="0.0.0.0",
|
|
190
|
+
port=self.port,
|
|
191
|
+
log_level="warning",
|
|
192
|
+
lifespan="off"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
server = uvicorn.Server(config)
|
|
196
|
+
self.server_task = asyncio.create_task(server.serve())
|
|
197
|
+
|
|
198
|
+
logging.getLogger('werkzeug').disabled = True
|
|
199
|
+
logging.getLogger('uvicorn.error').propagate = False
|
|
200
|
+
logging.getLogger('uvicorn.access').propagate = False
|
|
201
|
+
|
|
202
|
+
await asyncio.sleep(2) # Give server time to start
|
|
203
|
+
|
|
204
|
+
if self.debug:
|
|
205
|
+
print(f"\033[32mINFO\033[0m:\t \033[1;32mEasyHTTP \033[37m{__version__}\033[0m has been started!")
|
|
206
|
+
print(f"\033[32mINFO\033[0m:\t Device's ID: {self.id}")
|
|
207
|
+
print(f"\033[32mINFO\033[0m:\t EasyHTTP starting on port {self.port}")
|
|
208
|
+
print(f"\033[32mINFO\033[0m:\t API running on \033[1mhttp://{self._get_local_ip()}:{self.port}/easyhttp/api\033[0m")
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
print(f"\033[31mERROR\033[0m:\t Failed to start server: {e}")
|
|
212
|
+
raise
|
|
213
|
+
|
|
214
|
+
async def stop(self) -> None:
|
|
215
|
+
"""Gracefully stop the HTTP server and cancel the server task."""
|
|
216
|
+
|
|
217
|
+
if self.server_task:
|
|
218
|
+
self.server_task.cancel()
|
|
219
|
+
try:
|
|
220
|
+
await self.server_task
|
|
221
|
+
except asyncio.CancelledError:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
async def send(self, device_id: str, command_type: Union[int, 'commands'], data: Optional[Any] = None) -> Optional[dict]:
|
|
225
|
+
"""Send a JSON-formatted command to another device.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
device_id: ID of the target device (must be 6 characters).
|
|
229
|
+
command_type: Command type (commands enum member) or its integer value.
|
|
230
|
+
data: JSON-serializable data to send (dict, list, str, or None).
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
Response JSON dict if successful, None otherwise.
|
|
234
|
+
Response typically contains 'type', 'header', and optionally 'data' fields.
|
|
235
|
+
|
|
236
|
+
Note:
|
|
237
|
+
The device must be added to the devices cache before sending.
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
if device_id not in self.devices:
|
|
241
|
+
if self.debug:
|
|
242
|
+
print(f"\033[31mERROR\033[0m:\t Device {device_id} not found in devices cache")
|
|
243
|
+
return None
|
|
244
|
+
|
|
245
|
+
packet = {
|
|
246
|
+
"version": __version__,
|
|
247
|
+
"type": command_type if isinstance(command_type, self.commands) else command_type,
|
|
248
|
+
"header": {
|
|
249
|
+
"sender_id": self.id,
|
|
250
|
+
"sender_port": self.port,
|
|
251
|
+
"recipient_id": device_id,
|
|
252
|
+
"timestamp": int(time.time())
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if data:
|
|
257
|
+
packet['data'] = data
|
|
258
|
+
|
|
259
|
+
recipient_url = f"http://{self.devices[device_id]['ip']}:{self.devices[device_id]['port']}/easyhttp/api"
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
async with aiohttp.ClientSession() as session:
|
|
263
|
+
async with session.post(recipient_url, json=packet, timeout=3) as response:
|
|
264
|
+
if response.status == 200:
|
|
265
|
+
return await response.json()
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
except Exception as e:
|
|
269
|
+
if self.debug:
|
|
270
|
+
print(f'\033[31mERROR\033[0m:\t Failed to send to {device_id}: {e}')
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
async def ping(self, device_id: str) -> bool:
|
|
274
|
+
"""Send a PING request to a device and check if it's online.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
device_id: ID of the device to ping.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
True if device responded with PONG, False otherwise.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
response = await self.send(device_id, self.commands.PING.value)
|
|
284
|
+
|
|
285
|
+
if response and response.get('type') == self.commands.PONG.value:
|
|
286
|
+
if self.debug:
|
|
287
|
+
print(f"\033[32mPING\033[0m:\t {device_id} is online")
|
|
288
|
+
if device_id in self.devices:
|
|
289
|
+
self.devices[device_id]['last_seen'] = time.time()
|
|
290
|
+
return True
|
|
291
|
+
else:
|
|
292
|
+
if self.debug:
|
|
293
|
+
print(f"\033[31mPING\033[0m:\t {device_id} is offline or not responding")
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
async def fetch(self, device_id: str, query: Optional[Any] = None) -> Optional[dict]:
|
|
297
|
+
"""Send a FETCH request to another device and return the response.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
device_id: ID of the target device.
|
|
301
|
+
query: Query data to send with the FETCH request.
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
Response data from the device, or None if failed.
|
|
305
|
+
The dict typically contains 'type', 'header', and 'data' fields.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
response = await self.send(device_id, self.commands.FETCH.value, query)
|
|
309
|
+
return response
|
|
310
|
+
|
|
311
|
+
async def push(self, device_id: str, data: Optional[Any] = None) -> bool:
|
|
312
|
+
"""Send data to another device using PUSH command.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
device_id: ID of the target device.
|
|
316
|
+
data: JSON-serializable data to send.
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
True if data was successfully sent and acknowledged, False otherwise.
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
TypeError: If data is not JSON-serializable.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
if data is not None and not isinstance(data, (dict, list, str)):
|
|
326
|
+
raise TypeError("Data must be JSON-serializable (dict, list, str)")
|
|
327
|
+
|
|
328
|
+
response = await self.send(device_id, self.commands.PUSH, data)
|
|
329
|
+
|
|
330
|
+
if response and response.get('type') == self.commands.ACK.value:
|
|
331
|
+
if self.debug:
|
|
332
|
+
print(f"\033[32mPUSH\033[0m:\t Successfully wrote to {device_id}")
|
|
333
|
+
return True
|
|
334
|
+
else:
|
|
335
|
+
if self.debug:
|
|
336
|
+
print(f"\033[31mPUSH\033[0m:\t Error writing to {device_id}")
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
async def api_handler(self, request: Request) -> JSONResponse:
|
|
340
|
+
"""Handle incoming API requests and route commands to callbacks.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
request: FastAPI request object.
|
|
344
|
+
|
|
345
|
+
Returns:
|
|
346
|
+
JSONResponse: Response to the client.
|
|
347
|
+
"""
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
data = await request.json()
|
|
351
|
+
except:
|
|
352
|
+
return JSONResponse({"error": "Invalid JSON data"}, status_code=400)
|
|
353
|
+
|
|
354
|
+
if not data:
|
|
355
|
+
return JSONResponse({"error": "No JSON data"}, status_code=400)
|
|
356
|
+
|
|
357
|
+
command_type = data.get('type')
|
|
358
|
+
header = data.get('header', {})
|
|
359
|
+
sender_id = header.get('sender_id')
|
|
360
|
+
|
|
361
|
+
client_ip = request.client.host if request.client else "0.0.0.0"
|
|
362
|
+
|
|
363
|
+
if sender_id and sender_id != self.id and sender_id not in self.devices:
|
|
364
|
+
self.devices[sender_id] = {
|
|
365
|
+
'ip': client_ip,
|
|
366
|
+
'port': header.get('sender_port', self.port),
|
|
367
|
+
'last_seen': int(time.time())
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
# Handle PING response
|
|
371
|
+
if command_type == self.commands.PING.value:
|
|
372
|
+
if self.callbacks['on_ping']:
|
|
373
|
+
callback = self.callbacks['on_ping']
|
|
374
|
+
if asyncio.iscoroutinefunction(callback):
|
|
375
|
+
await callback(
|
|
376
|
+
sender_id=sender_id,
|
|
377
|
+
timestamp=header.get('timestamp')
|
|
378
|
+
)
|
|
379
|
+
else:
|
|
380
|
+
callback(
|
|
381
|
+
sender_id=sender_id,
|
|
382
|
+
timestamp=header.get('timestamp')
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
return JSONResponse({
|
|
386
|
+
"version": __version__,
|
|
387
|
+
"type": self.commands.PONG.value,
|
|
388
|
+
"header": {
|
|
389
|
+
"sender_id": self.id,
|
|
390
|
+
"sender_port": self.port,
|
|
391
|
+
"recipient_id": sender_id,
|
|
392
|
+
"timestamp": int(time.time())
|
|
393
|
+
}
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
# Handle PONG answer
|
|
397
|
+
elif command_type == self.commands.PONG.value:
|
|
398
|
+
if self.callbacks['on_pong']:
|
|
399
|
+
callback = self.callbacks['on_pong']
|
|
400
|
+
if asyncio.iscoroutinefunction(callback):
|
|
401
|
+
await callback(
|
|
402
|
+
sender_id=sender_id,
|
|
403
|
+
timestamp=header.get('timestamp')
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
callback(
|
|
407
|
+
sender_id=sender_id,
|
|
408
|
+
timestamp=header.get('timestamp')
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
if self.debug:
|
|
412
|
+
print(f"\033[32mPONG\033[0m:\t Received from {sender_id}")
|
|
413
|
+
if sender_id in self.devices:
|
|
414
|
+
self.devices[sender_id]['last_seen'] = time.time()
|
|
415
|
+
return JSONResponse({"status": "pong_received"})
|
|
416
|
+
|
|
417
|
+
# Handle FETCH response
|
|
418
|
+
elif command_type == self.commands.FETCH.value:
|
|
419
|
+
if self.callbacks['on_fetch']:
|
|
420
|
+
callback = self.callbacks['on_fetch']
|
|
421
|
+
if asyncio.iscoroutinefunction(callback):
|
|
422
|
+
response_data = await callback (
|
|
423
|
+
sender_id=sender_id,
|
|
424
|
+
query=data.get('data'),
|
|
425
|
+
timestamp=header.get('timestamp')
|
|
426
|
+
)
|
|
427
|
+
else:
|
|
428
|
+
response_data = callback (
|
|
429
|
+
sender_id=sender_id,
|
|
430
|
+
query=data.get('data'),
|
|
431
|
+
timestamp=header.get('timestamp')
|
|
432
|
+
)
|
|
433
|
+
if response_data:
|
|
434
|
+
return JSONResponse({
|
|
435
|
+
"version": __version__,
|
|
436
|
+
"type": self.commands.DATA.value,
|
|
437
|
+
"header": {
|
|
438
|
+
"sender_id": self.id,
|
|
439
|
+
"sender_port": self.port,
|
|
440
|
+
"recipient_id": sender_id,
|
|
441
|
+
"timestamp": int(time.time())
|
|
442
|
+
},
|
|
443
|
+
"data": response_data
|
|
444
|
+
})
|
|
445
|
+
return JSONResponse({"status": "fetch_handled"})
|
|
446
|
+
|
|
447
|
+
# Handle PUSH response
|
|
448
|
+
elif command_type == self.commands.PUSH.value:
|
|
449
|
+
if not self.callbacks['on_push']:
|
|
450
|
+
return JSONResponse({
|
|
451
|
+
"version": __version__,
|
|
452
|
+
"type": self.commands.NACK.value,
|
|
453
|
+
"header": {
|
|
454
|
+
"sender_id": self.id,
|
|
455
|
+
"sender_port": self.port,
|
|
456
|
+
"recipient_id": sender_id,
|
|
457
|
+
"timestamp": int(time.time())
|
|
458
|
+
},
|
|
459
|
+
}, status_code=400)
|
|
460
|
+
|
|
461
|
+
callback = self.callbacks['on_push']
|
|
462
|
+
if asyncio.iscoroutinefunction(callback):
|
|
463
|
+
success = await callback(
|
|
464
|
+
sender_id=sender_id,
|
|
465
|
+
data=data.get('data'),
|
|
466
|
+
timestamp=header.get('timestamp')
|
|
467
|
+
)
|
|
468
|
+
else:
|
|
469
|
+
success = callback(
|
|
470
|
+
sender_id=sender_id,
|
|
471
|
+
data=data.get('data'),
|
|
472
|
+
timestamp=header.get('timestamp')
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
if success:
|
|
476
|
+
return JSONResponse({
|
|
477
|
+
"version": __version__,
|
|
478
|
+
"type": self.commands.ACK.value,
|
|
479
|
+
"header": {
|
|
480
|
+
"sender_id": self.id,
|
|
481
|
+
"sender_port": self.port,
|
|
482
|
+
"recipient_id": sender_id,
|
|
483
|
+
"timestamp": int(time.time())
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
else:
|
|
487
|
+
return JSONResponse({
|
|
488
|
+
"version": __version__,
|
|
489
|
+
"type": self.commands.NACK.value,
|
|
490
|
+
"header": {
|
|
491
|
+
"sender_id": self.id,
|
|
492
|
+
"sender_port": self.port,
|
|
493
|
+
"recipient_id": sender_id,
|
|
494
|
+
"timestamp": int(time.time())
|
|
495
|
+
}
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
# Handle DATA
|
|
499
|
+
elif command_type == self.commands.DATA.value:
|
|
500
|
+
if self.callbacks['on_data']:
|
|
501
|
+
callback = self.callbacks['on_data']
|
|
502
|
+
if asyncio.iscoroutinefunction(callback):
|
|
503
|
+
await callback(
|
|
504
|
+
sender_id=sender_id,
|
|
505
|
+
data=data.get('data'),
|
|
506
|
+
timestamp=header.get('timestamp')
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
callback(
|
|
510
|
+
sender_id=sender_id,
|
|
511
|
+
data=data.get('data'),
|
|
512
|
+
timestamp=header.get('timestamp')
|
|
513
|
+
)
|
|
514
|
+
return JSONResponse({"status": "data_received"})
|
|
515
|
+
|
|
516
|
+
# Handle unknown command types
|
|
517
|
+
return JSONResponse({"error": "Unknown command type"}, status_code=400)
|
easyhttp/wrapper.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""EasyHTTP - Simple HTTP-based P2P framework for IoT."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Optional, Any, Callable
|
|
5
|
+
from .core import EasyHTTPAsync, __version__
|
|
6
|
+
|
|
7
|
+
class EasyHTTP:
|
|
8
|
+
"""Simple HTTP-based P2P framework with asynchronous core for IoT."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, debug: bool = False, port: int = 5000, config_file: Optional[str] = None):
|
|
11
|
+
"""Initialize the EasyHTTP instance.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
debug: Enable debug output. Defaults to False.
|
|
15
|
+
port: Port to run the HTTP server on. Defaults to 5000.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
self._core = EasyHTTPAsync(debug=debug, port=port, config_file=config_file)
|
|
19
|
+
self._loop = None
|
|
20
|
+
self._running = False
|
|
21
|
+
|
|
22
|
+
self.commands = self._core.commands
|
|
23
|
+
self.__version__ = __version__
|
|
24
|
+
|
|
25
|
+
def _ensure_loop(self):
|
|
26
|
+
"""Ensure event loop is running."""
|
|
27
|
+
if not self._loop:
|
|
28
|
+
self._loop = asyncio.new_event_loop()
|
|
29
|
+
asyncio.set_event_loop(self._loop)
|
|
30
|
+
|
|
31
|
+
def on(self, event: str, callback_func: Callable) -> None:
|
|
32
|
+
"""Register a callback function for a specific event.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
event: Event name ('on_ping', 'on_fetch', etc.).
|
|
36
|
+
callback_func: Function to call when the event occurs.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
ValueError: If the event is unknown.
|
|
40
|
+
"""
|
|
41
|
+
self._core.on(event, callback_func)
|
|
42
|
+
|
|
43
|
+
def add(self, device_id: str, device_ip: str, device_port: int) -> None:
|
|
44
|
+
"""Manually add a device to the local devices cache.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
device_id: 6-character device identifier.
|
|
48
|
+
device_ip: IP address of the device.
|
|
49
|
+
device_port: Port number of the device.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If device_id is not 6 characters.
|
|
53
|
+
"""
|
|
54
|
+
self._core.add(device_id, device_ip, device_port)
|
|
55
|
+
|
|
56
|
+
def start(self) -> None:
|
|
57
|
+
"""Start the HTTP server and generate a device ID if not set."""
|
|
58
|
+
self._ensure_loop()
|
|
59
|
+
self._loop.run_until_complete(self._core.start())
|
|
60
|
+
self._running = True
|
|
61
|
+
|
|
62
|
+
def stop(self) -> None:
|
|
63
|
+
"""Gracefully stop the HTTP server and cancel the server task."""
|
|
64
|
+
if self._running:
|
|
65
|
+
self._loop.run_until_complete(self._core.stop())
|
|
66
|
+
self._running = False
|
|
67
|
+
|
|
68
|
+
def send(self, device_id: str, command_type: Any, data: Optional[Any] = None) -> Optional[dict]:
|
|
69
|
+
"""Send a JSON-formatted command to another device.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
device_id: ID of the target device (must be 6 characters).
|
|
73
|
+
command_type: Command type (commands enum member) or its integer value.
|
|
74
|
+
data: JSON-serializable data to send (dict, list, str, or None).
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
Response JSON dict if successful, None otherwise.
|
|
78
|
+
Response typically contains 'type', 'header', and optionally 'data' fields.
|
|
79
|
+
|
|
80
|
+
Note:
|
|
81
|
+
The device must be added to the devices cache before sending.
|
|
82
|
+
"""
|
|
83
|
+
if not self._loop:
|
|
84
|
+
self._ensure_loop()
|
|
85
|
+
return self._loop.run_until_complete(
|
|
86
|
+
self._core.send(device_id, command_type, data)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def ping(self, device_id: str) -> bool:
|
|
90
|
+
"""Send a PING request to a device and check if it's online.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
device_id: ID of the device to ping.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
True if device responded with PONG, False otherwise.
|
|
97
|
+
"""
|
|
98
|
+
return self._loop.run_until_complete(
|
|
99
|
+
self._core.ping(device_id)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def fetch(self, device_id: str, query: Optional[Any] = None) -> Optional[dict]:
|
|
103
|
+
"""Send a FETCH request to another device and return the response.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
device_id: ID of the target device.
|
|
107
|
+
query: Query data to send with the FETCH request.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Response data from the device, or None if failed.
|
|
111
|
+
The dict typically contains 'type', 'header', and 'data' fields.
|
|
112
|
+
"""
|
|
113
|
+
return self._loop.run_until_complete(
|
|
114
|
+
self._core.fetch(device_id, query)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def push(self, device_id: str, data: Optional[Any] = None) -> bool:
|
|
118
|
+
"""Send data to another device using PUSH command.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
device_id: ID of the target device.
|
|
122
|
+
data: JSON-serializable data to send.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
True if data was successfully sent and acknowledged, False otherwise.
|
|
126
|
+
|
|
127
|
+
Raises:
|
|
128
|
+
TypeError: If data is not JSON-serializable.
|
|
129
|
+
"""
|
|
130
|
+
return self._loop.run_until_complete(
|
|
131
|
+
self._core.push(device_id, data)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Context manager support
|
|
135
|
+
def __enter__(self):
|
|
136
|
+
"""Enter the sync context manager."""
|
|
137
|
+
self.start()
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
141
|
+
"""Exit the sync context manager."""
|
|
142
|
+
self.stop()
|
|
143
|
+
|
|
144
|
+
# Property accessors
|
|
145
|
+
@property
|
|
146
|
+
def id(self) -> str:
|
|
147
|
+
"""Get device ID."""
|
|
148
|
+
return self._core.id
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def devices(self) -> dict:
|
|
152
|
+
"""Get devices cache."""
|
|
153
|
+
return self._core.devices
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: easyhttp-python
|
|
3
|
+
Version: 0.3.2
|
|
4
|
+
Summary: Simple HTTP-based P2P framework for IoT
|
|
5
|
+
Author-email: slpuk <yarik6052@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/slpuk/easyhttp-python
|
|
8
|
+
Project-URL: Documentation, https://github.com/slpuk/easyhttp-python#readme
|
|
9
|
+
Project-URL: Repository, https://github.com/slpuk/easyhttp-python
|
|
10
|
+
Project-URL: Issue Tracker, https://github.com/slpuk/easyhttp-python/issues
|
|
11
|
+
Keywords: iot,p2p,http,framework
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
15
|
+
Classifier: Topic :: Communications
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Requires-Python: >=3.7
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: fastapi>=0.103.2
|
|
28
|
+
Requires-Dist: uvicorn[standard]>=0.22.0
|
|
29
|
+
Requires-Dist: aiohttp>=3.7.0
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
32
|
+
Requires-Dist: black; extra == "dev"
|
|
33
|
+
Requires-Dist: flake8; extra == "dev"
|
|
34
|
+
Dynamic: license-file
|
|
35
|
+
|
|
36
|
+
# EasyHTTP
|
|
37
|
+
|
|
38
|
+
[](https://github.com/slpuk/easyhttp-python)
|
|
39
|
+

|
|
40
|
+

|
|
41
|
+

|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
> **A lightweight HTTP-based P2P framework for IoT and device-to-device communication**
|
|
45
|
+
|
|
46
|
+
## 🛠️ Changelog
|
|
47
|
+
- Added context managers support
|
|
48
|
+
- Fixed some bugs
|
|
49
|
+
|
|
50
|
+
## 📖 About
|
|
51
|
+
|
|
52
|
+
**EasyHTTP** is a simple yet powerful framework with asynchronous core that enables P2P (peer-to-peer) communication between devices using plain HTTP.
|
|
53
|
+
|
|
54
|
+
### Key Features:
|
|
55
|
+
- **🔄 P2P Architecture** - No central server required
|
|
56
|
+
- **🧩 Dual API:** `EasyHTTP` (synchronous) and `EasyHTTPAsync` (asynchronous) with the same methods
|
|
57
|
+
- **📡 Event-Driven Communication** - Callback-based architecture
|
|
58
|
+
- **🆔 Human-Readable Device IDs** - Base32 identifiers instead of IP addresses
|
|
59
|
+
- **✅ Easy to Use** - Simple API with minimal setup
|
|
60
|
+
- **🚀 Performance** - Asynchronous code and lightweight libraries(FastAPI/aiohttp)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
## 🏗️ Architecture
|
|
64
|
+
|
|
65
|
+
### Device Identification
|
|
66
|
+
Instead of using hard-to-remember IP addresses, each device in the EasyHTTP network has a unique 6-character identifier:
|
|
67
|
+
|
|
68
|
+
- **Format**: 6 characters from Base32 alphabet (without ambiguous characters)
|
|
69
|
+
- **Alphabet**: `23456789ABCDEFGHJKLMNPQRSTUVWXYZ`
|
|
70
|
+
- **Examples**: `7H8G2K`, `AB3F9Z`, `X4R7T2`
|
|
71
|
+
- **Generation**: Randomly generated on first boot, stored in device configuration
|
|
72
|
+
|
|
73
|
+
### Command System
|
|
74
|
+
EasyHTTP uses a simple JSON-based command system:
|
|
75
|
+
|
|
76
|
+
| Command | Value | Description |
|
|
77
|
+
|---------|-------|-------------|
|
|
78
|
+
| `PING` | 1 | Check if another device is reachable |
|
|
79
|
+
| `PONG` | 2 | Response to ping request |
|
|
80
|
+
| `FETCH` | 3 | Request data from a device |
|
|
81
|
+
| `DATA` | 4 | Send data or answer to FETCH |
|
|
82
|
+
| `PUSH` | 5 | Request to write/execute on remote device |
|
|
83
|
+
| `ACK` | 6 | Success/confirmation |
|
|
84
|
+
| `NACK` | 7 | Error/reject |
|
|
85
|
+
|
|
86
|
+
### Basic Example with Callbacks (synchronous)
|
|
87
|
+
```python
|
|
88
|
+
import time
|
|
89
|
+
from easyhttp import EasyHTTP
|
|
90
|
+
|
|
91
|
+
# Callback function
|
|
92
|
+
def handle_data(sender_id, data, timestamp):
|
|
93
|
+
# Callback for incoming DATA responses
|
|
94
|
+
print(f"From {sender_id}: {data}")
|
|
95
|
+
|
|
96
|
+
def handle_fetch(sender_id, query, timestamp):
|
|
97
|
+
# Callback for FETCH requests - returns data when someone requests it
|
|
98
|
+
print(f"FETCH request from {sender_id}")
|
|
99
|
+
return {
|
|
100
|
+
"temperature": 23.5,
|
|
101
|
+
"humidity": 45,
|
|
102
|
+
"status": "normal",
|
|
103
|
+
"timestamp": timestamp
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def handle_push(sender_id, data, timestamp):
|
|
107
|
+
# Callback for PUSH requests - handle control commands
|
|
108
|
+
print(f"Control from {sender_id}: {data}")
|
|
109
|
+
if data and data.get("command") == "led":
|
|
110
|
+
state = data.get("state", "off")
|
|
111
|
+
print(f"[CONTROL] Turning LED {state}")
|
|
112
|
+
# Here you can add real GPIO control
|
|
113
|
+
return True # Successful → ACK
|
|
114
|
+
return False # Error → NACK
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
# Initializing EasyHTTP - sync wrapper of EasyHTTPAsync
|
|
118
|
+
easy = EasyHTTP(debug=True, port=5000)
|
|
119
|
+
|
|
120
|
+
# Setting up callback functions
|
|
121
|
+
easy.on('on_ping', handle_ping)
|
|
122
|
+
easy.on('on_pong', handle_pong)
|
|
123
|
+
easy.on('on_fetch', handle_fetch)
|
|
124
|
+
easy.on('on_data', handle_data)
|
|
125
|
+
easy.on('on_push', handle_push)
|
|
126
|
+
|
|
127
|
+
easy.start() # Starting server
|
|
128
|
+
print(f"Device {easy.id} is running on port 5000!")
|
|
129
|
+
|
|
130
|
+
# Adding device
|
|
131
|
+
easy.add("ABC123", "192.168.1.100", 5000)
|
|
132
|
+
print("Added device ABC123")
|
|
133
|
+
|
|
134
|
+
# Monitoring device's status
|
|
135
|
+
try:
|
|
136
|
+
while True:
|
|
137
|
+
if easy.ping("ABC123"):
|
|
138
|
+
print("Device ABC123 is online")
|
|
139
|
+
else:
|
|
140
|
+
print("Device ABC123 is offline")
|
|
141
|
+
|
|
142
|
+
time.sleep(5)
|
|
143
|
+
|
|
144
|
+
except KeyboardInterrupt:
|
|
145
|
+
print("\nStopping device...")
|
|
146
|
+
easy.stop() # Stopping server
|
|
147
|
+
|
|
148
|
+
# Starting main process
|
|
149
|
+
if __name__ == "__main__":
|
|
150
|
+
main()
|
|
151
|
+
```
|
|
152
|
+
**More examples available on [GitHub](https://github.com/slpuk/easyhttp-python)**
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
easyhttp/__init__.py,sha256=1MUoeXjG1dWEDDM_i8BrrpNwFu4EF3tos16LY6VAytc,163
|
|
2
|
+
easyhttp/core.py,sha256=Dr1mnn6C4qiOVmDtJMtjA4AxyFr4oIg6hWa7yrS0of8,19132
|
|
3
|
+
easyhttp/wrapper.py,sha256=2rA7I5eJR_x31YWfUIxzssqlfzE2egihsmw7PVwg0a0,5281
|
|
4
|
+
easyhttp_python-0.3.2.dist-info/licenses/LICENSE,sha256=nkDB7rjnRVh38AZftJtY1IEwTNYqzt9-1csUHIRZqGI,1083
|
|
5
|
+
easyhttp_python-0.3.2.dist-info/METADATA,sha256=e205jFToQrxJvMbtbqRUr78LxO4ylTefViFPyrTgeuU,5804
|
|
6
|
+
easyhttp_python-0.3.2.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
7
|
+
easyhttp_python-0.3.2.dist-info/top_level.txt,sha256=hXtOmZbZY_T3tJWgX_q2py0BhG48txcA16eH6ZawTqE,9
|
|
8
|
+
easyhttp_python-0.3.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 slpuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
easyhttp
|