├── LICENSE.md ├── README.md ├── boot.py ├── code.py └── wifimgr.py /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2023 Robert Klebe, dotpointer 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 | # CircuitPython WiFi Manager 2 | 3 | A WiFi manager for CircuitPython. Opens an access point to allow the user to 4 | configure the device to configure the device to connect to available WiFi 5 | networks. When the device is configured it then connects to the first 6 | available matching network and hands over the control to your code. 7 | 8 | Based upon the original version in MicroPython, see the Original MicroPython 9 | version authors section. 10 | 11 | ## Description 12 | 13 | WiFi manager for CircuitPython 8+. 14 | 15 | ## Compatibility 16 | 17 | Tested on WeMos/LOLIN S2 Mini/Adafruit Feather ESP32-S2 with CircuitPython 8.0.2. 18 | 19 | Should be compatible with other CircuitPython 8+ devices too, please try it 20 | out to find out. 21 | 22 | The device must have CircuitPython WiFi hardware support. 23 | 24 | ## Main Features 25 | 26 | - Brings up an access point with a web based connection manager 27 | located at http://192.168.4.1/ if no network as been configured 28 | - Saves WiFi SSID:s and passwords in "wifi.dat" in CSV format 29 | - Connects automatically to the first available matching network 30 | - Easy to apply 31 | - Reset settings GPIO button support to let the user reset WiFi settings 32 | - USB write protected GPIO button support to try it out while connected to the computer 33 | 34 | ## Usage 35 | 36 | Install CircuitPython 8+ on your device. 37 | 38 | Upload boot.py, code.py and wifimgr.py to the file system of the device. 39 | 40 | Write your code below the connection prodedure in code.py or import it from 41 | code.py. 42 | 43 | Setup a GPIO to GND jumper wire or button in boot.py to control when 44 | CircuitPython or the connected computer can write to the file system. 45 | 46 | Connect to the WifiManager_AABBCC network, the password is "password". 47 | Visit http://192.168.4.1/ to configure. 48 | 49 | ## How it works 50 | 51 | It scans for available networks and then checks "wifi.dat" for matching 52 | network SSID:s and then it tries to connect the matching networks. 53 | 54 | If that did not succeed then it opens an access point with a web page 55 | to allow the user to configure WiFi client settings. Then it tries to 56 | connect and saves the settings to "wifi.dat". 57 | 58 | Then when a connection to an access point has been established it 59 | hands over the control to your code. 60 | 61 | ## Important to know 62 | 63 | Only the device or the USB host (like a computer) are allowed write-access 64 | to the file system in CircuitPython - not both at the same time. Therefore 65 | there are GPIO options in boot.py to setup a button to choose which one should 66 | have write access. Connect the GPIO and GND (push the button) to give 67 | CircuitPython write permission. 68 | 69 | When the code.py has completed the connection stops. This is by CircuitPython 70 | design. It is maybe obvious, but to keep the device responding to ping for 71 | instance you need to have a simple loop preferably with a sleep timeout 72 | running. 73 | 74 | ## How to regain write permission 75 | 76 | If you loose write permission to the file system of the device then you can 77 | do this to remove the file that locks it through a serial connection: 78 | 79 | 1. Make a backup of your files on the file system to a folder on your computer. 80 | 81 | 2. Open a serial connection. For example in Debian Linux you can use screen: 82 | apt install screen, then ls /dev/ttyACM* to find the current serial port number - 83 | note the number it is ending in, usually 0, then screen /dev/ttyACM0 - replace 84 | 0 with your number, then press Enter. 85 | 86 | 2. Press Ctrl+C, then Enter to get the REPL prompt. 87 | 88 | 3. Type import os, then os.listdir() to list files or os.unlink('boot.py') to 89 | remove boot.py or another file that is doing the locking, then hard reset the 90 | device by removing USB or power or connecting the EN pin to GND if available. 91 | 92 | ## Notable behaviour differences from the MicroPython version 93 | 94 | It does not automatically connect to the first open network it finds, 95 | because that did not seem secure nor usable. 96 | 97 | It does not open an access point if none of the configured networks 98 | are connectable because of security reasons. 99 | 100 | ## Issues and contributions 101 | 102 | If you find an issue please also supply a possible solution if possible, maybe 103 | as a pull request. 104 | 105 | ## Authors 106 | 107 | * **Robert Klebe** - *Development* - [dotpointer](https://github.com/dotpointer) 108 | 109 | ## Original MicroPython version authors 110 | 111 | * **Tayfun ULU** - *Original MicroPython version* - [tayfunulu](https://github.com/tayfunulu/WiFiManager/) 112 | 113 | * **CPOPP** - *Original MicroPython version - web server* - [CPOPP](https://github.com/cpopp/MicroPythonSamples) 114 | 115 | ## License 116 | 117 | This project is licensed under the MIT License - see the 118 | [LICENSE.md](LICENSE.md) file for details. 119 | -------------------------------------------------------------------------------- /boot.py: -------------------------------------------------------------------------------- 1 | import digitalio 2 | import microcontroller 3 | import storage 4 | 5 | # make the USB read only button, to allow CircuitPython to write to the file 6 | # system 7 | # usage: change the GPIO below, then connect a jumper wire or button between 8 | # the GPIO and the GND pin 9 | button_usb_readonly = digitalio.DigitalInOut(microcontroller.pin.GPIO38) 10 | button_usb_readonly.direction = digitalio.Direction.INPUT 11 | button_usb_readonly.pull = digitalio.Pull.UP 12 | if not button_usb_readonly.value: 13 | storage.remount("/", False) 14 | 15 | # reset settings button, to reset the wifi settings, useful to wire to a reset 16 | # button on the physical hardware to allow the user to reset the wifi settings 17 | # usage: change the GPIO below, then connect a jumper wire or button between 18 | # the GPIO and the GND pin to empty the wifi.dat file, please note that 19 | # CircuitPython needs write access, see the note above about this 20 | button_reset_settings = digitalio.DigitalInOut(microcontroller.pin.GPIO40) 21 | button_reset_settings.direction = digitalio.Direction.INPUT 22 | button_reset_settings.pull = digitalio.Pull.UP 23 | if not button_reset_settings.value: 24 | print('Emptying wifi.dat') 25 | try: 26 | with open('wifi.dat', 'w') as f: 27 | f.write('') 28 | except OSError as e: 29 | print("Exception", str(e)) 30 | -------------------------------------------------------------------------------- /code.py: -------------------------------------------------------------------------------- 1 | import supervisor 2 | import time 3 | import wifimgr 4 | 5 | # disable auto reload to stop unexpected reloads 6 | supervisor.set_next_code_file(filename = 'code.py', reload_on_error = False, reload_on_success = False) 7 | supervisor.runtime.autoreload = False 8 | 9 | wlan = wifimgr.get_connection() 10 | if wlan is None: 11 | print("Could not initialize the network connection.") 12 | while True: 13 | pass 14 | 15 | # put your code below this line, example below to keep it running, 16 | # otherwise it will not respond to ping 17 | print("WifiManager routines has ended") 18 | while True: 19 | time.sleep(1) 20 | -------------------------------------------------------------------------------- /wifimgr.py: -------------------------------------------------------------------------------- 1 | import re 2 | import socketpool 3 | import storage 4 | import time 5 | import wifi 6 | 7 | # extract access point mac address 8 | mac_ap = ' '.join([hex(i) for i in wifi.radio.mac_address_ap]) 9 | mac_ap = mac_ap.replace('0x', '').replace(' ', '').upper() 10 | 11 | # access point settings 12 | AP_SSID = "WifiManager_" + mac_ap[5:10] + mac_ap[1:2] 13 | AP_PASSWORD = "password" 14 | # AP_AUTHMODES = [wifi.AuthMode.OPEN] 15 | AP_AUTHMODES = [wifi.AuthMode.WPA2, wifi.AuthMode.PSK] 16 | 17 | FILE_NETWORK_PROFILES = 'wifi.dat' 18 | ap_enabled = False 19 | server_socket = None 20 | 21 | def do_connect(ssid, password): 22 | wifi.radio.enabled = True 23 | if wifi.radio.ap_info is not None: 24 | return None 25 | print('Trying to connect to "%s"...' % ssid) 26 | try: 27 | wifi.radio.connect(ssid, password) 28 | except Exception as e: 29 | print("Exception", str(e)) 30 | for retry in range(200): 31 | connected = wifi.radio.ap_info is not None 32 | if connected: 33 | break 34 | time.sleep(0.1) 35 | print('.', end='') 36 | if connected: 37 | t = [] 38 | t.append(str(wifi.radio.ipv4_address)) 39 | t.append(str(wifi.radio.ipv4_subnet)) 40 | t.append(str(wifi.radio.ipv4_gateway)) 41 | t.append(str(wifi.radio.ipv4_dns)) 42 | print('\nConnected. Network config: IP: ', end='') 43 | print('%s, subnet: %s, gateway: %s, DNS: %s' % tuple(t)) 44 | 45 | else: 46 | print('\nFailed. Not Connected to: ' + ssid) 47 | return connected 48 | 49 | 50 | def get_connection(): 51 | """return a working wifi.radio connection or None""" 52 | 53 | while wifi.radio.ap_info is None: 54 | # first check if there already is any connection: 55 | if wifi.radio.ap_info is not None: 56 | print('WiFi connection detected') 57 | return wifi.radio 58 | 59 | connected = False 60 | # connecting takes time, wait and retry 61 | time.sleep(3) 62 | if wifi.radio.ap_info is not None: 63 | print('WiFi connection detected') 64 | return wifi.radio 65 | 66 | # read known network profiles from file 67 | profiles = read_profiles() 68 | 69 | # search networks in range 70 | wifi.radio.enabled = True 71 | 72 | # networks are configured 73 | if (len(profiles)): 74 | networks = [] 75 | for n in wifi.radio.start_scanning_networks(): 76 | networks.append([n.ssid, n.bssid, n.channel, n.rssi, n.authmode]) 77 | wifi.radio.stop_scanning_networks() 78 | 79 | for ssid, bssid, channel, rssi, authmodes in sorted(networks, key=lambda x: x[3], reverse=True): 80 | encrypted = 0 81 | authmodes_text = [] 82 | for authmode in authmodes: 83 | if (authmode == wifi.AuthMode.OPEN): 84 | authmodes_text.append('Open') 85 | elif (authmode == wifi.AuthMode.WEP): 86 | authmodes_text.append('WEP') 87 | encrypted = 1 88 | elif (authmode == wifi.AuthMode.WEP): 89 | authmodes_text.append('WPA') 90 | encrypted = 1 91 | elif (authmode == wifi.AuthMode.WPA): 92 | authmodes_text.append('WPA') 93 | encrypted = 1 94 | elif (authmode == wifi.AuthMode.WPA2): 95 | authmodes_text.append('WPA2') 96 | encrypted = 1 97 | elif (authmode == wifi.AuthMode.WPA3): 98 | authmodes_text.append('WPA3') 99 | encrypted = 1 100 | elif (authmode == wifi.AuthMode.PSK): 101 | authmodes_text.append('PSK') 102 | encrypted = 1 103 | elif (authmode == wifi.AuthMode.ENTERPRISE): 104 | authmodes_text.append('ENTERPRISE') 105 | encrypted = 1 106 | authmodes_text = ', '.join(authmodes_text) 107 | print("Found \"%s\", #%d, %d dB, %s" % (ssid, channel, rssi, authmodes_text), end='') 108 | if ssid in profiles: 109 | print(", known") 110 | if encrypted: 111 | password = profiles[ssid] 112 | connected = do_connect(ssid, password) 113 | else: # open 114 | print(", open") 115 | connected = do_connect(ssid, None) 116 | else: 117 | print(", unknown") 118 | if connected: 119 | break 120 | # no networks configured 121 | else: 122 | connected = start_ap() 123 | 124 | if connected: 125 | return wifi.radio 126 | 127 | def handle_configure(client, request): 128 | global ap_enabled 129 | print('Handle configure start') 130 | print("Request:", request.strip()) 131 | match = re.search("ssid=([^&]*)&password=(.*)", request) 132 | 133 | if match is None: 134 | send_response(client, "Parameters not found", status_code=400) 135 | print('Handle configure aborted, missing parameters') 136 | return False 137 | ssid = match.group(1).replace("%3F", "?").replace("%21", "!") 138 | password = match.group(2).replace("%3F", "?").replace("%21", "!") 139 | 140 | if len(ssid) == 0: 141 | send_response(client, "SSID must be provided", status_code=400) 142 | print('Handling configure aborted, no SSID provided') 143 | return False 144 | 145 | if do_connect(ssid, password): 146 | try: 147 | profiles = read_profiles() 148 | except OSError: 149 | profiles = {} 150 | profiles[ssid] = password 151 | write_result = write_profiles(profiles) 152 | response = get_html_head() + """\ 153 |

154 | """ 155 | response = response + """\ 156 | Successfully connected to the WiFi network "%(ssid)s". 157 | """ % dict(ssid=ssid) 158 | 159 | if write_result is False: 160 | print('Failed to write changes') 161 | response = response + """\ 162 |

163 | Failed to save changes. 164 | """ 165 | response = response + """\ 166 |

167 | """ 168 | response = response + get_html_footer() 169 | send_response(client, response) 170 | time.sleep(30) 171 | if write_result: 172 | if ap_enabled: 173 | wifi.radio.stop_ap() 174 | ap_enabled = False 175 | print('Access point stopped') 176 | time.sleep(5) 177 | print('Handle configure end, connected') 178 | # to require write success: 179 | # else: 180 | # wifi.radio.stop_station() 181 | # print('Handle configure end, connected and disconnected') 182 | # return write_result 183 | else: 184 | 185 | response = get_html_head() 186 | response = response + """\ 187 |

Could not connect to the WiFi network "%(ssid)s".

188 |
189 |
190 | 191 |
192 |
193 | """ % dict(ssid=ssid) 194 | response = response + get_html_footer() 195 | send_response(client, response) 196 | print('Handle configure ended, no connection') 197 | return False 198 | 199 | def handle_not_found(client, url): 200 | print('Handle not found start') 201 | send_response(client, "Path not found: {}".format(url), status_code=404) 202 | print('Handle not found end') 203 | 204 | def get_html_footer(): 205 | return """\ 206 | 215 | 216 | 217 | """ 218 | 219 | def get_html_head(): 220 | global AP_SSID 221 | return """\ 222 | 223 | 224 | 225 | """ + AP_SSID + """ - Wi-Fi client setup 226 | 306 | 307 | 308 |

""" + AP_SSID + """ - Wi-Fi client setup

309 | """ 310 | 311 | def sendall(client, data): 312 | data = data.replace(' ', ' ') 313 | while len(data): 314 | # split data in chunks to avoid EAGAIN exception 315 | part = data[0:512] 316 | data = data[len(part):len(data)] 317 | print('Sending: ' + str(len(part)) + 'b') 318 | # EAGAIN too much data exception catcher 319 | while True: 320 | try: 321 | client.sendall(part) 322 | except OSError as e: 323 | print("Exception", str(e)) 324 | time.sleep(0.25) 325 | pass 326 | break 327 | 328 | def handle_root(client): 329 | print('Handle / start') 330 | wifi.radio.enabled = True 331 | 332 | networks = [] 333 | for n in wifi.radio.start_scanning_networks(): 334 | print("Found \"%s\", #%s" % (n.ssid, n.channel)) 335 | networks.append([n.ssid, n.channel]) 336 | wifi.radio.stop_scanning_networks() 337 | send_header(client) 338 | sendall(client, get_html_head()) 339 | sendall(client, """\ 340 |
341 | """) 342 | while len(networks): 343 | network = networks.pop(0) 344 | sendall(client, """\ 345 |
346 | 347 | 348 |
349 |
350 | """.format(network[0], network[1])) 351 | sendall(client, """\ 352 |
353 |

354 |
355 | """) 356 | 357 | if storage.getmount('/').readonly: 358 | sendall(client, """\ 359 |

Warning, the file system is in read-only mode, settings will not be saved.

360 | """) 361 | else: 362 | sendall(client, """\ 363 |

364 | The SSID and password will be saved in the 365 | "%(filename)s" on the device. 366 |

367 | """ % dict(filename=FILE_NETWORK_PROFILES)) 368 | sendall(client, get_html_footer()) 369 | client.close() 370 | print('Handle / end') 371 | 372 | def read_profiles(): 373 | profiles = {} 374 | try: 375 | with open(FILE_NETWORK_PROFILES) as f: 376 | lines = f.readlines() 377 | for line in lines: 378 | ssid, password = line.strip("\n").split(";") 379 | profiles[ssid] = password 380 | except OSError: 381 | profiles = {} 382 | return profiles 383 | 384 | def send_header(client, status_code=200, content_length=None): 385 | sendall(client, "HTTP/1.0 {} OK\r\n".format(status_code)) 386 | sendall(client, "Content-Type: text/html\r\n") 387 | if content_length is not None: 388 | sendall(client, "Content-Length: {}\r\n".format(content_length)) 389 | sendall(client, "\r\n") 390 | 391 | def send_response(client, payload, status_code=200): 392 | content_length = len(payload) 393 | send_header(client, status_code, content_length) 394 | if content_length > 0: 395 | sendall(client, payload) 396 | client.close() 397 | 398 | def start_ap(port=80): 399 | global ap_enabled, server_socket 400 | 401 | addr = socketpool.SocketPool(wifi.radio).getaddrinfo('0.0.0.0', port)[0][-1] 402 | 403 | if server_socket: 404 | server_socket.close() 405 | server_socket = None 406 | 407 | wifi.radio.enabled = True 408 | 409 | if ap_enabled is False: 410 | # to use encrypted AP, use authmode=[wifi.AuthMode.WPA2, wifi.AuthMode.PSK] 411 | if (AP_AUTHMODES[0] == wifi.AuthMode.OPEN): 412 | wifi.radio.start_ap(ssid=AP_SSID, authmode=AP_AUTHMODES) 413 | else: 414 | wifi.radio.start_ap(ssid=AP_SSID, password=AP_PASSWORD, authmode=AP_AUTHMODES) 415 | ap_enabled = True 416 | 417 | server_socket = socketpool.SocketPool(wifi.radio).socket() 418 | server_socket.bind(addr) 419 | server_socket.listen(1) 420 | if storage.getmount('/').readonly: 421 | print('File system is read only') 422 | else: 423 | print('File system is writeable') 424 | print('Access point started, connect to WiFi "' + AP_SSID + '"', end='') 425 | if (AP_AUTHMODES[0] != wifi.AuthMode.OPEN): 426 | print(', the password is "' + AP_PASSWORD + '"') 427 | else: 428 | print('') 429 | print('Visit http://' + str(wifi.radio.ipv4_address_ap) + '/ in your web browser') 430 | # print('Listening on:', addr) 431 | 432 | while True: 433 | if wifi.radio.ap_info is not None: 434 | print('WiFi connection detected') 435 | if ap_enabled: 436 | wifi.radio.stop_ap() 437 | ap_enabled = False 438 | print('Access point stopped') 439 | return True 440 | 441 | # EAGAIN exception catcher 442 | while True: 443 | try: 444 | client, addr = server_socket.accept() 445 | except OSError as e: 446 | print("Exception", str(e)) 447 | time.sleep(0.25) 448 | pass 449 | break 450 | 451 | print('Client connected - %s:%s' % addr) 452 | try: 453 | client.settimeout(5) 454 | 455 | request = b"" 456 | try: 457 | while "\r\n\r\n" not in request: 458 | buffer = bytearray(512) 459 | client.recv_into(buffer, 512) 460 | request += buffer 461 | print('Received data') 462 | except OSError: 463 | pass 464 | 465 | # Handle form data from Safari on macOS and iOS; it sends \r\n\r\nssid=&password= 466 | try: 467 | buffer = bytearray(1024) 468 | client.recv_into(buffer, 1024) 469 | request += buffer 470 | print("Received form data after \\r\\n\\r\\n(i.e. from Safari on macOS or iOS)") 471 | except OSError: 472 | pass 473 | 474 | request = request.decode().strip("\x00").replace('%23', '#') 475 | 476 | print("Request is: {}".format(request)) 477 | if "HTTP" not in request: # skip invalid requests 478 | continue 479 | 480 | url = re.search("(?:GET|POST) (.*?)(?:\\?.*?)? HTTP", request).group(1) 481 | print("URL is {}".format(url)) 482 | 483 | if url == "/": 484 | handle_root(client) 485 | elif url == "/configure": 486 | handle_configure(client, request) 487 | else: 488 | handle_not_found(client, url) 489 | 490 | finally: 491 | client.close() 492 | 493 | def write_profiles(profiles): 494 | print('Write profiles start') 495 | lines = [] 496 | for ssid, password in profiles.items(): 497 | print('Preparing line for "' + ssid + '"') 498 | lines.append("%s;%s\n" % (ssid, password)) 499 | try: 500 | print('Writing ' + FILE_NETWORK_PROFILES) 501 | with open(FILE_NETWORK_PROFILES, "w") as f: 502 | f.write(''.join(lines)) 503 | return True 504 | except OSError as e: 505 | print("Exception", str(e)) 506 | return False 507 | print('Write profiles end') 508 | --------------------------------------------------------------------------------