2 |
3 | This notice is here just in case you find this code copied somewhere without a clear link back to its origins.
4 |
--------------------------------------------------------------------------------
/lib/logging.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | CRITICAL = 50
4 | ERROR = 40
5 | WARNING = 30
6 | INFO = 20
7 | DEBUG = 10
8 | NOTSET = 0
9 |
10 | _level_dict = {
11 | CRITICAL: "CRITICAL",
12 | ERROR: "ERROR",
13 | WARNING: "WARNING",
14 | INFO: "INFO",
15 | DEBUG: "DEBUG",
16 | }
17 |
18 | _stream = sys.stderr
19 |
20 |
21 | class Logger:
22 |
23 | level = NOTSET
24 |
25 | def __init__(self, name):
26 | self.name = name
27 |
28 | def _level_str(self, level):
29 | l = _level_dict.get(level)
30 | if l is not None:
31 | return l
32 | return "LVL%s" % level
33 |
34 | def setLevel(self, level):
35 | self.level = level
36 |
37 | def isEnabledFor(self, level):
38 | return level >= (self.level or _level)
39 |
40 | def log(self, level, msg, *args):
41 | if level >= (self.level or _level):
42 | _stream.write("%s:%s:" % (self._level_str(level), self.name))
43 | if not args:
44 | print(msg, file=_stream)
45 | else:
46 | print(msg % args, file=_stream)
47 |
48 | def debug(self, msg, *args):
49 | self.log(DEBUG, msg, *args)
50 |
51 | def info(self, msg, *args):
52 | self.log(INFO, msg, *args)
53 |
54 | def warning(self, msg, *args):
55 | self.log(WARNING, msg, *args)
56 |
57 | warn = warning
58 |
59 | def error(self, msg, *args):
60 | self.log(ERROR, msg, *args)
61 |
62 | def critical(self, msg, *args):
63 | self.log(CRITICAL, msg, *args)
64 |
65 | def exc(self, e, msg, *args):
66 | self.log(ERROR, msg, *args)
67 | sys.print_exception(e, _stream)
68 |
69 |
70 | _level = INFO
71 | _loggers = {}
72 |
73 |
74 | def getLogger(name):
75 | if name not in _loggers:
76 | _loggers[name] = Logger(name)
77 | return _loggers[name]
78 |
79 |
80 | def basicConfig(level=INFO, filename=None, stream=None):
81 | global _level, _stream
82 | _level = level
83 | if stream:
84 | _stream = stream
85 | if filename is not None:
86 | print("logging.basicConfig: filename arg is not supported")
87 |
--------------------------------------------------------------------------------
/lib/micro_dns_srv.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright 2018 Jean-Christophe Bos & HC2 (www.hc2.fr)
3 |
4 | import socket
5 | import sys
6 | import select
7 |
8 |
9 | class MicroDNSSrv:
10 |
11 | # ============================================================================
12 | # ===( Utils )================================================================
13 | # ============================================================================
14 |
15 | @staticmethod
16 | def ipV4StrToBytes(ipStr):
17 | try:
18 | parts = ipStr.split(".")
19 | if len(parts) == 4:
20 | return bytes(
21 | [int(parts[0]), int(parts[1]), int(parts[2]), int(parts[3])]
22 | )
23 | except:
24 | pass
25 | return None
26 |
27 | # ----------------------------------------------------------------------------
28 |
29 | @staticmethod
30 | def _getAskedDomainName(packet):
31 | try:
32 | queryType = (packet[2] >> 3) & 15
33 | qCount = (packet[4] << 8) | packet[5]
34 | if queryType == 0 and qCount == 1:
35 | pos = 12
36 | domName = ""
37 | while True:
38 | domPartLen = packet[pos]
39 | if domPartLen == 0:
40 | break
41 | domName += ("." if len(domName) > 0 else "") + packet[
42 | pos + 1 : pos + 1 + domPartLen
43 | ].decode()
44 | pos += 1 + domPartLen
45 | return domName
46 | except:
47 | pass
48 | return None
49 |
50 | # ----------------------------------------------------------------------------
51 |
52 | @staticmethod
53 | def _getPacketAnswerA(packet, ipV4Bytes):
54 | try:
55 | queryEndPos = 12
56 | while True:
57 | domPartLen = packet[queryEndPos]
58 | if domPartLen == 0:
59 | break
60 | queryEndPos += 1 + domPartLen
61 | queryEndPos += 5
62 |
63 | return b"".join(
64 | [
65 | packet[:2], # Query identifier
66 | b"\x85\x80", # Flags and codes
67 | packet[4:6], # Query question count
68 | b"\x00\x01", # Answer record count
69 | b"\x00\x00", # Authority record count
70 | b"\x00\x00", # Additional record count
71 | packet[12:queryEndPos], # Query question
72 | b"\xc0\x0c", # Answer name as pointer
73 | b"\x00\x01", # Answer type A
74 | b"\x00\x01", # Answer class IN
75 | b"\x00\x00\x00\x1E", # Answer TTL 30 secondes
76 | b"\x00\x04", # Answer data length
77 | ipV4Bytes,
78 | ]
79 | ) # Answer data
80 | except:
81 | pass
82 |
83 | return None
84 |
85 | # ============================================================================
86 |
87 | def pump(self, s, event):
88 | if s != self._server:
89 | return
90 |
91 | if event != select.POLLIN:
92 | raise Exception("unexpected event {} on server socket".format(event))
93 |
94 | try:
95 | packet, cliAddr = self._server.recvfrom(256)
96 | domName = self._getAskedDomainName(packet)
97 | if domName:
98 | domName = domName.lower()
99 | ipB = self._resolve(domName)
100 | if ipB:
101 | packet = self._getPacketAnswerA(packet, ipB)
102 | if packet:
103 | self._server.sendto(packet, cliAddr)
104 | except Exception as e:
105 | sys.print_exception(e)
106 |
107 | # ============================================================================
108 | # ===( Constructor )==========================================================
109 | # ============================================================================
110 |
111 | def __init__(self, resolve, poller, address="", port=53):
112 | self._resolve = resolve
113 | self._server = socket.socket(
114 | socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP
115 | )
116 | self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
117 | self._server.bind((address, port))
118 |
119 | poller.register(self._server, select.POLLIN | select.POLLERR | select.POLLHUP)
120 |
121 | def shutdown(self, poller):
122 | poller.unregister(self._server)
123 | self._server.close()
124 |
125 | # ============================================================================
126 | # ============================================================================
127 | # ============================================================================
128 |
--------------------------------------------------------------------------------
/lib/micro_web_srv_2/http_request.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright 2019 Jean-Christophe Bos & HC2 (www.hc2.fr)
3 |
4 | from .libs.url_utils import UrlUtils
5 | from .http_response import HttpResponse
6 | import json
7 | import sys
8 |
9 | # ============================================================================
10 | # ===( HttpRequest )==========================================================
11 | # ============================================================================
12 |
13 |
14 | class HttpRequest:
15 |
16 | MAX_RECV_HEADER_LINES = 100
17 |
18 | # ------------------------------------------------------------------------
19 |
20 | def __init__(self, config, xasCli, process_request):
21 | self._timeout_sec = config.timeout_sec
22 | self._xasCli = xasCli
23 | self._process_request = process_request
24 |
25 | self._httpVer = ""
26 | self._method = ""
27 | self._path = ""
28 | self._headers = {}
29 | self._content = None
30 | self._response = HttpResponse(config, self)
31 |
32 | self._recvLine(self._onFirstLineRecv)
33 |
34 | # ------------------------------------------------------------------------
35 |
36 | def _recvLine(self, onRecv):
37 | self._xasCli.AsyncRecvLine(onLineRecv=onRecv, timeoutSec=self._timeout_sec)
38 |
39 | # ------------------------------------------------------------------------
40 |
41 | def _onFirstLineRecv(self, xasCli, line, arg):
42 | try:
43 | elements = line.strip().split()
44 | if len(elements) == 3:
45 | self._httpVer = elements[2].upper()
46 | self._method = elements[0].upper()
47 | elements = elements[1].split("?", 1)
48 | self._path = UrlUtils.UnquotePlus(elements[0])
49 | self._queryString = elements[1] if len(elements) > 1 else ""
50 | self._queryParams = {}
51 | if self._queryString:
52 | elements = self._queryString.split("&")
53 | for s in elements:
54 | p = s.split("=", 1)
55 | if len(p) > 0:
56 | v = UrlUtils.Unquote(p[1]) if len(p) > 1 else ""
57 | self._queryParams[UrlUtils.Unquote(p[0])] = v
58 | self._recvLine(self._onHeaderLineRecv)
59 | else:
60 | self._response.ReturnBadRequest()
61 | except Exception as e:
62 | sys.print_exception(e)
63 | self._response.ReturnBadRequest()
64 |
65 | # ------------------------------------------------------------------------
66 |
67 | def _onHeaderLineRecv(self, xasCli, line, arg):
68 | try:
69 | elements = line.strip().split(":", 1)
70 | if len(elements) == 2:
71 | if len(self._headers) < HttpRequest.MAX_RECV_HEADER_LINES:
72 | self._headers[elements[0].strip().lower()] = elements[1].strip()
73 | self._recvLine(self._onHeaderLineRecv)
74 | else:
75 | self._response.ReturnEntityTooLarge()
76 | elif len(elements) == 1 and len(elements[0]) == 0:
77 | self._process_request(self)
78 | else:
79 | self._response.ReturnBadRequest()
80 | except Exception as e:
81 | sys.print_exception(e)
82 | self._response.ReturnBadRequest()
83 |
84 | # ------------------------------------------------------------------------
85 |
86 | def async_data_recv(self, size, on_content_recv):
87 | def _on_content_recv(xasCli, content, arg):
88 | self._content = content
89 | on_content_recv()
90 | self._content = None
91 |
92 | self._xasCli.AsyncRecvData(
93 | size=size, onDataRecv=_on_content_recv, timeoutSec=self._timeout_sec
94 | )
95 |
96 | # ------------------------------------------------------------------------
97 |
98 | def GetPostedURLEncodedForm(self):
99 | res = {}
100 | if self.ContentType.lower() == "application/x-www-form-urlencoded":
101 | try:
102 | elements = bytes(self._content).decode("UTF-8").split("&")
103 | for s in elements:
104 | p = s.split("=", 1)
105 | if len(p) > 0:
106 | v = UrlUtils.UnquotePlus(p[1]) if len(p) > 1 else ""
107 | res[UrlUtils.UnquotePlus(p[0])] = v
108 | except Exception as e:
109 | sys.print_exception(e)
110 | return res
111 |
112 | # ------------------------------------------------------------------------
113 |
114 | def GetPostedJSONObject(self):
115 | if self.ContentType.lower() == "application/json":
116 | try:
117 | s = bytes(self._content).decode("UTF-8")
118 | return json.loads(s)
119 | except Exception as e:
120 | sys.print_exception(e)
121 | return None
122 |
123 | # ------------------------------------------------------------------------
124 |
125 | def GetHeader(self, name):
126 | if not isinstance(name, str) or len(name) == 0:
127 | raise ValueError('"name" must be a not empty string.')
128 | return self._headers.get(name.lower(), "")
129 |
130 | # ------------------------------------------------------------------------
131 |
132 | @property
133 | def UserAddress(self):
134 | return self._xasCli.CliAddr
135 |
136 | # ------------------------------------------------------------------------
137 |
138 | @property
139 | def HttpVer(self):
140 | return self._httpVer
141 |
142 | # ------------------------------------------------------------------------
143 |
144 | @property
145 | def Method(self):
146 | return self._method
147 |
148 | # ------------------------------------------------------------------------
149 |
150 | @property
151 | def Path(self):
152 | return self._path
153 |
154 | # ------------------------------------------------------------------------
155 |
156 | @property
157 | def QueryString(self):
158 | return self._queryString
159 |
160 | # ------------------------------------------------------------------------
161 |
162 | @property
163 | def QueryParams(self):
164 | return self._queryParams
165 |
166 | # ------------------------------------------------------------------------
167 |
168 | @property
169 | def Host(self):
170 | return self._headers.get("host", "")
171 |
172 | # ------------------------------------------------------------------------
173 |
174 | @property
175 | def Accept(self):
176 | s = self._headers.get("accept", None)
177 | if s:
178 | return [x.strip() for x in s.split(",")]
179 | return []
180 |
181 | # ------------------------------------------------------------------------
182 |
183 | @property
184 | def AcceptEncodings(self):
185 | s = self._headers.get("accept-encoding", None)
186 | if s:
187 | return [x.strip() for x in s.split(",")]
188 | return []
189 |
190 | # ------------------------------------------------------------------------
191 |
192 | @property
193 | def AcceptLanguages(self):
194 | s = self._headers.get("accept-language", None)
195 | if s:
196 | return [x.strip() for x in s.split(",")]
197 | return []
198 |
199 | # ------------------------------------------------------------------------
200 |
201 | @property
202 | def Cookies(self):
203 | s = self._headers.get("cookie", None)
204 | if s:
205 | return [x.strip() for x in s.split(";")]
206 | return []
207 |
208 | # ------------------------------------------------------------------------
209 |
210 | @property
211 | def CacheControl(self):
212 | return self._headers.get("cache-control", "")
213 |
214 | # ------------------------------------------------------------------------
215 |
216 | @property
217 | def Referer(self):
218 | return self._headers.get("referer", "")
219 |
220 | # ------------------------------------------------------------------------
221 |
222 | @property
223 | def ContentType(self):
224 | return self._headers.get("content-type", "").split(";", 1)[0].strip()
225 |
226 | # ------------------------------------------------------------------------
227 |
228 | @property
229 | def ContentLength(self):
230 | try:
231 | return int(self._headers.get("content-length", 0))
232 | except:
233 | return 0
234 |
235 | # ------------------------------------------------------------------------
236 |
237 | @property
238 | def UserAgent(self):
239 | return self._headers.get("user-agent", "")
240 |
241 | # ------------------------------------------------------------------------
242 |
243 | @property
244 | def Origin(self):
245 | return self._headers.get("origin", "")
246 |
247 | # ------------------------------------------------------------------------
248 |
249 | @property
250 | def IsUpgrade(self):
251 | return "upgrade" in self._headers.get("connection", "").lower()
252 |
253 | # ------------------------------------------------------------------------
254 |
255 | @property
256 | def Upgrade(self):
257 | return self._headers.get("upgrade", "")
258 |
259 | # ------------------------------------------------------------------------
260 |
261 | @property
262 | def Content(self):
263 | return self._content
264 |
265 | # ------------------------------------------------------------------------
266 |
267 | @property
268 | def Response(self):
269 | return self._response
270 |
271 | # ------------------------------------------------------------------------
272 |
273 | @property
274 | def XAsyncTCPClient(self):
275 | return self._xasCli
276 |
277 |
278 | # ============================================================================
279 | # ============================================================================
280 | # ============================================================================
281 |
--------------------------------------------------------------------------------
/lib/micro_web_srv_2/http_response.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright 2019 Jean-Christophe Bos & HC2 (www.hc2.fr)
3 |
4 | from os import stat
5 | import json
6 | import sys
7 |
8 | import logging
9 |
10 |
11 | _logger = logging.getLogger("response")
12 |
13 |
14 | # Read a file, given a path relative to the directory containing this `.py` file.
15 | def _read_relative(filename):
16 | from shim import join, dirname, read_text
17 |
18 | return read_text(join(dirname(__file__), filename))
19 |
20 |
21 | # ============================================================================
22 | # ===( HttpResponse )=========================================================
23 | # ============================================================================
24 |
25 |
26 | class HttpResponse:
27 |
28 | _RESPONSE_CODES = {
29 | 100: "Continue",
30 | 101: "Switching Protocols",
31 | 200: "OK",
32 | 201: "Created",
33 | 202: "Accepted",
34 | 203: "Non-Authoritative Information",
35 | 204: "No Content",
36 | 205: "Reset Content",
37 | 206: "Partial Content",
38 | 300: "Multiple Choices",
39 | 301: "Moved Permanently",
40 | 302: "Found",
41 | 303: "See Other",
42 | 304: "Not Modified",
43 | 305: "Use Proxy",
44 | 307: "Temporary Redirect",
45 | 400: "Bad Request",
46 | 401: "Unauthorized",
47 | 402: "Payment Required",
48 | 403: "Forbidden",
49 | 404: "Not Found",
50 | 405: "Method Not Allowed",
51 | 406: "Not Acceptable",
52 | 407: "Proxy Authentication Required",
53 | 408: "Request Timeout",
54 | 409: "Conflict",
55 | 410: "Gone",
56 | 411: "Length Required",
57 | 412: "Precondition Failed",
58 | 413: "Request Entity Too Large",
59 | 414: "Request-URI Too Long",
60 | 415: "Unsupported Media Type",
61 | 416: "Requested Range Not Satisfiable",
62 | 417: "Expectation Failed",
63 | 500: "Internal Server Error",
64 | 501: "Not Implemented",
65 | 502: "Bad Gateway",
66 | 503: "Service Unavailable",
67 | 504: "Gateway Timeout",
68 | 505: "HTTP Version Not Supported",
69 | }
70 |
71 | _CODE_CONTENT_TMPL = _read_relative("status-code.html")
72 |
73 | # ------------------------------------------------------------------------
74 |
75 | def __init__(self, config, request):
76 | self._not_found_url = config.not_found_url
77 | self._allow_all_origins = config.allow_all_origins
78 | self._server_name = config.server_name
79 |
80 | self._request = request
81 | self._xasCli = request.XAsyncTCPClient
82 |
83 | self._headers = {}
84 | self._allowCaching = False
85 | self._acAllowOrigin = None
86 | self._contentType = None
87 | self._contentCharset = None
88 | self._contentLength = 0
89 | self._stream = None
90 | self._sendingBuf = None
91 | self._hdrSent = False
92 |
93 | self._switch_result = None
94 |
95 | # ------------------------------------------------------------------------
96 |
97 | def SetHeader(self, name, value):
98 | if not isinstance(name, str) or len(name) == 0:
99 | raise ValueError('"name" must be a not empty string.')
100 | if value is None:
101 | raise ValueError('"value" cannot be None.')
102 | self._headers[name] = str(value)
103 |
104 | # ------------------------------------------------------------------------
105 |
106 | def _onDataSent(self, xasCli, arg):
107 | if self._stream:
108 | try:
109 | n = self._stream.readinto(self._sendingBuf)
110 | if n < len(self._sendingBuf):
111 | self._stream.close()
112 | self._stream = None
113 | self._sendingBuf = self._sendingBuf[:n]
114 | except Exception as e:
115 | sys.print_exception(e)
116 | self._xasCli.Close()
117 | _logger.error(
118 | 'stream cannot be read for request "%s".', self._request._path
119 | )
120 | return
121 | if self._sendingBuf:
122 | if self._contentLength:
123 | self._xasCli.AsyncSendSendingBuffer(
124 | size=len(self._sendingBuf), onDataSent=self._onDataSent
125 | )
126 | if not self._stream:
127 | self._sendingBuf = None
128 | else:
129 |
130 | def onChunkHdrSent(xasCli, arg):
131 | def onChunkDataSent(xasCli, arg):
132 | def onLastChunkSent(xasCli, arg):
133 | self._xasCli.AsyncSendData(
134 | b"0\r\n\r\n", onDataSent=self._onDataSent
135 | )
136 |
137 | if self._stream:
138 | onDataSent = self._onDataSent
139 | else:
140 | self._sendingBuf = None
141 | onDataSent = onLastChunkSent
142 | self._xasCli.AsyncSendData(b"\r\n", onDataSent=onDataSent)
143 |
144 | self._xasCli.AsyncSendSendingBuffer(
145 | size=len(self._sendingBuf), onDataSent=onChunkDataSent
146 | )
147 |
148 | data = ("%x\r\n" % len(self._sendingBuf)).encode()
149 | self._xasCli.AsyncSendData(data, onDataSent=onChunkHdrSent)
150 | else:
151 | self._xasCli.OnClosed = None
152 | self._xasCli.Close()
153 |
154 | # ------------------------------------------------------------------------
155 |
156 | def _onClosed(self, xasCli, closedReason):
157 | if self._stream:
158 | try:
159 | self._stream.close()
160 | except Exception as e:
161 | sys.print_exception(e)
162 | self._stream = None
163 | self._sendingBuf = None
164 |
165 | # ------------------------------------------------------------------------
166 |
167 | def _reason(self, code):
168 | return self._RESPONSE_CODES.get(code, "Unknown reason")
169 |
170 | # ------------------------------------------------------------------------
171 |
172 | def _makeBaseResponseHdr(self, code):
173 | reason = self._reason(code)
174 | host = self._request.Host
175 | host = " to {}".format(host) if host else ""
176 | _logger.info(
177 | "from %s:%s%s %s %s >> [%s] %s",
178 | self._xasCli.CliAddr[0],
179 | self._xasCli.CliAddr[1],
180 | host,
181 | self._request._method,
182 | self._request._path,
183 | code,
184 | reason,
185 | )
186 | if self._allow_all_origins:
187 | self._acAllowOrigin = self._request.Origin
188 | if self._acAllowOrigin:
189 | self.SetHeader("Access-Control-Allow-Origin", self._acAllowOrigin)
190 | self.SetHeader("Server", self._server_name)
191 | hdr = ""
192 | for n in self._headers:
193 | hdr += "%s: %s\r\n" % (n, self._headers[n])
194 | resp = "HTTP/1.1 %s %s\r\n%s\r\n" % (code, reason, hdr)
195 | return resp.encode("ISO-8859-1")
196 |
197 | # ------------------------------------------------------------------------
198 |
199 | def _makeResponseHdr(self, code):
200 | self.SetHeader("Connection", "Close")
201 | if self._allowCaching:
202 | self.SetHeader("Cache-Control", "public, max-age=31536000")
203 | else:
204 | self.SetHeader("Cache-Control", "no-cache, no-store, must-revalidate")
205 | if self._contentType:
206 | ct = self._contentType
207 | if self._contentCharset:
208 | ct += "; charset=%s" % self._contentCharset
209 | self.SetHeader("Content-Type", ct)
210 | if self._contentLength:
211 | self.SetHeader("Content-Length", self._contentLength)
212 | return self._makeBaseResponseHdr(code)
213 |
214 | # ------------------------------------------------------------------------
215 |
216 | def _on_switched(self, xas_cli, _):
217 | if not self._switch_result:
218 | return
219 |
220 | self._switch_result(xas_cli.detach_socket())
221 | self._switch_result = None
222 |
223 | def SwitchingProtocols(self, upgrade, switch_result=None):
224 | self._switch_result = switch_result
225 | if not isinstance(upgrade, str) or len(upgrade) == 0:
226 | raise ValueError('"upgrade" must be a not empty string.')
227 | if self._hdrSent:
228 | _logger.warning(
229 | 'response headers already sent for request "%s".', self._request._path
230 | )
231 | return
232 | self.SetHeader("Connection", "Upgrade")
233 | self.SetHeader("Upgrade", upgrade)
234 | data = self._makeBaseResponseHdr(101)
235 | self._xasCli.AsyncSendData(data, self._on_switched)
236 | self._hdrSent = True
237 |
238 | # ------------------------------------------------------------------------
239 |
240 | def ReturnStream(self, code, stream):
241 | if not isinstance(code, int) or code <= 0:
242 | raise ValueError('"code" must be a positive integer.')
243 | if not hasattr(stream, "readinto") or not hasattr(stream, "close"):
244 | raise ValueError('"stream" must be a readable buffer protocol object.')
245 | if self._hdrSent:
246 | _logger.warning(
247 | 'response headers already sent for request "%s".', self._request._path
248 | )
249 | try:
250 | stream.close()
251 | except Exception as e:
252 | sys.print_exception(e)
253 | return
254 | if self._request._method != "HEAD":
255 | self._stream = stream
256 | self._sendingBuf = memoryview(self._xasCli.SendingBuffer)
257 | self._xasCli.OnClosed = self._onClosed
258 | else:
259 | try:
260 | stream.close()
261 | except Exception as e:
262 | sys.print_exception(e)
263 | if not self._contentType:
264 | self._contentType = "application/octet-stream"
265 | if not self._contentLength:
266 | self.SetHeader("Transfer-Encoding", "chunked")
267 | data = self._makeResponseHdr(code)
268 | self._xasCli.AsyncSendData(data, onDataSent=self._onDataSent)
269 | self._hdrSent = True
270 |
271 | # ------------------------------------------------------------------------
272 |
273 | # An accept header can contain patterns, e.g. "text/*" but this function only handles the pattern "*/*".
274 | def _status_code_content(self, code):
275 | for type in self._request.Accept:
276 | type = type.rsplit(";", 1)[0] # Strip ";q=weight".
277 | if type in ["text/html", "*/*"]:
278 | content = self._CODE_CONTENT_TMPL.format(
279 | code=code, reason=self._reason(code)
280 | )
281 | return "text/html", content
282 | if type == "application/json":
283 | content = {"code": code, "name": self._reason(code)}
284 | return "application/json", json.dumps(content)
285 | return None, None
286 |
287 | def Return(self, code, content=None):
288 | if not isinstance(code, int) or code <= 0:
289 | raise ValueError('"code" must be a positive integer.')
290 | if self._hdrSent:
291 | _logger.warning(
292 | 'response headers already sent for request "%s".', self._request._path
293 | )
294 | return
295 | if not content:
296 | (self._contentType, content) = self._status_code_content(code)
297 |
298 | if content:
299 | if isinstance(content, str):
300 | content = content.encode("UTF-8")
301 | if not self._contentType:
302 | self._contentType = "text/html"
303 | self._contentCharset = "UTF-8"
304 | elif not self._contentType:
305 | self._contentType = "application/octet-stream"
306 | self._contentLength = len(content)
307 |
308 | data = self._makeResponseHdr(code)
309 |
310 | if content and self._request._method != "HEAD":
311 | data += bytes(content)
312 |
313 | self._xasCli.AsyncSendData(data, onDataSent=self._onDataSent)
314 | self._hdrSent = True
315 |
316 | # ------------------------------------------------------------------------
317 |
318 | def ReturnJSON(self, code, obj):
319 | if not isinstance(code, int) or code <= 0:
320 | raise ValueError('"code" must be a positive integer.')
321 | self._contentType = "application/json"
322 | try:
323 | content = json.dumps(obj)
324 | except:
325 | raise ValueError('"obj" cannot be converted into JSON format.')
326 | self.Return(code, content)
327 |
328 | # ------------------------------------------------------------------------
329 |
330 | def ReturnOk(self, content=None):
331 | self.Return(200, content)
332 |
333 | # ------------------------------------------------------------------------
334 |
335 | def ReturnOkJSON(self, obj):
336 | self.ReturnJSON(200, obj)
337 |
338 | # ------------------------------------------------------------------------
339 |
340 | def ReturnFile(self, filename, attachmentName=None):
341 | if not isinstance(filename, str) or len(filename) == 0:
342 | raise ValueError('"filename" must be a not empty string.')
343 | if attachmentName is not None and not isinstance(attachmentName, str):
344 | raise ValueError('"attachmentName" must be a string or None.')
345 | try:
346 | size = stat(filename)[6]
347 | except:
348 | self.ReturnNotFound()
349 | return
350 | try:
351 | file = open(filename, "rb")
352 | except:
353 | self.ReturnForbidden()
354 | return
355 | if attachmentName:
356 | cd = 'attachment; filename="%s"' % attachmentName.replace('"', "'")
357 | self.SetHeader("Content-Disposition", cd)
358 | if not self._contentType:
359 | raise ValueError('"ContentType" must be set')
360 | self._contentLength = size
361 | self.ReturnStream(200, file)
362 |
363 | # ------------------------------------------------------------------------
364 |
365 | def ReturnNotModified(self):
366 | self.Return(304)
367 |
368 | # ------------------------------------------------------------------------
369 |
370 | def ReturnRedirect(self, location):
371 | if not isinstance(location, str) or len(location) == 0:
372 | raise ValueError('"location" must be a not empty string.')
373 | self.SetHeader("Location", location)
374 | self.Return(307)
375 |
376 | # ------------------------------------------------------------------------
377 |
378 | def ReturnBadRequest(self):
379 | self.Return(400)
380 |
381 | # ------------------------------------------------------------------------
382 |
383 | def ReturnForbidden(self):
384 | self.Return(403)
385 |
386 | # ------------------------------------------------------------------------
387 |
388 | def ReturnNotFound(self):
389 | if self._not_found_url:
390 | self.ReturnRedirect(self._not_found_url)
391 | else:
392 | self.Return(404)
393 |
394 | # ------------------------------------------------------------------------
395 |
396 | def ReturnMethodNotAllowed(self):
397 | self.Return(405)
398 |
399 | # ------------------------------------------------------------------------
400 |
401 | def ReturnEntityTooLarge(self):
402 | self.Return(413)
403 |
404 | # ------------------------------------------------------------------------
405 |
406 | def ReturnInternalServerError(self):
407 | self.Return(500)
408 |
409 | # ------------------------------------------------------------------------
410 |
411 | def ReturnNotImplemented(self):
412 | self.Return(501)
413 |
414 | # ------------------------------------------------------------------------
415 |
416 | def ReturnServiceUnavailable(self):
417 | self.Return(503)
418 |
419 | # ------------------------------------------------------------------------
420 |
421 | @property
422 | def Request(self):
423 | return self._request
424 |
425 | # ------------------------------------------------------------------------
426 |
427 | @property
428 | def UserAddress(self):
429 | return self._xasCli.CliAddr
430 |
431 | # ------------------------------------------------------------------------
432 |
433 | @property
434 | def AllowCaching(self):
435 | return self._allowCaching
436 |
437 | @AllowCaching.setter
438 | def AllowCaching(self, value):
439 | self._check_value("AllowCaching", value, isinstance(value, bool))
440 | self._allowCaching = value
441 |
442 | # ------------------------------------------------------------------------
443 |
444 | @property
445 | def AccessControlAllowOrigin(self):
446 | return self._acAllowOrigin
447 |
448 | @AccessControlAllowOrigin.setter
449 | def AccessControlAllowOrigin(self, value):
450 | self._check_none_or_str("AccessControlAllowOrigin", value)
451 | self._acAllowOrigin = value
452 |
453 | # ------------------------------------------------------------------------
454 |
455 | @property
456 | def ContentType(self):
457 | return self._contentType
458 |
459 | @ContentType.setter
460 | def ContentType(self, value):
461 | self._check_none_or_str("ContentType", value)
462 | self._contentType = value
463 |
464 | # ------------------------------------------------------------------------
465 |
466 | @property
467 | def ContentCharset(self):
468 | return self._contentCharset
469 |
470 | @ContentCharset.setter
471 | def ContentCharset(self, value):
472 | self._check_none_or_str("ContentCharset", value)
473 | self._contentCharset = value
474 |
475 | # ------------------------------------------------------------------------
476 |
477 | @property
478 | def ContentLength(self):
479 | return self._contentLength
480 |
481 | @ContentLength.setter
482 | def ContentLength(self, value):
483 | self._check_value("ContentLength", value, isinstance(value, int) and value >= 0)
484 | self._contentLength = value
485 |
486 | # ------------------------------------------------------------------------
487 |
488 | @property
489 | def HeadersSent(self):
490 | return self._hdrSent
491 |
492 | # ------------------------------------------------------------------------
493 |
494 | def _check_value(self, name, value, condition):
495 | if not condition:
496 | raise ValueError('{} is not a valid value for "{}"'.format(value, name))
497 |
498 | def _check_none_or_str(self, name, value):
499 | self._check_value(name, value, value is None or isinstance(value, str))
500 |
501 |
502 | # ============================================================================
503 | # ============================================================================
504 | # ============================================================================
505 |
--------------------------------------------------------------------------------
/lib/micro_web_srv_2/libs/url_utils.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright 2019 Jean-Christophe Bos & HC2 (www.hc2.fr)
3 |
4 |
5 | class UrlUtils:
6 |
7 | # ----------------------------------------------------------------------------
8 |
9 | @staticmethod
10 | def Unquote(s):
11 | r = str(s).split("%")
12 | try:
13 | b = r[0].encode()
14 | for i in range(1, len(r)):
15 | try:
16 | b += bytes([int(r[i][:2], 16)]) + r[i][2:].encode()
17 | except:
18 | b += b"%" + r[i].encode()
19 | return b.decode("UTF-8")
20 | except:
21 | return str(s)
22 |
23 | # ----------------------------------------------------------------------------
24 |
25 | @staticmethod
26 | def UnquotePlus(s):
27 | return UrlUtils.Unquote(str(s).replace("+", " "))
28 |
29 | # ============================================================================
30 | # ============================================================================
31 | # ============================================================================
32 |
--------------------------------------------------------------------------------
/lib/micro_web_srv_2/libs/xasync_sockets.py:
--------------------------------------------------------------------------------
1 | # The MIT License (MIT)
2 | # Copyright 2019 Jean-Christophe Bos & HC2 (www.hc2.fr)
3 |
4 |
5 | import sys
6 | from time import ticks_ms, ticks_diff, ticks_add
7 |
8 | import logging
9 |
10 |
11 | _logger = logging.getLogger("xasync")
12 |
13 |
14 | # ============================================================================
15 | # ===( XClosedReason )========================================================
16 | # ============================================================================
17 |
18 |
19 | class XClosedReason:
20 | Error = 0x00
21 | ClosedByHost = 0x01
22 | ClosedByPeer = 0x02
23 | Timeout = 0x03
24 | Detached = 0x04
25 |
26 |
27 | # ============================================================================
28 | # ===( XAsyncSocket )=========================================================
29 | # ============================================================================
30 |
31 |
32 | class XAsyncSocketException(Exception):
33 | pass
34 |
35 |
36 | class XAsyncSocket:
37 | def __init__(self, asyncSocketsPool, socket, recvBufSlot=None, sendBufSlot=None):
38 | if type(self) is XAsyncSocket:
39 | raise XAsyncSocketException(
40 | "XAsyncSocket is an abstract class and must be implemented."
41 | )
42 | self._asyncSocketsPool = asyncSocketsPool
43 | self._socket = socket
44 | self._recvBufSlot = recvBufSlot
45 | self._sendBufSlot = sendBufSlot
46 | self._expire_time_millis = None
47 | self._onClosed = None
48 | try:
49 | socket.settimeout(0)
50 | socket.setblocking(0)
51 | if (recvBufSlot is not None and type(recvBufSlot) is not XBufferSlot) or (
52 | sendBufSlot is not None and type(sendBufSlot) is not XBufferSlot
53 | ):
54 | raise Exception()
55 | asyncSocketsPool.AddAsyncSocket(self)
56 | except Exception as e:
57 | sys.print_exception(e)
58 | raise XAsyncSocketException("XAsyncSocket : Arguments are incorrects.")
59 |
60 | # ------------------------------------------------------------------------
61 |
62 | def _setExpireTimeout(self, timeoutSec):
63 | try:
64 | if timeoutSec and timeoutSec > 0:
65 | self._expire_time_millis = ticks_add(ticks_ms(), timeoutSec * 1000)
66 | except:
67 | raise XAsyncSocketException(
68 | '"timeoutSec" is incorrect to set expire timeout.'
69 | )
70 |
71 | # ------------------------------------------------------------------------
72 |
73 | def _removeExpireTimeout(self):
74 | self._expire_time_millis = None
75 |
76 | # ------------------------------------------------------------------------
77 |
78 | # A subclass can choose to do something on socket expiration.
79 | def _expired(self):
80 | pass
81 |
82 | # ------------------------------------------------------------------------
83 |
84 | def pump_expire(self):
85 | if self._expire_time_millis:
86 | diff = ticks_diff(ticks_ms(), self._expire_time_millis)
87 | if diff > 0:
88 | self._expired()
89 | self._close(XClosedReason.Timeout)
90 |
91 | # ------------------------------------------------------------------------
92 |
93 | def detach_socket(self):
94 | socket = self._socket
95 | if not self._close(XClosedReason.Detached, do_close=False):
96 | raise XAsyncSocketException("Failed to detach socket")
97 | return socket
98 |
99 | def _close(
100 | self, closedReason=XClosedReason.Error, triggerOnClosed=True, do_close=True
101 | ):
102 | if self._asyncSocketsPool.RemoveAsyncSocket(self):
103 | try:
104 | if do_close:
105 | self._socket.close()
106 | except Exception as e:
107 | sys.print_exception(e)
108 | self._socket = None
109 | if self._recvBufSlot is not None:
110 | self._recvBufSlot = None
111 | if self._sendBufSlot is not None:
112 | self._sendBufSlot = None
113 | if triggerOnClosed and self._onClosed:
114 | try:
115 | self._onClosed(self, closedReason)
116 | except Exception as ex:
117 | raise XAsyncSocketException(
118 | 'Error when handling the "OnClose" event : %s' % ex
119 | )
120 | return True
121 | return False
122 |
123 | # ------------------------------------------------------------------------
124 |
125 | def GetSocketObj(self):
126 | return self._socket
127 |
128 | # ------------------------------------------------------------------------
129 |
130 | def Close(self):
131 | return self._close(XClosedReason.ClosedByHost)
132 |
133 | # ------------------------------------------------------------------------
134 |
135 | def OnReadyForReading(self):
136 | pass
137 |
138 | # ------------------------------------------------------------------------
139 |
140 | def OnReadyForWriting(self):
141 | pass
142 |
143 | # ------------------------------------------------------------------------
144 |
145 | def OnExceptionalCondition(self):
146 | self._close()
147 |
148 | # ------------------------------------------------------------------------
149 |
150 | @property
151 | def ExpireTimeSec(self):
152 | return self._expire_time_millis / 1000
153 |
154 | @property
155 | def OnClosed(self):
156 | return self._onClosed
157 |
158 | @OnClosed.setter
159 | def OnClosed(self, value):
160 | self._onClosed = value
161 |
162 |
163 | # ============================================================================
164 | # ===( XAsyncTCPClient )======================================================
165 | # ============================================================================
166 |
167 |
168 | class XAsyncTCPClientException(Exception):
169 | pass
170 |
171 |
172 | class XAsyncTCPClient(XAsyncSocket):
173 | def __init__(self, asyncSocketsPool, cliSocket, cliAddr, recvBufSlot, sendBufSlot):
174 | try:
175 | super().__init__(asyncSocketsPool, cliSocket, recvBufSlot, sendBufSlot)
176 | self._cliAddr = cliAddr if cliAddr else ("0.0.0.0", 0)
177 | self._onFailsToConnect = None
178 | self._onConnected = None
179 | self._onDataRecv = None
180 | self._onDataRecvArg = None
181 | self._onDataSent = None
182 | self._onDataSentArg = None
183 | self._sizeToRecv = None
184 | self._rdLinePos = None
185 | self._rdLineEncoding = None
186 | self._rdBufView = None
187 | self._wrBufView = None
188 | except Exception as e:
189 | sys.print_exception(e)
190 | raise XAsyncTCPClientException(
191 | "Error to creating XAsyncTCPClient, arguments are incorrects."
192 | )
193 |
194 | # ------------------------------------------------------------------------
195 |
196 | def _expired(self):
197 | # This actually happens regularly. It seems to be a speed trick used by browsers,
198 | # they open multiple concurrent connections in _anticipation_ of needing them for
199 | # additional requests, e.g. as a page loads. Some of these connections are never
200 | # used and this eventually triggers the expiration logic here.
201 | _logger.debug(
202 | "connection from %s:%s expired", self._cliAddr[0], self._cliAddr[1]
203 | )
204 |
205 | # ------------------------------------------------------------------------
206 |
207 | def Close(self):
208 | if self._wrBufView:
209 | try:
210 | self._socket.send(self._wrBufView)
211 | except Exception as e:
212 | sys.print_exception(e)
213 | return self._close(XClosedReason.ClosedByHost)
214 |
215 | # ------------------------------------------------------------------------
216 |
217 | def OnReadyForReading(self):
218 | while True:
219 | if self._rdLinePos is not None:
220 | # In the context of reading a line,
221 | while True:
222 | try:
223 | try:
224 | b = self._socket.recv(1)
225 | except BlockingIOError as bioErr:
226 | if bioErr.errno != 35:
227 | self._close()
228 | return
229 | except:
230 | self._close()
231 | return
232 | except:
233 | self._close()
234 | return
235 | if b:
236 | if b == b"\n":
237 | lineLen = self._rdLinePos
238 | self._rdLinePos = None
239 | self._asyncSocketsPool.NotifyNextReadyForReading(
240 | self, False
241 | )
242 | self._removeExpireTimeout()
243 | if self._onDataRecv:
244 | line = self._recvBufSlot.Buffer[:lineLen]
245 | try:
246 | line = bytes(line).decode(self._rdLineEncoding)
247 | except:
248 | line = None
249 | try:
250 | self._onDataRecv(self, line, self._onDataRecvArg)
251 | except Exception as ex:
252 | sys.print_exception(ex)
253 | raise XAsyncTCPClientException(
254 | 'Error when handling the "OnDataRecv" event : %s'
255 | % ex
256 | )
257 | return
258 | elif b != b"\r":
259 | if self._rdLinePos < self._recvBufSlot.Size:
260 | self._recvBufSlot.Buffer[self._rdLinePos] = ord(b)
261 | self._rdLinePos += 1
262 | else:
263 | self._close()
264 | return
265 | else:
266 | self._close(XClosedReason.ClosedByPeer)
267 | return
268 | elif self._sizeToRecv:
269 | # In the context of reading data,
270 | recvBuf = self._rdBufView[-self._sizeToRecv :]
271 | try:
272 | try:
273 | n = self._socket.recv_into(recvBuf)
274 | except BlockingIOError as bioErr:
275 | if bioErr.errno != 35:
276 | self._close()
277 | return
278 | except:
279 | self._close()
280 | return
281 | except:
282 | try:
283 | n = self._socket.readinto(recvBuf)
284 | except:
285 | self._close()
286 | return
287 | if not n:
288 | self._close(XClosedReason.ClosedByPeer)
289 | return
290 | self._sizeToRecv -= n
291 | if not self._sizeToRecv:
292 | data = self._rdBufView
293 | self._rdBufView = None
294 | self._asyncSocketsPool.NotifyNextReadyForReading(self, False)
295 | self._removeExpireTimeout()
296 | if self._onDataRecv:
297 | try:
298 | self._onDataRecv(self, data, self._onDataRecvArg)
299 | except Exception as ex:
300 | raise XAsyncTCPClientException(
301 | 'Error when handling the "OnDataRecv" event : %s' % ex
302 | )
303 | return
304 | else:
305 | return
306 |
307 | # ------------------------------------------------------------------------
308 |
309 | def OnReadyForWriting(self):
310 | if self._wrBufView:
311 | try:
312 | n = self._socket.send(self._wrBufView)
313 | except:
314 | return
315 | self._wrBufView = self._wrBufView[n:]
316 | if not self._wrBufView:
317 | self._asyncSocketsPool.NotifyNextReadyForWriting(self, False)
318 | if self._onDataSent:
319 | try:
320 | self._onDataSent(self, self._onDataSentArg)
321 | except Exception as ex:
322 | raise XAsyncTCPClientException(
323 | 'Error when handling the "OnDataSent" event : %s' % ex
324 | )
325 |
326 | # ------------------------------------------------------------------------
327 |
328 | def AsyncRecvLine(
329 | self, lineEncoding="UTF-8", onLineRecv=None, onLineRecvArg=None, timeoutSec=None
330 | ):
331 | if self._rdLinePos is not None or self._sizeToRecv:
332 | raise XAsyncTCPClientException(
333 | "AsyncRecvLine : Already waiting asynchronous receive."
334 | )
335 | if self._socket:
336 | self._setExpireTimeout(timeoutSec)
337 | self._rdLinePos = 0
338 | self._rdLineEncoding = lineEncoding
339 | self._onDataRecv = onLineRecv
340 | self._onDataRecvArg = onLineRecvArg
341 | self._asyncSocketsPool.NotifyNextReadyForReading(self, True)
342 | return True
343 | return False
344 |
345 | # ------------------------------------------------------------------------
346 |
347 | def AsyncRecvData(
348 | self, size=None, onDataRecv=None, onDataRecvArg=None, timeoutSec=None
349 | ):
350 | if self._rdLinePos is not None or self._sizeToRecv:
351 | raise XAsyncTCPClientException(
352 | "AsyncRecvData : Already waiting asynchronous receive."
353 | )
354 | if self._socket:
355 | if size is None:
356 | size = self._recvBufSlot.Size
357 | elif not isinstance(size, int) or size <= 0:
358 | raise XAsyncTCPClientException('AsyncRecvData : "size" is incorrect.')
359 | if size <= self._recvBufSlot.Size:
360 | self._rdBufView = memoryview(self._recvBufSlot.Buffer)[:size]
361 | else:
362 | try:
363 | self._rdBufView = memoryview(bytearray(size))
364 | except:
365 | raise XAsyncTCPClientException(
366 | "AsyncRecvData : No enought memory to receive %s bytes." % size
367 | )
368 | self._setExpireTimeout(timeoutSec)
369 | self._sizeToRecv = size
370 | self._onDataRecv = onDataRecv
371 | self._onDataRecvArg = onDataRecvArg
372 | self._asyncSocketsPool.NotifyNextReadyForReading(self, True)
373 | return True
374 | return False
375 |
376 | # ------------------------------------------------------------------------
377 |
378 | def AsyncSendData(self, data, onDataSent=None, onDataSentArg=None):
379 | if self._socket:
380 | try:
381 | if bytes([data[0]]):
382 | if self._wrBufView:
383 | self._wrBufView = memoryview(bytes(self._wrBufView) + data)
384 | else:
385 | self._wrBufView = memoryview(data)
386 | self._onDataSent = onDataSent
387 | self._onDataSentArg = onDataSentArg
388 | self._asyncSocketsPool.NotifyNextReadyForWriting(self, True)
389 | return True
390 | except Exception as e:
391 | sys.print_exception(e)
392 | raise XAsyncTCPClientException('AsyncSendData : "data" is incorrect.')
393 | return False
394 |
395 | # ------------------------------------------------------------------------
396 |
397 | def AsyncSendSendingBuffer(self, size=None, onDataSent=None, onDataSentArg=None):
398 | if self._wrBufView:
399 | raise XAsyncTCPClientException(
400 | "AsyncSendBufferSlot : Already waiting to send data."
401 | )
402 | if self._socket:
403 | if size is None:
404 | size = self._sendBufSlot.Size
405 | if size > 0 and size <= self._sendBufSlot.Size:
406 | self._wrBufView = memoryview(self._sendBufSlot.Buffer)[:size]
407 | self._onDataSent = onDataSent
408 | self._onDataSentArg = onDataSentArg
409 | self._asyncSocketsPool.NotifyNextReadyForWriting(self, True)
410 | return True
411 | return False
412 |
413 | # ------------------------------------------------------------------------
414 |
415 | @property
416 | def CliAddr(self):
417 | return self._cliAddr
418 |
419 | @property
420 | def SendingBuffer(self):
421 | return self._sendBufSlot.Buffer
422 |
423 | @property
424 | def OnFailsToConnect(self):
425 | return self._onFailsToConnect
426 |
427 | @OnFailsToConnect.setter
428 | def OnFailsToConnect(self, value):
429 | self._onFailsToConnect = value
430 |
431 | @property
432 | def OnConnected(self):
433 | return self._onConnected
434 |
435 | @OnConnected.setter
436 | def OnConnected(self, value):
437 | self._onConnected = value
438 |
439 |
440 | # ============================================================================
441 | # ===( XBufferSlot )==========================================================
442 | # ============================================================================
443 |
444 |
445 | class XBufferSlot:
446 | def __init__(self, size):
447 | self._size = size
448 | self._buffer = bytearray(size)
449 |
450 | @property
451 | def Size(self):
452 | return self._size
453 |
454 | @property
455 | def Buffer(self):
456 | return self._buffer
457 |
458 |
459 | # ============================================================================
460 | # ============================================================================
461 | # ============================================================================
462 |
--------------------------------------------------------------------------------
/lib/micro_web_srv_2/status-code.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Status code {code}
6 |
7 |
8 | Status code [{code}] {reason}
9 |
10 |
--------------------------------------------------------------------------------
/lib/schedule.py:
--------------------------------------------------------------------------------
1 | # Python job scheduling for humans.
2 | #
3 | # An in-process scheduler for periodic jobs that uses the builder pattern
4 | # for configuration. Schedule lets you run Python functions (or any other
5 | # callable) periodically at pre-determined intervals using a simple,
6 | # human-friendly syntax.
7 | #
8 | # Inspired by Addam Wiggins' article "Rethinking Cron" [1] and the
9 | # "clockwork" Ruby module [2][3].
10 | #
11 | # Features:
12 | # - A simple to use API for scheduling jobs.
13 | # - Very lightweight and no external dependencies.
14 | # - Excellent test coverage.
15 | # - Works with Python 2.7 and 3.3
16 | #
17 | # Usage:
18 | # >>> import schedule
19 | # >>> import time
20 | #
21 | # >>> def job(message='stuff'):
22 | # >>> print("I'm working on:", message)
23 | #
24 | # >>> schedule.every(10).seconds.do(job)
25 | #
26 | # >>> while True:
27 | # >>> schedule.run_pending()
28 | # >>> time.sleep(1)
29 | #
30 | # [1] http://adam.heroku.com/past/2010/4/13/rethinking_cron/
31 | # [2] https://github.com/tomykaira/clockwork
32 | # [3] http://adam.heroku.com/past/2010/6/30/replace_cron_with_clockwork/
33 | import logging
34 | import time
35 |
36 | logger = logging.getLogger("schedule")
37 |
38 |
39 | def now():
40 | return time.time()
41 |
42 |
43 | CancelJob = object()
44 |
45 |
46 | class Scheduler(object):
47 | def __init__(self):
48 | self.jobs = []
49 |
50 | def run_pending(self):
51 | # Run all jobs that are scheduled to run.
52 | #
53 | # Please note that it is *intended behavior that tick() does not
54 | # run missed jobs*. For example, if you've registered a job that
55 | # should run every minute and you only call tick() in one hour
56 | # increments then your job won't be run 60 times in between but
57 | # only once.
58 | runnable_jobs = (job for job in self.jobs if job.should_run)
59 | for job in sorted(runnable_jobs):
60 | self._run_job(job)
61 |
62 | def run_all(self):
63 | # Run all jobs regardless if they are scheduled to run or not.
64 | logger.info("Running *all* %i jobs", len(self.jobs))
65 | for job in self.jobs:
66 | self._run_job(job)
67 |
68 | def clear(self):
69 | # Deletes all scheduled jobs.
70 | del self.jobs[:]
71 |
72 | def cancel_job(self, job):
73 | # Delete a scheduled job.
74 | try:
75 | self.jobs.remove(job)
76 | except ValueError:
77 | pass
78 |
79 | def every(self, interval=1):
80 | # Schedule a new periodic job.
81 | job = Job(interval)
82 | self.jobs.append(job)
83 | return job
84 |
85 | def _run_job(self, job):
86 | ret = job.run()
87 | if ret is CancelJob:
88 | self.cancel_job(job)
89 |
90 | @property
91 | def next_run(self):
92 | # Datetime when the next job should run.
93 | if not self.jobs:
94 | return None
95 | return min(self.jobs).next_run
96 |
97 | @property
98 | def idle_seconds(self):
99 | # Number of seconds until `next_run`.
100 | return self.next_run - now()
101 |
102 |
103 | class Job(object):
104 | # A periodic job as used by `Scheduler`.
105 |
106 | def __init__(self, interval):
107 | self.interval = interval # pause interval
108 | self.job_func = None # the job job_func to run
109 | self.last_run = None # time of the last run
110 | self.next_run = None # time of the next run
111 |
112 | def __lt__(self, other):
113 | # PeriodicJobs are sortable based on the scheduled time
114 | # they run next.
115 | return self.next_run < other.next_run
116 |
117 | @property
118 | def seconds(self):
119 | return self
120 |
121 | def do(self, job_func):
122 | # Specifies the job_func that should be called every time the
123 | # job runs.
124 | self.job_func = job_func
125 | self._schedule_next_run()
126 | return self
127 |
128 | @property
129 | def should_run(self):
130 | # True if the job should be run now.
131 | return now() >= self.next_run
132 |
133 | def run(self):
134 | # Run the job and immediately reschedule it.
135 | logger.debug("Running job %s", self)
136 | ret = self.job_func()
137 | self.last_run = now()
138 | self._schedule_next_run()
139 | return ret
140 |
141 | def _schedule_next_run(self):
142 | # Compute the instant when this job should run next.
143 | self.next_run = now() + self.interval
144 |
--------------------------------------------------------------------------------
/lib/shim.py:
--------------------------------------------------------------------------------
1 | from os import stat
2 |
3 |
4 | # This file contains functions and constants that exist in CPython but don't exist in MicroPython 1.12.
5 |
6 |
7 | # os.stat.S_IFDIR.
8 | S_IFDIR = 1 << 14
9 |
10 |
11 | # os.path.exists.
12 | def exists(path):
13 | try:
14 | stat(path)
15 | return True
16 | except:
17 | return False
18 |
19 |
20 | # os.path.isdir.
21 | def isdir(path):
22 | return exists(path) and stat(path)[0] & S_IFDIR != 0
23 |
24 |
25 | # pathlib.Path.read_text.
26 | def read_text(filename):
27 | with open(filename, "r") as file:
28 | return file.read()
29 |
30 |
31 | # Note: `join`, `split` and `dirname` were copied from from https://github.com/micropython/micropython-lib/blob/master/os.path/os/path.py
32 |
33 |
34 | # os.path.join.
35 | def join(*args):
36 | # TODO: this is non-compliant
37 | if type(args[0]) is bytes:
38 | return b"/".join(args)
39 | else:
40 | return "/".join(args)
41 |
42 |
43 | # os.path.split.
44 | def split(path):
45 | if path == "":
46 | return "", ""
47 | r = path.rsplit("/", 1)
48 | if len(r) == 1:
49 | return "", path
50 | head = r[0] # .rstrip("/")
51 | if not head:
52 | head = "/"
53 | return head, r[1]
54 |
55 |
56 | # os.path.dirname.
57 | def dirname(path):
58 | return split(path)[0]
59 |
--------------------------------------------------------------------------------
/lib/slim/fileserver_module.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from shim import isdir, exists
4 |
5 |
6 | _logger = logging.getLogger("fileserver_module")
7 |
8 |
9 | class FileserverModule:
10 | _DEFAULT_PAGE = "index.html"
11 |
12 | def __init__(self, mime_types, root="www"):
13 | self._mime_types = mime_types
14 | self._root = root
15 |
16 | def OnRequest(self, request):
17 | if request.IsUpgrade or request.Method not in ("GET", "HEAD"):
18 | return
19 |
20 | (filename, compressed) = self._resolve_physical_path(request.Path)
21 | if filename:
22 | ct = self._get_mime_type_from_filename(filename)
23 | if ct:
24 | request.Response.AllowCaching = True
25 | request.Response.ContentType = ct
26 |
27 | if compressed:
28 | request.Response.SetHeader("Content-Encoding", "gzip")
29 | filename = compressed
30 | request.Response.ReturnFile(filename)
31 | else:
32 | _logger.warning("no MIME type for %s", filename)
33 | request.Response.ReturnForbidden()
34 | else:
35 | request.Response.ReturnNotFound()
36 |
37 | def _resolve_physical_path(self, url_path):
38 | if ".." in url_path:
39 | return None, None # Disallow trying to escape the root.
40 |
41 | if url_path.endswith("/"):
42 | url_path = url_path[:-1]
43 | path = self._root + url_path
44 |
45 | if isdir(path):
46 | path = path + "/" + self._DEFAULT_PAGE
47 |
48 | if exists(path):
49 | return path, None
50 |
51 | compressed = path + ".gz"
52 |
53 | # The tuple parentheses aren't optional here.
54 | return (path, compressed) if exists(compressed) else (None, None)
55 |
56 | def _get_mime_type_from_filename(self, filename):
57 | def ext(name):
58 | partition = name.rpartition(".")
59 | return None if partition[0] == "" else partition[2].lower()
60 |
61 | return self._mime_types.get(ext(filename), None)
62 |
--------------------------------------------------------------------------------
/lib/slim/single_socket_pool.py:
--------------------------------------------------------------------------------
1 | import select
2 |
3 |
4 | # Even without threading we _could_ handle multiple sockets concurrently.
5 | # However this socket pool handles only a single socket at a time in order to avoid the
6 | # memory overhead of needing more than one send and receive XBufferSlot at a time.
7 | class SingleSocketPool:
8 | def __init__(self, poller):
9 | self._poller = poller
10 | self._async_socket = None
11 | self._mask = 0
12 |
13 | def AddAsyncSocket(self, async_socket):
14 | assert self._async_socket is None, "previous socket has not yet been removed"
15 | self._mask = select.POLLERR | select.POLLHUP
16 | self._async_socket = async_socket
17 |
18 | def RemoveAsyncSocket(self, async_socket):
19 | self._check(async_socket)
20 | self._poller.unregister(self._async_socket.GetSocketObj())
21 | self._async_socket = None
22 | return True # Caller XAsyncSocket._close will close the underlying socket.
23 |
24 | def NotifyNextReadyForReading(self, async_socket, notify):
25 | self._check(async_socket)
26 | self._update(select.POLLIN, notify)
27 |
28 | def NotifyNextReadyForWriting(self, async_socket, notify):
29 | self._check(async_socket)
30 | self._update(select.POLLOUT, notify)
31 |
32 | def _update(self, event, set):
33 | if set:
34 | self._mask |= event
35 | else:
36 | self._mask &= ~event
37 | self._poller.register(self._async_socket.GetSocketObj(), self._mask)
38 |
39 | def _check(self, async_socket):
40 | assert self._async_socket == async_socket, "unexpected socket"
41 |
42 | def has_async_socket(self):
43 | return self._async_socket is not None
44 |
45 | def pump(self, s, event):
46 | if s != self._async_socket.GetSocketObj():
47 | return
48 |
49 | if event & select.POLLIN:
50 | event &= ~select.POLLIN
51 | self._async_socket.OnReadyForReading()
52 |
53 | if event & select.POLLOUT:
54 | event &= ~select.POLLOUT
55 | self._async_socket.OnReadyForWriting()
56 |
57 | # If there are still bits left in event...
58 | if event:
59 | self._async_socket.OnExceptionalCondition()
60 |
61 | def pump_expire(self):
62 | if self._async_socket:
63 | self._async_socket.pump_expire()
64 |
--------------------------------------------------------------------------------
/lib/slim/slim_config.py:
--------------------------------------------------------------------------------
1 | class SlimConfig:
2 | _DEFAULT_TIMEOUT = 4 # 4 seconds - 2 seconds is too low for some mobile browsers.
3 |
4 | def __init__(
5 | self,
6 | timeout_sec=_DEFAULT_TIMEOUT,
7 | allow_all_origins=False,
8 | not_found_url=None,
9 | server_name="Slim Server (MicroPython)",
10 | ):
11 | self.timeout_sec = timeout_sec
12 | self.allow_all_origins = allow_all_origins
13 | self.not_found_url = not_found_url
14 | self.server_name = server_name
15 |
--------------------------------------------------------------------------------
/lib/slim/slim_server.py:
--------------------------------------------------------------------------------
1 | import select
2 | import socket
3 | import logging
4 |
5 | from micro_web_srv_2.http_request import HttpRequest
6 | from micro_web_srv_2.libs.xasync_sockets import XBufferSlot, XAsyncTCPClient
7 | from slim.single_socket_pool import SingleSocketPool
8 | from slim.slim_config import SlimConfig
9 |
10 | _logger = logging.getLogger("server")
11 |
12 |
13 | class SlimServer:
14 | RESPONSE_PENDING = object()
15 |
16 | # The backlog argument to `listen` isn't optional for the ESP32 port.
17 | # Internally any passed in backlog value is clipped to a maximum of 255.
18 | _LISTEN_MAX = 255
19 |
20 | # Slot size from MicroWebSrv2.SetEmbeddedConfig.
21 | _SLOT_SIZE = 1024
22 |
23 | # Python uses "" to refer to INADDR_ANY, i.e. all interfaces.
24 | def __init__(self, poller, address="", port=80, config=SlimConfig()):
25 | self._config = config
26 | self._server_socket = self._create_server_socket(address, port)
27 |
28 | poller.register(
29 | self._server_socket, select.POLLIN | select.POLLERR | select.POLLHUP
30 | )
31 |
32 | self._socket_pool = SingleSocketPool(poller)
33 |
34 | self._modules = []
35 | self._recv_buf_slot = XBufferSlot(self._SLOT_SIZE)
36 | self._send_buf_slot = XBufferSlot(self._SLOT_SIZE)
37 |
38 | def shutdown(self, poller):
39 | poller.unregister(self._server_socket)
40 | self._server_socket.close()
41 |
42 | def add_module(self, instance):
43 | self._modules.append(instance)
44 |
45 | def _process_request_modules(self, request):
46 | for modInstance in self._modules:
47 | try:
48 | r = modInstance.OnRequest(request)
49 | if r is self.RESPONSE_PENDING or request.Response.HeadersSent:
50 | return
51 | except Exception as ex:
52 | name = type(modInstance).__name__
53 | _logger.error(
54 | 'Exception in request handler of module "%s" (%s).', name, ex
55 | )
56 |
57 | request.Response.ReturnNotImplemented()
58 |
59 | def _create_server_socket(self, address, port):
60 | server_socket = socket.socket()
61 |
62 | server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
63 | server_socket.bind((address, port))
64 | server_socket.listen(self._LISTEN_MAX)
65 |
66 | return server_socket
67 |
68 | def pump(self, s, event):
69 | # If not already processing a request, see if a new request has come in.
70 | if not self._socket_pool.has_async_socket():
71 | if s != self._server_socket:
72 | return
73 |
74 | if event != select.POLLIN:
75 | raise Exception("unexpected event {} on server socket".format(event))
76 |
77 | client_socket, client_address = self._server_socket.accept()
78 |
79 | # XAsyncTCPClient adds itself to _socket_pool (via the ctor of its parent XAsyncSocket).
80 | tcp_client = XAsyncTCPClient(
81 | self._socket_pool,
82 | client_socket,
83 | client_address,
84 | self._recv_buf_slot,
85 | self._send_buf_slot,
86 | )
87 | # HttpRequest registers itself to receive data via tcp_client and once
88 | # it's read the request, it calls the given process_request callback.
89 | HttpRequest(
90 | self._config, tcp_client, process_request=self._process_request_modules
91 | )
92 | else: # Else process the existing request.
93 | self._socket_pool.pump(s, event)
94 |
95 | def pump_expire(self):
96 | self._socket_pool.pump_expire()
97 |
--------------------------------------------------------------------------------
/lib/slim/web_route_module.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | import logging
4 |
5 | from slim.slim_server import SlimServer
6 |
7 |
8 | _logger = logging.getLogger("route_module")
9 |
10 |
11 | # MicroPython 1.12 doesn't have the enum module introduced in Python 3.4.
12 | class HttpMethod:
13 | GET = "GET"
14 | HEAD = "HEAD"
15 | POST = "POST"
16 | PUT = "PUT"
17 | DELETE = "DELETE"
18 | OPTIONS = "OPTIONS"
19 | PATCH = "PATCH"
20 |
21 |
22 | class RegisteredRoute:
23 | def __init__(self, method, routePath, handler):
24 | self._check_value("method", method, isinstance(method, str) and len(method) > 0)
25 | self._check_value(
26 | "routePath",
27 | routePath,
28 | isinstance(routePath, str) and routePath.startswith("/"),
29 | )
30 | method = method.upper()
31 | if len(routePath) > 1 and routePath.endswith("/"):
32 | routePath = routePath[:-1]
33 |
34 | self.Handler = handler
35 | self.Method = method
36 | self.RoutePath = routePath
37 |
38 | def _check_value(self, name, value, condition):
39 | if not condition:
40 | raise ValueError('{} is not a valid value for "{}"'.format(value, name))
41 |
42 |
43 | class WebRouteModule:
44 | _MAX_CONTENT_LEN = 16 * 1024 # Content len from MicroWebSrv2.SetEmbeddedConfig
45 |
46 | def __init__(self, routes, max_content_len=_MAX_CONTENT_LEN):
47 | self._max_content_len = max_content_len
48 | self._registeredRoutes = routes
49 |
50 | def OnRequest(self, request):
51 | route_result = self._resolve_route(request.Method, request.Path)
52 | if not route_result:
53 | return
54 |
55 | def route_request():
56 | self._route_request(request, route_result)
57 |
58 | cnt_len = request.ContentLength
59 | if not cnt_len:
60 | route_request()
61 | elif request.Method not in ("GET", "HEAD"):
62 | if cnt_len <= self._max_content_len:
63 | try:
64 | request.async_data_recv(size=cnt_len, on_content_recv=route_request)
65 | return SlimServer.RESPONSE_PENDING
66 | except:
67 | _logger.error(
68 | "not enough memory to read a content of %s bytes.", cnt_len
69 | )
70 | request.Response.ReturnServiceUnavailable()
71 | else:
72 | request.Response.ReturnEntityTooLarge()
73 | else:
74 | request.Response.ReturnBadRequest()
75 |
76 | def _route_request(self, request, route_result):
77 | try:
78 | route_result.Handler(request)
79 | if not request.Response.HeadersSent:
80 | _logger.warning("no response was sent from route %s.", route_result)
81 | request.Response.ReturnNotImplemented()
82 | except Exception as ex:
83 | sys.print_exception(ex)
84 | _logger.error("exception raised from route %s", route_result)
85 | request.Response.ReturnInternalServerError()
86 |
87 | def _resolve_route(self, method, path):
88 | path = path.lower()
89 | if len(path) > 1 and path.endswith("/"):
90 | path = path[:-1]
91 | for regRoute in self._registeredRoutes:
92 | if regRoute.Method == method and regRoute.RoutePath == path:
93 | return regRoute
94 | return None
95 |
--------------------------------------------------------------------------------
/lib/slim/ws_manager.py:
--------------------------------------------------------------------------------
1 | import select
2 | import socket
3 | import sys
4 | import websocket
5 |
6 | from hashlib import sha1
7 | from binascii import b2a_base64
8 |
9 | from logging import getLogger
10 |
11 |
12 | _logger = getLogger("ws_manager")
13 |
14 |
15 | class WsManager:
16 | _WS_SPEC_GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # See https://stackoverflow.com/a/13456048/245602
17 |
18 | def __init__(self, poller, message_extractor, message_handler):
19 | self._poller = poller
20 | self._message_extractor = message_extractor
21 | self._message_handler = message_handler
22 | self._clients = {}
23 |
24 | def pump_ws_clients(self, s, event):
25 | if not isinstance(s, socket.socket):
26 | return
27 | fileno = s.fileno()
28 | if fileno not in self._clients:
29 | return
30 | if event != select.POLLIN:
31 | _logger.warning("unexpected event {} on socket {}".format(event, fileno))
32 | self._remove_ws_client(fileno)
33 | return
34 | ws_client = self._clients[fileno]
35 | try:
36 | message = self._message_extractor(ws_client.ws.readinto)
37 | except Exception as e:
38 | sys.print_exception(e)
39 | self._remove_ws_client(fileno)
40 | return
41 | if message:
42 | print(message)
43 | self._message_handler(message)
44 |
45 | def _remove_ws_client(self, fileno):
46 | self._clients.pop(fileno).close(self._poller)
47 |
48 | def _add_ws_client(self, client_socket):
49 | ws_client = _WsClient(self._poller, client_socket)
50 | self._clients[ws_client.fileno] = ws_client
51 |
52 | def upgrade_connection(self, request):
53 | key = request.GetHeader("Sec-Websocket-Key")
54 | if not key:
55 | return False
56 |
57 | sha = sha1(key.encode())
58 | sha.update(self._WS_SPEC_GUID)
59 | accept = b2a_base64(sha.digest()).decode()[:-1]
60 | request.Response.SetHeader("Sec-WebSocket-Accept", accept)
61 | request.Response.SwitchingProtocols("websocket", self._add_ws_client)
62 | return True
63 |
64 |
65 | class _WsClient:
66 | def __init__(self, poller, client_socket):
67 | self._socket = client_socket
68 | self.ws = websocket.websocket(client_socket, True)
69 | self.fileno = client_socket.fileno()
70 | # poller.register doesn't complain if you register ws but it fails when you call ipoll.
71 | poller.register(client_socket, select.POLLIN | select.POLLERR | select.POLLHUP)
72 |
73 | def close(self, poller):
74 | poller.unregister(self._socket)
75 | try:
76 | self.ws.close()
77 | except: # noqa: E722
78 | pass
79 |
--------------------------------------------------------------------------------
/lib/wifi_setup/captive_portal.py:
--------------------------------------------------------------------------------
1 | # The compiler needs a lot of space to process the server classes etc. so
2 | # import them first before anything else starts to consume memory.
3 | from slim.slim_server import SlimServer
4 | from slim.slim_config import SlimConfig
5 | from slim.fileserver_module import FileserverModule
6 | from slim.web_route_module import WebRouteModule, RegisteredRoute, HttpMethod
7 | from micro_dns_srv import MicroDNSSrv
8 | from shim import join, dirname
9 |
10 | import network
11 | import select
12 | import logging
13 |
14 | from schedule import Scheduler, CancelJob
15 |
16 |
17 | _logger = logging.getLogger("captive_portal")
18 |
19 |
20 | # Rather than present a login page, this is a captive portal that lets you set up
21 | # access to your network. See docs/captive-portal.md for more about captive portals.
22 | class CaptivePortal:
23 | def run(self, essid, connect):
24 | self._schedule = Scheduler()
25 | self._connect = connect
26 | self._alive = True
27 | self._timeout_job = None
28 |
29 | self._ap = network.WLAN(network.AP_IF)
30 | self._ap.active(True)
31 | self._ap.config(essid=essid) # You can't set values before calling active(...).
32 |
33 | poller = select.poll()
34 |
35 | addr = self._ap.ifconfig()[0]
36 | slim_server = self._create_slim_server(poller, essid)
37 | dns = self._create_dns(poller, addr)
38 |
39 | _logger.info("captive portal web server and DNS started on %s", addr)
40 |
41 | # If no timeout is given `ipoll` blocks and the for-loop goes forever.
42 | # With a timeout the for-loop exits every time the timeout expires.
43 | # I.e. the underlying iterable reports that it has no more elements.
44 | while self._alive:
45 | # Under the covers polling is done with a non-blocking ioctl call and the timeout
46 | # (or blocking forever) is implemented with a hard loop, so there's nothing to be
47 | # gained (e.g. reduced power consumption) by using a timeout greater than 0.
48 | for (s, event) in poller.ipoll(0):
49 | # If event has bits other than POLLIN or POLLOUT then print it.
50 | if event & ~(select.POLLIN | select.POLLOUT):
51 | self._print_select_event(event)
52 | slim_server.pump(s, event)
53 | dns.pump(s, event)
54 |
55 | slim_server.pump_expire() # Expire inactive client sockets.
56 | self._schedule.run_pending()
57 |
58 | slim_server.shutdown(poller)
59 | dns.shutdown(poller)
60 |
61 | self._ap.active(False)
62 |
63 | def _create_slim_server(self, poller, essid):
64 | # See the captive portal notes in docs/captive-portal.md for why we redirect not-found
65 | # URLs and why we redirect them to an absolute URL (rather than a path like "/").
66 | # `essid` is used as the target host but any name could be used, e.g. "wifi-setup".
67 | config = SlimConfig(not_found_url="http://{}/".format(essid))
68 |
69 | slim_server = SlimServer(poller, config=config)
70 |
71 | # fmt: off
72 | slim_server.add_module(WebRouteModule([
73 | RegisteredRoute(HttpMethod.GET, "/api/access-points", self._request_access_points),
74 | RegisteredRoute(HttpMethod.POST, "/api/access-point", self._request_access_point),
75 | RegisteredRoute(HttpMethod.POST, "/api/alive", self._request_alive)
76 | ]))
77 | # fmt: on
78 |
79 | root = self._get_relative("www")
80 | # fmt: off
81 | slim_server.add_module(FileserverModule({
82 | "html": "text/html",
83 | "css": "text/css",
84 | "js": "application/javascript",
85 | "woff2": "font/woff2",
86 | "ico": "image/x-icon",
87 | "svg": "image/svg+xml"
88 | }, root))
89 | # fmt: on
90 |
91 | return slim_server
92 |
93 | # Find a file, given a path relative to the directory contain this `.py` file.
94 | @staticmethod
95 | def _get_relative(filename):
96 | return join(dirname(__file__), filename)
97 |
98 | @staticmethod
99 | def _create_dns(poller, addr):
100 | addr_bytes = MicroDNSSrv.ipV4StrToBytes(addr)
101 |
102 | def resolve(name):
103 | _logger.info("resolving %s", name)
104 | return addr_bytes
105 |
106 | return MicroDNSSrv(resolve, poller)
107 |
108 | def _request_access_points(self, request):
109 | # Tuples are of the form (SSID, BSSID, channel, RSSI, authmode, hidden).
110 | points = [(p[0], p[3], p[4]) for p in self._ap.scan()]
111 | request.Response.ReturnOkJSON(points)
112 |
113 | def _request_access_point(self, request):
114 | data = request.GetPostedURLEncodedForm()
115 | _logger.debug("connect request data %s", data)
116 | ssid = data.get("ssid", None)
117 | if not ssid:
118 | request.Response.ReturnBadRequest()
119 | return
120 |
121 | password = data.get("password", None)
122 |
123 | result = self._connect(ssid, password)
124 | if not result:
125 | request.Response.ReturnForbidden()
126 | else:
127 | request.Response.ReturnOkJSON({"message": result})
128 |
129 | def _request_alive(self, request):
130 | data = request.GetPostedURLEncodedForm()
131 | timeout = data.get("timeout", None)
132 | if not timeout:
133 | request.Response.ReturnBadRequest()
134 | return
135 |
136 | _logger.debug("timeout %s", timeout)
137 | timeout = int(timeout) + self._TOLERANCE
138 | if self._timeout_job:
139 | self._schedule.cancel_job(self._timeout_job)
140 | self._timeout_job = self._schedule.every(timeout).seconds.do(self._timed_out)
141 |
142 | request.Response.Return(self._NO_CONTENT)
143 |
144 | # If a client specifies a keep-alive period of Xs then they must ping again within Xs plus a fixed "tolerance".
145 | _TOLERANCE = 1
146 | _NO_CONTENT = 204
147 |
148 | def _timed_out(self):
149 | _logger.info("keep-alive timeout expired.")
150 | self._alive = False
151 | self._timeout_job = None
152 | return CancelJob # Tell scheduler that we want one-shot behavior.
153 |
154 | _POLL_EVENTS = {
155 | select.POLLIN: "IN",
156 | select.POLLOUT: "OUT",
157 | select.POLLHUP: "HUP",
158 | select.POLLERR: "ERR",
159 | }
160 |
161 | def _print_select_event(self, event):
162 | mask = 1
163 | while event:
164 | if event & 1:
165 | _logger.info("event %s", self._POLL_EVENTS.get(mask, mask))
166 | event >>= 1
167 | mask <<= 1
168 |
--------------------------------------------------------------------------------
/lib/wifi_setup/credentials.py:
--------------------------------------------------------------------------------
1 | import errno
2 | import btree
3 | import logging
4 |
5 | _logger = logging.getLogger("credentials")
6 |
7 |
8 | # Credentials uses `btree` to store and retrieve data. In retrospect it would
9 | # probably have been at least as easy to just write and read it as JSON.
10 | class Credentials:
11 | _SSID = b"ssid"
12 | _PASSWORD = b"password"
13 | _CREDENTIALS = "credentials"
14 |
15 | def __init__(self, filename=_CREDENTIALS):
16 | self._filename = filename
17 |
18 | def get(self):
19 | def action(db):
20 | ssid = db.get(self._SSID)
21 | password = db.get(self._PASSWORD)
22 |
23 | return (ssid, password) if ssid else (None, None)
24 |
25 | return self._db_action(action)
26 |
27 | def put(self, ssid, password):
28 | def action(db):
29 | db[self._SSID] = ssid
30 | if password:
31 | db[self._PASSWORD] = password
32 | else:
33 | self._pop(db, self._PASSWORD, None)
34 |
35 | self._db_action(action)
36 |
37 | def clear(self):
38 | def action(db):
39 | self._pop(db, self._SSID, None)
40 | self._pop(db, self._PASSWORD, None)
41 |
42 | self._db_action(action)
43 |
44 | def _db_action(self, action):
45 | with self._access(self._filename) as f:
46 | db = btree.open(f) # Btree doesn't support `with`.
47 | try:
48 | return action(db)
49 | finally:
50 | # Note that closing the DB does a flush.
51 | db.close()
52 |
53 | # `btree` doesn't support the standard dictionary `pop`.
54 | @staticmethod
55 | def _pop(d, key, default):
56 | if key in d:
57 | r = d[key]
58 | del d[key]
59 | return r
60 | else:
61 | return default
62 |
63 | # Open or create a file in binary mode for updating.
64 | @staticmethod
65 | def _access(filename):
66 | # Python `open` mode characters are a little non-intuitive.
67 | # For details see https://docs.python.org/3/library/functions.html#open
68 | try:
69 | return open(filename, "r+b")
70 | except OSError as e:
71 | if e.args[0] != errno.ENOENT:
72 | raise e
73 | _logger.info("creating %s", filename)
74 | return open(filename, "w+b")
75 |
--------------------------------------------------------------------------------
/lib/wifi_setup/wifi_setup.py:
--------------------------------------------------------------------------------
1 | import network
2 | import time
3 | import logging
4 |
5 | from wifi_setup.credentials import Credentials
6 |
7 |
8 | _logger = logging.getLogger("wifi_setup")
9 |
10 |
11 | class WiFiSetup:
12 | # My ESP32 takes about 2 seconds to join, so 8s is a long timeout.
13 | _CONNECT_TIMEOUT = 8000
14 |
15 | # The default `message` function returns the device's IP address but
16 | # one could provide a function that e.g. returned an MQTT topic ID.
17 | def __init__(self, essid, message=None):
18 | self._essid = essid
19 | # You can't use a static method as a default argument
20 | # https://stackoverflow.com/a/21672157/245602
21 | self._message = message if message else self._default_message
22 |
23 | self._credentials = Credentials()
24 | self._sta = network.WLAN(network.STA_IF)
25 | self._sta.active(True)
26 |
27 | def has_ssid(self):
28 | return self._credentials.get()[0] is not None
29 |
30 | def connect(self):
31 | ssid, password = self._credentials.get()
32 |
33 | return self._sta if ssid and self._connect(ssid, password) else None
34 |
35 | def setup(self):
36 | from wifi_setup.captive_portal import CaptivePortal
37 |
38 | # `run` will only return once WiFi is setup.
39 | CaptivePortal().run(self._essid, self._connect_new)
40 |
41 | return self._sta
42 |
43 | def connect_or_setup(self):
44 | if not self.connect():
45 | self.setup()
46 |
47 | return self._sta
48 |
49 | @staticmethod
50 | def clear():
51 | Credentials().clear()
52 |
53 | def _connect_new(self, ssid, password):
54 | if not self._connect(ssid, password):
55 | return None
56 |
57 | self._credentials.put(ssid, password)
58 | return self._message(self._sta)
59 |
60 | @staticmethod
61 | def _default_message(sta):
62 | return sta.ifconfig()[0]
63 |
64 | def _connect(self, ssid, password):
65 | _logger.info("attempting to connect to %s", ssid)
66 |
67 | # Now use the ESSID, i.e. the temporary access point name, as the device
68 | # hostname when making the DHCP request. MicroPython will then also
69 | # advertise this name using mDNS and you should be able to access the
70 | # device as .local.
71 | self._sta.config(dhcp_hostname=self._essid)
72 |
73 | # Password may be none if the network is open.
74 | self._sta.connect(ssid, password)
75 |
76 | if not self._sync_wlan_connect(self._sta):
77 | _logger.error("failed to connect to %s", ssid)
78 | return False
79 |
80 | _logger.info("connected to %s with address %s", ssid, self._sta.ifconfig()[0])
81 | return True
82 |
83 | # I had hoped I could use wlan.status() to e.g. report if the password was wrong.
84 | # But with MicroPython 1.12 (and my Ubiquiti UniFi AP AC-PRO) wlan.status() doesn't prove very useful.
85 | # See https://forum.micropython.org/viewtopic.php?f=18&t=7942
86 | @staticmethod
87 | def _sync_wlan_connect(wlan, timeout=_CONNECT_TIMEOUT):
88 | start = time.ticks_ms()
89 | while True:
90 | if wlan.isconnected():
91 | return True
92 | diff = time.ticks_diff(time.ticks_ms(), start)
93 | if diff > timeout:
94 | wlan.disconnect()
95 | return False
96 |
--------------------------------------------------------------------------------
/lib/wifi_setup/www/3rdpartylicenses.txt.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/3rdpartylicenses.txt.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/assets/css/typeface-roboto.css.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/assets/css/typeface-roboto.css.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/assets/fonts/roboto-latin-300.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/assets/fonts/roboto-latin-300.woff2
--------------------------------------------------------------------------------
/lib/wifi_setup/www/assets/fonts/roboto-latin-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/assets/fonts/roboto-latin-400.woff2
--------------------------------------------------------------------------------
/lib/wifi_setup/www/assets/fonts/roboto-latin-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/assets/fonts/roboto-latin-500.woff2
--------------------------------------------------------------------------------
/lib/wifi_setup/www/assets/svg/icons.svg.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/assets/svg/icons.svg.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/favicon.ico
--------------------------------------------------------------------------------
/lib/wifi_setup/www/index.html.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/index.html.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/main.33f601bdb3a63fce9f7e.js.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/main.33f601bdb3a63fce9f7e.js.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/polyfills.16f58c72a526f06bcd0f.js.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/polyfills.16f58c72a526f06bcd0f.js.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/runtime.7eddf4ffee702f67d455.js.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/runtime.7eddf4ffee702f67d455.js.gz
--------------------------------------------------------------------------------
/lib/wifi_setup/www/styles.e28960cc817e73558aa2.css.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/lib/wifi_setup/www/styles.e28960cc817e73558aa2.css.gz
--------------------------------------------------------------------------------
/main.py:
--------------------------------------------------------------------------------
1 | from wifi_setup.wifi_setup import WiFiSetup
2 |
3 | # You should give every device a unique name (to use as its access point name).
4 | ws = WiFiSetup("ding-5cd80b3")
5 | sta = ws.connect_or_setup()
6 | del ws
7 | print("WiFi is setup")
8 |
--------------------------------------------------------------------------------
/update-lib-www:
--------------------------------------------------------------------------------
1 | #!/bin/bash -e
2 |
3 | www=lib/wifi_setup/www
4 | material=../material-wifi-setup
5 |
6 | function fail {
7 | echo "Error: $1" 1>&2
8 | exit 1
9 | }
10 |
11 | # Check that material-wifi-setup been checked out and that @angular/cli been installed.
12 | [ -d $material ] || fail "expected to find material-wifi-setup checked out in $material"
13 | hash ng 2>/dev/null || fail '@angular/cli is not installed'
14 |
15 | # Make sure we don't destroy any local changes.
16 | [[ -z $(git status --short -- $www) ]] || fail "$www contains uncommitted changes"
17 |
18 | cd $material
19 |
20 | # Rebuild the distribution.
21 | rm -rf dist
22 | ng build --prod
23 |
24 | cd - > /dev/null
25 |
26 | # Move over the rebuilt distribution.
27 | git rm -q -r $www
28 | mv $material/dist/wifi-setup $www
29 |
30 | before=( $(du -hs $www) )
31 |
32 | # Search for files that are at least 1KiB.
33 | for file in $(find $www -type f -size +1k)
34 | do
35 | # Without `--no-name`, gzip includes a timestamp meaning zipping the
36 | # same file time twice results in results that look different to git.
37 | gzip --best --no-name $file
38 |
39 | # If the gzip makes little difference undo the compression.
40 | pct=$(gzip --list $file | sed -n 's/.*\s\([0-9.-]\+\)%\s.*/\1/p')
41 | if (( $(echo "$pct < 5" | bc -l ) ))
42 | then
43 | gunzip $file
44 | fi
45 | done
46 |
47 | after=( $(du -hs $www) )
48 |
49 | echo "Info: reduced size of www from ${before[0]} to ${after[0]}"
50 |
51 | git add $www
52 | echo "Info: any changes are now ready to be committed"
53 |
--------------------------------------------------------------------------------