esp-batch-uploader 1.0.0__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.
File without changes
@@ -0,0 +1,32 @@
1
+ # esp_batch_uploader/__main__.py
2
+
3
+ import asyncio
4
+ from esp_batch_uploader.uploader.batch_manager import run_batches
5
+ from esp_batch_uploader.config import parse_args
6
+ from esp_batch_uploader.logger import setup_logger
7
+
8
+ async def main():
9
+ args, files_dir = parse_args()
10
+ logger, status_logger = setup_logger(args.verbose)
11
+
12
+ logger.info("Starting ESP32 batch uploader")
13
+ status_logger.info("Starting ESP32 batch uploader")
14
+
15
+ try:
16
+ await run_batches(
17
+ files_dir=files_dir,
18
+ batch_size=args.batch_size,
19
+ udp_port=args.udp_port,
20
+ logger=logger,
21
+ status_logger=status_logger
22
+ )
23
+ except Exception as e:
24
+ logger.exception("Unexpected error during batch upload")
25
+ status_logger.error(f"Unexpected error: {e}")
26
+
27
+ # 👇 This is the entry point for CLI installation
28
+ def main_entry():
29
+ asyncio.run(main())
30
+
31
+ if __name__ == "__main__":
32
+ main_entry()
@@ -0,0 +1,52 @@
1
+ import os
2
+ import sys
3
+
4
+ def prompt_yes_no(prompt):
5
+ while True:
6
+ response = input(f"{prompt} (y/N): ").strip().lower()
7
+ if response == "":
8
+ return False # default to 'n'
9
+ if response in ("y", "n"):
10
+ return response == "y"
11
+ print("Please enter 'y' or 'n'.")
12
+
13
+ def prompt_int(prompt, default, min_val, max_val):
14
+ while True:
15
+ response = input(f"{prompt} [{default}]: ").strip()
16
+ if not response:
17
+ return default
18
+ if response.isdigit():
19
+ val = int(response)
20
+ if min_val <= val <= max_val:
21
+ return val
22
+ print(f"Please enter a number between {min_val} and {max_val}.")
23
+
24
+ def prompt_port(prompt, default):
25
+ while True:
26
+ response = input(f"{prompt} [{default}]: ").strip()
27
+ if not response:
28
+ return default
29
+ if response.isdigit() and 1 <= int(response) <= 65535:
30
+ return int(response)
31
+ print("Please enter a valid port number (1–65535).")
32
+
33
+ def parse_args():
34
+ files_dir = os.getcwd() # Current working directory
35
+ print(f"[INFO] Serving files from: {files_dir}")
36
+
37
+ verbose = prompt_yes_no("Enable verbose logging?")
38
+ batch_size = prompt_int("Enter batch size", default=5, min_val=1, max_val=20)
39
+ http_port = prompt_port("HTTP port", default=8000)
40
+ udp_port = prompt_port("UDP port", default=5757)
41
+
42
+ class Args:
43
+ pass
44
+
45
+ args = Args()
46
+ args.verbose = verbose
47
+ args.batch_size = batch_size
48
+ args.http_port = http_port
49
+ args.udp_port = udp_port
50
+ args.directory = files_dir
51
+
52
+ return args, files_dir
@@ -0,0 +1,30 @@
1
+ import logging
2
+ import datetime
3
+ import os
4
+
5
+ def setup_logger(verbose=False):
6
+ timestamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
7
+ logs_dir = os.path.join(os.getcwd(), 'logs')
8
+ os.makedirs(logs_dir, exist_ok=True)
9
+
10
+ traceback_log = os.path.join(logs_dir, f"traceback-file-transfer-{timestamp}.txt")
11
+ status_log = os.path.join(logs_dir, f"status-file-transfer-{timestamp}.txt")
12
+
13
+ logging.basicConfig(
14
+ level=logging.DEBUG if verbose else logging.INFO,
15
+ format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
16
+ handlers=[
17
+ logging.FileHandler(traceback_log, encoding='utf-8'),
18
+ logging.StreamHandler()
19
+ ]
20
+ )
21
+
22
+ status_logger = logging.getLogger("StatusLogger")
23
+ status_handler = logging.FileHandler(status_log, encoding='utf-8')
24
+ status_handler.setLevel(logging.INFO)
25
+ status_handler.setFormatter(logging.Formatter("%(asctime)s [STATUS] %(message)s"))
26
+ status_logger.addHandler(status_handler)
27
+ status_logger.propagate = False
28
+ status_logger.setLevel(logging.INFO)
29
+
30
+ return logging.getLogger("ESP32BatchServer"), status_logger
File without changes
@@ -0,0 +1,42 @@
1
+ # esp_batch_uploader/uploader/batch_manager.py
2
+
3
+ import os
4
+ from esp_batch_uploader.uploader.discovery import discover_esp32_devices
5
+ from esp_batch_uploader.uploader.upload_runner import upload_to_batch
6
+
7
+
8
+ def split_batches(devices, batch_size):
9
+ return [devices[i:i + batch_size] for i in range(0, len(devices), batch_size)]
10
+
11
+
12
+ async def run_batches(files_dir, batch_size, udp_port, logger, status_logger):
13
+ logger.info("Discovering ESP32 devices...")
14
+ devices = await discover_esp32_devices(udp_port)
15
+ if not devices:
16
+ logger.error("No devices found.")
17
+ status_logger.info("No devices found during discovery")
18
+ return
19
+
20
+ logger.info(f"Discovered {len(devices)} device(s): {devices}")
21
+ status_logger.info(f"Devices discovered: {devices}")
22
+
23
+ file_paths = [
24
+ os.path.join(files_dir, f)
25
+ for f in os.listdir(files_dir)
26
+ if os.path.isfile(os.path.join(files_dir, f))
27
+ ]
28
+
29
+ if not file_paths:
30
+ logger.error("No files to upload.")
31
+ return
32
+
33
+ batches = split_batches(devices, batch_size)
34
+ logger.info(f"Split into {len(batches)} batch(es) of size {batch_size}")
35
+
36
+ for i, batch in enumerate(batches, 1):
37
+ logger.info(f"Processing batch {i}/{len(batches)}: {batch}")
38
+ status_logger.info(f"Uploading to batch {i}: {batch}")
39
+ await upload_to_batch(batch, file_paths, logger, status_logger)
40
+
41
+ logger.info("All batches processed.")
42
+ status_logger.info("✅ All uploads completed")
@@ -0,0 +1,52 @@
1
+ import asyncio
2
+ import socket
3
+ import logging
4
+
5
+ logger = logging.getLogger("ESP32BatchServer.Discovery")
6
+
7
+ class UDPProtocol(asyncio.DatagramProtocol):
8
+ def __init__(self, queue):
9
+ self.queue = queue
10
+
11
+ def datagram_received(self, data, addr):
12
+ ip, _ = addr
13
+ msg = data.decode().strip()
14
+ logger.debug(f"Received UDP from {ip}: {msg}")
15
+ self.queue.put_nowait((ip, msg))
16
+
17
+
18
+ async def discover_esp32_devices(udp_port, timeout=5.0, http_port=8000):
19
+ loop = asyncio.get_running_loop()
20
+ queue = asyncio.Queue()
21
+
22
+ # Start UDP listener
23
+ transport, _ = await loop.create_datagram_endpoint(
24
+ lambda: UDPProtocol(queue),
25
+ local_addr=("0.0.0.0", udp_port)
26
+ )
27
+
28
+ # === Broadcast discovery message ===
29
+ broadcast_msg = f"SERVER:{socket.gethostbyname(socket.gethostname())}:{http_port}"
30
+ logger.info(f"Broadcasting: {broadcast_msg}")
31
+
32
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
33
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
34
+ sock.sendto(broadcast_msg.encode(), ("<broadcast>", udp_port))
35
+
36
+ logger.info(f"Listening for ESP32 responses on UDP port {udp_port} for {timeout}s")
37
+
38
+ discovered = set()
39
+ try:
40
+ start = loop.time()
41
+ while loop.time() - start < timeout:
42
+ try:
43
+ ip, msg = await asyncio.wait_for(queue.get(), timeout=timeout - (loop.time() - start))
44
+ if msg.startswith("ESP32_RESPONSE"):
45
+ discovered.add(ip)
46
+ logger.info(f"Discovered ESP32 at {ip}")
47
+ except asyncio.TimeoutError:
48
+ break
49
+ finally:
50
+ transport.close()
51
+
52
+ return list(discovered)
@@ -0,0 +1,51 @@
1
+ import os
2
+ import time
3
+ import aiohttp
4
+ import asyncio
5
+
6
+ class UploadClient:
7
+ def __init__(self, ip, logger, status_logger):
8
+ self.ip = ip
9
+ self.logger = logger
10
+ self.status_logger = status_logger
11
+
12
+ async def upload_file(self, session, file_path, remote_name):
13
+ if not os.path.exists(file_path):
14
+ self.logger.error(f"File does not exist: {file_path}")
15
+ return False
16
+
17
+ url = f"http://{self.ip}/upload/{remote_name}"
18
+ file_size = os.path.getsize(file_path)
19
+
20
+ self.logger.info(f"Uploading to {url} ({file_size / 1024:.2f} KB)")
21
+ self.status_logger.info(f"Start upload {remote_name} to {self.ip}")
22
+
23
+ try:
24
+ start_time = time.time()
25
+ with open(file_path, 'rb') as f:
26
+ async with session.post(url, data=f) as resp:
27
+ duration = time.time() - start_time
28
+ if resp.status == 200:
29
+ speed = file_size / duration if duration > 0 else 0
30
+ self.logger.info(f"✅ Uploaded {remote_name} to {self.ip} in {duration:.2f}s ({speed/1024/1024:.2f} MB/s)")
31
+ self.status_logger.info(f"✅ {self.ip} <- {remote_name} OK")
32
+ return True
33
+ else:
34
+ text = await resp.text()
35
+ self.logger.error(f"❌ Upload failed: {resp.status} - {text}")
36
+ self.status_logger.info(f"❌ {self.ip} <- {remote_name} FAILED")
37
+ return False
38
+ except Exception as e:
39
+ self.logger.exception(f"Upload error to {self.ip}: {e}")
40
+ self.status_logger.info(f"❌ {self.ip} <- {remote_name} ERROR")
41
+ return False
42
+
43
+ async def upload_files(self, file_paths):
44
+ async with aiohttp.ClientSession() as session:
45
+ for path in file_paths:
46
+ remote_name = os.path.basename(path)
47
+ success = await self.upload_file(session, path, remote_name)
48
+ if not success:
49
+ self.logger.warning(f"Retrying {remote_name} to {self.ip} after failure")
50
+ await asyncio.sleep(2)
51
+ await self.upload_file(session, path, remote_name)
@@ -0,0 +1,10 @@
1
+ import asyncio
2
+ from esp_batch_uploader.uploader.upload_client import UploadClient
3
+
4
+ async def upload_to_batch(batch_ips, file_paths, logger, status_logger):
5
+ tasks = []
6
+ for ip in batch_ips:
7
+ client = UploadClient(ip, logger, status_logger)
8
+ task = asyncio.create_task(client.upload_files(file_paths))
9
+ tasks.append(task)
10
+ await asyncio.gather(*tasks)
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: esp-batch-uploader
3
+ Version: 1.0.0
4
+ Summary: Batch file uploader to ESP32 devices over HTTP with UDP discovery
5
+ Author: Abdullah Bajwa
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Operating System :: OS Independent
8
+ Requires-Python: >=3.7
9
+ Requires-Dist: aiohttp
10
+ Dynamic: author
11
+ Dynamic: classifier
12
+ Dynamic: requires-dist
13
+ Dynamic: requires-python
14
+ Dynamic: summary
@@ -0,0 +1,14 @@
1
+ esp_batch_uploader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ esp_batch_uploader/__main__.py,sha256=zRoC4qGBY-SIrFcelLk51p_ZnmRTNGViMc3HIY3a-iA,939
3
+ esp_batch_uploader/config.py,sha256=mAGATYcVsHMpKSLaEV-nkya_e_9rpUhowQA3chgUWsM,1619
4
+ esp_batch_uploader/logger.py,sha256=ZOaQYVN6uAKGNSlloWqO2OCTXOVCrfW3VIV-HLm7LxY,1132
5
+ esp_batch_uploader/uploader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ esp_batch_uploader/uploader/batch_manager.py,sha256=IF8RrEOC0vEceTDruAN2auv0DMFpd93x8jjAeEldvrs,1523
7
+ esp_batch_uploader/uploader/discovery.py,sha256=L2Af2PylFAuydZbRnRFAMKO9fYDqljZS4KHm5BD3tfY,1696
8
+ esp_batch_uploader/uploader/upload_client.py,sha256=-9DPrZS9eVT_cmQ80TWKCvCTxaGUo_7SqM8RA3-O698,2262
9
+ esp_batch_uploader/uploader/upload_runner.py,sha256=_zpJeVqrIoLxhppITFfZyNsoVRmmH-ny3_IzZqAPXuA,381
10
+ esp_batch_uploader-1.0.0.dist-info/METADATA,sha256=I-itsu1y_me93zNHsJwEmBejv_ogx8fs7keAPWikLr8,416
11
+ esp_batch_uploader-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
12
+ esp_batch_uploader-1.0.0.dist-info/entry_points.txt,sha256=vfXlI_I2WrLcCX2az__iMQSSLZYhdYxhAIJwqj_1AfQ,70
13
+ esp_batch_uploader-1.0.0.dist-info/top_level.txt,sha256=atphBgAqEAQs-qumkBOCkhfGBeq3E1paULvOqcjnbDM,19
14
+ esp_batch_uploader-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ esp-upload = esp_batch_uploader.__main__:main_entry
@@ -0,0 +1 @@
1
+ esp_batch_uploader