microweb 0.1.1__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.
- microweb/__init__.py +0 -0
- microweb/cli.py +525 -0
- microweb/firmware/ESP32_GENERIC-20250415-v1.25.0.bin +0 -0
- microweb/firmware/boot.py +2 -0
- microweb/firmware/main.py +1 -0
- microweb/microweb.py +332 -0
- microweb/uploader.py +79 -0
- microweb/wifi.py +38 -0
- microweb-0.1.1.dist-info/METADATA +363 -0
- microweb-0.1.1.dist-info/RECORD +13 -0
- microweb-0.1.1.dist-info/WHEEL +5 -0
- microweb-0.1.1.dist-info/entry_points.txt +2 -0
- microweb-0.1.1.dist-info/top_level.txt +1 -0
microweb/microweb.py
ADDED
@@ -0,0 +1,332 @@
|
|
1
|
+
import usocket as socket
|
2
|
+
import ujson
|
3
|
+
import ure
|
4
|
+
import gc
|
5
|
+
import wifi
|
6
|
+
|
7
|
+
class Request:
|
8
|
+
def __init__(self, method, path, query_params, post_data):
|
9
|
+
self.method = method
|
10
|
+
self.path = path
|
11
|
+
self.query_params = query_params
|
12
|
+
self.form = post_data
|
13
|
+
|
14
|
+
class Response:
|
15
|
+
def __init__(self, content, status=200, content_type='text/html', headers=None):
|
16
|
+
self.content = content
|
17
|
+
self.status = status
|
18
|
+
self.content_type = content_type
|
19
|
+
self.headers = headers or {}
|
20
|
+
# Add CORS header by default
|
21
|
+
self.headers['Access-Control-Allow-Origin'] = '*'
|
22
|
+
|
23
|
+
def to_http_response(self):
|
24
|
+
status_text = {
|
25
|
+
200: 'OK',
|
26
|
+
404: 'Not Found',
|
27
|
+
405: 'Method Not Allowed',
|
28
|
+
500: 'Internal Server Error',
|
29
|
+
302: 'Found'
|
30
|
+
}
|
31
|
+
|
32
|
+
response = f'HTTP/1.1 {self.status} {status_text.get(self.status, "OK")}\r\n'
|
33
|
+
response += f'Content-Type: {self.content_type}\r\n'
|
34
|
+
|
35
|
+
for key, value in self.headers.items():
|
36
|
+
response += f'{key}: {value}\r\n'
|
37
|
+
|
38
|
+
response += '\r\n'
|
39
|
+
response += str(self.content)
|
40
|
+
|
41
|
+
return response
|
42
|
+
|
43
|
+
class MicroWeb:
|
44
|
+
def __init__(self, ssid=None, password=None, port=80, debug=False, ap=None):
|
45
|
+
self.routes = {}
|
46
|
+
self.static_files = {}
|
47
|
+
self.config = {'port': port, 'debug': debug}
|
48
|
+
self.session = {}
|
49
|
+
|
50
|
+
if ap and isinstance(ap, dict) and 'ssid' in ap:
|
51
|
+
ap_ssid = ap.get('ssid', 'ESP32-MicroWeb')
|
52
|
+
ap_password = ap.get('password', '12345678')
|
53
|
+
elif ssid and password:
|
54
|
+
ap_ssid = ssid
|
55
|
+
ap_password = password
|
56
|
+
else:
|
57
|
+
ap_ssid = 'ESP32-MicroWeb'
|
58
|
+
ap_password = '12345678'
|
59
|
+
|
60
|
+
wifi.setup_ap(ap_ssid, ap_password)
|
61
|
+
|
62
|
+
def route(self, path, methods=['GET']):
|
63
|
+
def decorator(func):
|
64
|
+
self.routes[path] = {'func': func, 'methods': methods}
|
65
|
+
return func
|
66
|
+
return decorator
|
67
|
+
|
68
|
+
def add_static(self, path, file_path):
|
69
|
+
self.static_files[path] = file_path
|
70
|
+
|
71
|
+
def render_template(self, template_file, **kwargs):
|
72
|
+
try:
|
73
|
+
with open(template_file, 'r') as f:
|
74
|
+
content = f.read()
|
75
|
+
|
76
|
+
if self.config['debug']:
|
77
|
+
print(f'Template content before replacement: {content[:100]}...')
|
78
|
+
print(f'Template variables: {kwargs}')
|
79
|
+
|
80
|
+
for key, value in kwargs.items():
|
81
|
+
patterns = [
|
82
|
+
'{%' + key + '%}',
|
83
|
+
'{% ' + key + ' %}',
|
84
|
+
'{% ' + key + ' %}',
|
85
|
+
]
|
86
|
+
for pattern in patterns:
|
87
|
+
content = content.replace(pattern, str(value))
|
88
|
+
|
89
|
+
if self.config['debug']:
|
90
|
+
print(f'Template content after replacement: {content[:100]}...')
|
91
|
+
|
92
|
+
return content
|
93
|
+
except Exception as e:
|
94
|
+
if self.config['debug']:
|
95
|
+
print(f'Template error: {e}')
|
96
|
+
return f'<h1>Template Error</h1><p>Template not found: {template_file}</p><p>Error: {str(e)}</p>'
|
97
|
+
|
98
|
+
def json_response(self, data, status=200):
|
99
|
+
return Response(ujson.dumps(data), status=status, content_type='application/json')
|
100
|
+
|
101
|
+
def html_response(self, content, status=200):
|
102
|
+
return Response(content, status=status, content_type='text/html')
|
103
|
+
|
104
|
+
def redirect(self, location, status=302):
|
105
|
+
return Response('', status=status, headers={'Location': location})
|
106
|
+
|
107
|
+
|
108
|
+
def parse_request(self, request):
|
109
|
+
try:
|
110
|
+
lines = request.split('\r\n')
|
111
|
+
if not lines or not lines[0]:
|
112
|
+
if self.config['debug']:
|
113
|
+
print('Invalid request: empty or no request line')
|
114
|
+
return None
|
115
|
+
|
116
|
+
request_parts = lines[0].split(' ')
|
117
|
+
if len(request_parts) < 2:
|
118
|
+
if self.config['debug']:
|
119
|
+
print('Invalid request: malformed request line')
|
120
|
+
return None
|
121
|
+
|
122
|
+
method = request_parts[0]
|
123
|
+
full_path = request_parts[1]
|
124
|
+
|
125
|
+
if self.config['debug']:
|
126
|
+
print(f'Raw request: {request[:100]}...')
|
127
|
+
print(f'Parsed method: {method}, path: {full_path}')
|
128
|
+
|
129
|
+
path = full_path.split('?')[0]
|
130
|
+
query_params = {}
|
131
|
+
|
132
|
+
if '?' in full_path:
|
133
|
+
query_string = full_path.split('?')[1]
|
134
|
+
for param in query_string.split('&'):
|
135
|
+
if '=' in param:
|
136
|
+
key, value = param.split('=', 1)
|
137
|
+
value = value.replace('%20', ' ').replace('%21', '!')
|
138
|
+
query_params[key] = value
|
139
|
+
|
140
|
+
post_data = {}
|
141
|
+
if method == 'POST':
|
142
|
+
body_start = -1
|
143
|
+
for i, line in enumerate(lines):
|
144
|
+
if line == '':
|
145
|
+
body_start = i + 1
|
146
|
+
break
|
147
|
+
|
148
|
+
if body_start > 0 and body_start < len(lines):
|
149
|
+
body = '\r\n'.join(lines[body_start:])
|
150
|
+
if self.config['debug']:
|
151
|
+
print(f'Request body: {body}')
|
152
|
+
|
153
|
+
# Check for Content-Type header case-insensitively
|
154
|
+
content_type_header = None
|
155
|
+
for line in lines:
|
156
|
+
if line.lower().startswith('content-type:'):
|
157
|
+
content_type_header = line.split(':', 1)[1].strip()
|
158
|
+
break
|
159
|
+
|
160
|
+
if content_type_header and 'application/x-www-form-urlencoded' in content_type_header.lower():
|
161
|
+
for param in body.split('&'):
|
162
|
+
if '=' in param:
|
163
|
+
key, value = param.split('=', 1)
|
164
|
+
post_data[key] = value.replace('%20', ' ')
|
165
|
+
elif content_type_header and 'application/json' in content_type_header.lower():
|
166
|
+
try:
|
167
|
+
post_data = ujson.loads(body)
|
168
|
+
except Exception as e:
|
169
|
+
if self.config['debug']:
|
170
|
+
print(f'JSON parse error: {e}')
|
171
|
+
post_data = {}
|
172
|
+
|
173
|
+
if self.config['debug']:
|
174
|
+
print(f'Parsed query_params: {query_params}')
|
175
|
+
print(f'Parsed post_data: {post_data}')
|
176
|
+
|
177
|
+
return Request(method, path, query_params, post_data)
|
178
|
+
except Exception as e:
|
179
|
+
if self.config['debug']:
|
180
|
+
print(f'Parse request error: {e}')
|
181
|
+
return None
|
182
|
+
|
183
|
+
|
184
|
+
def get_content_type(self, file_path):
|
185
|
+
if file_path.endswith('.html') or file_path.endswith('.htm'):
|
186
|
+
return 'text/html'
|
187
|
+
elif file_path.endswith('.css'):
|
188
|
+
return 'text/css'
|
189
|
+
elif file_path.endswith('.js'):
|
190
|
+
return 'application/javascript'
|
191
|
+
elif file_path.endswith('.json'):
|
192
|
+
return 'application/json'
|
193
|
+
elif file_path.endswith('.png'):
|
194
|
+
return 'image/png'
|
195
|
+
elif file_path.endswith('.jpg') or file_path.endswith('.jpeg'):
|
196
|
+
return 'image/jpeg'
|
197
|
+
elif file_path.endswith('.gif'):
|
198
|
+
return 'image/gif'
|
199
|
+
elif file_path.endswith('.ico'):
|
200
|
+
return 'image/x-icon'
|
201
|
+
elif file_path.endswith('.txt'):
|
202
|
+
return 'text/plain'
|
203
|
+
else:
|
204
|
+
return 'text/plain'
|
205
|
+
|
206
|
+
def match_route(self, path, route_pattern):
|
207
|
+
if route_pattern == path:
|
208
|
+
return True, None
|
209
|
+
|
210
|
+
if '<' in route_pattern and '>' in route_pattern:
|
211
|
+
regex_pattern = route_pattern
|
212
|
+
param_pattern = ure.compile(r'<([^>]+)>')
|
213
|
+
regex_pattern = param_pattern.sub(r'([^/]+)', regex_pattern)
|
214
|
+
|
215
|
+
if not regex_pattern.startswith('^'):
|
216
|
+
regex_pattern = '^' + regex_pattern
|
217
|
+
if not regex_pattern.endswith('$'):
|
218
|
+
regex_pattern = regex_pattern + '$'
|
219
|
+
|
220
|
+
try:
|
221
|
+
match = ure.match(regex_pattern, path)
|
222
|
+
if match:
|
223
|
+
return True, match
|
224
|
+
except Exception as e:
|
225
|
+
if self.config['debug']:
|
226
|
+
print(f'Regex match error: {e}')
|
227
|
+
return False, None
|
228
|
+
|
229
|
+
try:
|
230
|
+
pattern = route_pattern
|
231
|
+
if not pattern.startswith('^'):
|
232
|
+
pattern = '^' + pattern
|
233
|
+
if not pattern.endswith('$'):
|
234
|
+
pattern = pattern + '$'
|
235
|
+
|
236
|
+
match = ure.match(pattern, path)
|
237
|
+
if match:
|
238
|
+
return True, match
|
239
|
+
except Exception as e:
|
240
|
+
if self.config['debug']:
|
241
|
+
print(f'Direct regex error: {e}')
|
242
|
+
|
243
|
+
return False, None
|
244
|
+
|
245
|
+
def handle_request(self, request):
|
246
|
+
req = self.parse_request(request)
|
247
|
+
|
248
|
+
if not req:
|
249
|
+
return Response('<h1>400 Bad Request</h1>', status=400).to_http_response()
|
250
|
+
|
251
|
+
if self.config['debug']:
|
252
|
+
print(f'Request: {req.method} {req.path}')
|
253
|
+
|
254
|
+
if req.path in self.static_files:
|
255
|
+
try:
|
256
|
+
file_path = self.static_files[req.path]
|
257
|
+
with open(file_path, 'r') as f:
|
258
|
+
content = f.read()
|
259
|
+
content_type = self.get_content_type(file_path)
|
260
|
+
if self.config['debug']:
|
261
|
+
print(f'Serving static file: {file_path} as {content_type}')
|
262
|
+
return Response(content, content_type=content_type).to_http_response()
|
263
|
+
except Exception as e:
|
264
|
+
if self.config['debug']:
|
265
|
+
print(f'Static file error: {e}')
|
266
|
+
return Response('<h1>404 Not Found</h1><p>File not found</p>',
|
267
|
+
status=404).to_http_response()
|
268
|
+
|
269
|
+
for route_pattern, route_config in self.routes.items():
|
270
|
+
is_match, match_obj = self.match_route(req.path, route_pattern)
|
271
|
+
|
272
|
+
if is_match:
|
273
|
+
if req.method not in route_config['methods']:
|
274
|
+
return Response('<h1>405 Method Not Allowed</h1>',
|
275
|
+
status=405).to_http_response()
|
276
|
+
|
277
|
+
try:
|
278
|
+
if match_obj and hasattr(match_obj, 'group'):
|
279
|
+
result = route_config['func'](req, match_obj)
|
280
|
+
else:
|
281
|
+
result = route_config['func'](req)
|
282
|
+
|
283
|
+
if isinstance(result, Response):
|
284
|
+
return result.to_http_response()
|
285
|
+
elif isinstance(result, str):
|
286
|
+
return Response(result).to_http_response()
|
287
|
+
elif isinstance(result, dict):
|
288
|
+
return self.json_response(result).to_http_response()
|
289
|
+
else:
|
290
|
+
return Response(str(result)).to_http_response()
|
291
|
+
|
292
|
+
except Exception as e:
|
293
|
+
if self.config['debug']:
|
294
|
+
print(f'Route handler error: {e}')
|
295
|
+
return Response(f'<h1>500 Internal Server Error</h1><p>{str(e)}</p>',
|
296
|
+
status=500).to_http_response()
|
297
|
+
|
298
|
+
return Response('<h1>404 Not Found</h1><p>Page not found</p>',
|
299
|
+
status=404).to_http_response()
|
300
|
+
|
301
|
+
def run(self):
|
302
|
+
s = socket.socket()
|
303
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
304
|
+
s.bind(('0.0.0.0', self.config['port']))
|
305
|
+
s.listen(5)
|
306
|
+
|
307
|
+
if self.config['debug']:
|
308
|
+
print(f"MicroWeb running on http://0.0.0.0:{self.config['port']}")
|
309
|
+
|
310
|
+
while True:
|
311
|
+
conn = None
|
312
|
+
try:
|
313
|
+
conn, addr = s.accept()
|
314
|
+
if self.config['debug']:
|
315
|
+
print(f'Connection from {addr}')
|
316
|
+
|
317
|
+
request = conn.recv(1024).decode('utf-8')
|
318
|
+
if request:
|
319
|
+
response = self.handle_request(request)
|
320
|
+
conn.send(response.encode('utf-8'))
|
321
|
+
|
322
|
+
except Exception as e:
|
323
|
+
if self.config['debug']:
|
324
|
+
print(f'Request handling error: {e}')
|
325
|
+
finally:
|
326
|
+
if conn:
|
327
|
+
try:
|
328
|
+
conn.close()
|
329
|
+
except:
|
330
|
+
pass
|
331
|
+
gc.collect()
|
332
|
+
|
microweb/uploader.py
ADDED
@@ -0,0 +1,79 @@
|
|
1
|
+
import subprocess
|
2
|
+
import os
|
3
|
+
import serial.tools.list_ports
|
4
|
+
|
5
|
+
def upload_file(file_path, port=None,destination=None):
|
6
|
+
"""Upload a file to the ESP32 filesystem using mpremote."""
|
7
|
+
if not port:
|
8
|
+
ports = [p.device for p in serial.tools.list_ports.comports()]
|
9
|
+
port = ports[0] if ports else None
|
10
|
+
if not port:
|
11
|
+
raise Exception("No ESP32 found. Specify --port.")
|
12
|
+
|
13
|
+
file_name = file_path.split('/')[-1].split('\\')[-1]
|
14
|
+
|
15
|
+
try:
|
16
|
+
if not os.path.exists(file_path):
|
17
|
+
raise Exception(f"File {file_path} does not exist.")
|
18
|
+
|
19
|
+
# Use mpremote to copy the file
|
20
|
+
cmd = ['mpremote', 'connect', port, 'cp', file_path, f':{file_name}']
|
21
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
22
|
+
|
23
|
+
if result.returncode != 0:
|
24
|
+
raise Exception(f"mpremote failed: {result.stderr}")
|
25
|
+
|
26
|
+
# Verify file was uploaded
|
27
|
+
verify_cmd = ['mpremote', 'connect', port, 'ls']
|
28
|
+
verify_result = subprocess.run(verify_cmd, capture_output=True, text=True, timeout=10)
|
29
|
+
|
30
|
+
if verify_result.returncode == 0 and file_name in verify_result.stdout:
|
31
|
+
print(f"Successfully uploaded {file_name}")
|
32
|
+
else:
|
33
|
+
raise Exception(f"File {file_name} not found after upload")
|
34
|
+
|
35
|
+
except subprocess.TimeoutExpired:
|
36
|
+
raise Exception(f"Upload timeout for {file_name}")
|
37
|
+
except Exception as e:
|
38
|
+
raise Exception(f"Upload error for {file_name}: {e}")
|
39
|
+
|
40
|
+
def create_directory(dir_name, port=None):
|
41
|
+
"""Create a directory on the ESP32 filesystem."""
|
42
|
+
if not port:
|
43
|
+
ports = [p.device for p in serial.tools.list_ports.comports()]
|
44
|
+
port = ports[0] if ports else None
|
45
|
+
if not port:
|
46
|
+
raise Exception("No ESP32 found. Specify --port.")
|
47
|
+
|
48
|
+
try:
|
49
|
+
cmd = ['mpremote', 'connect', port, 'mkdir', f':{dir_name}']
|
50
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
51
|
+
|
52
|
+
# mkdir might fail if directory exists, that's ok
|
53
|
+
if result.returncode == 0 or 'exists' in result.stderr.lower():
|
54
|
+
print(f"Directory {dir_name} ready")
|
55
|
+
else:
|
56
|
+
raise Exception(f"Failed to create directory {dir_name}: {result.stderr}")
|
57
|
+
|
58
|
+
except Exception as e:
|
59
|
+
raise Exception(f"Directory creation error for {dir_name}: {e}")
|
60
|
+
|
61
|
+
def verify_files(port, expected_files):
|
62
|
+
"""Verify that expected files are present on the ESP32."""
|
63
|
+
try:
|
64
|
+
cmd = ['mpremote', 'connect', port, 'ls']
|
65
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
66
|
+
|
67
|
+
if result.returncode != 0:
|
68
|
+
raise Exception(f"Failed to list files: {result.stderr}")
|
69
|
+
|
70
|
+
output = result.stdout
|
71
|
+
missing = [f for f in expected_files if f not in output]
|
72
|
+
|
73
|
+
if missing:
|
74
|
+
print(f"Warning: Missing files on ESP32: {missing}")
|
75
|
+
else:
|
76
|
+
print("All expected files verified on ESP32")
|
77
|
+
|
78
|
+
except Exception as e:
|
79
|
+
print(f"Error verifying files: {e}")
|
microweb/wifi.py
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
import network
|
2
|
+
import time
|
3
|
+
|
4
|
+
def setup_ap(ssid="ESP32-MicroWeb", password="12345678"):
|
5
|
+
"""Setup ESP32 as Access Point only."""
|
6
|
+
sta = network.WLAN(network.STA_IF)
|
7
|
+
sta.active(False)
|
8
|
+
|
9
|
+
ap = network.WLAN(network.AP_IF)
|
10
|
+
ap.active(True)
|
11
|
+
|
12
|
+
ap.config(
|
13
|
+
essid=ssid,
|
14
|
+
password=password,
|
15
|
+
authmode=network.AUTH_WPA_WPA2_PSK,
|
16
|
+
channel=11
|
17
|
+
)
|
18
|
+
|
19
|
+
while not ap.active():
|
20
|
+
time.sleep(0.1)
|
21
|
+
|
22
|
+
print("=" * 40)
|
23
|
+
print("ESP32 Access Point Ready!")
|
24
|
+
print(f"SSID: {ssid}")
|
25
|
+
print(f"Password: {password}")
|
26
|
+
print("IP Address:", ap.ifconfig()[0])
|
27
|
+
print("Connect to this WiFi and visit:")
|
28
|
+
print(f"http://{ap.ifconfig()[0]}")
|
29
|
+
print("=" * 40)
|
30
|
+
|
31
|
+
return ap.ifconfig()[0]
|
32
|
+
|
33
|
+
def get_ip():
|
34
|
+
"""Get the AP IP address."""
|
35
|
+
ap = network.WLAN(network.AP_IF)
|
36
|
+
if ap.active():
|
37
|
+
return ap.ifconfig()[0]
|
38
|
+
return None
|