├── .gitignore ├── LICENSE ├── README.md ├── TODO.md ├── demo ├── main.py └── www │ └── index.html.gz ├── docs ├── NOTES.md ├── captive-portal.md ├── images │ ├── connectivitycheck.png │ ├── ding-5cd80b3.png │ ├── labeled-device.jpg │ ├── screenshot.png │ └── steps │ │ ├── 01-home-network.jpg │ │ ├── 02-wifi-settings.png │ │ ├── 03-device-advertising.png │ │ ├── 04-tap-to-sign-in.png │ │ ├── 05-select-network.png │ │ ├── 06-password.png │ │ ├── 07-password-hidden.png │ │ ├── 08-password-visible.png │ │ ├── 09-connecting.png │ │ ├── 10-connected.png │ │ ├── 11-shutting-down.png │ │ ├── 12-revert-to-home.png │ │ ├── 13-launcher.jpg │ │ ├── 14-chrome.png │ │ └── 15-ghost-demo.png ├── python-poetry.md ├── request-examples.md ├── screen-setup.md └── steps.md ├── lib ├── README.md ├── logging.py ├── micro_dns_srv.py ├── micro_web_srv_2 │ ├── http_request.py │ ├── http_response.py │ ├── libs │ │ ├── url_utils.py │ │ └── xasync_sockets.py │ └── status-code.html ├── schedule.py ├── shim.py ├── slim │ ├── fileserver_module.py │ ├── single_socket_pool.py │ ├── slim_config.py │ ├── slim_server.py │ ├── web_route_module.py │ └── ws_manager.py └── wifi_setup │ ├── captive_portal.py │ ├── credentials.py │ ├── wifi_setup.py │ └── www │ ├── 3rdpartylicenses.txt.gz │ ├── assets │ ├── css │ │ └── typeface-roboto.css.gz │ ├── fonts │ │ ├── roboto-latin-300.woff2 │ │ ├── roboto-latin-400.woff2 │ │ └── roboto-latin-500.woff2 │ └── svg │ │ └── icons.svg.gz │ ├── favicon.ico │ ├── index.html.gz │ ├── main.33f601bdb3a63fce9f7e.js.gz │ ├── polyfills.16f58c72a526f06bcd0f.js.gz │ ├── runtime.7eddf4ffee702f67d455.js.gz │ └── styles.e28960cc817e73558aa2.css.gz ├── main.py └── update-lib-www /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | env/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 George Hawkins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | MicroPython WiFi setup 2 | ====================== 3 | 4 | ![screenshot](docs/images/screenshot.png) 5 | 6 | For a quick walkthru of the following steps, with screenshots, click [here](docs/steps.md). For a quick video of the setup process in action, click [here](https://george-hawkins.github.io/micropython-wifi-setup/). 7 | 8 | The system works like this: 9 | 10 | * Configure your device with a unique ID, to use as its access point name, and physically label the device with the ID. 11 | * On startup, the WiFi setup process checks if it already has credentials for a WiFi network and if so tries to connect to that. 12 | * If it does not have existing credentials or cannot connect, it creates a temporary access point. 13 | * The user looks at the label on the device and selects that access point on their phone. 14 | * The access point is open but behaves like a [captive portal](https://en.wikipedia.org/wiki/Captive_portal), the phone detects this and prompts the user to go to a login webpage. 15 | * Unlike the kind of login pages used by public WiFi services, this one presents the user with a list of WiFi networks that the device can see. 16 | * The user selects the WiFi network to which they want to connect the device and are then prompted for the password for that network. 17 | * Once the device has successfully connected to the network, the user is presented with the device's new IP address. 18 | * Then the temporary access point shuts down (and the standard behavior for the phone is to automatically return to the previous network). 19 | * The user can now use the IP address they have to access the device. 20 | * The device stores the network credentials and will use them to reconnect whenever the device is restarted. 21 | 22 | Notes: 23 | 24 | * the setup process takes a callback that can perform additional steps on connecting to the network and can return something other than an IP address, e.g. an MQTT topic name. 25 | * when connecting to your WiFi network, the code now also uses the unique ID, that you give your device, as its `dhcp_hostname`. MicroPython will (in addition to using this name when making the DHCP request) advertise this name using mDNS. This _should_ make it possible to access your device as _unique-name.local_ which is far more convenient than using an IP address and should continue to work even if the underlying IP address changes. However, mDNS support varies by platform (though in general, all modern operating systems support it). A bigger issue is that the underlying ESP-IDF currently has a bug in how it replies to mDNS - I've filed an issue relating to this, see [`NOTES.md`](docs/NOTES.md) for more details. 26 | 27 | Using this library 28 | ------------------ 29 | 30 | ![device](docs/images/labeled-device.jpg) 31 | 32 | Each device should be given a unique name. Then you just need to add the following to the start of your `main.py`: 33 | 34 | 35 | ```python 36 | from wifi_setup.wifi_setup import WiFiSetup 37 | 38 | ws = WiFiSetup("ding-5cd80b1") 39 | sta = ws.connect_or_setup() 40 | ``` 41 | 42 | Above, I've specified `"ding-5cd80b1"` as the device's unique name (which it will use as the name of the temporary access point that it creates). And calling `ws.connect_or_setup()` will go through the steps outlined up above. Once complete the resulting [`network.WLAN`](https://docs.micropython.org/en/latest/library/network.WLAN.html), corresponding to the connected network, will be assigned to `sta`. 43 | 44 | Or you can take a more fine grained approach: 45 | 46 | ```python 47 | ws = WiFiSetup("ding-5cd80b1") 48 | sta = None 49 | if ws.has_ssid(): 50 | sta = ws.connect() 51 | if not sta: 52 | sta = ws.setup() 53 | ``` 54 | 55 | Here `ws.has_ssid()` checks if credentials for an SSID already exist, if so just connect with `ws.connect()` (if this fails it returns `None`). If there are no existing credentials or `ws.connect()` fails then call `ws.setup()` to create a temporary access point so that the user can go through the steps up above. 56 | 57 | If you don't want your board to automatically go into setup mode, you could e.g. make calling `ws.setup()` conditional on a button being held down during startup. 58 | 59 | If you want to you can clear any existing credentials with the static method `WiFiSetup.clear()`. 60 | 61 | Note that the intention is that `WiFiSetup` is just used at startup - it's **not** working away continuously and your device will **not** randomly enter setup mode whenever your WiFi becomes unavailable. 62 | 63 | Basic setup 64 | ----------- 65 | 66 | If you haven't already got Python 3 installed on your local system, see my [notes](https://github.com/george-hawkins/snippets/blob/master/install-python.md) on installing it. 67 | 68 | And if you're new to MicroPython, see my [notes](https://github.com/george-hawkins/micropython-notes/blob/master/getting-started.md) on getting it installed on your development board. 69 | 70 | This library has been tested with the stable version of MicroPython 1.12 for both ESP-IDF 3.x and 4.x on boards with both WROOM and WROVER modules. 71 | 72 | To get started, first checkout this project: 73 | 74 | $ git clone git@github.com:george-hawkins/micropython-wifi-setup.git 75 | $ cd micropython-wifi-setup 76 | 77 | Then create a Python venv and install [`rshell`](https://github.com/dhylands/rshell): 78 | 79 | $ python3 -m venv env 80 | $ source env/bin/activate 81 | $ pip install --upgrade pip 82 | $ pip install rshell 83 | 84 | Once set up, the `source` step is the only one you need to repeat - you need to use it whenever you open a new terminal session in order to activate the environment. If virtual environments are new to you, see my notes [here](https://github.com/george-hawkins/snippets/blob/master/python-venv.md). For more about `rshell`, see my notes [here](https://github.com/george-hawkins/micropython-notes/blob/master/tools-filesystem-and-repl.md#rshell). 85 | 86 | All the snippets below assume you've set the variable `PORT` to point to the serial device corresponding to your board. 87 | 88 | On Mac you typically just need to do: 89 | 90 | $ PORT=/dev/cu.SLAB_USBtoUART 91 | 92 | And on Linux, it's typically: 93 | 94 | $ PORT=/dev/ttyUSB0 95 | 96 | Then start `rshell` to interact with the MicroPython board: 97 | 98 | $ rshell --buffer-size 512 --quiet -p $PORT 99 | > 100 | 101 | Within `rshell` just copy over the library like so to your board: 102 | 103 | > cp -r lib /pyboard 104 | 105 | The `lib` directory contains a substantial amount of code so copying it across takes about 130 seconds. A progress meter would be nice but as it is `rshell` sits there silently until copying is complete. 106 | 107 | Then to copy over a demo `main.py` and some supporting files: 108 | 109 | > cd demo 110 | > cp -r main.py www /pyboard 111 | 112 | The demo just includes the Python code outlined up above followed by a simple web server that can be accessed, once the board is connected to a network, in order to demonstrate that the whole process worked. 113 | 114 | Then enter the REPL and press the reset button on the board (the `EN` button if you're using an Espressif ESP32 DevKitC board): 115 | 116 | > repl 117 | ... 118 | INFO:captive_portal:captive portal web server and DNS started on 192.168.4.1 119 | 120 | You'll see a lot of boot related lines scroll by and finally, you should see it announce that it's started a captive portal. Then just go to your phone and walk through the phone related steps that are shown with screenshots [here](docs/steps.md). Once connected to your network, your board will serve a single web page (of a cute little ghost). 121 | 122 | If you reset the board it will now always try to connect to the network you just configured via your phone. If you want to clear the stored credentials just do: 123 | 124 | > repl 125 | >>> WiFiSetup.clear() 126 | 127 | Idle timeout 128 | ------------ 129 | 130 | By default, when your device has not already been configured with the credentials for a WiFi network, it will wait forever for someone to connect to its captive portal and provide it with appropriate credentials. If you'd like to be able to configure it to instead timeout after a given amount of time, e.g. 5 minutes, you can do that by making the following changes to [`captive_portal.py`](lib/wifi_setup/captive_portal.py). 131 | 132 | First pull in the utility functions `exists` and `read_text` below the existing `Scheduler` import: 133 | 134 | ``` 135 | from schedule import Scheduler, CancelJob 136 | from shim import exists, read_text 137 | ``` 138 | 139 | Then add a constant called `_IDLE_TIMEOUT` at the start of the `CaptivePortal` class: 140 | 141 | ```Python 142 | class CaptivePortal: 143 | _IDLE_TIMEOUT = "idle-timeout.txt" 144 | ``` 145 | 146 | And finally in the `run()` method, add the following block just _before_ the `while self._alive` loop: 147 | 148 | ```Python 149 | if exists(self._IDLE_TIMEOUT): 150 | idle_timeout = int(read_text(self._IDLE_TIMEOUT)) 151 | _logger.info("idle timeout set to %ds", idle_timeout) 152 | 153 | def expire(): 154 | _logger.info("idle timeout expired") 155 | self._alive = False 156 | return CancelJob 157 | 158 | self._schedule.every(idle_timeout).seconds.do(expire) 159 | ``` 160 | 161 | All this does is check if a file called `idle-timeout.txt` exists and, if it does, then it expects it to contain a number that it uses as the timeout value (in seconds). 162 | 163 | So to create `idle-timeout.txt`, you can use `rshell` again: 164 | 165 | $ rshell --buffer-size 512 --quiet -p $PORT 166 | $ cd /pyboard 167 | $ echo 300 > idle-timeout.txt 168 | 169 | Here, we stored the value `300` in `idle-timeout.txt`, so now the `CaptivePortal` loop is scheduled to timeout after 300s, i.e. 5 minutes. 170 | 171 | Note: the logic above uses the existing scheduler `self._schedule` to run the `expiry` job every `idle_timeout` seconds - however, the job never runs more than once as it returns `CancelJob` which tells the scheduler not to run the job again. 172 | 173 | PyPI and this library 174 | --------------------- 175 | 176 | I did initially try to make this library available via [PyPI](https://pypi.org/) so that it could be installed using [`upip`](https://docs.micropython.org/en/latest/reference/packages.html#upip-package-manager). This took much longer than it should have due to my insistence on trying to get the process to work using [Python Poetry](https://python-poetry.org/) (see my notes [here](docs/python-poetry.md)). But in the end, the effort was all rather pointless as `upip` has not worked for quite some time on the ESP32 port of MicroPython (due to TLS related issues, see issue [#5543](https://github.com/micropython/micropython/issues/5543)). 177 | 178 | Anyway, there something fundamentally odd about installing a library that's meant to set up a WiFi connection using a tool, i.e. `upip`, that requires that your board already has a WiFi connection. 179 | 180 | Given that, issue #5543 and Thorsten von Eicken's [comment](https://github.com/micropython/micropython/issues/5543#issuecomment-621341369) that "I have the feeling that long term MP users don't use upip" (I did not use `upip` at any point when creating this project), I decided to give up on publishing this library to PyPI. 181 | 182 | Using micropython-wifi-setup in your own project 183 | ------------------------------------------------ 184 | 185 | Let's say you have a project called `my-project`, then I suggest the following approach to using the micropython-wifi-setup library within this project: 186 | 187 | $ ls 188 | my-project ... 189 | $ git clone git@github.com:george-hawkins/micropython-wifi-setup.git 190 | $ LIB=$PWD/micropython-wifi-setup/lib 191 | $ cd my-project 192 | $ ln -s $LIB . 193 | $ echo lib/ >> .gitignore 194 | 195 | You could of course just copy the micropython-wifi-setup `lib` directory into your own project but this means your copy is frozen in time. The approach just outlined means that's it's easy to keep up-to-date with changes and it makes it easier to contribute back any improvements you make to the original project, to the benefit of a wider audience. 196 | 197 | Web resources 198 | ------------- 199 | 200 | The web-related resources found in [`lib/wifi_setup/www`](lib/wifi_setup/www) were created in another project - [material-wifi-setup](https://github.com/george-hawkins/material-wifi-setup). 201 | 202 | If you check out that project in the same parent directory as this project and then make changes there, you can rebuild the resources and copy them here like so: 203 | 204 | $ ./update-lib-www 205 | 206 | The script `update-lib-www` does some basic sanity checking and ensures that it doesn't stomp on any changes made locally in this project. Note that it compresses some of the resources when copying them here. 207 | 208 | ### Supported browsers 209 | 210 | The provided web interface should work for any version of Chrome, Firefox, Edge or Safari released in the last few years. It may not work for older tablets or phones that have gone out of support and are no longer receiving updates. It is possible to support older browsers but this means significantly increasing the size of the web resource included here - for more details see the "supported browser versions" section [here](https://github.com/george-hawkins/material-wifi-setup#supported-browser-versions). 211 | 212 | Captive portals 213 | --------------- 214 | 215 | This library uses what's termed a captive portal - this depends on being able to respond to all DNS requests. This works fine on phones - it's the same process that's used whenever you connect to public WiFi at a coffee shop or an airport. However, on a laptop or desktop, things may be different. If you've explicitly set your nameserver to something like Google's [public DNS](https://en.wikipedia.org/wiki/Google_Public_DNS) then your computer may never try resolving addresses via the DNS server that's part of this library. In this case, once connected to the temporary access point, you have to explicitly navigate to the IP address we saw in the logging output above, i.e.: 216 | 217 | INFO:captive_portal:captive portal web server and DNS started on 192.168.4.1 218 | 219 | A more sophisticated setup would sniff all packets and spot DNS requests, even to external but unreachable services like 8.8.8.8, and spoof a response - however, this requires [promiscuous mode](https://en.wikipedia.org/wiki/Promiscuous_mode) which isn't currently supported in MicroPython. 220 | 221 | For more about captive portals see the captive portal notes in [`docs/captive-portal.md`](docs/captive-portal.md). 222 | 223 | Reusable parts 224 | -------------- 225 | 226 | There's a substantial amount of code behind the WiFi setup process. Some of the pieces may be useful in your own project. 227 | 228 | The most interesting elements are cut-down versions of [MicroWebSrv2](https://github.com/jczic/MicroWebSrv2), [MicroDNSSrv](https://github.com/jczic/MicroDNSSrv/), [schedule](https://github.com/rguillon/schedule) and [logging](https://github.com/micropython/micropython-lib/blob/master/logging). The web server, i.e. MicroWebSrv2, is the most dramatically reworked of these, though the core request and response classes (and the underlying networking-related classes) remain much as they were. 229 | 230 | Most of the changes were undertaken to reduce memory usage and to get everything to work well with an event loop based around `select.poll()` and `select.poll.ipoll(...)` (where services are fed with events - what I call pumping). All threading has been removed from the web server and it can only handle one request at a time. Given the use of polling, it would be possible to support multiple concurrent requests - the reason for not doing this, is to avoid the additional memory required to support send and receive buffers for more than one request at a time. 231 | 232 | ### The web server 233 | 234 | How to reuse the web server: 235 | 236 | ```python 237 | import select 238 | from slim.slim_server import SlimServer 239 | from slim.fileserver_module import FileserverModule 240 | 241 | poller = select.poll() 242 | 243 | slim_server = SlimServer(poller) 244 | slim_server.add_module(FileserverModule({"html": "text/html"})) 245 | 246 | while True: 247 | for (s, event) in poller.ipoll(0): 248 | slim_server.pump(s, event) 249 | slim_server.pump_expire() 250 | ``` 251 | 252 | Create a `www` directory and add an `index.html` there. For every different file suffix used, you have to add a suffix-to-[MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types) mapping. In the snippet above the only mapping provided is from the suffix `html` to MIME type `text/html`. 253 | 254 | One feature that I added to the web server is the ability to store your files in compressed form, e.g. `index.html.gz` rather than `index.html`, this allowed me to reduce by almost two-thirds the storage needed for the web resources used by this project. See the compression section [here](docs/request-examples.md#compression) for more details. 255 | 256 | Note: if you're looking at the web server code and notice camelCase used in some places and more [PEP 8](https://www.python.org/dev/peps/pep-0008/) compliant snake_case in others, this is deliberate - the original code used camelCase and I used snake_case to make clearer what functions and variables I'd introduced. 257 | 258 | ### The REST module 259 | 260 | The web server functionality is organized into modules - above I used the [`FileserverModule`](lib/slim/fileserver_module.py) for serving static files. There's also a [`WebRouteModule`](https://github.com/george-hawkins/micropython-wifi-setup/blob/master/lib/slim/web_route_module.py) that can be used to provide REST-like behavior: 261 | 262 | ```python 263 | from slim.web_route_module import WebRouteModule, RegisteredRoute, HttpMethod 264 | 265 | def _hello(request): 266 | request.Response.ReturnOkJSON({"message": "hello"}) 267 | 268 | def _log(request): 269 | data = request.GetPostedURLEncodedForm() 270 | message = password = data.get("message", None) 271 | print(message) 272 | request.Response.ReturnOk() 273 | 274 | slim_server.add_module(WebRouteModule([ 275 | RegisteredRoute(HttpMethod.GET, "/api/hello", _hello), 276 | RegisteredRoute(HttpMethod.POST, "/api/log", _log) 277 | ])) 278 | ``` 279 | 280 | Then you can test this logic like so (replace `$ADDR` with the address of your device): 281 | 282 | ``` 283 | $ curl http://$ADDR/api/hello 284 | {"message": "hello"} 285 | $ curl --data 'message=foobar' http://$ADDR/api/log 286 | ``` 287 | 288 | For more on using `curl` like this with the server, and on how things like how setting the header `Accept: application/json` affects things, see these [notes](docs/request-examples.md). 289 | 290 | **Important:** for each request, the modules are called in the order that they're registered using `add_module(...)`, if you use `WebRouteModule` you _must_ register it before `FileserverModule` as currently, the `FileserverModule` will respond to any `GET` request that it cannot resolve with `404 Not Found` without giving another module a chance to handle the request. 291 | 292 | Note: the original MicroWebSrv2 logic also supported [route arguments](https://github.com/jczic/MicroWebSrv2/blob/master/docs/index.md#route-args), i.e. you could specify values as part of the path, e.g. it could automatically parse out the `5cd80b1` value from a path like `/fetch/id/5cd80b1`. This behavior isn't supported in this cut-down version, parameters are only parsed out of the [query string](https://en.wikipedia.org/wiki/Query_string), e.g. `/fetch?id=5cd80b1`, or out of form data (as shown in the snippet above). 293 | 294 | ### The Websocket manager 295 | 296 | Unlike all the other functionality included in the `lib` subdirectory, the websocket functionality is not used in the WiFi setup process. It is provided as an extra that can be used to control the device once WiFi is setup. 297 | 298 | The [`WsManager`](lib/slim/ws_manager.py) works in combination with the `WebRouteModule` introduced above - you just need to create a `WsManager` instance and then specify its `upgrade_connection` method as the handler for a given route. E.g. below it's associated with the route `/socket`. You can then make a websocket connection to the given route and `WsManager` will handle upgrading the connection. 299 | 300 | When creating a `WsManager` instance, you need to specify two functions: 301 | 302 | * A `consumer` function that will be passed a function that behaves like [`readinto`](https://docs.python.org/3/library/io.html#io.RawIOBase.readinto). The `consumer` should keep reading until there are no more bytes available and should accumulate those bytes into some kind of result message. If it encounters any exceptions, it should just let these bubble up and the calling `WsManager` instance will close and discard the underlying websocket. If it cannot create a complete message out of the bytes available it needs to hang onto the intermediate result, return `None` and complete the message once more bytes become available. 303 | * A `processor` function that does something useful with the messages generated by the `consumer`. 304 | 305 | ```python 306 | from slim.ws_manager import WsManager 307 | 308 | buffer = memoryview(bytearray(128)) 309 | 310 | def consumer(readfn): 311 | message = None 312 | while True: 313 | count = readfn(buffer) 314 | if not count: 315 | break 316 | # Else decode the buffer and accumulate it into `message`. 317 | return message 318 | 319 | def processor(message): 320 | # Do something with the given message. 321 | print(repr(message)) 322 | 323 | ws_manager = WsManager(poller, consumer, processor) 324 | 325 | slim_server.add_module(WebRouteModule([ 326 | RegisteredRoute(HttpMethod.GET, "/socket", ws_manager.upgrade_connection) 327 | ])) 328 | 329 | while True: 330 | for (s, event) in poller.ipoll(0): 331 | ws_manager.pump_ws_clients(s, event) 332 | ``` 333 | 334 | Note that a websocket is a fairly low-level construct and it's up to you to implement some kind of protocol, on top of the raw-bytes level, that defines what a message is and how they're delimited. For an example of this, see how `WsManager` is used in [`main.py`](https://github.com/george-hawkins/micropython-lighthouse-controls/blob/master/main.py) in my [micropython-lighthouse-controls](https://github.com/george-hawkins/micropython-lighthouse-controls) repo. 335 | 336 | You can test out the websocket functionality with a command-line tool like [`websocat`](https://github.com/vi/websocat). E.g. to connect to the above route and send the bytes `abc`: 337 | 338 | ``` 339 | $ websocat ws://$ADDR/socket 340 | abc 341 | ``` 342 | 343 | Replace `$ADDR` with the address of your device. 344 | 345 | ### DNS 346 | 347 | Being able to respond to DNS requests is central to implementing the captive portal used by this library. However once your device is connected to an existing network, it's less obvious what use one could make of a mini-DNS server on a MicroPython board. It is just documented here for completeness. 348 | 349 | ```python 350 | from micro_dns_srv import MicroDNSSrv 351 | 352 | addr = sta.ifconfig()[0] 353 | addrBytes = MicroDNSSrv.ipV4StrToBytes(addr) 354 | 355 | def resolve(name): 356 | print("Resolving", name) 357 | return addrBytes 358 | 359 | dns = MicroDNSSrv(resolve, poller) 360 | 361 | while True: 362 | for (s, event) in poller.ipoll(0): 363 | dns.pump(s, event) 364 | ``` 365 | 366 | Here `sta` is a `network.WLAN` instance corresponding to your current network connection, we use it to get the device's address and convert it into the byte format used by DNS. We provide a `resolve` function that, given a `name`, returns an address - the simple example function just prints the name and resolves every name to the board's address. 367 | 368 | ### Scheduler 369 | 370 | If you're used to [Node.js](https://nodejs.org/en/about/), you're probably also used to using timers to schedule functions to be called at some point in the future. The [`Scheduler`](https://github.com/george-hawkins/micropython-wifi-setup/blob/master/lib/schedule.py) provides similar functionality: 371 | 372 | ```python 373 | from schedule import Scheduler, CancelJob 374 | 375 | def do_something(): 376 | print("foobar") 377 | return CancelJob 378 | 379 | schedule = Scheduler() 380 | job = schedule.every(5).seconds.do(do_something) 381 | 382 | while True: 383 | schedule.run_pending() 384 | ``` 385 | 386 | Here we've registered a job to execute in 5 seconds time. If the function `do_something` didn't return anything then it would be run every 5 seconds forevermore, returning `CancelJob` cancels the job. The job can also be canceled by calling `schedule.cancel_job(...)` on the `job` object we created. 387 | 388 | `schedule.run_pending()` needs to be called regularly - this works well in combination with the event pumping loop seen in the previous examples. However, if used on its own it would probably make sense to combine it with `time.sleep(1)` in the loop shown in the example here. 389 | 390 | Note: this cut-down version of `Scheduler` only supports `seconds`. 391 | 392 | ### Logger 393 | 394 | The [`Logger`](lib/logging.py) just provides behavior that mimics that of the standard CPython [logging](https://docs.python.org/3/howto/logging.html) and behaves much as you'd expect: 395 | 396 | ```python 397 | import logging 398 | 399 | _logger = logging.getLogger(__name__) 400 | 401 | name = "alpha" 402 | count = 42 403 | 404 | _logger.warning("%s is now %d", name, count) 405 | ``` 406 | 407 | This will log something like `WARNING:my_module:alpha is now 42` to standard error. As you can see, the logger uses the classic [printf-style formatting](https://docs.python.org/3/library/stdtypes.html#old-string-formatting) that you typically see used with the `%` operator, e.g. `"%s is %d" % ("alpha", 42)`. However here you don't need the `%` operator and the arguments don't need to be wrapped up as a tuple. 408 | 409 | Note: this cut-down version of `Logger` requires that you create `Logger` instances, as shown above, it doesn't support the usage `logging.warning(...)` where a default root logger is used. 410 | 411 | Regrets 412 | ------- 413 | 414 | Back in 2013, I achieved my 15 seconds of internet fame by showing that a third party could easily spy on the Smart Config process used by the TI CC3000 (and, I believe, subsequent chips in that range) and recover the user's WiFi password (see [here](https://electronics.stackexchange.com/a/84965/27099) for more details). So I'm a little ashamed to have produced a library where it's even easier to spy on the process involved. A third party simply has to connect to the open temporary access point created by this library and, using [promiscuous mode](https://en.wikipedia.org/wiki/Promiscuous_mode), watch the unencrypted packets exchanged between the user's phone and the device. 415 | 416 | Once configured, the credentials are then stored in an easy to recover format on the board (see the [BTree](http://docs.micropython.org/en/latest/library/btree.html) file there called `credentials`). To do better than this would require more interaction between MicroPython and the underlying system, e.g. some systems provide the ability to store credentials such that only the device's WiFi process can recover the necessary information (without it being easily available to anyone who can physically access the board). 417 | 418 | I also regret not having created unit tests as I went along. At the start, I thought this project would be rather more trivial than it turned out. By the end, the absence was very much missed when making changes to the code. In the sister project [material-wifi-setup](https://github.com/george-hawkins/material-wifi-setup), I even actively removed the test related elements included by `ng new` when I created the project. 419 | 420 | Notes 421 | ----- 422 | 423 | See [`docs/NOTES.md`](docs/NOTES.md) for more implementation details. 424 | 425 | Licenses and credits 426 | -------------------- 427 | 428 | The code developed for this project is licensed under the [MIT license](LICENSE). 429 | 430 | The web server code is derived from [MicroWebSrv2](https://github.com/jczic/MicroWebSrv2), authored by Jean-Christophe Bos at HC2 and licensed under the [MIT license](https://github.com/jczic/MicroWebSrv2/blob/master/LICENSE.md). 431 | 432 | The DNS server code is derived from [MicroDNSSrv](https://github.com/jczic/MicroDNSSrv/), also authored by Jean-Christophe Bos and licensed under the MIT license. 433 | 434 | The schedule code is derived from [schedule](https://github.com/rguillon/schedule), authored by Renaud Guillon and licensed under the [MIT license](https://github.com/rguillon/schedule/blob/master/LICENSE.txt). 435 | 436 | The logging package is derived from [micropython-lib/logging](https://github.com/micropython/micropython-lib/blob/master/logging), authored by Paul Sokolovsky (aka Pfalcon) and licensed under the [MIT license](https://github.com/micropython/micropython-lib/blob/master/logging/setup.py) (see `license` key). 437 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | TODO 2 | ---- 3 | 4 | When you complete the setup process, you'll find that a lot of heap is no longer available even if you explicitly delete the setup object. 5 | 6 | This is a bit disappointing as a lot of code changes were made to ensure there aren't random structures, e.g. file local maps, left lying around at the end of the process. 7 | 8 | Deleting all the modules gets you back some of your memory: 9 | 10 | for key in sys.modules: 11 | del sys.modules[key] 12 | 13 | See 14 | 15 | However the order you delete them seems to make a big difference to how much memory you eventually recover. 16 | 17 | Maybe have a CheckpointModules class that can checkpoint `sys.modules` and restore a checkpoint (with a list of modules not to remove, e.g. the modules that are present when WiFiSetup does its job without needing the portal). 18 | 19 | See - in particular `micropython.mem_info(1)`. 20 | 21 | Try using `globals()`, `locals()` and anything similar to find out what holds onto memory after the portal has exited. 22 | -------------------------------------------------------------------------------- /demo/main.py: -------------------------------------------------------------------------------- 1 | import micropython 2 | import gc 3 | import select 4 | 5 | # Display memory available at startup. 6 | gc.collect() 7 | micropython.mem_info() 8 | 9 | from wifi_setup.wifi_setup import WiFiSetup 10 | 11 | # You should give every device a unique name (to use as its access point name). 12 | ws = WiFiSetup("ding-5cd80b3") 13 | sta = ws.connect_or_setup() 14 | del ws 15 | print("WiFi is setup") 16 | 17 | # Display memory available once the WiFi setup process is complete. 18 | gc.collect() 19 | micropython.mem_info() 20 | 21 | # Demo that the device is now accessible by starting a web server that serves 22 | # the contents of ./www - just an index.html file that displays a cute ghost. 23 | 24 | from slim.slim_server import SlimServer 25 | from slim.fileserver_module import FileserverModule 26 | 27 | poller = select.poll() 28 | 29 | slim_server = SlimServer(poller) 30 | slim_server.add_module(FileserverModule({"html": "text/html"})) 31 | 32 | while True: 33 | for (s, event) in poller.ipoll(0): 34 | slim_server.pump(s, event) 35 | slim_server.pump_expire() 36 | -------------------------------------------------------------------------------- /demo/www/index.html.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/demo/www/index.html.gz -------------------------------------------------------------------------------- /docs/NOTES.md: -------------------------------------------------------------------------------- 1 | Notes 2 | ===== 3 | 4 | This page contains miscellaneous notes that were accumulated in the process of creating this project. 5 | 6 | There are also some additional notes, separated out into [`screen-setup.md`](screen-setup.md) (basic usage notes for `screen`) and [`request-examples.md`](request-examples.md) (notes on using `curl` during development). 7 | 8 | Connection failure reason 9 | ------------------------- 10 | 11 | It's not currently possible to report why connecting to an access point fails, e.g. an invalid password. 12 | 13 | For more details, see my MicroPython [forum post](https://forum.micropython.org/viewtopic.php?t=7942) about how `WLAN.status()` currently works. 14 | 15 | WiFi password encryption 16 | ------------------------ 17 | 18 | Ideally, you'd encrypt your WiFi password with an AES key printed on the device. 19 | 20 | This would mean only that particular device, with the key pre-installed, could decrypt your password. It would also mean that only the person who's got the device and can read the printed key can take control of it, i.e. register it with their WiFi. 21 | 22 | However an AES key is at minimum 128 bits, i.e. 32 hex digits, which is more than most people would want to type in - and you'd probably want to include two checksum digits so that it's possible to point out if the key looks good or not. 23 | 24 | One possibility would be to use a [password-based key derivation function](https://en.wikipedia.org/wiki/Key_derivation_function) (PBKDF) to generate a key from a more reasonable length password (see step 5. and the text below it in this Crypto StackExchange [answer](https://crypto.stackexchange.com/a/53554/8854)). Currently, [Argon2](https://en.wikipedia.org/wiki/Argon2) seems to be the first-choice PBKDF, however, according to this [answer](https://forum.micropython.org/viewtopic.php?p=36116#p36116) on the MicroPython forums all such algorithms consume noticeable amounts of ROM, making them "unlikely to ever appear by default in micropython firmware". 25 | 26 | Testing connection timeout logic 27 | -------------------------------- 28 | 29 | To test the timeout logic that expires sockets, replace `$ADDR` with the address of your device and try: 30 | 31 | $ telnet $ADDR 80 32 | 33 | Just leave it there or paste in e.g. just the first line of a request: 34 | 35 | GET / HTTP/1.1 36 | 37 | Within a few seconds (the time configured via `SlimConfig.timeout_sec`) the server will drop the connection. 38 | 39 | MicroPython UNIX port 40 | --------------------- 41 | 42 | Installing the UNIX port of MicroPython on your local system is very convenient using `pyenv`. Normally, you only mark one version of Python as active using `pyenv`. It is possible though to make both your normal CPython version and MicroPython available at the same time. 43 | 44 | First, determine the currently active version of Python: 45 | 46 | 47 | $ pyenv global 48 | 3.6.9 49 | $ python --version 50 | Python 3.6.9 51 | 52 | Then install MicroPython and make both it and your current Python version available: 53 | 54 | $ pyenv update 55 | $ pyenv install micropython-1.12 56 | $ pyenv global 3.6.9 micropython-1.12 57 | 58 | Check that you can access both: 59 | 60 | $ python --version 61 | Python 3.6.9 62 | $ micropython 63 | MicroPython v1.12 on 2020-04-26; linux version 64 | ... 65 | 66 | Due to PR [#1587](https://github.com/pyenv/pyenv/pull/1587), you should make sure your `pyenv` is up-to-date - hence the use of `pyenv update` above. 67 | 68 | On macOS, you _may_ have to set `PKG_CONFIG_PATH` and `LDFLAGS` as shown in `pyenv` issue [#1588](https://github.com/pyenv/pyenv/issues/1588) before installing MicroPython. 69 | 70 | REPL munges JSON 71 | ---------------- 72 | 73 | Using the MicroPython REPL, you can dump the visible access points like so: 74 | 75 | >>> json.dumps([(t[0], binascii.hexlify(t[1]), t[2], t[3], t[4], t[5]) for t in sta.scan()]) 76 | 77 | However the REPL escapes single quotes, i.e. "Foo's AP" is displayed as "Foo\\'s AP", which is invalid JSON. This is just a REPL artifact. To get the un-munged JSON: 78 | 79 | >>> import network 80 | >>> import json 81 | >>> import binascii 82 | >>> sta = network.WLAN(network.STA_IF) 83 | >>> sta.active(True) 84 | >>> with open('data.json', 'w') as f: 85 | >>> json.dump([(t[0], binascii.hexlify(t[1]), t[2], t[3], t[4], t[5]) for t in sta.scan()], f) 86 | 87 | $ rshell --buffer-size 512 --quiet -p $PORT cp /pyboard/data.json . 88 | 89 | The results (prettified) are something like this: 90 | 91 | ```json 92 | [ 93 | [ 94 | "Foo's AP", 95 | "44fe3b8a9f87", 96 | 11, 97 | -82, 98 | 3, 99 | false 100 | ], 101 | [ 102 | "UPC Wi-Free", 103 | "3a431d3e4ec7", 104 | 11, 105 | -56, 106 | 5, 107 | false 108 | ] 109 | ] 110 | ``` 111 | 112 | Try mpy-cross 113 | ------------- 114 | 115 | Look at what affect using [mpy-cross](https://github.com/george-hawkins/micropython-notes/blob/master/precompiling.md) has on available memory. 116 | 117 | You can check available memory like so: 118 | 119 | >>> import micropython 120 | >>> micropython.mem_info() 121 | ... 122 | >>> import gc 123 | >>> gc.collect() 124 | >>> micropython.mem_info() 125 | ... 126 | 127 | Maybe it makes no difference _once things are compiled_ and simply ensures that the compiler won't run out of memory doing its job. 128 | 129 | PyCharm Python version 130 | ---------------------- 131 | 132 | If you've created your venv before you open the project in PyCharm then it will automatically pick up the Python version from the venv. Otherwise, go to _Settings / Project:my-project-name / Project Interpreter_ - click the cog and select _Add_, it should automatically select _Existing environment_ and the interpreter in the venv - you just have to press OK. 133 | 134 | Black and Flake8 135 | ---------------- 136 | 137 | The code here is formatted with [Black](https://black.readthedocs.io/en/stable/) and checked with [Flake8](https://flake8.pycqa.org/en/latest/). 138 | 139 | $ pip install black 140 | $ pip install flake8 141 | 142 | To reformat, provide a list of files and/or directories to `black`: 143 | 144 | $ black ... 145 | 146 | To check, provide a list of files and/or directories to `flake8`: 147 | 148 | $ flake8 ... | fgrep -v -e E501 -e E203 -e E722 149 | 150 | Here `fgrep` is used to ignore E501 (line too long) and E203 (whitespace before ':') as these are rules that Black and Flake8 disagree on. I also ignore E722 (do not use bare 'except') as I'm not prepared to enforce this rule in the code. 151 | 152 | Android screen recorder 153 | ----------------------- 154 | 155 | The [usage video](https://george-hawkins.github.io/micropython-wifi-setup/) was recorded with the open-source [ScreenCam](https://play.google.com/store/apps/details?id=com.orpheusdroid.screenrecorder) using the default settings. 156 | 157 | It was edited with [iMovie](https://www.apple.com/imovie/) and exported at 540p / medium quality / best compression. 158 | 159 | It was then cropped to size using this SuperUser StackExchange [answer](https://superuser.com/a/810524) like so: 160 | 161 | $ ffmpeg -ss 20 -i wifi-setup2.mp4 -vframes 10 -vf cropdetect -f null - 162 | Stream #0:0(und): Video: h264 (High) ... 960x540 ... 163 | [Parsed_cropdetect_0 @ 0x7fa729e00f00] x1:327 x2:632 ... crop=304:528:328:6 164 | $ ffplay -vf crop=304:540:328:0 wifi-setup3.mp4 165 | $ ffmpeg -i wifi-setup3.mp4 -vf crop=304:540:328:0 output.mp4 166 | 167 | For whatever reason, the suggested cropping was a little over-aggressive (6 pixels at top and bottom) so I manually adjusted the values. 168 | 169 | 170 | mDNS bug 171 | -------- 172 | 173 | I used Wireshark to capture a pcap file: 174 | 175 | $ sudo wireshark 176 | 177 | My host system is a wired Ubuntu machine - initially I used _eth0_ as the capture interface but, for whatever reason, this didn't see UDP traffic. Selecting the _any_ interface instead resolved this. 178 | 179 | I then used the display filter `udp.port eq 5353` and used `dig` to query a device that I knew would respond correctly and then to query an ESP32 device. 180 | 181 | $ dig +short -p 5353 @224.0.0.251 daedalus.local 182 | 192.168.0.17 183 | $ dig +short -p 5353 @224.0.0.251 ding-5cd80b3.local 184 | ;; Warning: ID mismatch: expected ID 39315, got 0 185 | ;; Warning: ID mismatch: expected ID 39315, got 0 186 | ;; Warning: ID mismatch: expected ID 39315, got 0 187 | ;; connection timed out; no servers could be reached 188 | 189 | I saved the resulting pcap file and noted the particular frames that I wanted to keep. I then edited the pcap file to produce one containing just these frames: 190 | 191 | $ editcap -r capture.pcapng mdns.pcapng 35 36 91 93 172 174 261 263 192 | 193 | I've logged issue [#5574](https://github.com/espressif/esp-idf/issues/5574) against the ESP-IDF to cover this. 194 | -------------------------------------------------------------------------------- /docs/captive-portal.md: -------------------------------------------------------------------------------- 1 | Captive portal 2 | ============== 3 | 4 | When you connect to a commercial WiFi network, e.g. one provided by a coffee shop, you often have to go through a login process. 5 | 6 | There's nothing very sophisticated about how this is achieved. When you first connect, you do not have full internet access and the network's DNS server responds to all DNS requests with the address of the web server serving the login page. This web server then redirects all unfound paths to the login page (rather than the usual behavior of returning `404 Not Found`). 7 | 8 | So if you try to go to e.g. , the network's DNS responds to `news.ycombinator.com` with the address of the login web server and then it redirects the request for `/item?id=22867627` to its login page. 9 | 10 | For an end-user, trying to access a web page and then being redirected to a login page is a bit confusing so these days most OSes try to detect this upfront and immediately present the login page as part of the process of selecting the WiFi network. 11 | 12 | They do this by probing for a URL that they know exists and which has a defined response, e.g. Android devices typically check for . If they get the defined response, e.g. `204 No Content`, then they assume they have full internet access, if they get no response they know they're on a private network with no internet access and if they get a redirect they assume they're in a captive portal and prompt the user to login via the page that they're redirected to. 13 | 14 | Each OS does things _slightly_ differently but for more on the fairly representative process used by Chromium see their [network portal detection](https://www.chromium.org/chromium-os/chromiumos-design-docs/network-portal-detection) documentation. 15 | 16 | So the captive portal setup used by this project requires two things - a DNS server and a web server. Very lightweight implementations of both are used. These are derived from [MicroWebSrv2](https://github.com/jczic/MicroWebSrv2) and [MicroDNSSrv](https://github.com/jczic/MicroDNSSrv) (both by [Jean-Christophe Bos](https://github.com/jczic)). 17 | 18 | Absolute redirects 19 | ------------------ 20 | 21 | Normally when you do a redirect, you redirect to a path, e.g. "/", rather than an absolute URL. However, if you do this in a captive portal setup then the hostname of the probe URL ends up being shown as the login URL. 22 | 23 | E.g. if the probe URL is and you redirect to "/" then the login URL is displayed as http://connectivitycheck.gstatic.com/ (see the first of the images here). Whereas if you redirect to an absolute URL then this gets displayed as the login URL (second image). 24 | 25 | ![relative redirect](images/connectivitycheck.png)  ![absolute redirect](images/ding-5cd80b3.png) 26 | 27 | There's no technical difference between the two - all hostnames resolve to the same address - but the fact that many captive portals simply redirect to a path means that no end of issues are logged with Google about failure to login to `connectivitycheck.gstatic.com` as the result of non-working portals all over the world (that have nothing to do with Google). 28 | 29 | DNS spoofing 30 | ------------ 31 | 32 | On laptops and desktops, people often configure a fixed DNS server, e.g. [8.8.8.8](https://en.wikipedia.org/wiki/Google_Public_DNS). In such a setup the captive portal would have to spy on DNS traffic and spoof responses in order to achieve redirects. This is possible with the ESP32 (using [promiscuous mode](https://en.wikipedia.org/wiki/Promiscuous_mode)) but this capability is not currently exposed in MicroPython 1.12. 33 | -------------------------------------------------------------------------------- /docs/images/connectivitycheck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/connectivitycheck.png -------------------------------------------------------------------------------- /docs/images/ding-5cd80b3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/ding-5cd80b3.png -------------------------------------------------------------------------------- /docs/images/labeled-device.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/labeled-device.jpg -------------------------------------------------------------------------------- /docs/images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/screenshot.png -------------------------------------------------------------------------------- /docs/images/steps/01-home-network.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/01-home-network.jpg -------------------------------------------------------------------------------- /docs/images/steps/02-wifi-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/02-wifi-settings.png -------------------------------------------------------------------------------- /docs/images/steps/03-device-advertising.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/03-device-advertising.png -------------------------------------------------------------------------------- /docs/images/steps/04-tap-to-sign-in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/04-tap-to-sign-in.png -------------------------------------------------------------------------------- /docs/images/steps/05-select-network.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/05-select-network.png -------------------------------------------------------------------------------- /docs/images/steps/06-password.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/06-password.png -------------------------------------------------------------------------------- /docs/images/steps/07-password-hidden.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/07-password-hidden.png -------------------------------------------------------------------------------- /docs/images/steps/08-password-visible.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/08-password-visible.png -------------------------------------------------------------------------------- /docs/images/steps/09-connecting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/09-connecting.png -------------------------------------------------------------------------------- /docs/images/steps/10-connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/10-connected.png -------------------------------------------------------------------------------- /docs/images/steps/11-shutting-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/11-shutting-down.png -------------------------------------------------------------------------------- /docs/images/steps/12-revert-to-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/12-revert-to-home.png -------------------------------------------------------------------------------- /docs/images/steps/13-launcher.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/13-launcher.jpg -------------------------------------------------------------------------------- /docs/images/steps/14-chrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/14-chrome.png -------------------------------------------------------------------------------- /docs/images/steps/15-ghost-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/george-hawkins/micropython-wifi-setup/f9c43a1cd3230b151ae205a03355db33934d452d/docs/images/steps/15-ghost-demo.png -------------------------------------------------------------------------------- /docs/python-poetry.md: -------------------------------------------------------------------------------- 1 | Managing MicroPython packages with Poetry 2 | ========================================= 3 | 4 | This page documents the rather involved steps needed to manage this project and publish it to [PyPI](https://pypi.org/) using [Python Poetry](https://python-poetry.org/). 5 | 6 | In the end, I gave up on this idea for the simple reason that MicroPython's [`upip`](https://docs.micropython.org/en/latest/reference/packages.html#upip-package-manager) doesn't work at this time, on the ESP32 port, and hasn't worked for quite some time (see MicroPython issue [#5543](https://github.com/micropython/micropython/issues/5543), including one [comment](https://github.com/micropython/micropython/issues/5543#issuecomment-621306525) from me). 7 | 8 | When starting in on this, the difference between a [source distribution](https://docs.python.org/3/distutils/sourcedist.html), an egg and a wheel wasn't clear to me (see ["wheel vs egg"](https://packaging.python.org/discussions/wheel-vs-egg/)). And I'm not entirely sure that the distinction between a source distribution and an egg is clear in how MicroPython handles things. The MicroPython documentation says it uses the source distribution format for packaging. However, it depends on egg-related data included in such a distribution. It's true that [setuptools](https://setuptools.readthedocs.io/en/latest/) includes egg information in a source distribution. However, Poetry does not and this appears to be valid. It would probably be clearer if `upip` explicitly worked with eggs rather than with source distributions that happen to contain egg-related data. 9 | 10 | It took me quite a while to realize that there's a fair degree of mismatch between what Poetry produces and what the MicroPython `upip` package manager wants to consume. 11 | 12 | Poetry creates a source distribution that contains a `setup.py` but does not contain the egg-related data that `upip` wants. Poetry has the egg-related support it needs for dealing with existing packages, however, it only builds source distributions (without egg-related data) and the newer wheel format. 13 | 14 | `upip` depends on [`setuptool.setup`](https://setuptools.readthedocs.io/en/latest/setuptools.html), in your project's `setup.py`, being run with `cmdclass` set to bind `sdist` to the version provided by [`sdist_upip.py`](https://github.com/micropython/micropython-lib/blob/master/sdist_upip.py) (see the [documentation](https://docs.micropython.org/en/latest/reference/packages.html#creating-distribution-packages)). This results in a source distribution with a custom compression size and with `setup.py` excluded from the bundle (see [here](https://docs.micropython.org/en/latest/reference/packages.html#distribution-packages) for more details). Poetry auto-generates a `setup.py` when building a source distribution and bundles it in with the distribution. However, the `setup.py` isn't executed at this point (it's only executed later by `pip` when _installing_ the distribution) so it can't affect e.g. the compression or anything else. 15 | 16 | Given all that, let's see how I got to a point where I could create and publish MicroPython compatible packages to PyPI. However, if I was doing this again I would create `setup.py` by hand and not introduce Poetry into the mix. 17 | 18 | Installation 19 | ------------ 20 | 21 | I installed Poetry as per the [installation instructions](https://python-poetry.org/docs/#installation): 22 | 23 | ``` 24 | $ curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python 25 | $ poetry completions bash | sudo tee /etc/bash_completion.d/poetry.bash-completion > /dev/null 26 | $ poetry init 27 | $ poetry check 28 | ``` 29 | 30 | If you're using a venv then Poetry will use this, however if one isn't currently active, Poetry will use its own venv management mechanism (creating venvs under `~/.cache/pypoetry/virtualenvs` on Linux). 31 | 32 | I prefer to use the standard mechanism: 33 | 34 | ``` 35 | $ python3 -m venv env 36 | $ source env/bin/activate 37 | $ pip install --upgrade pip 38 | ``` 39 | 40 | Then to create a Poetry `pyproject.toml` run and complete the questions asked by `poetry init`, like so: 41 | 42 | ``` 43 | $ poetry init 44 | Package name [foo-bar]: 45 | Version [0.1.0]: 46 | Description []: 47 | Author [George Hawkins , n to skip]: George Hawkins 48 | License []: MIT 49 | Compatible Python versions [^3.6]: 50 | Would you like to define your main dependencies interactively? (yes/no) [yes] 51 | ... 52 | Search for package to add (or leave blank to continue): micropython-logging 53 | ... 54 | Enter the version constraint to require (or leave blank to use the latest version): 55 | Using version ^0.5.2 for micropython-logging 56 | Add a package: 57 | Would you like to define your development dependencies interactively? (yes/no) [yes] no 58 | ... 59 | Do you confirm generation? (yes/no) [yes] 60 | ``` 61 | 62 | Note: it's fine to exclude your email address for the `Author` field (although setuptools does generate a warning if it's not present). 63 | 64 | Normally, you'd then do: 65 | 66 | ``` 67 | $ poetry install 68 | ``` 69 | 70 | However, as noted later this won't work as MicroPython dependencies, like micropython-logging, aren't installable. 71 | 72 | The install process would produce a `poetry.lock` file. If your project is not a library then you should check-in the `poetry.lock` created by Poetry, however for a library you should not (see [here](https://python-poetry.org/docs/libraries/#lock-file)). 73 | 74 | Note that the `build` and `publish` steps, covered later, don't depend on `install` having been run or having succeeded. 75 | 76 | PyPI token 77 | ---------- 78 | 79 | I [registered](https://pypi.org/account/register/) for a PyPI account and, once logged in, created a [token](https://pypi.org/manage/account/token/) called "pypi" (the name is unimportant, it's just used to identify the token, in the list shown on your main account page where you can revoke it later if needed) with a scope of _Entire account_. 80 | 81 | I saved displayed token to `~/pypi-token` and then, as per the Poetry configuring credentials [instructions](https://python-poetry.org/docs/repositories/#configuring-credentials): 82 | 83 | ``` 84 | poetry config pypi-token.pypi $(< ~/pypi-token) 85 | ``` 86 | 87 | Usually, `config` entries end up in your global Poetry config file (on Linux, it's `.config/pypoetry/config.toml`, for other platforms see [here](https://python-poetry.org/docs/configuration/)). However, for tokens, Poetry uses [keyring](https://pypi.org/project/keyring/) to store this value in your system's keyring service. 88 | 89 | Building and publishing 90 | ----------------------- 91 | 92 | In your `.toml` file you should name your project with minuses for spaces, e.g. `name = "foo-bar"`, however everywhere else these minuses become underscores, e.g. the corresponding subdirectory name is `foo_bar`. 93 | 94 | Minimal project layout: 95 | 96 | ``` 97 | pyproject.toml 98 | foo_bar 99 | +-- foo.py 100 | ``` 101 | 102 | Then to build a source distribution - `sdist` - and a wheel: 103 | 104 | ``` 105 | $ poetry build 106 | ``` 107 | 108 | This generates a `dist` subdirectory (which you should add to `.gitignore`). 109 | 110 | Then you can publish it to PyPI: 111 | 112 | ``` 113 | $ poetry publish 114 | ``` 115 | 116 | Your project is available on PyPI and, if logged in, you can find it under your [projects](https://pypi.org/manage/projects/). 117 | 118 | Problems begin 119 | -------------- 120 | 121 | Unfortunately, if any of your dependencies are MicroPython ones you can't install the resulting package because you simply can't install the dependencies, e.g. try directly installing something like micropython-logging: 122 | 123 | ``` 124 | $ pip install micropython-logging 125 | ... 126 | FileNotFoundError: [Errno 2] No such file or directory: '/tmp/pip-install-kfqm4aru/micropython-logging/setup.py' 127 | ... 128 | ``` 129 | 130 | As noted [here](http://docs.micropython.org/en/latest/reference/packages.html), MicroPython packages don't include the expected `setup.py`. 131 | 132 | You can only install such packages using `upip`: 133 | 134 | ``` 135 | $ micropython -m upip install -p lib micropython-logging 136 | Installing to: lib/ 137 | ... 138 | Installing micropython-logging 0.3 from https://micropython.org/pi/logging/logging-0.3.tar.gz 139 | ``` 140 | 141 | Above, I use `micropython` on Linux - see the notes [here](NOTES.md#micropython-unix-port) on using `pyenv` to install MicroPython. 142 | 143 | Addressing the issues 144 | --------------------- 145 | 146 | `upip` expects just to see a single format available on PyPI and chokes if it sees more than one - so it immediately fails on seeing both the sdist and wheel packages published by Poetry. 147 | 148 | You can force Poetry only to build the sdist package like this: 149 | 150 | ``` 151 | $ poetry build --format=sdist 152 | ``` 153 | 154 | I thought I would be able to disable building the wheel package via the `pyproject.toml` however adding the following didn't work as expected: 155 | 156 | ``` 157 | packages = [ 158 | { include = "foo_bar", format = "sdist" }, 159 | ] 160 | ``` 161 | 162 | Note that you use the underscore variant of the package name, this was the first thing that got me. However, this does not disable building of the wheel, see issue [#2365](https://github.com/python-poetry/poetry/issues/2365) that I logged against Poetry. 163 | 164 | Note also that unfortunately, the [documentation](https://python-poetry.org/docs/pyproject/#packages) states: 165 | 166 | > Using packages disables the package auto-detection feature meaning you have to explicitly specify the "default" package. 167 | 168 | Aside: another interesting thing you can do with `packages` is specify that a package is in a non-standardly named directory, e.g. `{ include = "my_package", from = "lib" }`. 169 | 170 | --- 171 | 172 | Next, it turns out that while `upip` works with source distributions, it depends on the egg-related information that setuptools includes in a source distribution but which Poetry does not. In particular, it expects to find a file of the form `foo_bar.egg-info/requires.txt` that contains the dependencies of your project (see [`upip.py:92](https://github.com/micropython/micropython/blob/69661f3/tools/upip.py#L92)). 173 | 174 | You can generate `requires.txt` by hand and it will be included in your sdist package - this is enough to keep `upip` happy. 175 | 176 | However, you can also get Poetry to create a `setup.py` which can be used to generate `requires.txt`. First: 177 | 178 | ``` 179 | $ poetry build --format=sdist 180 | $ name=foo-bar-0.1.0 181 | $ tar --to-stdout -xf dist/$name.tar.gz $name/setup.py > setup.py 182 | ``` 183 | 184 | Now use `setup.py` to generate the egg information: 185 | 186 | ``` 187 | $ python setup.py sdist 188 | ``` 189 | 190 | Setuptools automatically generates the egg information that Poetry does not. You can also ask setuptools to only generate the egg information (and not a full source distribution) like so: 191 | 192 | ``` 193 | $ python setup.py egg_info 194 | ``` 195 | 196 | The `requires.txt` file will contain any version constraints specified in the `.toml` file, however, `upip` cannot handle these constraints and they should be removed (as shown in the fully worked example later). 197 | 198 | Once you have a generated or hand-crafted `requires.txt`, Poetry will include it in the sdist package that it creates and this is enough to convince the UNIX port of `upip` to accept your package (published to PyPI _without_ the corresponding wheel) and install it along with its dependencies: 199 | 200 | ``` 201 | $ micropython -m upip install -p lib foo-bar 202 | Installing to: lib/ 203 | Warning: micropython.org SSL certificate is not validated 204 | Installing foo-bar 0.1.0 from https://files.pythonhosted.org/packages/a9/17/7373487a933881dcaa93e7fb3b11bdd7966799620f84c211c42ec0ad9760/foo-bar-0.1.0.tar.gz 205 | Installing micropython-logging 0.3 from https://micropython.org/pi/logging/logging-0.3.tar.gz 206 | ``` 207 | 208 | However, it will still fail on other MicroPython ports as it wasn't produced by `sdist_upip.sdist` with the required compression size etc. 209 | 210 | Upip fails on the ESP32 port 211 | ---------------------------- 212 | 213 | Anyway at the moment `upip` on the ESP32 port fails for everything: 214 | 215 | ``` 216 | $ rshell --buffer-size 512 --quiet -p $PORT 217 | > repl 218 | >>> import network 219 | >>> wlan = network.WLAN(network.STA_IF) 220 | >>> wlan.active(True) 221 | >>> wlan.connect('MyWiFiNetwork', 'MyWiFiPassword') 222 | >>> import upip 223 | >>> upip.install('micropython-logging') 224 | Installing to: /lib/ 225 | I (183850) wifi: bcn_timout,ap_probe_send_start 226 | I (186350) wifi: ap_probe_send over, resett wifi status to disassoc 227 | I (186350) wifi: state: run -> init (c800) 228 | I (186350) wifi: pm stop, total sleep time: 47458331 us / 60262159 us 229 | 230 | I (186350) wifi: new:<1,0>, old:<1,0>, ap:<255,255>, sta:<1,0>, prof:1 231 | mbedtls_ssl_handshake error: -71 232 | I (186360) wifi: STA_DISCONNECTED, reason:200 233 | beacon timeout 234 | Error installing 'micropython-logging': [Errno 5] EIO, packages may be partially installed 235 | >>> 236 | ``` 237 | 238 | It seem `upip` hasn't worked on the ESP32 port of MicroPython for quite some time - see [#5543](https://github.com/micropython/micropython/issues/5543). 239 | 240 | So this makes publishing to PyPI a rather moot point (except that one uses the UNIX port). 241 | 242 | Putting it altogether 243 | --------------------- 244 | 245 | However, given all that it is possible to build and publish a package to PyPI, using Poetry, that could be installed if `upip` currently worked on the ESP32 port. The following is a fully worked example. 246 | 247 | Create the `foo-bar` project directory and setup a venv: 248 | 249 | ``` 250 | $ mkdir foo-bar 251 | $ cd foo-bar 252 | $ python3 -m venv env 253 | $ source env/bin/activate 254 | $ pip install --upgrade pip 255 | ``` 256 | 257 | Create the project content: 258 | 259 | ``` 260 | $ mkdir foo_bar 261 | $ echo 'print("foo-bar")' > foo_bar/foo.py 262 | ``` 263 | 264 | Create the `pyproject.toml` (with `micropython-logging` as a single simple dependency): 265 | 266 | ``` 267 | $ poetry init 268 | Package name [foo-bar]: 269 | Version [0.1.0]: 270 | Description []: 271 | Author [George Hawkins , n to skip]: George Hawkins 272 | License []: MIT 273 | Compatible Python versions [^3.6]: 274 | Would you like to define your main dependencies interactively? (yes/no) [yes] 275 | ... 276 | Search for package to add (or leave blank to continue): micropython-logging 277 | ... 278 | Enter the version constraint to require (or leave blank to use the latest version): 279 | Add a package: 280 | Would you like to define your development dependencies interactively? (yes/no) [yes] no 281 | ... 282 | Do you confirm generation? (yes/no) [yes] 283 | ``` 284 | 285 | As note above, `upip` can't handle version constraints so you have to edit `pyproject.toml` and change the contraint for `micropython-logging` from `"^0.5.2"` to just `""`: 286 | 287 | ``` 288 | $ vim pyproject.toml 289 | ``` 290 | 291 | Produce a source distribution and extract the `setup.py` file from it: 292 | 293 | ``` 294 | $ poetry build --format=sdist 295 | $ name=foo-bar-0.1.0 296 | $ tar --to-stdout -xf dist/$name.tar.gz $name/setup.py > setup.py 297 | ``` 298 | 299 | Edit it to include `import sdist_upip` and `'cmdclass': {{'sdist': sdist_upip.sdist}},` as per the MicroPython [instructions](http://docs.micropython.org/en/latest/reference/packages.html#creating-distribution-packages): 300 | 301 | ``` 302 | $ vim setup.py 303 | ``` 304 | 305 | Download the `sdist_upip.py` that's now needed by `setup.py` as a result of our changes: 306 | 307 | ``` 308 | $ curl -O https://raw.githubusercontent.com/micropython/micropython-lib/master/sdist_upip.py 309 | ``` 310 | 311 | Now get setuptools, rather than Poetry to regenerate the source distribution: 312 | 313 | ``` 314 | $ python setup.py sdist 315 | ``` 316 | 317 | You can now get Poetry to publish this to PyPI: 318 | 319 | ``` 320 | $ poetry publish 321 | ``` 322 | 323 | And the UNIX port of MicroPython can install it and its dependencies: 324 | 325 | ``` 326 | $ micropython -m upip install -p lib foo-bar 327 | ``` 328 | 329 | As could any other _working_ port of `upip`. 330 | 331 | In the end, it's all a bit pointless and as you can see from the above example it would be easier to create `setup.py` directly, work with it and leave Poetry out of the equation. 332 | 333 | micropython-logging version 334 | --------------------------- 335 | 336 | If you look up you'll see that `upip` (using the MicroPython UNIX port) installs version 0.3 of MicroPython: 337 | 338 | ``` 339 | $ micropython -m upip install -p lib micropython-logging 340 | ... 341 | Installing micropython-logging 0.3 from https://micropython.org/pi/logging/logging-0.3.tar.gz 342 | ``` 343 | 344 | Whereas Poetry picks up version 0.52: 345 | 346 | ``` 347 | $ poetry init 348 | ... 349 | Search for package to add (or leave blank to continue): micropython-logging 350 | ... 351 | Enter the version constraint to require (or leave blank to use the latest version): 352 | Using version ^0.5.2 for micropython-logging 353 | ``` 354 | 355 | This confused me for a while. The difference is that `upip` tries micropython.org before it tries pypi.org (see [here](https://github.com/micropython/micropython/blob/69661f3/tools/upip.py#L20)) and: 356 | 357 | * micropython.org has version 0.3 of [micropython/micropython-lib/tree/master/logging](https://github.com/micropython/micropython-lib/tree/master/logging). 358 | * pypi.org has version 0.52 of [pfalcon/pycopy-lib/tree/master/logging](https://github.com/pfalcon/pycopy-lib/tree/master/logging). 359 | 360 | I.e. it's not just different versions, it's also different GitHub projects that are involved. 361 | 362 | Poetry only looks at PyPI so it picks up the version published there. 363 | 364 | Notes 365 | ----- 366 | 367 | If you wanted to add the ability to automatically add `cmdclass` to `setup.py` you'd have to modify the behavior around the `SETUP` string in [`poetry/masonry/builders/sdist.py`](https://github.com/python-poetry/poetry/blob/master/poetry/masonry/builders/sdist.py). 368 | 369 | Remember that if you do submit a Poetry pull-request related to this, and it gets accepted, you'd need to set the minimum Poetry version appropriately in the `.toml` file, i.e. change the line: 370 | 371 | ``` 372 | requires = ["poetry>=0.12"] 373 | ``` 374 | 375 | I asked whether Poetry can produce eggs [here](https://discordapp.com/channels/487711540787675139/487711540787675143/70506829603405836) on their Discord channel - but never received any follow-up. I don't believe it can. 376 | -------------------------------------------------------------------------------- /docs/request-examples.md: -------------------------------------------------------------------------------- 1 | Curl for development 2 | ==================== 3 | 4 | During development, it can be useful to have the WiFi setup process listening for requests on your main network rather than starting its own access point. 5 | 6 | In this state, you can use `curl` to experiment with the basic REST interface of the setup process. 7 | 8 | Example requests 9 | ---------------- 10 | 11 | Set the address to use for all requests: 12 | 13 | $ ADDR=192.168.0.178 14 | 15 | Request the root document: 16 | 17 | $ curl -v $ADDR 18 | 19 | Or: 20 | 21 | $ curl -v $ADDR/index.html 22 | 23 | Deliberately generate a 404 (Not Found): 24 | 25 | $ curl -v $ADDR/unknown 26 | 27 | Set things up to make including the JSON accept header easier and make the same request with this header: 28 | 29 | $ JSON='Accept: application/json' 30 | $ curl -v -H "$JSON" $ADDR/unknown 31 | 32 | The response body now comes back as JSON rather than HTML. 33 | 34 | Some requests only return JSON: 35 | 36 | $ curl -v $ADDR/api/access-points 37 | 38 | There's a slight pause for this request as it goes off and scans for access points. 39 | 40 | If you've got `jq` installed try it again like so: 41 | 42 | $ curl -s -v $ADDR/api/access-points | jq . 43 | 44 | Try the authentication endpoint: 45 | 46 | $ curl -v -H "$JSON" --data 'ssid=alpha&password=beta' $ADDR/api/access-point 47 | 48 | Get it to fail by not providing an SSID: 49 | 50 | $ curl -v -H "$JSON" --data 'password=beta' $ADDR/api/access-point 51 | 52 | Oddy `-v` doesn't show the data sent with `--data`, if you want to see what exactly is sent you need to use `--trace-ascii`: 53 | 54 | $ curl --trace-ascii - -H "$JSON" --data 'ssid=alpha&password=beta' $ADDR/api/access-point 55 | 56 | The output isn't very readable - but everything is there. 57 | 58 | Compression 59 | ----------- 60 | 61 | One of the features I added to the web server used here is that if you request a file like `index.html` and there's no such file, it then checks for `index.html.gz`. If this file exists then the compressed file is served with the `Content-Encoding` header set to `gzip` to indicate this. This saves on storage space on the device and in transmission time to the client (which can typically handle the decompression step far faster than the board could). 62 | 63 | Technically a server should only serve compressed content if the client used the `Accept-Encoding` to indicate that it can consume such content. However, in this setup this check isn't done and is simply assumed. 64 | 65 | This is fine for normal browsers, which all accept compressed content, but if you request static content using `curl` you may be surprised when you get back binary content rather than the expected HTML, Javascript or whatever. 66 | 67 | You can tell `curl` to advertise that it accepts compressed content and to handle the decompression with the `--compressed` flag: 68 | 69 | $ curl -v --compressed $ADDR/index.html 70 | 71 | For more details see the Wikipedia [HTTP compression page](https://en.wikipedia.org/wiki/HTTP_compression). 72 | -------------------------------------------------------------------------------- /docs/screen-setup.md: -------------------------------------------------------------------------------- 1 | Screen and rshell 2 | ----------------- 3 | 4 | I found it useful to use [`screen`](https://www.gnu.org/software/screen/) during development. Here are some basic usage notes. 5 | 6 | Start `screen`: 7 | 8 | $ screen -q 9 | 10 | Split the `screen` with `ctrl-A` and `S`. 11 | 12 | Tab to the new area with `ctrl-A` and `tab`. 13 | 14 | Start a shell here with `ctrl-A` and `c`. 15 | 16 | Activate you venv and start `rshell`: 17 | 18 | $ source env/bin/activate 19 | $ PORT=/dev/ttyUSB0 20 | $ rshell --buffer-size 512 --quiet -p $PORT 21 | > repl 22 | 23 | Tab back to the first area and use `curl`: 24 | 25 | $ ADDR=192.168.0.178 26 | $ curl -v $ADDR/index.html 27 | 28 | To scroll backward within an area use `ctrl-A` and `ESC` - you enter a quasi-vi mode and can move around with your mouse scroll wheel or the usual vi movement keys. 29 | 30 | To exit scroll mode (actually it's called _copy mode_) just press `ESC` again (actually any key which doesn't have a special _copy mode_ meaning will do). 31 | 32 | The above `screen` commands all work the same on Mac and Linux however, in some cases they're different. E.g. quit is `ctrl-A` and `\` on Linux, while on Mac it's `ctrl-A` and `ctrl-\`. 33 | -------------------------------------------------------------------------------- /docs/steps.md: -------------------------------------------------------------------------------- 1 | WiFi setup steps 2 | ================ 3 | 4 | **1.** Install this library on your MicroPython device and add the following to the start of your `main.py`: 5 | 6 | ```python 7 | from wifi_setup.wifi_setup import WiFiSetup 8 | 9 | # You should give every device a unique name (to use as its access point name). 10 | ws = WiFiSetup("ding-5cd80b5") 11 | sta = ws.connect_or_setup() 12 | print("WiFi is setup") 13 | ``` 14 | 15 | Once the device is restarted, you're ready to connect it to the same WiFi network that you use with your phone and other devices. 16 | 17 | **2.** Currently, your phone is on your home network _Foobar Net_. 18 | 19 | 20 | 21 | 22 | **3.** You go to WiFi settings. 23 | 24 | 25 | 26 | **4.** You look at your MicroPython device, which you labeled with the name you gave it in the Python code above. 27 | 28 | ![device](images/labeled-device.jpg) 29 | 30 | The eagle-eyed will notice the mismatch between "ding-5cd80b1" here and "ding-5cd80b5" seen elsewhere - I haven't updated this photo to match. 31 | 32 | **5.** You plug in your device and see it appear as an open network in your WiFi settings. 33 | 34 | 35 | 36 | **6.** You select it and, like a network in a coffee shop or airport, you're told you have to log in. 37 | 38 | 39 | 40 | **7.** However, instead of a login screen, you're presented with a list of networks that your MicroPython device can see. You should select the network, i.e. _Foobar Net_, to which you want to connect your device. 41 | 42 | 43 | 44 | **8.** Once you select your network, you have to enter the network's WiFi password. 45 | 46 | 47 | 48 | **9.** By default the password is hidden. 49 | 50 | 51 | 52 | **10.** But you can click the visibility icon if you prefer the password to be visible as you type it. 53 | 54 | 55 | 56 | **11.** Once you click connect, it whirrs away for a while as your device attempts to connect to the network with the credentials you entered. 57 | 58 | 59 | 60 | **12.** If all goes well, it tells you that it has connected and displays the IP address (or anything you want) that the device now has on that network. You press the copy icon, to hang onto the IP address, and click the OK button. 61 | 62 | 63 | 64 | **13.** The device now shuts down the temporary WiFi network that was created for this setup process. 65 | 66 | 67 | 68 | **14.** With the temporary network now gone, the usual behavior of your phone is to automatically return you to your previous network, i.e _Foobar Net_. 69 | 70 | 71 | 72 | **15.** Now you return to your phone's main screen and select your web browser application, i.e. usually Chrome or Safari. 73 | 74 | 75 | 76 | 77 | **16.** You paste the IP address, that you copied earlier, into the web address field. 78 | 79 | 80 | 81 | **17.** For the purposes of this demo, your MicroPython device just serves up a web page of a cute little ghost hovering up and down. 82 | 83 | 84 | 85 | The entered credentials are stored on the device and the next time it is restarted it will reconnect to the network rather than starting the temporary WiFi network seen here for setup. 86 | 87 | Notes 88 | ----- 89 | 90 | No other device or service is involved other than your phone and your MicroPython device, i.e. your MicroPython device serves the web pages involved to your phone and manages the whole login process. 91 | 92 | You may notice a red lock icon in some of the screenshots, this indicates that the network involved is using WPA2 Enterprise which is not currently supported. 93 | 94 | By default the IP address of your device is displayed when it successfully connects to the chosen network however, you can configure the code involved to return whatever you want, e.g. an MQTT topic name. 95 | 96 | The dialog that presents you with the IP address uses a kind of [dead man's switch](https://en.wikipedia.org/wiki/Dead_man%27s_switch) logic in the background that keeps the temporary WiFi network alive as long as it's showing. So the temporary WiFi network will shut down if you explicitly close the dialog (by pressing OK) or if you e.g. simply manually switch back to your normal WiFi network. 97 | 98 | The ghost is a tiny CSS demo by Helen V. Holmes and was found [here](https://codepen.io/scoooooooby/pen/pecdI). 99 | -------------------------------------------------------------------------------- /lib/README.md: -------------------------------------------------------------------------------- 1 | This code comes from 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 | --------------------------------------------------------------------------------