├── .gitignore ├── LICENSE ├── README.md ├── system_flow.dot ├── system_flow.png ├── system_flow.svg ├── test ├── __init__.py ├── logging.py ├── network.py ├── networks_always.json ├── networks_fallback.json ├── networks_never.json ├── requirements.txt ├── sample_scans.py ├── test_async.py ├── test_manager.py └── webrepl.py ├── test_wifimanager.py ├── unittest.py ├── wifi_manager ├── __init__.py ├── metadata.txt ├── setup.py └── wifi_manager.py └── wifi_manager_tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | /Icon 103 | /.idea 104 | /.DS_Store 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017, Mitchell Currie 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # micropython-wifimanager 2 | A simple network configuration utility for MicroPython on boards such as ESP8266 and ESP32. 3 | 4 | #### Configuration 5 | 6 | Simply upload your JSON file with your networks, the default path is '/networks.json', which is specified in the class property `config_file`. 7 | 8 | A sample configuration may look like this: 9 | 10 | { 11 | "schema": 2, 12 | "known_networks": [ 13 | { 14 | "ssid": "User\u2019s iPhone", 15 | "password": "Password1", 16 | "enables_webrepl": false 17 | }, 18 | { 19 | "ssid": "HomeNetwork", 20 | "password": "Password2", 21 | "enables_webrepl": true 22 | } 23 | ], 24 | "access_point": { 25 | "config": { 26 | "essid": "Micropython-Dev", 27 | "channel": 11, 28 | "hidden": false, 29 | "password": "P@55W0rd" 30 | }, 31 | "enables_webrepl": true, 32 | "start_policy": "fallback" 33 | } 34 | } 35 | 36 | #### Configuration schema 37 | 38 | * **schema**: currently this should be `2` 39 | * **known_networks**: list of networks to connect to, in order of most preferred first 40 | * SSID - the name of the access point 41 | * password - the clear test password to use 42 | * enables_webrepl - a boolean value to indicate if connection to this network desires webrepl being started 43 | * **access_point**: the details for the access point (AP) of this device 44 | * config - the keys for the AP config, exactly as per the micropython documentation 45 | * enables_weprepl - a boolean value to indicate if ceating this network desires webrepl being started 46 | * start_policy - A policy from the below list to indicate when to enable the AP 47 | * 'always' - regardless of the connection to any base station, AP will be started 48 | * 'fallback' - the AP will only be started if no network could be connected to 49 | * 'never' - The AP will not be started under any condition 50 | 51 | #### Simple usage (one shot) 52 | 53 | Here's an example of how to use the WifiManager. 54 | 55 | MicroPython v1.9.4 on 2018-05-11; ESP32 module with ESP32 56 | Type "help()" for more information. 57 | >>> from wifi_manager import WifiManager 58 | >>> WifiManager.setup_network() 59 | connecting to network Foo-Network... 60 | WebREPL daemon started on ws://10.1.1.234:8266 61 | Started webrepl in normal mode 62 | True 63 | 64 | 65 | #### Asynchronous usage (event loop) 66 | 67 | The WifiManager can be run asynchronously, via the cooperative scheduling that micropthon has in uasyncio. If you call `WifiManager.start_managing()` as follows, it will ensure that periodically the network status is scanned, and connection will be re-established as per preferences as needed. 68 | 69 | import uasyncio as asyncio 70 | import logging 71 | from wifi_manager import WifiManager 72 | 73 | logging.basicConfig(level=logging.WARNING) 74 | WifiManager.start_managing() 75 | asyncio.get_event_loop().run_forever() 76 | 77 | 78 | #### Contribution 79 | 80 | Found a bug, or want a feature? open an issue. 81 | 82 | If you want to contribute, create a pull request. 83 | 84 | #### System flow 85 | 86 | ![System flow](./system_flow.png) 87 | -------------------------------------------------------------------------------- /system_flow.dot: -------------------------------------------------------------------------------- 1 | digraph G { 2 | "asynchronous" -> "event loop" [label = "start_managing()"] 3 | "event loop" -> "check status" [label = "manage()"] 4 | "check status" -> "sleep" [label = "connected"] 5 | "check status" -> "read config" [label = "disconnected"] 6 | "synchronous" -> "read config" [label = "setup_network()"] 7 | "read config" -> "connect" [label = "scan wifi"] 8 | "connect" -> "do AP" [label = "finished"] 9 | "connect" -> "retry" [label = "error"] 10 | "retry" -> "connect" [label = "next"] 11 | "retry" -> "do AP" [label = "failed"] 12 | "do AP" -> "WebRepl" 13 | "WebRepl" -> "sleep" [label = "asynchronous"] 14 | "WebRepl" -> "return" [label = "synchronous"] 15 | "sleep" -> "event loop" 16 | 17 | {rank = same; "asynchronous"; "event loop"; "synchronous";} 18 | } 19 | -------------------------------------------------------------------------------- /system_flow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchins/micropython-wifimanager/92b841b8c034c8be782f223fae6dcaecfda206bb/system_flow.png -------------------------------------------------------------------------------- /system_flow.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | 14 | asynchronous 15 | 16 | asynchronous 17 | 18 | 19 | 20 | event loop 21 | 22 | event loop 23 | 24 | 25 | 26 | asynchronous->event loop 27 | 28 | 29 | start_managing() 30 | 31 | 32 | 33 | check status 34 | 35 | check status 36 | 37 | 38 | 39 | event loop->check status 40 | 41 | 42 | manage() 43 | 44 | 45 | 46 | sleep 47 | 48 | sleep 49 | 50 | 51 | 52 | check status->sleep 53 | 54 | 55 | connected 56 | 57 | 58 | 59 | read config 60 | 61 | read config 62 | 63 | 64 | 65 | check status->read config 66 | 67 | 68 | disconnected 69 | 70 | 71 | 72 | sleep->event loop 73 | 74 | 75 | 76 | 77 | 78 | connect 79 | 80 | connect 81 | 82 | 83 | 84 | read config->connect 85 | 86 | 87 | scan wifi 88 | 89 | 90 | 91 | synchronous 92 | 93 | synchronous 94 | 95 | 96 | 97 | synchronous->read config 98 | 99 | 100 | setup_network() 101 | 102 | 103 | 104 | do AP 105 | 106 | do AP 107 | 108 | 109 | 110 | connect->do AP 111 | 112 | 113 | finished 114 | 115 | 116 | 117 | retry 118 | 119 | retry 120 | 121 | 122 | 123 | connect->retry 124 | 125 | 126 | error 127 | 128 | 129 | 130 | WebRepl 131 | 132 | WebRepl 133 | 134 | 135 | 136 | do AP->WebRepl 137 | 138 | 139 | 140 | 141 | 142 | retry->connect 143 | 144 | 145 | next 146 | 147 | 148 | 149 | retry->do AP 150 | 151 | 152 | failed 153 | 154 | 155 | 156 | WebRepl->sleep 157 | 158 | 159 | asynchronous 160 | 161 | 162 | 163 | return 164 | 165 | return 166 | 167 | 168 | 169 | WebRepl->return 170 | 171 | 172 | synchronous 173 | 174 | 175 | 176 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchins/micropython-wifimanager/92b841b8c034c8be782f223fae6dcaecfda206bb/test/__init__.py -------------------------------------------------------------------------------- /test/logging.py: -------------------------------------------------------------------------------- 1 | # 07/07/2018 via https://raw.githubusercontent.com/micropython/micropython-lib/master/logging/logging.py 2 | import sys 3 | 4 | CRITICAL = 50 5 | ERROR = 40 6 | WARNING = 30 7 | INFO = 20 8 | DEBUG = 10 9 | NOTSET = 0 10 | 11 | _level_dict = { 12 | CRITICAL: "CRIT", 13 | ERROR: "ERROR", 14 | WARNING: "WARN", 15 | INFO: "INFO", 16 | DEBUG: "DEBUG", 17 | } 18 | 19 | _stream = sys.stderr 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 | def error(self, msg, *args): 58 | self.log(ERROR, msg, *args) 59 | 60 | def critical(self, msg, *args): 61 | self.log(CRITICAL, msg, *args) 62 | 63 | def exc(self, e, msg, *args): 64 | self.log(ERROR, msg, *args) 65 | sys.print_exception(e, _stream) 66 | 67 | def exception(self, msg, *args): 68 | self.exc(sys.exc_info()[1], msg, *args) 69 | 70 | 71 | _level = INFO 72 | _loggers = {} 73 | 74 | def getLogger(name): 75 | if name in _loggers: 76 | return _loggers[name] 77 | l = Logger(name) 78 | _loggers[name] = l 79 | return l 80 | 81 | def info(msg, *args): 82 | getLogger(None).info(msg, *args) 83 | 84 | def debug(msg, *args): 85 | getLogger(None).debug(msg, *args) 86 | 87 | def basicConfig(level=INFO, filename=None, stream=None, format=None): 88 | global _level, _stream 89 | _level = level 90 | if stream: 91 | _stream = stream 92 | if filename is not None: 93 | print("logging.basicConfig: filename arg is not supported") 94 | if format is not None: 95 | print("logging.basicConfig: format arg is not supported") -------------------------------------------------------------------------------- /test/network.py: -------------------------------------------------------------------------------- 1 | # Stubbed status 2 | STAT_IDLE = 1 3 | STAT_CONNECTING = 2 4 | STAT_GOT_IP = 3 5 | STAT_NO_AP_FOUND = 10 6 | STAT_WRONG_PASSWORD = 11 7 | STAT_BEACON_TIMEOUT = 12 8 | STAT_ASSOC_FAIL = 13 9 | STAT_HANDSHAKE_TIMEOUT = 14 10 | 11 | class AbstractNetwork: 12 | def __init__(self): 13 | self.config_dict = {} 14 | self.is_active = False 15 | 16 | def active(self, *args): 17 | if len(args) > 0: 18 | self.is_active = args[0] 19 | else: 20 | return self.is_active 21 | 22 | def config(self, *args, **kwargs): 23 | if len(kwargs) > 0: 24 | for key in kwargs.keys(): 25 | self.config_dict[key] = kwargs[key] 26 | elif len(args) == 1: 27 | return self.config_dict[args[0]] 28 | 29 | 30 | # The client access interface 31 | class STA_IF(AbstractNetwork): 32 | def __init__(self): 33 | AbstractNetwork.__init__(self) 34 | self.scan_results = [] 35 | self.connected = False 36 | # Cache the argument to 'successful' connect call (if the network was in scan_results) 37 | self.DEBUG_CONNECTED_SSID = None 38 | self.DEBUG_CONNECTED_BSSID = None 39 | 40 | #def connect(self, ssid, key=None, **kwargs): 41 | def connect(self, ssid, key=None, *, bssid): 42 | for network in self.scan_results: 43 | should_connect = network[0].decode('utf-8') == ssid and \ 44 | (bssid is None or bssid == network[1]) 45 | if should_connect: 46 | self.connected = True 47 | self.DEBUG_CONNECTED_SSID = ssid 48 | self.DEBUG_CONNECTED_BSSID = bssid 49 | return 50 | self.connected = False 51 | 52 | def scan(self): 53 | return self.scan_results 54 | 55 | def isconnected(self): 56 | return self.connected 57 | 58 | def status(self): 59 | # "STAT_IDLE" "STAT_CONNECTING" "STAT_WRONG_PASSWORD" "STAT_NO_AP_FOUND" "STAT_CONNECT_FAIL" "STAT_GOT_IP" 60 | if self.connected: 61 | return STAT_GOT_IP 62 | else: 63 | return STAT_IDLE 64 | 65 | 66 | # The adhoc access point master 67 | class AP_IF(AbstractNetwork): 68 | def __init__(self): 69 | AbstractNetwork.__init__(self) 70 | 71 | 72 | # Function to imitate the micropython network factory 73 | interfaces = {STA_IF: STA_IF(), AP_IF: AP_IF()} 74 | 75 | 76 | def WLAN(kind): 77 | return interfaces[kind] 78 | 79 | 80 | def DEBUG_RESET(): 81 | interfaces[STA_IF] = STA_IF() 82 | interfaces[AP_IF] = AP_IF() 83 | -------------------------------------------------------------------------------- /test/networks_always.json: -------------------------------------------------------------------------------- 1 | { 2 | "known_networks": [ 3 | { 4 | "ssid": "HomeNetwork", 5 | "password": "XYZ12345", 6 | "enables_webrepl": false 7 | } 8 | ], 9 | "access_point": { 10 | "essid": "Micropython-Dev", 11 | "channel": 11, 12 | "hidden": false, 13 | "password": "P@55W0rd", 14 | "enables_webrepl": true, 15 | "start_policy": "always" 16 | } 17 | } -------------------------------------------------------------------------------- /test/networks_fallback.json: -------------------------------------------------------------------------------- 1 | { 2 | "known_networks": [ 3 | { 4 | "ssid": "HomeNetwork", 5 | "password": "XYZ12345", 6 | "enables_webrepl": false 7 | } 8 | ], 9 | "access_point": { 10 | "essid": "Micropython-Dev", 11 | "channel": 11, 12 | "hidden": false, 13 | "password": "P@55W0rd", 14 | "enables_webrepl": true, 15 | "start_policy": "fallback" 16 | } 17 | } -------------------------------------------------------------------------------- /test/networks_never.json: -------------------------------------------------------------------------------- 1 | { 2 | "known_networks": [ 3 | { 4 | "ssid": "HomeNetwork", 5 | "password": "XYZ12345", 6 | "enables_webrepl": false 7 | } 8 | ], 9 | "access_point": { 10 | "essid": "Micropython-Dev", 11 | "channel": 11, 12 | "hidden": false, 13 | "password": "P@55W0rd", 14 | "enables_webrepl": true, 15 | "start_policy": "never" 16 | } 17 | } -------------------------------------------------------------------------------- /test/requirements.txt: -------------------------------------------------------------------------------- 1 | micropython-json 2 | micropython-time 3 | micropython-os 4 | micropython-uasyncio 5 | -------------------------------------------------------------------------------- /test/sample_scans.py: -------------------------------------------------------------------------------- 1 | # Simple Case 2 | def scan1(): 3 | return [ 4 | (b'HomeNetwork', b'\x90r@\x1f\xf0\xe4', 11, -51, 3, False), 5 | (b'Telstra524E82', b'$\x7f RN\x88', 1, -71, 3, False), 6 | (b'Skynet', b'\x00\x04\xedL\xd3\xaf', 3, -82, 4, False), 7 | (b'Telstra2957', b'\x10\xdaC\xf6\x86\x87', 1, -86, 3, False), 8 | (b'Fon WiFi', b'\x12\xdaC\xf6\x86\x8a', 1, -86, 0, False), 9 | (b'Telstra Air', b'\x12\xdaC\xf6\x86\x89', 1, -87, 0, False), 10 | (b'NetComm ', b'\x18\x1fE\x15\x05"', 6, -91, 4, False) 11 | ] 12 | 13 | 14 | # Two SSIDs one stronger than the other (Stronger is last to entice failure from lazy breaking) 15 | def scan2(): 16 | return [ 17 | (b'HomeNetwork', b'\x90\'\xe4]"\xc5', 6, -83, 4, False), 18 | (b'Telstra524E82', b'$\x7f RN\x88', 1, -71, 3, False), 19 | (b'iiNet7580ED', b'\xe0\xb9\xe5u\x80\xed', 11, -82, 3, False), 20 | (b'Skynet', b'\x00\x04\xedL\xd3\xaf', 3, -83, 4, False), 21 | (b'HomeNetwork', b'\x90r@\x1f\xf0\xe4', 11, -51, 3, False), 22 | (b'SEC_LinkShare_f297a2', b'\xd0f{\n{\x15', 3, -92, 3, False), 23 | (b'HUAWEI-B315-D0B0', b'T\xb1! \xd0\xb0', 5, -92, 3, False), 24 | (b'NetComm ', b'\x18\x1fE\x15\x05"', 6, -95, 4, False) 25 | ] 26 | 27 | # Test for not much going on 28 | def scan3(): 29 | return [ 30 | (b'NetComm ', b'\x18\x1fE\x15\x05"', 6, -95, 4, False) 31 | ] -------------------------------------------------------------------------------- /test/test_async.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | # Hackery - we are not and cannot test under micropython 5 | from . import network 6 | from . import webrepl 7 | from . import sample_scans 8 | from . import logging 9 | sys.modules['network'] = network 10 | sys.modules['webrepl'] = webrepl 11 | sys.modules['logging'] = logging 12 | 13 | # Important - do hackery before importing me 14 | from wifi_manager import WifiManager 15 | -------------------------------------------------------------------------------- /test/test_manager.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import unittest 4 | import uasyncio as asyncio 5 | # Hackery - we are not and cannot test under micropython 6 | from . import network 7 | from . import webrepl 8 | from . import sample_scans 9 | from . import logging 10 | sys.modules['network'] = network 11 | sys.modules['webrepl'] = webrepl 12 | sys.modules['logging'] = logging 13 | 14 | # Important - do hackery before importing me 15 | from wifi_manager import WifiManager 16 | 17 | class LogicTests(unittest.TestCase): 18 | 19 | # Choose BSSID for a network among a list of unique SSID 20 | def test_fallback_choose_single(self): 21 | network.DEBUG_RESET() 22 | interface = network.WLAN(network.STA_IF) 23 | host = network.WLAN(network.AP_IF) 24 | interface.scan_results = sample_scans.scan1() 25 | WifiManager.config_file = 'test/networks_fallback.json' 26 | WifiManager.setup_network() 27 | # Checks on the client 28 | self.assertTrue(interface.isconnected()) 29 | self.assertTrue(interface.DEBUG_CONNECTED_SSID == "HomeNetwork") 30 | self.assertTrue(interface.DEBUG_CONNECTED_BSSID == b'\x90r@\x1f\xf0\xe4') 31 | # Checks on th AP 32 | self.assertTrue(not host.active()) 33 | 34 | # Choose BSSID for a network among a list with multiple instances of the SSID 35 | def test_fallback_choose_best(self): 36 | network.DEBUG_RESET() 37 | interface = network.WLAN(network.STA_IF) 38 | host = network.WLAN(network.AP_IF) 39 | interface.scan_results = sample_scans.scan2() 40 | WifiManager.config_file = 'test/networks_fallback.json' 41 | WifiManager.setup_network() 42 | # Checks on the client 43 | self.assertTrue(interface.isconnected()) 44 | self.assertTrue(interface.DEBUG_CONNECTED_SSID == "HomeNetwork") 45 | self.assertTrue(interface.DEBUG_CONNECTED_BSSID == b'\x90r@\x1f\xf0\xe4') 46 | # Checks on th AP 47 | self.assertTrue(not host.active()) 48 | 49 | # No known networks found and fallback AP policy so should have just the AP started 50 | def test_fallback_ap(self): 51 | network.DEBUG_RESET() 52 | interface = network.WLAN(network.STA_IF) 53 | host = network.WLAN(network.AP_IF) 54 | interface.scan_results = sample_scans.scan3() 55 | WifiManager.config_file = 'test/networks_fallback.json' 56 | WifiManager.setup_network() 57 | # Checks on the client 58 | self.assertTrue(not interface.isconnected()) 59 | self.assertTrue(interface.DEBUG_CONNECTED_SSID is None) 60 | self.assertTrue(interface.DEBUG_CONNECTED_BSSID is None) 61 | # Checks on th AP 62 | self.assertTrue(host.active()) 63 | self.assertTrue(host.config_dict['essid'] == "Micropython-Dev") 64 | 65 | # Should have both managed access and AP started as AP always policy and known networks joined 66 | def test_always_ap(self): 67 | network.DEBUG_RESET() 68 | interface = network.WLAN(network.STA_IF) 69 | host = network.WLAN(network.AP_IF) 70 | interface.scan_results = sample_scans.scan1() 71 | WifiManager.config_file = 'test/networks_always.json' 72 | WifiManager.setup_network() 73 | # Checks on the client 74 | self.assertTrue(interface.isconnected()) 75 | self.assertTrue(interface.DEBUG_CONNECTED_SSID == "HomeNetwork") 76 | self.assertTrue(interface.DEBUG_CONNECTED_BSSID == b'\x90r@\x1f\xf0\xe4') 77 | # Checks on th AP 78 | self.assertTrue(host.active()) 79 | self.assertTrue(host.config_dict['essid'] == "Micropython-Dev") 80 | 81 | # Should connect to nothing as no known networks and no AP policy 82 | def test_never_ap(self): 83 | network.DEBUG_RESET() 84 | interface = network.WLAN(network.STA_IF) 85 | host = network.WLAN(network.AP_IF) 86 | interface.scan_results = sample_scans.scan1() 87 | WifiManager.config_file = 'test/networks_always.json' 88 | WifiManager.setup_network() 89 | # Checks on the client 90 | self.assertTrue(interface.isconnected()) 91 | self.assertTrue(interface.DEBUG_CONNECTED_SSID == "HomeNetwork") 92 | self.assertTrue(interface.DEBUG_CONNECTED_BSSID == b'\x90r@\x1f\xf0\xe4') 93 | # Checks on th AP 94 | self.assertTrue(host.active()) 95 | self.assertTrue(host.config_dict['essid'] == "Micropython-Dev") 96 | 97 | class AsyncTests(unittest.TestCase): 98 | 99 | def testStart(self): 100 | network.DEBUG_RESET() 101 | WifiManager.config_file = 'test/networks_fallback.json' 102 | WifiManager.start_managing() 103 | loop = asyncio.get_event_loop() 104 | async def tester(): 105 | print("Time's up") 106 | # TODO: Tests here 107 | loop.run_until_complete(tester()) 108 | #loop.run_until_complete() 109 | 110 | 111 | if __name__ == '__main__': 112 | unittest.main() 113 | -------------------------------------------------------------------------------- /test/webrepl.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mitchins/micropython-wifimanager/92b841b8c034c8be782f223fae6dcaecfda206bb/test/webrepl.py -------------------------------------------------------------------------------- /test_wifimanager.py: -------------------------------------------------------------------------------- 1 | """Unit tests for wifi_manager.py""" 2 | import functools 3 | import unittest 4 | import sys 5 | 6 | sys.modules['network'] = __import__('fake_network') 7 | 8 | # That upon which we test 9 | import wifi_manager 10 | 11 | # The tests 12 | 13 | class SchedulerTests(unittest.TestCase): 14 | 15 | def setUp(self): 16 | #if 'network' in sys.modules: 17 | # del sys.modules['network'] 18 | pass 19 | 20 | def test_fail(self): 21 | assert False -------------------------------------------------------------------------------- /unittest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | 4 | class SkipTest(Exception): 5 | pass 6 | 7 | 8 | class AssertRaisesContext: 9 | 10 | def __init__(self, exc): 11 | self.expected = exc 12 | 13 | def __enter__(self): 14 | return self 15 | 16 | def __exit__(self, exc_type, exc_value, tb): 17 | if exc_type is None: 18 | assert False, "%r not raised" % self.expected 19 | if issubclass(exc_type, self.expected): 20 | return True 21 | return False 22 | 23 | 24 | class TestCase: 25 | 26 | def fail(self, msg=''): 27 | assert False, msg 28 | 29 | def assertEqual(self, x, y, msg=''): 30 | if not msg: 31 | msg = "%r vs (expected) %r" % (x, y) 32 | assert x == y, msg 33 | 34 | def assertNotEqual(self, x, y, msg=''): 35 | if not msg: 36 | msg = "%r not expected to be equal %r" % (x, y) 37 | assert x != y, msg 38 | 39 | def assertAlmostEqual(self, x, y, places=None, msg='', delta=None): 40 | if x == y: 41 | return 42 | if delta is not None and places is not None: 43 | raise TypeError("specify delta or places not both") 44 | 45 | if delta is not None: 46 | if abs(x - y) <= delta: 47 | return 48 | if not msg: 49 | msg = '%r != %r within %r delta' % (x, y, delta) 50 | else: 51 | if places is None: 52 | places = 7 53 | if round(abs(y-x), places) == 0: 54 | return 55 | if not msg: 56 | msg = '%r != %r within %r places' % (x, y, places) 57 | 58 | assert False, msg 59 | 60 | def assertNotAlmostEqual(self, x, y, places=None, msg='', delta=None): 61 | if delta is not None and places is not None: 62 | raise TypeError("specify delta or places not both") 63 | 64 | if delta is not None: 65 | if not (x == y) and abs(x - y) > delta: 66 | return 67 | if not msg: 68 | msg = '%r == %r within %r delta' % (x, y, delta) 69 | else: 70 | if places is None: 71 | places = 7 72 | if not (x == y) and round(abs(y-x), places) != 0: 73 | return 74 | if not msg: 75 | msg = '%r == %r within %r places' % (x, y, places) 76 | 77 | assert False, msg 78 | 79 | def assertIs(self, x, y, msg=''): 80 | if not msg: 81 | msg = "%r is not %r" % (x, y) 82 | assert x is y, msg 83 | 84 | def assertIsNot(self, x, y, msg=''): 85 | if not msg: 86 | msg = "%r is %r" % (x, y) 87 | assert x is not y, msg 88 | 89 | def assertIsNone(self, x, msg=''): 90 | if not msg: 91 | msg = "%r is not None" % x 92 | assert x is None, msg 93 | 94 | def assertIsNotNone(self, x, msg=''): 95 | if not msg: 96 | msg = "%r is None" % x 97 | assert x is not None, msg 98 | 99 | def assertTrue(self, x, msg=''): 100 | if not msg: 101 | msg = "Expected %r to be True" % x 102 | assert x, msg 103 | 104 | def assertFalse(self, x, msg=''): 105 | if not msg: 106 | msg = "Expected %r to be False" % x 107 | assert not x, msg 108 | 109 | def assertIn(self, x, y, msg=''): 110 | if not msg: 111 | msg = "Expected %r to be in %r" % (x, y) 112 | assert x in y, msg 113 | 114 | def assertIsInstance(self, x, y, msg=''): 115 | assert isinstance(x, y), msg 116 | 117 | def assertRaises(self, exc, func=None, *args, **kwargs): 118 | if func is None: 119 | return AssertRaisesContext(exc) 120 | 121 | try: 122 | func(*args, **kwargs) 123 | assert False, "%r not raised" % exc 124 | except Exception as e: 125 | if isinstance(e, exc): 126 | return 127 | raise 128 | 129 | 130 | 131 | def skip(msg): 132 | def _decor(fun): 133 | # We just replace original fun with _inner 134 | def _inner(self): 135 | raise SkipTest(msg) 136 | return _inner 137 | return _decor 138 | 139 | def skipIf(cond, msg): 140 | if not cond: 141 | return lambda x: x 142 | return skip(msg) 143 | 144 | def skipUnless(cond, msg): 145 | if cond: 146 | return lambda x: x 147 | return skip(msg) 148 | 149 | 150 | class TestSuite: 151 | def __init__(self): 152 | self.tests = [] 153 | def addTest(self, cls): 154 | self.tests.append(cls) 155 | 156 | class TestRunner: 157 | def run(self, suite): 158 | res = TestResult() 159 | for c in suite.tests: 160 | run_class(c, res) 161 | 162 | print("Ran %d tests\n" % res.testsRun) 163 | if res.failuresNum > 0 or res.errorsNum > 0: 164 | print("FAILED (failures=%d, errors=%d)" % (res.failuresNum, res.errorsNum)) 165 | else: 166 | msg = "OK" 167 | if res.skippedNum > 0: 168 | msg += " (%d skipped)" % res.skippedNum 169 | print(msg) 170 | 171 | return res 172 | 173 | class TestResult: 174 | def __init__(self): 175 | self.errorsNum = 0 176 | self.failuresNum = 0 177 | self.skippedNum = 0 178 | self.testsRun = 0 179 | 180 | def wasSuccessful(self): 181 | return self.errorsNum == 0 and self.failuresNum == 0 182 | 183 | # TODO: Uncompliant 184 | def run_class(c, test_result): 185 | o = c() 186 | set_up = getattr(o, "setUp", lambda: None) 187 | tear_down = getattr(o, "tearDown", lambda: None) 188 | for name in dir(o): 189 | if name.startswith("test"): 190 | print("%s (%s) ..." % (name, c.__qualname__), end="") 191 | m = getattr(o, name) 192 | set_up() 193 | try: 194 | test_result.testsRun += 1 195 | m() 196 | print(" ok") 197 | except SkipTest as e: 198 | print(" skipped:", e.args[0]) 199 | test_result.skippedNum += 1 200 | except Exception as e: 201 | print ("Unexpected error:", e) 202 | test_result.failuresNum += 1 203 | # Uncomment to investigate failure in detail 204 | #raise 205 | continue 206 | finally: 207 | tear_down() 208 | 209 | 210 | def main(module="__main__"): 211 | def test_cases(m): 212 | for tn in dir(m): 213 | c = getattr(m, tn) 214 | if isinstance(c, object) and isinstance(c, type) and issubclass(c, TestCase): 215 | yield c 216 | 217 | m = __import__(module) 218 | suite = TestSuite() 219 | for c in test_cases(m): 220 | suite.addTest(c) 221 | runner = TestRunner() 222 | result = runner.run(suite) 223 | # Terminate with non zero return code in case of failures 224 | sys.exit(result.failuresNum > 0) 225 | -------------------------------------------------------------------------------- /wifi_manager/__init__.py: -------------------------------------------------------------------------------- 1 | from .wifi_manager import WifiManager 2 | -------------------------------------------------------------------------------- /wifi_manager/metadata.txt: -------------------------------------------------------------------------------- 1 | srctype=dummy 2 | type=module 3 | version = 0.0.1 4 | -------------------------------------------------------------------------------- /wifi_manager/setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | # Remove current dir from sys.path, otherwise setuptools will peek up our 3 | # module instead of system's. 4 | sys.path.pop(0) 5 | from setuptools import setup 6 | sys.path.append("..") 7 | import sdist_upip 8 | 9 | from distutils.core import setup 10 | setup( 11 | name = 'micropython-wifimanager', 12 | cmdclass={'sdist': sdist_upip.sdist}, 13 | py_modules = ['wifi_manager'], 14 | version = '0.3.6', 15 | description = 'A simple network configuration utility for MicroPython on the ESP-8266 and ESP-32 boards', 16 | long_description = "# micropython-wifimanager\r\nA simple network configuration utility for MicroPython on boards such as ESP8266 and ESP32.\r\n\r\n#### Configuration\r\n\r\nSimply upload your JSON file with your networks, the default path is '/networks.json', which is specified in the class property `config_file`.\r\n\r\nA sample configuration may look like this:\r\n\r\n\t{\r\n\t\t\"schema\": 2,\r\n\t\t\"known_networks\": [\r\n\t\t\t{\r\n\t\t\t\t\"ssid\": \"User\\u2019s iPhone\",\r\n\t\t\t\t\"password\": \"Password1\",\r\n\t\t\t\t\"enables_webrepl\": false\r\n\t\t\t},\r\n\t\t\t{\r\n\t\t\t\t\"ssid\": \"HomeNetwork\",\r\n\t\t\t\t\"password\": \"Password2\",\r\n\t\t\t\t\"enables_webrepl\": true\r\n\t\t\t}\r\n\t\t],\r\n\t\t\"access_point\": {\r\n\t\t\t\"config\": {\r\n\t\t\t\t\"essid\": \"Micropython-Dev\",\r\n\t\t\t\t\"channel\": 11,\r\n\t\t\t\t\"hidden\": false,\r\n\t\t\t\t\"password\": \"P@55W0rd\"\r\n\t\t\t},\r\n\t\t\t\"enables_webrepl\": true,\r\n\t\t\t\"start_policy\": \"fallback\"\r\n\t\t}\r\n\t}\r\n\r\n#### Configuration schema\r\n\r\n* **schema**: currently this should be `2`\r\n* **known_networks**: list of networks to connect to, in order of most preferred first\r\n\t* SSID - the name of the access point\r\n\t* password - the clear test password to use\r\n\t* enables_webrepl - a boolean value to indicate if connection to this network desires webrepl being started\r\n* **access_point**: the details for the access point (AP) of this device\r\n\t* config - the keys for the AP config, exactly as per the micropython documentation\r\n\t* enables_weprepl - a boolean value to indicate if ceating this network desires webrepl being started\r\n\t* start_policy - A policy from the below list to indicate when to enable the AP\r\n\t\t* 'always' - regardless of the connection to any base station, AP will be started\r\n\t\t* 'fallback' - the AP will only be started if no network could be connected to\r\n\t\t* 'never' - The AP will not be started under any condition\r\n\r\n#### Simple usage (one shot)\r\n\r\nHere's an example of how to use the WifiManager.\r\n\r\n\tMicroPython v1.9.4 on 2018-05-11; ESP32 module with ESP32\r\n\tType \"help()\" for more information.\r\n\t>>> from wifi_manager import WifiManager\r\n\t>>> WifiManager.setup_network()\r\n\tconnecting to network Foo-Network...\r\n\tWebREPL daemon started on ws://10.1.1.234:8266\r\n\tStarted webrepl in normal mode\r\n\tTrue\r\n\r\n\r\n#### Asynchronous usage (event loop)\r\n\r\nThe WifiManager can be run asynchronously, via the cooperative scheduling that micropthon has in uasyncio. If you call `WifiManager.start_managing()` as follows, it will ensure that periodically the network status is scanned, and connection will be re-established as per preferences as needed.\r\n\r\n\timport uasyncio as asyncio\r\n\tfrom wifi_manager import WifiManager\r\n\r\n\tWifiManager.start_managing()\r\n\tasyncio.get_event_loop().run_forever()\r\n\r\n\r\n#### Contribution\r\n\r\nFound a bug, or want a feature? open an issue.\r\n\r\nIf you want to contribute, create a pull request.\r\n\r\n#### System flow\r\n\r\n![System flow](https://github.com/mitchins/micropython-wifimanager/raw/master/system_flow.png)\r\n", 17 | long_description_content_type='text/markdown', 18 | author = 'Mitchell Currie', 19 | author_email = 'mitch@mitchellcurrie.com', 20 | url = 'https://github.com/mitchins/micropython-wifimanager', 21 | download_url = 'https://github.com/mitchins/micropython-wifimanager/archive/0.3.4.tar.gz', 22 | keywords = ['micropython', 'esp8266', 'esp32', 'wifi', 'manager'], 23 | classifiers = [], 24 | ) 25 | -------------------------------------------------------------------------------- /wifi_manager/wifi_manager.py: -------------------------------------------------------------------------------- 1 | """Implementation of a controller to connect to preferred wifi network(s) [For ESP8266, micro-python] 2 | 3 | Config is loaded from a file kept by default in '/networks.json' 4 | 5 | Priority of networks is determined implicitly by order in array, first being the highest. 6 | It will go through the list of preferred networks, connecting to the ones it detects present. 7 | 8 | Default behaviour is to always start the webrepl after setup, 9 | and only start the access point if we can't connect to a known access point ourselves. 10 | 11 | Future scope is to use BSSID instead of SSID when micropython allows it, 12 | this would allow multiple access points with the same name, and we can select by signal strength. 13 | 14 | 15 | """ 16 | 17 | import json 18 | import time 19 | import os 20 | 21 | # Micropython modules 22 | import network 23 | try: 24 | import webrepl 25 | except ImportError: 26 | pass 27 | try: 28 | import uasyncio as asyncio 29 | except ImportError: 30 | pass 31 | 32 | # Micropython libraries (install view uPip) 33 | try: 34 | import logging 35 | log = logging.getLogger("wifi_manager") 36 | except ImportError: 37 | # Todo: stub logging, this can probably be improved easily, though logging is common to install 38 | def fake_log(msg, *args): 39 | print("[?] No logger detected. (log dropped)") 40 | log = type("", (), {"debug": fake_log, "info": fake_log, "warning": fake_log, "error": fake_log, 41 | "critical": fake_log})() 42 | 43 | class WifiManager: 44 | webrepl_triggered = False 45 | _ap_start_policy = "never" 46 | config_file = '/networks.json' 47 | 48 | # Starts the managing call as a co-op async activity 49 | @classmethod 50 | def start_managing(cls): 51 | loop = asyncio.get_event_loop() 52 | loop.create_task(cls.manage()) # Schedule ASAP 53 | # Make sure you loop.run_forever() (we are a guest here) 54 | 55 | # Checks the status and configures if needed 56 | @classmethod 57 | async def manage(cls): 58 | while True: 59 | status = cls.wlan().status() 60 | # ESP32 does not currently return 61 | if (status != network.STAT_GOT_IP) or \ 62 | (cls.wlan().ifconfig()[0] == '0.0.0.0'): # temporary till #3967 63 | log.info("Network not connected: managing") 64 | # Ignore connecting status for now.. ESP32 is a bit strange 65 | # if status != network.STAT_CONNECTING: <- do not care yet 66 | cls.setup_network() 67 | await asyncio.sleep(10) # Pause 5 seconds 68 | 69 | @classmethod 70 | def wlan(cls): 71 | return network.WLAN(network.STA_IF) 72 | 73 | @classmethod 74 | def accesspoint(cls): 75 | return network.WLAN(network.AP_IF) 76 | 77 | @classmethod 78 | def wants_accesspoint(cls) -> bool: 79 | static_policies = {"never": False, "always": True} 80 | if cls._ap_start_policy in static_policies: 81 | return static_policies[cls._ap_start_policy] 82 | # By default, that leaves "Fallback" 83 | return cls.wlan().status() != network.STAT_GOT_IP # Discard intermediate states and check for not connected/ok 84 | 85 | @classmethod 86 | def setup_network(cls) -> bool: 87 | # now see our prioritised list of networks and find the first available network 88 | try: 89 | with open(cls.config_file, "r") as f: 90 | config = json.loads(f.read()) 91 | cls.preferred_networks = config['known_networks'] 92 | cls.ap_config = config["access_point"] 93 | if config.get("schema", 0) != 2: 94 | log.warning("Did not get expected schema [2] in JSON config.") 95 | except Exception as e: 96 | log.error("Failed to load config file, no known networks selected") 97 | cls.preferred_networks = [] 98 | return 99 | 100 | # set things up 101 | cls.webrepl_triggered = False # Until something wants it 102 | cls.wlan().active(True) 103 | 104 | # scan what’s available 105 | available_networks = [] 106 | for network in cls.wlan().scan(): 107 | ssid = network[0].decode("utf-8") 108 | bssid = network[1] 109 | strength = network[3] 110 | available_networks.append(dict(ssid=ssid, bssid=bssid, strength=strength)) 111 | # Sort fields by strongest first in case of multiple SSID access points 112 | available_networks.sort(key=lambda station: station["strength"], reverse=True) 113 | 114 | # Get the ranked list of BSSIDs to connect to, ranked by preference and strength amongst duplicate SSID 115 | candidates = [] 116 | for aPreference in cls.preferred_networks: 117 | for aNetwork in available_networks: 118 | if aPreference["ssid"] == aNetwork["ssid"]: 119 | connection_data = { 120 | "ssid": aNetwork["ssid"], 121 | "bssid": aNetwork["bssid"], # NB: One day we might allow collection by exact BSSID 122 | "password": aPreference["password"], 123 | "enables_webrepl": aPreference["enables_webrepl"]} 124 | candidates.append(connection_data) 125 | 126 | for new_connection in candidates: 127 | log.info("Attempting to connect to network {0}...".format(new_connection["ssid"])) 128 | # Micropython 1.9.3+ supports BSSID specification so let's use that 129 | if cls.connect_to(ssid=new_connection["ssid"], password=new_connection["password"], 130 | bssid=new_connection["bssid"]): 131 | log.info("Successfully connected {0}".format(new_connection["ssid"])) 132 | cls.webrepl_triggered = new_connection["enables_webrepl"] 133 | break # We are connected so don't try more 134 | 135 | 136 | # Check if we are to start the access point 137 | cls._ap_start_policy = cls.ap_config.get("start_policy", "never") 138 | should_start_ap = cls.wants_accesspoint() 139 | cls.accesspoint().active(should_start_ap) 140 | if should_start_ap: # Only bother setting the config if it WILL be active 141 | log.info("Enabling your access point...") 142 | cls.accesspoint().config(**cls.ap_config["config"]) 143 | cls.webrepl_triggered = cls.ap_config["enables_webrepl"] 144 | cls.accesspoint().active(cls.wants_accesspoint()) # It may be DEACTIVATED here 145 | 146 | # may need to reload the config if access points trigger it 147 | 148 | # start the webrepl according to the rules 149 | if cls.webrepl_triggered: 150 | try: 151 | webrepl.start() 152 | except NameError: 153 | # Log error here (not after import) to not log it if webrepl is not configured to start. 154 | log.error("Failed to start webrepl, module is not available.") 155 | 156 | # return the success status, which is ultimately if we connected to managed and not ad hoc wifi. 157 | return cls.wlan().isconnected() 158 | 159 | @classmethod 160 | def connect_to(cls, *, ssid, password, **kwargs) -> bool: 161 | cls.wlan().connect(ssid, password, **kwargs) 162 | 163 | for check in range(0, 10): # Wait a maximum of 10 times (10 * 500ms = 5 seconds) for success 164 | if cls.wlan().isconnected(): 165 | return True 166 | time.sleep_ms(500) 167 | return False 168 | -------------------------------------------------------------------------------- /wifi_manager_tests.py: -------------------------------------------------------------------------------- 1 | # 1. Check for missing config file 2 | 3 | 4 | ### TODO: All of this. 5 | # Set WiFi access point name (formally known as ESSID) and WiFi channel 6 | ap.config(essid='My AP', channel=11) 7 | # Query params one by one 8 | print(ap.config('essid')) 9 | print(ap.config('channel')) 10 | Following are commonly supported parameters (availability of a specific parameter depends on network technology type, driver, and MicroPython port). 11 | 12 | Parameter Description 13 | mac MAC address (bytes) 14 | essid WiFi access point name (string) 15 | channel WiFi channel (integer) 16 | hidden Whether ESSID is hidden (boolean) 17 | authmode Authentication mode supported (enumeration, see module constants) 18 | password Access password (string) --------------------------------------------------------------------------------