esp-batch-uploader 1.0.0__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,16 @@
1
+ # MANIFEST.in
2
+
3
+ # Include the README and requirements
4
+ include README.md
5
+ include requirements.txt
6
+
7
+ # Include the entire package source code
8
+ recursive-include esp_batch_uploader *
9
+
10
+ # Exclude unnecessary files (safety)
11
+ exclude *.log
12
+ exclude *.zip
13
+ exclude *.pyc
14
+ exclude __pycache__/
15
+ exclude logs/
16
+ exclude uploads/
@@ -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,72 @@
1
+ # ESP32 Batch File Uploader
2
+
3
+ This tool discovers multiple ESP32 devices on the network via UDP and uploads files to them over HTTP. Each ESP32 acts as a file upload server.
4
+
5
+ ## Features
6
+
7
+ - Automatically discovers ESP32 devices via UDP broadcast
8
+ - Organizes devices into configurable batches
9
+ - Uploads files to each device sequentially, but uploads to devices in parallel
10
+ - Logs detailed and high-level status to file and console
11
+ - Modular and extensible design
12
+
13
+ ## Setup
14
+
15
+ ### 1. Install Dependencies
16
+
17
+ ```bash
18
+ pip install -r requirements.txt
19
+ ```
20
+
21
+ ### 2. ESP32 Server
22
+
23
+ Each ESP32 should expose an HTTP endpoint like:
24
+
25
+ ```
26
+ POST /upload/<filename>
27
+ Body: Raw binary data
28
+ ```
29
+
30
+ Respond with HTTP 200 on success.
31
+
32
+ ### 3. Run the Script
33
+
34
+ ```bash
35
+ python -m esp_batch_uploader
36
+ ```
37
+
38
+ You'll be prompted to configure:
39
+
40
+ - Verbose logging
41
+ - Batch size
42
+ - HTTP and UDP ports
43
+
44
+ The tool will:
45
+
46
+ 1. Discover ESP32s
47
+ 2. Split them into batches
48
+ 3. Upload files from your current working directory
49
+
50
+ ## Logs
51
+
52
+ Two log files are generated in `./logs/`:
53
+ - `traceback-*.txt` – Detailed logs and errors
54
+ - `status-*.txt` – High-level status of uploads
55
+
56
+ ## File Structure
57
+
58
+ ```
59
+ esp_batch_uploader/
60
+ ├── __main__.py
61
+ ├── config.py
62
+ ├── logger.py
63
+ ├── uploader/
64
+ │ ├── batch_manager.py
65
+ │ ├── discovery.py
66
+ │ ├── upload_client.py
67
+ │ └── upload_runner.py
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT
@@ -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
@@ -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,19 @@
1
+ MANIFEST.in
2
+ README.md
3
+ requirements.txt
4
+ setup.py
5
+ esp_batch_uploader/__init__.py
6
+ esp_batch_uploader/__main__.py
7
+ esp_batch_uploader/config.py
8
+ esp_batch_uploader/logger.py
9
+ esp_batch_uploader.egg-info/PKG-INFO
10
+ esp_batch_uploader.egg-info/SOURCES.txt
11
+ esp_batch_uploader.egg-info/dependency_links.txt
12
+ esp_batch_uploader.egg-info/entry_points.txt
13
+ esp_batch_uploader.egg-info/requires.txt
14
+ esp_batch_uploader.egg-info/top_level.txt
15
+ esp_batch_uploader/uploader/__init__.py
16
+ esp_batch_uploader/uploader/batch_manager.py
17
+ esp_batch_uploader/uploader/discovery.py
18
+ esp_batch_uploader/uploader/upload_client.py
19
+ esp_batch_uploader/uploader/upload_runner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ esp-upload = esp_batch_uploader.__main__:main_entry
@@ -0,0 +1 @@
1
+ esp_batch_uploader
@@ -0,0 +1 @@
1
+ aiohttp
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ from setuptools import setup, find_packages
2
+
3
+ setup(
4
+ name='esp-batch-uploader',
5
+ version='1.0.0',
6
+ description='Batch file uploader to ESP32 devices over HTTP with UDP discovery',
7
+ author='Abdullah Bajwa', # Optional but good practice
8
+ packages=find_packages(exclude=["tests*", "examples*"]), # Avoid packaging examples/tests
9
+ install_requires=[
10
+ 'aiohttp'
11
+ ],
12
+ entry_points={
13
+ 'console_scripts': [
14
+ 'esp-upload = esp_batch_uploader.__main__:main_entry'
15
+ ],
16
+ },
17
+ python_requires='>=3.7',
18
+ classifiers=[
19
+ 'Programming Language :: Python :: 3',
20
+ 'Operating System :: OS Independent',
21
+ ],
22
+ )