├── .gitignore ├── .rshell-ignore ├── LICENSE ├── README.md ├── __init__.py ├── docs └── PB_LINK.md ├── esp_link ├── .rshell-ignore ├── __init__.py ├── _boot.py ├── asi2c.py ├── esp_link.py └── inisetup.py ├── firmware-combined.bin ├── images ├── block diagram.odg ├── block_diagram.png ├── block_diagram_orig.odg ├── block_diagram_orig.png ├── block_diagram_pyboard.odg └── block_diagram_pyboard.png ├── iot ├── .rshell-ignore ├── __init__.py ├── client.mpy ├── client.py ├── examples │ ├── .rshell-ignore │ ├── __init__.py │ ├── c_app.py │ ├── local.py │ └── s_app_cp.py ├── examples_server.py ├── primitives │ ├── .rshell-ignore │ ├── __init__.py │ ├── queue.py │ └── switch.py ├── qos │ ├── .rshell-ignore │ ├── __init__.py │ ├── c_qos.py │ ├── c_qos_fast.py │ ├── check_mid.py │ ├── local.py │ ├── s_qos_cp.py │ └── s_qos_fast.py ├── remote │ ├── .rshell-ignore │ ├── __init__.py │ ├── c_comms_rx.py │ ├── c_comms_tx.py │ ├── local.py │ └── s_comms_cp.py └── server.py └── pb_link ├── .rshell-ignore ├── __init__.py ├── app_base.py ├── asi2c.py ├── asi2c_i.py ├── config.py ├── pb_client.py └── s_app.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 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 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Peter Hinch 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 | # Introduction 2 | 3 | This library provides a resilient full duplex communication link between a WiFi 4 | connected board and a server on the wired LAN. The board may be an ESP8266, 5 | ESP32 or other target including the Pyboard D. The design is such that the code 6 | can run for indefinite periods. Temporary WiFi or server outages are tolerated 7 | without message loss. 8 | 9 | The API is simple and consistent between client and server applications, 10 | comprising `write` and `readline` methods. The `ujson` library enables various 11 | Python objects to be exchanged. Guaranteed message delivery is available. 12 | 13 | This project is a collaboration between Peter Hinch and Kevin Köck. 14 | 15 | As of July 2020 it has been updated to use (and require) `uasyncio` V3. See 16 | [section 3.1.1](./README.md#311-existing-users) for details of consequent API 17 | changes. 18 | 19 | # 0. MicroPython IOT application design 20 | 21 | IOT (Internet of Things) systems commonly comprise a set of endpoints on a WiFi 22 | network. Internet access is provided by an access point (AP) linked to a 23 | router. Endpoints run an internet protocol such as MQTT or HTTP and normally 24 | run continuously. They may be located in places which are hard to access: 25 | reliability is therefore paramount. Security is also a factor for endpoints 26 | exposed to the internet. 27 | 28 | Under MicroPython the available hardware for endpoints is limited. Testing has 29 | been done on the ESP8266, ESP32 and the Pyboard D. 30 | 31 | The ESP8266 remains as a readily available inexpensive device which, with care, 32 | is capable of long term reliable operation. It does suffer from limited 33 | resources, in particular RAM. Achieving resilient operation in the face of WiFi 34 | or server outages is not straightforward: see 35 | [this document](https://github.com/peterhinch/micropython-samples/tree/master/resilient). 36 | The approach advocated here simplifies writing robust ESP8266 IOT applications 37 | by providing a communications channel with inherent resilience. 38 | 39 | The usual arrangement for MicroPython internet access is as below. 40 | ![Image](./images/block_diagram_orig.png) 41 | 42 | Running internet protocols on ESP8266 nodes has the following drawbacks: 43 | 1. It can be difficult to ensure resilience in the face of outages of WiFi and 44 | of the remote endpoint. 45 | 2. Running TLS on the ESP8266 is demanding in terms of resources: establishing 46 | a connection can take 30s. 47 | 3. There are potential security issues for internet-facing nodes. 48 | 4. The security issue creates a requirement periodically to install patches to 49 | firmware or to libraries. This raises the issue of physical access. 50 | 5. Internet applications can be demanding of RAM. 51 | 52 | This document proposes an approach where multiple remote nodes communicate with 53 | a local server. This runs CPython or MicroPython code and supports the internet 54 | protocol required by the application. The server and the remote nodes 55 | communicate using a simple protocol based on the exchange of lines of text. The 56 | server can run on a Linux box such as a Raspberry Pi; this can run 24/7 at 57 | minimal running cost. 58 | 59 | ![Image](./images/block_diagram.png) 60 | 61 | Benefits are: 62 | 1. Security is handled on a device with an OS. Updates are easily accomplished. 63 | 2. The text-based protocol minimises the attack surface presented by nodes. 64 | 3. The protocol is resilient in the face of outages of WiFi and of the server: 65 | barring errors in the application design, crash-free 24/7 operation is a 66 | realistic prospect. 67 | 4. The amount of code running on the remote is smaller than that required to 68 | run a resilient internet protocol such as [this MQTT version](https://github.com/peterhinch/micropython-mqtt.git). 69 | 5. The server side application runs on a relatively powerful machine. Even 70 | minimal hardware such as a Raspberry Pi has the horsepower easily to support 71 | TLS and to maintain concurrent links to multiple client nodes. Use of 72 | threading is feasible. 73 | 6. The option to use CPython on the server side enables access to the full 74 | suite of Python libraries including internet modules. 75 | 76 | The principal drawback is that in addition to application code on the ESP8266 77 | node, application code is also required on the PC to provide the "glue" linking 78 | the internet protocol with each of the client nodes. In many applications this 79 | code may be minimal. 80 | 81 | There are use-cases where conectivity is entirely local, for example logging 82 | locally acquired data or using some nodes to control and monitor others. In 83 | such cases no internet protocol is required and the server side application 84 | merely passes data between nodes and/or logs data to disk. 85 | 86 | This architecture can be extended to non-networked clients such as the Pyboard 87 | V1.x. This is described and diagrammed [here](./README.md#9-extension-to-the-pyboard). 88 | 89 | # 1. Contents 90 | 91 | This repo comprises code for resilent full-duplex connections between a server 92 | application and multiple clients. Each connection is like a simplified socket, 93 | but one which persists through outages and offers guaranteed message delivery. 94 | 95 | 0. [MicroPython IOT application design](./README.md#0-microPython-iot-application-design) 96 | 1. [Contents](./README.md#1-contents) 97 | 2. [Design](./README.md#2-design) 98 | 2.1 [Protocol](./README.md#21-protocol) 99 | 3. [Files and packages](./README.md#3-files-and-packages) 100 | 3.1 [Installation](./README.md#31-installation) 101 |      3.1.1 [Existing users](./README.md#311-existing-users) 102 |      3.1.2 [Firmware and dependency](./README.md#312-firmware-and-dependency) 103 |      3.1.3 [Preconditions for demos](./README.md#313-preconditions-for-demos) 104 | 3.2 [Usage](./README.md#32-usage) 105 |      3.2.1 [The main demo](./README.md#321-the-main-demo) 106 |      3.2.2 [The remote control demo](./README.md#322-the-remote-control-demo) 107 |      3.2.3 [Quality of Service demo](./README.md#323-quality-of-service-demo) 108 |      3.2.4 [The fast qos demo](./README.md#324-the-fast-qos-demo) 109 |      3.2.5 [Troubleshooting the demos](./README.md#325-troubleshooting-the-demos) 110 | 4. [Client side applications](./README.md#4-client-side-applications) 111 | 4.1 [The Client class](./README.md#41-the-client-class) 112 | 4.1.1 [Initial Behaviour](./README.md#411-initial-behaviour) 113 | 4.1.2 [Watchdog Timer](./README.md#412-watchdog-timer) 114 | 5. [Server side applications](./README.md#5-server-side-applications) 115 | 5.1 [The server module](./README.md#51-the-server-module) 116 | 6. [Ensuring resilience](./README.md#6-ensuring-resilience) Guidelines for application design. 117 | 7. [Quality of service](./README.md#7-quality-of-service) Guaranteeing message delivery. 118 | 7.1 [The qos argument](./README.md#71-the-qos-argument) 119 | 7.2 [The wait argument](./README.md#71-the-wait-argument) Concurrent writes of qos messages. 120 | 8. [Performance](./README.md#8-performance) 121 | 8.1 [Latency and throughput](./README.md#81-latency-and-throughput) 122 | 8.2 [Client RAM utilisation](./README.md#82-client-ram-utilisation) 123 | 8.3 [Platform reliability](./README.md#83-platform-reliability) 124 | 9. [Extension to the Pyboard](./README.md#9-extension-to-the-pyboard) 125 | 10. [How it works](./README.md#10-how-it-works) 126 | 10.1 [Interface and client module](./README.md#101-interface-and-client-module) 127 | 10.2 [Server module](./README.md#102-server-module) 128 | 129 | # 2. Design 130 | 131 | The code is asynchronous and based on `asyncio`. Client applications on the 132 | remote import `client.py` which provides the interface to the link. The server 133 | side application uses `server.py`. 134 | 135 | Messages are required to be complete lines of text. They typically comprise an 136 | arbitrary Python object encoded using JSON. The newline character ('\n') is not 137 | allowed within a message but is optional as the final character. 138 | 139 | Guaranteed message delivery is supported. This is described in 140 | [section 7](./README.md#7-quality-of-service). Performance limitations are 141 | discussed in [section 8](./README.md#8-performance). 142 | 143 | ## 2.1 Protocol 144 | 145 | Client and server applications use `readline` and `write` methods to 146 | communicate: in the case of an outage of WiFi or the connected endpoint, the 147 | method will pause until the outage ends. While the system is tolerant of 148 | runtime server and WiFi outages, this does not apply on initialisation. The 149 | server must accessible before clients are started. 150 | 151 | The link status is determined by periodic exchanges of keepalive messages. This 152 | is transparent to the application. If a keepalive is not received within a user 153 | specified timeout an outage is declared. On the client the WiFi is disconnected 154 | and a reconnection procedure is initiated. On the server the connection is 155 | closed and it awaits a new connection. 156 | 157 | Each client has a unique ID which is an arbitrary string. In the demo programs 158 | this is stored in `local.py`. The ID enables the server application to 159 | determine which physical client is associated with an incoming connection. 160 | 161 | ###### [Contents](./README.md#1-contents) 162 | 163 | # 3. Files and packages 164 | 165 | This repo has been updated for `uasyncio` V3. This is incorporated in daily 166 | builds of firmware and will be available in release builds later than V1.12. 167 | Server code may be run under CPython V3.8 or above. It may be run under 168 | MicroPython (Unix build), but at the time of writing this requires 169 | [this fix](https://github.com/micropython/micropython/issues/6109#issuecomment-639376529) 170 | to incorporate `uasyncio`. 171 | 172 | Directory `iot`: 173 | 1. `client.py` / `client.mpy` Client module. The ESP8266 has insufficient RAM 174 | to compile `client.py` so the precompiled `client.mpy` should be used. See 175 | note below. 176 | 2. `server.py` Server module. (runs under CPython 3.5+ or MicroPython 1.10+). 177 | Directory `iot/primitives`: 178 | 1. `__init__.py` Functions common to `Client` and `Server`. 179 | 2. `switch.py` Debounced switch interface. Used by `remote` demo. 180 | Optional directories containing Python packages: 181 | 1. `iot/examples` A simple example. Up to four clients communicate with a 182 | single server instance. 183 | 2. `iot/remote` Demo uses the library to enable one client to control another. 184 | This may need adapting for your hardware. 185 | 3. `iot/qos` Demonstrates and tests the qos (quality of service) feature, see 186 | [Quality of service](./README.md#7-quality-of-service). 187 | 4. `iot/pb1` Contians packages enabling a Pyboard V1.x to communicate with the 188 | server via an ESP8266 connected by I2C. See [documentation](./pb_link/README.md). 189 | 190 | NOTE: The file `client.mpy` works with daily builds at the time of writing. The 191 | bytecode format changes occasionally. If an application throws a bytecode error 192 | it is necessary to cross-compile `client.py` with the associated version of 193 | `mpy-cross`. Or raise an issue and I will post an update. 194 | 195 | ## 3.1 Installation 196 | 197 | This section describes the installation of the library and the demos. The 198 | ESP8266 has limited RAM: there are specific recommendations for installation on 199 | that platform. 200 | 201 | ### 3.1.1 Existing users 202 | 203 | It is recommended to remove the old version and re-install as below. 204 | 205 | There have been API changes to accommodate the new `uasyncio` version: the 206 | event loop argument is no longer required or accepted in `Client` and `Server` 207 | constructors. The directory structure has changed, requiring minor changes to 208 | `import` statements. 209 | 210 | ### 3.1.2 Firmware and dependency 211 | 212 | On ESP8266, RAM can be saved by building firmware from source, freezing 213 | `client.py` as bytecode. If this is not done, it is necessary to 214 | [cross compile](https://github.com/micropython/micropython/tree/master/mpy-cross) 215 | `client.py`. The file `client.mpy` is provided for those unable to do this. If 216 | freezing, create an `iot` directory in your modules directory and copy 217 | `iot/client.py` and the directory `iot/primitives` and contents there. 218 | 219 | Pre-requisites: firmware must be a current daily build or a release build after 220 | V1.12. If upgrading, particularly on an ESP8266, it is wise to erase flash 221 | prior to installtion. In particular this will ensure the use of littlefs with 222 | its associated RAM saving. 223 | 224 | This repository is a python package, consequently on the client the directory 225 | structure must be retained. The following installs all demos on the target. 226 | 227 | On your PC move to a directory of your choice and clone the repository there: 228 | ``` 229 | git clone https://github.com/peterhinch/micropython-iot 230 | ``` 231 | Installation consists of copying the `iot` directory and contents to an `iot` 232 | directory on the boot device. On ESP8266 or ESP32 the boot device is`/pyboard`. 233 | On the Pyboard D it will be `/flash` or `/sd` depending on whether an SD card 234 | is fitted. 235 | 236 | Copying may be done using any tool but I recommend 237 | [rshell](https://github.com/dhylands/rshell). If this is used start in the 238 | directory on your PC containing the clone, start `rshell` and issue (adapting 239 | the boot device for your platform): 240 | ``` 241 | rsync iot /pyboard/iot 242 | ``` 243 | On ESP8266, unless frozen, it is necessary to delete `client.py` to force the 244 | use of `client.mpy`: 245 | ``` 246 | rm /pyboard/iot/client.py 247 | ``` 248 | 249 | ### 3.1.3 Preconditions for demos 250 | 251 | The demo programs store client configuration data in a file `local.py`. Each 252 | demo has its own `local.py` located in the directory of the demo code. This 253 | contains the following constants which should be edited to match local 254 | conditions. Remove the `use_my_local` hack designed for my WiFi privacy.: 255 | 256 | ```python 257 | MY_ID = '1' # Client-unique string. 258 | SERVER = '192.168.0.10' # Server IP address. 259 | SSID = 'use_my_local' # Insert your WiFi credentials 260 | PW = 'PASSWORD' 261 | PORT = 8123 262 | TIMEOUT = 2000 263 | # The following may be deleted 264 | if SSID == 'use_my_local': 265 | from iot.examples.my_local import * 266 | ``` 267 | 268 | The ESP8266 can store WiFi credentials in flash memory. If desired, ESP8266 269 | clients can be initialised to connect to the local network prior to running 270 | the demos. In this case the SSID and PW variables may optionally be empty 271 | strings (`SSID = ''`). 272 | 273 | Note that the server-side examples below specify `python3` in the run command. 274 | In every case `micropython` may be substituted to run under the Unix build of 275 | MicroPython. 276 | 277 | ## 3.2 Usage 278 | 279 | ### 3.2.1 The main demo 280 | 281 | This illustrates up to four clients communicating with the server. The demo 282 | expects the clients to have ID's in the range 1 to 4: if using multiple clients 283 | edit each one's `local.py` accordingly. 284 | 285 | On the server navigate to the parent directory of `iot` and run: 286 | ``` 287 | python3 -m iot.examples.s_app_cp 288 | ``` 289 | or 290 | ``` 291 | micropython -m iot.examples.s_app_cp 292 | ``` 293 | On each client run: 294 | ``` 295 | import iot.examples.c_app 296 | ``` 297 | 298 | ### 3.2.2 The remote control demo 299 | 300 | This shows one ESP8266 controlling another. The transmitter should have a 301 | pushbutton between GPIO 0 and gnd, both should have an LED on GPIO 2. 302 | 303 | On the server navigate to the parent directory of `iot` and run: 304 | ``` 305 | python3 -m iot.remote.s_comms_cp 306 | ``` 307 | or 308 | ``` 309 | micropython -m iot.remote.s_comms_cp 310 | ``` 311 | 312 | On the esp8266 run (on transmitter and receiver respectively): 313 | 314 | ``` 315 | import iot.remote.c_comms_tx 316 | import iot.remote.c_comms_rx 317 | ``` 318 | 319 | ### 3.2.3 Quality of Service demo 320 | 321 | This test program verifies that each message (in each direction) is received 322 | exactly once. On the server navigate to the parent directory of `iot` and run: 323 | ``` 324 | python3 -m iot.qos.s_qos_cp 325 | ``` 326 | or 327 | ``` 328 | micropython -m iot.qos.s_qos_cp 329 | ``` 330 | On the client, after editing `/pyboard/qos/local.py`, run: 331 | ``` 332 | import iot.qos.c_qos 333 | ``` 334 | 335 | ### 3.2.4 The fast qos demo 336 | 337 | This tests the option of concurrent `qos` writes. This is an advanced feature 338 | discussed in [section 7.1](./README.md#71-the-wait-argument). To run the demo, 339 | on the server navigate to the parent directory of `iot` and run: 340 | ``` 341 | python3 -m iot.qos.s_qos_fast 342 | ``` 343 | or 344 | ``` 345 | micropython -m iot.qos.s_qos_fast 346 | ``` 347 | On the client, after editing `/pyboard/qos/local.py`, run: 348 | ``` 349 | import iot.qos.c_qos_fast 350 | ``` 351 | 352 | ### 3.2.5 Troubleshooting the demos 353 | 354 | If `local.py` specifies an SSID, on startup the demo programs will pause 355 | indefinitely if unable to connect to the WiFi. If `SSID` is an empty string the 356 | assumption is an ESP8266 with stored credentials; if this fails to connect an 357 | `OSError` will be thrown. An `OSError` will also be thrown if initial 358 | connectivity with the server cannot be established. 359 | 360 | ###### [Contents](./README.md#1-contents) 361 | 362 | # 4. Client side applications 363 | 364 | A client-side application instantiates a `Client` and launches a coroutine 365 | which awaits it. After the pause the `Client` has connected to the server and 366 | communication can begin. This is done using `Client.write` and 367 | `Client.readline` methods. 368 | 369 | Every client ha a unique ID (`MY_ID`) typically stored in `local.py`. The ID 370 | comprises a string subject to the same constraint as messages: 371 | 372 | Messages comprise a single line of text; if the line is not terminated with a 373 | newline ('\n') the client library will append it. Newlines are only allowed as 374 | the last character. Blank lines will be ignored. 375 | 376 | A basic client-side application has this form: 377 | ```python 378 | import uasyncio as asyncio 379 | import ujson 380 | from iot import client 381 | import local # or however you configure your project 382 | 383 | 384 | class App: 385 | def __init__(self, verbose): 386 | self.cl = client.Client(local.MY_ID, local.SERVER, 387 | local.PORT, local.SSID, local.PW, 388 | local.TIMEOUT, conn_cb=self.state, 389 | verbose=verbose) 390 | asyncio.create_task(self.start()) 391 | 392 | async def start(self): 393 | await self.cl # Wait until client has connected to server 394 | asyncio.create_task(self.reader()) 395 | await self.writer() # Wait forever 396 | 397 | def state(self, state): # Callback for change in connection status 398 | print("Connection state:", state) 399 | 400 | async def reader(self): 401 | while True: 402 | line = await self.cl.readline() # Wait until data received 403 | data = ujson.loads(line) 404 | print('Got', data, 'from server app') 405 | 406 | async def writer(self): 407 | data = [0, 0] 408 | count = 0 409 | while True: 410 | data[0] = count 411 | count += 1 412 | print('Sent', data, 'to server app\n') 413 | await self.cl.write(ujson.dumps(data)) 414 | await asyncio.sleep(5) 415 | 416 | def close(self): 417 | self.cl.close() 418 | 419 | app = None 420 | async def main(): 421 | global app # For closure by finally clause 422 | app = App(True) 423 | await app.start() # Wait forever 424 | 425 | try: 426 | asyncio.run(main()) 427 | finally: 428 | app.close() # Ensure proper shutdown e.g. on ctrl-C 429 | asyncio.new_event_loop() 430 | ``` 431 | If an outage of server or WiFi occurs, the `write` and `readline` methods will 432 | pause until connectivity has been restored. The server side API is similar. 433 | 434 | ###### [Contents](./README.md#1-contents) 435 | 436 | ## 4.1 The Client class 437 | 438 | The constructor has a substantial number of configuration options but in many 439 | cases defaults may be accepted for all but the first five. 440 | 441 | Constructor args: 442 | 1. `my_id` The client id. 443 | 2. `server` The server IP-Adress to connect to. 444 | 3. `port=8123` The port the server listens on. 445 | 4. `ssid=''` WiFi SSID. May be blank for ESP82666 with credentials in flash. 446 | 5. `pw=''` WiFi password. 447 | 6. `timeout=2000` Connection timeout in ms. If a connection is unresponsive 448 | for longer than this period an outage is assumed. 449 | 7. `conn_cb=None` Callback or coroutine that is called whenever the connection 450 | changes. 451 | 8. `conn_cb_args=None` Arguments that will be passed to the *connected_cb* 452 | callback. The callback will get these args preceeded by a `bool` indicating 453 | the new connection state. 454 | 9. `verbose=False` Provides optional debug output. 455 | 10. `led=None` If a `Pin` instance is passed it will be toggled each time a 456 | keepalive message is received. Can provide a heartbeat LED if connectivity is 457 | present. On Pyboard D a `Pin` or `LED` instance may be passed. 458 | 10. `wdog=False` If `True` a watchdog timer is created with a timeout of 20s. 459 | This will reboot the board if it crashes - the assumption is that the 460 | application will be restarted via `main.py`. 461 | 462 | Methods (asynchronous): 463 | 1. `readline` No args. Pauses until data received. Returns a line. 464 | 2. `write` Args: `buf`, `qos=True`, `wait=True`. `buf` holds a line of text. 465 | If `qos` is set, the system guarantees delivery. If it is clear messages may 466 | (rarely) be lost in the event of an outage. 467 | The `wait` arg determines the behaviour when multiple concurrent writes are 468 | launched with `qos` set. See [Quality of service](./README.md#7-quality-of-service). 469 | 470 | The following asynchronous methods are described in Initial Behaviour below. In 471 | most cases they can be ignored. 472 | 3. `bad_wifi` 473 | 4. `bad_server` 474 | 475 | Methods (synchronous): 476 | 1. `status` Returns `True` if connectivity is present. May also be read using 477 | function call syntax (via `__call__`). 478 | 2. `close` Closes the socket. Should be called in the event of an exception 479 | such as a `ctrl-c` interrupt. Also cancels the WDT in the case of a software 480 | WDT. 481 | 482 | Bound variable: 483 | 1. `connects` The number of times the `Client` instance has connected to WiFi. 484 | This is maintained for information only and provides some feedback on the 485 | reliability of the WiFi radio link. 486 | 487 | The `Client` class is awaitable. If 488 | ```python 489 | await client_instance 490 | ``` 491 | is issued, the coroutine will pause until connectivity is (re)established. 492 | 493 | Applications which always `await` the `write` method do not need to check or 494 | await the client status: `write` will pause until it can complete. If `write` 495 | is launched using `create_task` it is essential to check status otherwise 496 | during an outage unlimited numbers of coroutines will be created. 497 | 498 | The client buffers up to 20 incoming messages. To avoid excessive queue growth 499 | applications should have a single coroutine which spends most of its time 500 | awaiting incoming data. 501 | 502 | ###### [Contents](./README.md#1-contents) 503 | 504 | ### 4.1.1 Initial Behaviour 505 | 506 | When an application instantiates a `Client` it attemps to connect to WiFi and 507 | then to the server. Initial connection is handled by the following `Client` 508 | asynchronous bound methods (which may be modified by subclassing): 509 | 510 | 1. `bad_wifi` No args. 511 | 2. `bad_server` No args. Awaited if server refuses an initial connection. 512 | 513 | Note that, once a server link has been initially established, these methods 514 | will not be called: reconnection after outages of WiFi or server are automatic. 515 | 516 | The `bad_wifi` coro attempts to connect using the WiFi credentials passed to 517 | the constructor. This will pause until a connection has been achieved. The 518 | `bad_server` coro raises an `OSError`. Behaviour of either of these may be 519 | modified by subclassing. 520 | 521 | Platforms other than ESP8266 launch `bad_wifi` unconditionally on startup. In 522 | the case of an ESP8266 which has WiFi credentials stored in flash it will first 523 | attempt to connect using that data, only launching `bad_wifi` if this fails in 524 | a timeout period. This is to minimise flash wear. 525 | 526 | ### 4.1.2 Watchdog Timer 527 | 528 | This option provides a last-ditch protection mechanism to keep a client running 529 | in the event of a crash. The ESP8266 can (rarely) crash, usually as a result of 530 | external electrical disturbance. The WDT detects that the `Client` code is no 531 | longer running and issues a hard reset. Note that this implies a loss of 532 | program state. It also assumes that `main.py` contains a line of code which 533 | will restart the application. 534 | 535 | Debugging code with a WDT can be difficult because bugs or software interrupts 536 | will trigger unexpected resets. It is recommended not to enable this option 537 | until the code is stable. 538 | 539 | On the ESP8266 the WDT uses a sofware timer: it can be cancelled which 540 | simplifies debugging. See `examples/c_app.py` for the use of the `close` method 541 | in a `finally` clause. 542 | 543 | The WDT on the Pyboard D is a hardware implementation: it cannot be cancelled. 544 | It may be necessary to use safe boot to bypass `main.py` to access the code. 545 | 546 | ###### [Contents](./README.md#1-contents) 547 | 548 | # 5. Server side applications 549 | 550 | A typical example has an `App` class with one instance per physical client 551 | device. This enables instances to share data via class variables. Each instance 552 | launches a coroutine which acquires a `Connection` instance for its individual 553 | client (specified by its client_id). This process will pause until the client 554 | has connected with the server. Communication is then done using the `readline` 555 | and `write` methods of the `Connection` instance. 556 | 557 | Messages comprise a single line of text; if the line is not terminated with a 558 | newline (`\n`) the server library will append it. Newlines are only allowed as 559 | the last character. Blank lines will be ignored. 560 | 561 | A basic server-side application has this form: 562 | ```python 563 | import asyncio 564 | import json 565 | from iot import server 566 | import local # or however you want to configure your project 567 | 568 | class App: 569 | def __init__(self, client_id): 570 | self.client_id = client_id # This instance talks to this client 571 | self.conn = None # Will be Connection instance 572 | self.data = [0, 0, 0] # Exchange a 3-list with remote 573 | asyncio.create_task(self.start()) 574 | 575 | async def start(self): 576 | # await connection from the specific EP8266 client 577 | self.conn = await server.client_conn(self.client_id) 578 | asyncio.create_task(self.reader()) 579 | asyncio.create_task(self.writer()) 580 | 581 | async def reader(self): 582 | while True: 583 | # Next line will pause for client to send a message. In event of an 584 | # outage it will pause for its duration. 585 | line = await self.conn.readline() 586 | self.data = json.loads(line) 587 | print('Got', self.data, 'from remote', self.client_id) 588 | 589 | async def writer(self): 590 | count = 0 591 | while True: 592 | self.data[0] = count 593 | count += 1 594 | print('Sent', self.data, 'to remote', self.client_id, '\n') 595 | await self.conn.write(json.dumps(self.data)) # May pause in event of outage 596 | await asyncio.sleep(5) 597 | 598 | async def main(): 599 | clients = {1, 2, 3, 4} 600 | apps = [App(n) for n in clients] # Accept 4 clients with ID's 1-4 601 | await server.run(clients, True, local.PORT, local.TIMEOUT) # Verbose 602 | 603 | def run(): 604 | try: 605 | asyncio.run(main()) 606 | except KeyboardInterrupt: # Delete this if you want a traceback 607 | print('Interrupted') 608 | finally: 609 | server.Connection.close_all() 610 | asyncio.new_event_loop() 611 | 612 | if __name__ == "__main__": 613 | run() 614 | ``` 615 | 616 | ## 5.1 The server module 617 | 618 | Server-side applications should create and run a `server.run` task. This runs 619 | forever and takes the following args: 620 | 1. `expected` A set of expected client ID strings. 621 | 2. `verbose=False` If `True` output diagnostic messages. 622 | 3. `port=8123` TCP/IP port for connection. Must match clients. 623 | 4. `timeout=2000` Timeout for outage detection in ms. Must match the timeout 624 | of all `Client` instances. 625 | 626 | The `expected` arg causes the server to produce a warning message if an 627 | unexpected client connects, or if multiple clients have the same ID (this will 628 | cause tears before bedtime). 629 | 630 | The module is based on the `Connection` class. A `Connection` instance provides 631 | a communication channel to a specific client. The `Connection` instance for a 632 | given client is a singleton and is acquired by issuing 633 | ```python 634 | conn = await server.client_conn(client_id) 635 | ``` 636 | This will pause until connectivity has been established. It can be issued at 637 | any time: if the `Connection` has already been instantiated, that instance will 638 | be returned. The `Connection` constructor should not be called by applications. 639 | 640 | #### The `Connection` instance 641 | 642 | Methods (asynchronous): 643 | 1. `readline` No args. Pauses until data received. Returns a line. 644 | 2. `write` Args: `buf`, `qos=True`, `wait=True`. `buf` holds a line of text. 645 | If `qos` is set, the system guarantees delivery. If it is clear messages may 646 | (rarely) be lost in the event of an outage.__ 647 | The `wait` arg determines the behaviour when multiple concurrent writes are 648 | launched with `qos` set. See [Quality of service](./README.md#7-quality-of-service). 649 | 650 | Methods (synchronous): 651 | 1. `status` Returns `True` if connectivity is present. The connection state 652 | may also be retrieved using function call syntax (via `.__call__`). 653 | 2. `__getitem__` Enables the `Connection` of another client to be retrieved 654 | using list element access syntax. Will throw a `KeyError` if the client is 655 | unknown (has never connected). 656 | 657 | Class Method (synchronous): 658 | 1. `close_all` No args. Closes all sockets: call on exception (e.g. ctrl-c). 659 | 660 | Bound variable: 661 | 1. `nconns` Maintains a count of (re)connections for information or monitoring 662 | of outages. 663 | 664 | The `Connection` class is awaitable. If 665 | ```python 666 | await connection_instance 667 | ``` 668 | is issued, the coroutine will pause until connectivity is (re)established. 669 | 670 | Applications which always `await` the `write` method do not need to check or 671 | await the server status: `write` will pause until it can complete. If `write` 672 | is launched using `create_task` it is essential to check status otherwise 673 | during an outage unlimited numbers of coroutines will be created. 674 | 675 | The server buffers incoming messages but it is good practice to have a coro 676 | which spends most of its time waiting for incoming data. 677 | 678 | Server module coroutines: 679 | 680 | 1. `run` Args: `expected` `verbose=False` `port=8123` `timeout=2000` 681 | This is the main coro and starts the system. 682 | `expected` is a set containing the ID's of all clients. 683 | `verbose` causes debug messages to be printed. 684 | `port` is the port to listen to. 685 | `timeout` is the number of ms that can pass without a keepalive until the 686 | connection is considered dead. 687 | 2. `client_conn` Arg: `client_id`. Pauses until the sepcified client has 688 | connected. Returns the `Connection` instance for that client. 689 | 3. `wait_all` Args: `client_id=None` `peers=None`. See below. 690 | 691 | The `wait_all` coroutine is intended for applications where clients communicate 692 | with each other. Typical user code cannot proceed until a given set of clients 693 | have established initial connectivity. 694 | 695 | `wait_all`, where a `client_id` is specified, behaves as `client_conn` except 696 | that it pauses until further clients have also connected. If a `client_id` is 697 | passed it will returns that client's `Connection` instance. If `None` is passed 698 | the assumption is that the current client is already connected and the coro 699 | returns `None`. 700 | 701 | The `peers` argument defines which clients it must await: it must either be 702 | `None` or a set of client ID's. If a set of `client_id` values is passed, it 703 | pauses until all clients in the set have connected. If `None` is passed, it 704 | pauses until all clients specified in `run`'s `expected` set have connected. 705 | 706 | It is perhaps worth noting that the user application can impose a timeout on 707 | this by means of `asyncio.wait_for`. 708 | 709 | ###### [Contents](./README.md#1-contents) 710 | 711 | # 6. Ensuring resilience 712 | 713 | There are two principal ways of provoking `LmacRxBlk` errors and crashes. 714 | 1. Failing to close sockets when connectivity is lost. 715 | 2. Feeding excessive amounts of data to a socket after connectivity is lost: 716 | this causes an overflow to an internal ESP8266 buffer. 717 | 718 | These modules aim to address these issues transparently to application code, 719 | however it is possible to write applications which violate 2. 720 | 721 | There is a global `TIMEOUT` value defined in `local.py` which should be the 722 | same for the server and all clients. Each end of the link sends a `keepalive` 723 | (KA) packet (an empty line) at a rate guaranteed to ensure that at least one KA 724 | will be received in every `TIMEOUT` period. If it is not, connectivity is 725 | presumed lost and both ends of the interface adopt a recovery procedure. 726 | 727 | If an application always `await`s a write with `qos==True` there is no risk of 728 | Feeding excess data to a socket: this is because the coroutine does not return 729 | until the remote endpoint has acknowledged reception. 730 | 731 | On the other hand if multiple messages are sent within a timeout period with 732 | `qos==False` there is a risk of buffer overflow in the event of an outage. 733 | 734 | ###### [Contents](./README.md#1-contents) 735 | 736 | # 7. Quality of service 737 | 738 | In the presence of a stable WiFi link TCP/IP should ensure that packets sent 739 | are received intact. In the course of extensive testing with the ESP8266 we 740 | found that (very rarely) packets were lost. It is not known whether this 741 | behavior is specific to the ESP8266. Another mechanism for message loss is the 742 | case where a message is sent in the interval between an outage occurring and it 743 | being detected. This is likely to occur on all platforms. 744 | 745 | The client and server modules avoid message loss by the use of acknowledge 746 | packets: if a message is not acknowledged within a timeout period it is 747 | retransmitted. This implies duplication where the acknowledge packet is lost. 748 | Receive message de-duplication is employed to provide a guarantee that the 749 | message will be delivered exactly once. While delivery is guaranteed, 750 | timeliness is not. Messages are inevitably delayed for the duration of a WiFi 751 | or server outage where the `write` coroutine will pause for the duration. 752 | 753 | Guaranteed delivery involves a tradeoff against throughput and latency. This is 754 | managed by optional arguments to `.write`, namely `qos=True` and `wait=True`. 755 | 756 | ## 7.1 The qos argument 757 | 758 | Message integrity is determined by the `qos` argument. If `False` message 759 | delivery is not guaranteed. A use-case for disabling `qos` is in applications 760 | such as remote control. If the user presses a button and nothing happens they 761 | would simply repeat the action. Such messages are always sent immediately: the 762 | application should limit the rate at which they can be sent, particularly on 763 | ESP8266 clients, to avoid risk of buffer overflow. 764 | 765 | With `qos` set, the message will be delivered exactly once. 766 | 767 | Where successive `qos` messages are sent there may be a latency issue. By 768 | default the transmission of a `qos` message will be delayed until reception 769 | of its predecessor's acknowledge. Consequently the `write` coroutine will 770 | pause, introducing latency. This serves two purposes. Firstly it ensures that 771 | messages are received in the order in which they were sent (see below). 772 | 773 | Secondly consider the case where an outage has occurred but has not yet been 774 | detected. The first message is written, but no acknowledge is received. 775 | Subsequent messages are delayed, precluding the risk of ESP8266 buffer 776 | overflows. The interface resumes operation after the outage has cleared. 777 | 778 | ## 7.2 The wait argument 779 | 780 | This default can be changed with the `wait` argument to `write`. If `False` a 781 | `qos` message will be sent immediately, even if acknowledge packets from 782 | previous messages are pending. Applications should be designed to limit the 783 | number of such `qos` messages sent in quick succession: on ESP8266 clients 784 | buffer overflows can occur. 785 | 786 | In testing in 2019 the ESP32 was not resilient under these circumstances; this 787 | appears to have been fixed in current firmware builds. Nevertheless setting 788 | `wait=False` potentially risks resilience. If used, applications should be 789 | tested to verify quality of service in the presence of WiFi outages. 790 | 791 | If messages are sent with `wait=False` there is a chance that they may not be 792 | received in the order in which they were sent. As described above, in the event 793 | of `qos` message loss, retransmission occurs after a timeout period has 794 | elapsed. During that timeout period the application may have successfully sent 795 | another non-waiting `qos` message resulting in out of order reception. 796 | 797 | The demo programs `qos/c_qos_fast.py` (client) and `qos/s_qos_fast.py` issue 798 | four `write` operations with `wait=False` in quick succession. This number is 799 | probably near the maximum on an ESP8266. Note the need explicitly to check for 800 | connectivity before issuing the `write`: this is to avoid spawning large 801 | numbers of coroutines during an outage. 802 | 803 | In summary specifying `wait=False` should be considered an "advanced" option 804 | requiring testing to prove that resilence is maintained. 805 | 806 | ###### [Contents](./README.md#1-contents) 807 | 808 | # 8. Performance 809 | 810 | ## 8.1 Latency and throughput 811 | 812 | The interface is intended to provide low latency: if a switch on one node 813 | controls a pin on another, a reasonably quick response can be expected. The 814 | link is not designed for high throughput because of the buffer overflow issue 815 | discussed in [section 6](./README.md#6-ensuring-resilence). This is essentially 816 | a limitation of the ESP8266 device: more agressive use of the `wait` arg may be 817 | possible on platforms such as the Pyboard D. 818 | 819 | In practice latency on the order of 100-200ms is normal; if an outage occurs 820 | latency will inevitably persist for the duration. 821 | 822 | **TIMEOUT** 823 | 824 | This defaults to 2s. On `Client` it is a constructor argument, on the server 825 | it is an arg to `server.run`. Its value should be common to all clients and 826 | the sever. It determines the time taken to detect an outage and the frequency 827 | of `keepalive` packets. This time was chosen on the basis of measured latency 828 | periods on WiFi networks. It may be increased at the expense of slower outage 829 | detection. Reducing it may result in spurious timeouts with unnecessary WiFi 830 | reconnections. 831 | 832 | ## 8.2 Client RAM utilisation 833 | 834 | On ESP8266 with a current (June 2020) daily build the demo reports over 20KB 835 | free. Free RAM of 25.9KB was achieved with compiled firmware with frozen 836 | bytecode as per [Installation](./README.md#31-installation). 837 | 838 | ## 8.3 Platform reliability 839 | 840 | In extensive testing the Pyboard D performed impeccably: no failures of any 841 | kind were observed in weeks of testing through over 1000 outages. 842 | 843 | ESP32 was prone to occasional spontaneous reboots. It would typically run for a 844 | few days through multiple WiFi outages before rebooting. 845 | 846 | ESP8266 still occasionally crashes and it is recommended to use the watchdog 847 | feature to reboot it should this occur. 848 | 849 | It would take a very long time to achieve more than a subjective impression of 850 | the effectof usage options on failure rate. The precautionary principle 851 | suggests maximising free ram with frozen bytecode on ESP8266 and avoiding 852 | concurrent `qos==1` writes on ESPx platforms. 853 | 854 | ###### [Contents](./README.md#1-contents) 855 | 856 | # 9. Extension to the Pyboard 857 | 858 | This extends the resilient link to MicroPython targets lacking a network 859 | interface; for example the Pyboard V1.x. Connectivity is provided by an ESP8266 860 | running a fixed firmware build: this needs no user code. 861 | 862 | The interface between the Pyboard and the ESP8266 uses I2C and is based on the 863 | [existing I2C module](https://github.com/peterhinch/micropython-async/tree/master/v3/as_drivers/i2c). 864 | 865 | ![Image](./images/block_diagram_pyboard.png) 866 | 867 | Resilient behaviour includes automatic recovery from WiFi and server outages; 868 | also from ESP8266 crashes. 869 | 870 | See [documentation](./docs/PB_LINK.md). 871 | 872 | # 10. How it works 873 | 874 | ## 10.1 Interface and client module 875 | 876 | The `client` module was designed on the expectation that client applications 877 | will usually be simple: acquiring data from sensors and periodically sending it 878 | to the server and/or receiving data from the server and using it to control 879 | devices. Developers of such applications probably don't need to be concerned 880 | with the operation of the module. 881 | 882 | There are ways in which applications can interfere with the interface's 883 | operation either by blocking or by attempting to operate at excessive data 884 | rates. Such designs can produce an erroneous appearance of poor WiFi 885 | connectivity. 886 | 887 | Outages are detected by a timeout of the receive tasks at either end. Each peer 888 | sends periodic `keepalive` messages consisting of a single newline character, 889 | and each peer has a continuously running read task. If no message is received 890 | in the timeout period (2s by default) an outage is declared. 891 | 892 | From the client's perspective an outage may be of the WiFi or the server. In 893 | practice WiFi outages are more common: server outages on a LAN are typically 894 | caused by the developer testing new code. The client assumes a WiFi outage. It 895 | disconnects from the network for long enough to ensure that the server detects 896 | the outage. It then attempts repeatedly to reconnect. When it does so, it 897 | checks that the connection is stable for a period (it might be near the limit 898 | of WiFi range). 899 | 900 | If this condition is met it attempts to reconnect to the server. If this 901 | succeeds the client runs. Its status becomes `True` when it first receives data 902 | from the server. 903 | 904 | A client or server side application which blocks or hogs processor time can 905 | prevent the timely transmission of `keepalive` messages. This will cause the 906 | server to declare an outage: the consequence is a sequence of disconnect 907 | and reconnect events even in the presence of a strong WiFi signal. 908 | 909 | ## 10.2 Server module 910 | 911 | Server-side applications communicate via a `Connection` instance. This is 912 | unique to a client. It is instantiated when a specified client first connects 913 | and exists forever. During an outage its status becomes `False` for the 914 | duration. The `Connection` instance is retrieved as follows, with the 915 | `client_conn` method pausing until initial connectivity has been achieved: 916 | ```python 917 | import server 918 | # Class details omitted 919 | self.conn = await server.client_conn(self.client_id) 920 | ``` 921 | Each client must have a unique ID. When the server detects an incoming 922 | connection on the port it reads the client ID from the client. If a 923 | `Connection` instance exists for that ID its status is updated, otherwise a 924 | `Connection` is instantiated. 925 | 926 | The `Connection` has a continuously running coroutine `._read` which reads data 927 | from the client. If an outage occurs it calls the `._close` method which closes 928 | the socket, setting the bound variable `._sock` to `None`. This corresponds to 929 | a `False` status. The `._read` method pauses until a new connection occurs. The 930 | aim here is to read data from ESP8266 clients as soon as possible to minimise 931 | risk of buffer overflows. 932 | 933 | The `Connection` detects an outage by means of a timeout in the `._read` 934 | method: if no data or `keepalive` is received in that period an outage is 935 | declared, the socket is closed, and the `Connection` status becomes `False`. 936 | 937 | The `Connection` has a `._keepalive` method. This regularly sends `keepalive` 938 | messages to the client. Application code which blocks the scheduler can cause 939 | this not to be scheduled in a timely fashion with the result that the client 940 | declares an outage and disconnects. The consequence is a sequence of disconnect 941 | and reconnect events even in the presence of a strong WiFi signal. 942 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/__init__.py -------------------------------------------------------------------------------- /docs/PB_LINK.md: -------------------------------------------------------------------------------- 1 | # 0. IOT design for clients lacking a LAN interface 2 | 3 | This uses an ESP8266 to provide a resilient "socket-like" link between a non 4 | networked device (the client) and a server-side application. The ESP8266 runs a 5 | fixed configuration. All client-side application code resides on the client. 6 | 7 | Communication between the client and the ESP8266 uses I2C. The client must be 8 | capable of running I2C slave mode. This includes STM boards such as the 9 | Pyboard V1.x. In this doc the client device is referred to as the Pyboard. 10 | 11 | 0. [IOT design for clients lacking a LAN interface](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 12 | 1. [Wiring](./PB_LINK.md#1-wiring) 13 | 2. [Files](./PB_LINK.md#2-files) 14 | 3. [Running the demo](./PB_LINK.md#3-running-the-demo) 15 | 4. [The Pyboard application](./PB_LINK.md#4-the-pyboard-application) 16 | 4.1 [Configuration](./PB_LINK.md#41-configuration) 17 | 4.2 [Application design](./PB_LINK.md#42-application-design) 18 | 4.3 [Special messages](./PB_LINK.md#43-special-messages) 19 | 4.4 [The AppBase class](./PB_LINK.md#44-the-appbase-class) 20 | 5. [ESP8266 crash detection](./PB_LINK.md#5-esp8266-crash-detection) 21 | 6. [Quality of service](./PB_LINK.md#6-quality-of-service) 22 | 7. [Building ESP8266 firmware](./PB_LINK.md#7-building-esp8266-firmware) 23 | 24 | ###### [Main README](../README.md) 25 | 26 | # 1. Wiring 27 | 28 | Firmware should be installed on the ESP8266 prior to connecting it to the 29 | Pyboard. 30 | 31 | ESP8266 pin numbers are GPIO pins as used on the reference board. WeMos have 32 | their own numbering scheme. 33 | 34 | | Pyboard | ESP8266 | Notes | WeMos pins | 35 | |:-------:|:-------:|:--------:|:-----------| 36 | | gnd | gnd | | gnd | 37 | | X9 | 0 | I2C scl | D3 | 38 | | X10 | 2 | I2C sda | D4 | 39 | | X11 | 5 | syn | D1 | 40 | | X12 | rst | reset | reset | 41 | | Y8 | 4 | ack | D2 | 42 | 43 | Pyboard pins may be altered at will (with matching changes to `config.py`). 44 | The chosen pins enable hard I2C to be used but soft I2C with arbitrary pins 45 | also works. The `syn` and `ack` wires provide synchronisation and the `reset` 46 | line enables the Pyboard to reset the ESP8266 if it crashes and fails to 47 | respond. 48 | 49 | I2C requires the devices to be connected via short links and to share a common 50 | ground. The `sda` and `scl` lines require pullup resistors. The chosen ESP8266 51 | pins are equipped with pullups on most boards. If for some reason external 52 | pullups are needed, a typical value is 4.7KΩ to 3.3V. 53 | 54 | The I2C bus employed here cannot be shared with other devices. 55 | 56 | A common power supply is usual but not essential. If the Pyboard is powered by 57 | USB and the ESP8266 board has a voltage regulator, the ESP may be powered from 58 | the Pyboard `Vin` pin. 59 | 60 | ###### [Contents](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 61 | 62 | # 2. Installation 63 | 64 | A Python package is used so directory structures must be maintained. 65 | 66 | #### On the Pyboard 67 | 68 | These instructions assume an installation to the SD card. If installing to 69 | flash, substitute `flash` for `sd` below. 70 | 71 | On the Pyboard copy the directory `pb_link` and its contents to '/sd'. If using 72 | rshell this may be done by issuing: 73 | ``` 74 | cp -r pb_link /sd 75 | ``` 76 | Clone [the async repo](https://github.com/peterhinch/micropython-async) to 77 | your PC, navigate to `v3` and copy the `primitives` directory to `/sd`: 78 | ``` 79 | cp -r primitives /sd 80 | ``` 81 | 82 | Edit `iot/pb_link/config.py` to match local conditions, notably server IP 83 | address and WiFi credentials. WiFi credentials may be empty strings if the 84 | ESP8266 has been initialised with a WiFi connection. 85 | 86 | #### On the ESP8266 87 | 88 | For reliable operation this must be compiled as frozen bytecode. For those not 89 | wishing to compile a build, the provided `firmware-combined.bin` may be 90 | installed with the following commands: 91 | 92 | ``` 93 | esptool.py --port /dev/ttyUSB0 erase_flash 94 | esptool.py --port /dev/ttyUSB0 --baud 115200 write_flash --verify --flash_size=detect -fm dio 0 firmware-combined.bin 95 | ``` 96 | Erasure is essential. The build is designed to start on boot so no further 97 | steps are required. 98 | 99 | To compile your own build see 100 | [Section 7](./PB_LINK.md#7-building-esp8266-firmware). 101 | 102 | ### Dependency 103 | 104 | The Pyboard must be running a daily build or a release build later than V1.12. 105 | This is to ensure a compatible version of `uasyncio` (V3). 106 | 107 | ###### [Contents](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 108 | 109 | # 3. Running the demo 110 | 111 | Ensure `/sd/pb_link/config.py` matches local conditions for WiFi credentials 112 | and server IP address. 113 | 114 | On the server navigate to the parent directory of `pb_link` and run 115 | ``` 116 | micropython -m pb_link.s_app 117 | ``` 118 | or (given Python 3.8 or newer): 119 | ``` 120 | python3 -m pb_link.s_app 121 | ``` 122 | 123 | On the Pyboard issue 124 | ```python 125 | import pb_link.pb_client 126 | ``` 127 | 128 | # 4. The Pyboard application 129 | 130 | ## 4.1 Configuration 131 | 132 | There may be multiple Pyboards in a network, each using an ESP8266 with 133 | identical firmware. The file `config.py` contains configuration details which 134 | are common to all Pyboards which communicate with a single server. This will 135 | require adapting for local conditions. The `config` list has the following 136 | entries: 137 | 138 | 0. Port. `int`. Default 8123. If changing this, the server application must 139 | specify the same value to `server.run`. 140 | 1. Server IP. `str`. 141 | 2. Server timeout in ms `int`. Default 1500. If changing this, the server 142 | application must specify the same value to `server.run`. 143 | 3. Report frequency `int`. Report to Pyboard every N seconds (0: never). 144 | 4. SSID. `str`. 145 | 5. Password. `str`. 146 | 147 | If having a file with credential details is unacceptable an empty string ('') 148 | may be used in the SSID and Password fields. In this case the ESP8266 will 149 | attempt to connect to the WLAN which the device last used; if it fails there is 150 | no means of recovery and the link will fail. 151 | 152 | `config.py` also provides a `hardware` list. This contains `Pin` and `I2C ` 153 | details which may be changed. Pins are arbitrary and the I2C interface may be 154 | changed, optionally to use soft I2C. The I2C interface may not be shared with 155 | other devices. The `hardware` list comprises the following: 156 | 157 | The `hardware` list comprises the following elements: 158 | 0. `i2c` The I2C interface object. 159 | 1. `syn` A Pyboard `Pin` instance (synchronisation with ESP8266). 160 | 2. `ack` Ditto. 161 | 3. `rst` A reset tuple `(Pin, level, pulse_length)` where: 162 | `Pin` is the Pyboard Pin instance linked to ESP8266 reset. 163 | `Level` is 0 for the ESP8266. 164 | `pulse_length` A value of 200 is recommended (units are ms). 165 | 166 | ## 4.2 Application design 167 | 168 | The code should create a class subclassed from `app_base.AppBase`. The base 169 | class performs initialisation. When this is complete, a `.start` synchronous 170 | method is called which the user class should implement. 171 | 172 | Typically this will launch user coroutines and terminate, as in the demo. 173 | 174 | The `AppBase` class has `.readline` and `.write` coroutines which comprise the 175 | interface to the ESP8266 (and thence to the server). User coros communicate 176 | thus: 177 | 178 | ```python 179 | line = await self.readline() # Read a \n terminated line from server app 180 | await self.write(line) # Write a line. 181 | ``` 182 | The `.write` method will append a newline to the line if not present. The line 183 | should not contain internal newlines: it typically comprises a JSON encoded 184 | Python object. 185 | 186 | If the WiFi suffers an outage these methods may pause for the duration. 187 | 188 | ## 4.3 Special messages 189 | 190 | The ESP8266 sends messages to the Pyboard in response to changes in server 191 | status or under error conditions or because reports have been requested. These 192 | trigger asynchronous bound methods which the user may override. 193 | 194 | ## 4.4 The AppBase class 195 | 196 | Constructor args: 197 | 1. `conn_id` Connection ID. See below. 198 | 2. `config` List retrieved from `config.py` as described above. 199 | 3. `hardware` List retrieved from `config.py` defining the hardware interface. 200 | 4. `verbose` Provide debug output. 201 | 202 | Coroutines: 203 | 1. `readline` Read a newline-terminated line from the server. 204 | 2. `write` Args: `line`, `qos=True`, `wait=True`. Write a line to the server. 205 | `line` holds a line of text. If a terminating newline is not present one will 206 | be supplied. 207 | If `qos` is set, the system guarantees delivery. If it is clear messages may 208 | (rarely) be lost in the event of an outage.__ 209 | If `qos` and `wait` are both set, a `write` coroutine will pause before 210 | sending until any other pending instances have received acknowledge packets. 211 | This is discussed [in the main README](../README.md#7-quality-of-service). 212 | 3. `reboot` Physically reboot the ESP8266. The system will resynchronise and 213 | resume operation. 214 | 215 | Synchronous methods: 216 | 1. `close` Shuts down the Pyboard/ESP8266 interface. 217 | 218 | Asynchronous bound methods. These may be overridden in derived classes to 219 | modify the default behaviour. 220 | 221 | 1. `bad_wifi` No args. This runs on startup and attempts to connect to WiFi 222 | using credentials stored in flash. If this fails, it attempts to connect using 223 | credentials in the `config` list. If this fails an `OSError` is raised. 224 | 2. `bad_server` No args. Awaited if server refuses an initial connection. 225 | Raises an `OSError`. 226 | 3. `report` Regularly launched if reports are requested in the config. It 227 | receives a 3-list as an arg: `[connect_count, report_no, mem_free]` which 228 | describes the ESP8266 status. Prints the report. 229 | 4. `server_ok` Launched whenever the status of the link to the server changes, 230 | by a WiFi server outage starting or ending. Receives a single boolean arg `up` 231 | being the new status. Prints a message. 232 | 233 | If a WiFi or server outage occurs, `readline` and `write` coroutines will pause 234 | for the duration. 235 | 236 | The `bad_wifi` and `bad_server` coros run only on initialisation. Subsequent 237 | WiFi and server outages are handled transparently. 238 | 239 | The `conn_id` constructor arg defines the connection ID used by the server-side 240 | application, ensuring that the Pyboard app communicates with its matching 241 | server app. ID's may be any string but newline characters should not be present 242 | except (optionally) as the last character. 243 | 244 | Subclasses must define a synchronous `start` bound method. This takes no args. 245 | Typically it launches user coroutines. 246 | 247 | ###### [Contents](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 248 | 249 | # 5. ESP8266 crash detection 250 | 251 | The design of the ESP8266 communication link with the server is resilient and 252 | crashes should not occur. But power outages are always possible. If a reset 253 | wire is in place there are two levels of crash recovery. 254 | 255 | If I2C communication fails due to an ESP8266 reboot or power cycle, the 256 | underlying 257 | [asynchronous link](https://github.com/peterhinch/micropython-async/blob/master/v3/docs/I2C.md) 258 | will reboot the ESP8266 and re-synchronise without the need for explicit code. 259 | This caters for the bulk of potential failures and can be verified by pressing 260 | the ESP8266 reset button while the application is running. 261 | 262 | The `esp_link.py` driver sends periodic keepalives to the Pyboard. The 263 | `AppBase` pyboard client reboots the ESP8266 if these stop being received. This 264 | can be verified with a serial connection to the ESP8266 and issuing `ctrl-c`. 265 | 266 | ###### [Contents](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 267 | 268 | # 6. Quality of service 269 | 270 | Issues relating to message integrity and latency are discussed 271 | [in the main README](../README.md#7-quality-of-service). 272 | 273 | Note that if the ESP8266 actually crashes all bets are off. The system will 274 | recover but message loss may occur. Three observations: 275 | 1. In extensive testing crashes were very rare and may have had electrical 276 | causes such as noise on the power line. Only one crash was observed when 277 | powered by a battery. 278 | 2. The ESP8266 firmware reports nearly 26K of free RAM which is substantial. 279 | 3. A quality of service guarantee, even in the presence of crashes, may be 280 | achieved at application level using response messages. When designing such a 281 | system bear in mind that response messages may themselves be lost in the event 282 | of a crash. 283 | 284 | ###### [Contents](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 285 | 286 | # 7. Building ESP8266 firmware 287 | 288 | The following instructions assume that you have the toolchain for ESP8266 and 289 | can build and install standard firmware. They also assume knowledge of the 290 | process of freezing modules and the manifest system. They are based on Linux, 291 | the only OS for which I have any remotely recent experience. If you run 292 | Windows or OSX support will comprise the suggestion of a Linux VM ;-) 293 | 294 | Ensure your clone of 295 | [the MicroPython source](https://github.com/micropython/micropython) is up to 296 | date, and that of this repository. 297 | 298 | Create a directory on your PC for symlinks to modules to be frozen. In my case 299 | it's called `frozen`. It contains symlinks (denoted ->) to the following: 300 | 1. `_boot.py` -> `esp_link/_boot.py`. 301 | 2. `inisetup.py` -> `esp_link/inisetup.py`. 302 | 3. `flashbdev.py` -> `ports/esp8266/modules/flashbdev.py` in source tree. 303 | The `frozen` directory has a subdirectory `iot` containing: 304 | 1. `primitives` -> `iot/primitives` 305 | 2. `client.py` -> `iot/client.py` 306 | The `iot` directory has an `esp_link` subdirectory containing: 307 | 1. `as_i2c.py` -> `esp_link/as_i2c.py` 308 | 2. `esp_link.py` -> `esp_link/esp_link.py` 309 | 3. `__init__.py` an empty file created using `touch`. 310 | 311 | Create a PC file containing a manifest. Mine is `esp8266_iot_manifest.py`. It 312 | should be as follows (with the path changed to your `frozen` directory): 313 | ``` 314 | include("$(MPY_DIR)/extmod/uasyncio/manifest.py") 315 | freeze('/mnt/qnap2/data/Projects/MicroPython/micropython-iot/private/frozen') 316 | ``` 317 | I use the following build script, named `buildesplink` and marked executable. 318 | Adapt to ensure that `MANIFEST` points to your manifest file. The `cd` in line 319 | 8 will need to be changed to match the location of your source clone. The `j 8` 320 | arg to `make` may also be non-optimal for your PC. 321 | 322 | `PROJECT_DIR` causes the firmware build to be copied to the project root. This 323 | is done purely to maintain this repo: you can remove this and the build copy. 324 | 325 | You may also need to adapt the two instances of `--port /dev/ttyUSB0`. 326 | ```bash 327 | #! /bin/bash 328 | # Build for ESP8266 esp_link. Enforce flash erase. 329 | 330 | PROJECT_DIR='/mnt/qnap2/data/Projects/MicroPython/micropython-iot/' 331 | MANIFEST='/mnt/qnap2/Scripts/manifests/esp8266_iot_manifest.py' 332 | BUILD='build-GENERIC' 333 | 334 | cd /mnt/qnap2/data/Projects/MicroPython/micropython/ports/esp8266 335 | 336 | make clean 337 | if esptool.py --port /dev/ttyUSB0 erase_flash 338 | then 339 | if make -j 8 FROZEN_MANIFEST=$MANIFEST 340 | then 341 | cp $BUILD/firmware-combined.bin $PROJECT_DIR 342 | sleep 1 343 | esptool.py --port /dev/ttyUSB0 --baud 115200 write_flash --flash_size=detect -fm dio 0 $BUILD/firmware-combined.bin 344 | cd - 345 | else 346 | echo Build failure 347 | fi 348 | else 349 | echo Connect failure 350 | fi 351 | cd - 352 | ``` 353 | This script erases flash for the following reasons. Erasure ensures the use of 354 | littlefs which saves RAM. It also ensures that `boot.py` and `main.py` are 355 | created. The files `_boot.py` and `inisetup.py` handle filesystem creation and 356 | writing out of `boot.py` and `main.py`, but only if there is no pre-existing 357 | filesystem. 358 | 359 | ###### [Contents](./PB_LINK.md#0-iot-design-for-clients-lacking-a-lan-interface) 360 | -------------------------------------------------------------------------------- /esp_link/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /esp_link/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/esp_link/__init__.py -------------------------------------------------------------------------------- /esp_link/_boot.py: -------------------------------------------------------------------------------- 1 | import gc 2 | gc.threshold((gc.mem_free() + gc.mem_alloc()) // 4) 3 | import uos 4 | from flashbdev import bdev 5 | 6 | try: 7 | if bdev: 8 | uos.mount(bdev, '/') 9 | except OSError: 10 | import inisetup 11 | inisetup.setup() 12 | 13 | try: 14 | uos.stat('/main.py') 15 | except OSError: 16 | with open("/main.py", "w") as f: 17 | f.write("""\ 18 | import iot.esp_link.esp_link 19 | """) 20 | 21 | gc.collect() 22 | -------------------------------------------------------------------------------- /esp_link/asi2c.py: -------------------------------------------------------------------------------- 1 | # asi2c.py A communications link using I2C slave mode on Pyboard. 2 | # Channel and Responder classes. Adapted for uasyncio V3, WBUS DIP28. 3 | 4 | # The MIT License (MIT) 5 | # 6 | # Copyright (c) 2018-2020 Peter Hinch 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | import uasyncio as asyncio 27 | import machine 28 | import utime 29 | from micropython import const 30 | import io 31 | 32 | _MP_STREAM_POLL_RD = const(1) 33 | _MP_STREAM_POLL_WR = const(4) 34 | _MP_STREAM_POLL = const(3) 35 | _MP_STREAM_ERROR = const(-1) 36 | # Delay compensates for short Responder interrupt latency. Must be >= max delay 37 | # between Initiator setting a pin and initiating an I2C transfer: ensure 38 | # Initiator sets up first. 39 | _DELAY = const(20) # μs 40 | 41 | 42 | # Base class provides user interface and send/receive object buffers 43 | class Channel(io.IOBase): 44 | def __init__(self, i2c, own, rem, verbose, rxbufsize): 45 | self.rxbufsize = rxbufsize 46 | self.verbose = verbose 47 | self.synchronised = False 48 | # Hardware 49 | self.i2c = i2c 50 | self.own = own 51 | self.rem = rem 52 | own.init(mode=machine.Pin.OUT, value=1) 53 | rem.init(mode=machine.Pin.IN, pull=machine.Pin.PULL_UP) 54 | # I/O 55 | self.txbyt = b'' # Data to send 56 | self.txsiz = bytearray(2) # Size of .txbyt encoded as 2 bytes 57 | self.rxbyt = b'' 58 | self.rxbuf = bytearray(rxbufsize) 59 | self.rx_mv = memoryview(self.rxbuf) 60 | self.cantx = True # Remote can accept data 61 | 62 | async def _sync(self): 63 | self.verbose and print('Synchronising') 64 | self.own(0) 65 | while self.rem(): 66 | await asyncio.sleep_ms(100) 67 | # Both pins are now low 68 | await asyncio.sleep(0) 69 | self.verbose and print('Synchronised') 70 | self.synchronised = True 71 | 72 | def waitfor(self, val): # Initiator overrides 73 | while not self.rem() == val: 74 | pass 75 | 76 | # Get incoming bytes instance from memoryview. 77 | def _handle_rxd(self, msg): 78 | self.rxbyt = bytes(msg) 79 | 80 | def _txdone(self): 81 | self.txbyt = b'' 82 | self.txsiz[0] = 0 83 | self.txsiz[1] = 0 84 | 85 | # Stream interface 86 | 87 | def ioctl(self, req, arg): 88 | ret = _MP_STREAM_ERROR 89 | if req == _MP_STREAM_POLL: 90 | ret = 0 91 | if self.synchronised: 92 | if arg & _MP_STREAM_POLL_RD: 93 | if self.rxbyt: 94 | ret |= _MP_STREAM_POLL_RD 95 | if arg & _MP_STREAM_POLL_WR: 96 | if (not self.txbyt) and self.cantx: 97 | ret |= _MP_STREAM_POLL_WR 98 | return ret 99 | 100 | def readline(self): 101 | n = self.rxbyt.find(b'\n') 102 | if n == -1: 103 | t = self.rxbyt[:] 104 | self.rxbyt = b'' 105 | else: 106 | t = self.rxbyt[: n + 1] 107 | self.rxbyt = self.rxbyt[n + 1:] 108 | return t.decode() 109 | 110 | def read(self, n): 111 | t = self.rxbyt[:n] 112 | self.rxbyt = self.rxbyt[n:] 113 | return t.decode() 114 | 115 | # Set .txbyt to the required data. Return its size. So awrite returns 116 | # with transmission occurring in tha background. 117 | # uasyncio V3: Stream.drain() calls write with buf being a memoryview 118 | # and no off or sz args. 119 | def write(self, buf): 120 | if self.synchronised: 121 | if self.txbyt: # Initial call from awrite 122 | return 0 # Waiting for existing data to go out 123 | l = len(buf) 124 | self.txbyt = buf 125 | self.txsiz[0] = l & 0xff 126 | self.txsiz[1] = l >> 8 127 | return l 128 | return 0 129 | 130 | # User interface 131 | 132 | # Wait for sync 133 | async def ready(self): 134 | while not self.synchronised: 135 | await asyncio.sleep_ms(100) 136 | 137 | # Leave pin high in case we run again 138 | def close(self): 139 | self.own(1) 140 | 141 | 142 | # Responder is I2C master. It is cross-platform and uses machine. 143 | # It does not handle errors: if I2C fails it dies and awaits reset by initiator. 144 | # send_recv is triggered by Interrupt from Initiator. 145 | 146 | class Responder(Channel): 147 | addr = 0x12 148 | rxbufsize = 200 149 | 150 | def __init__(self, i2c, pin, pinack, verbose=True): 151 | super().__init__(i2c, pinack, pin, verbose, self.rxbufsize) 152 | loop = asyncio.get_event_loop() 153 | loop.create_task(self._run()) 154 | 155 | async def _run(self): 156 | await self._sync() # own pin ->0, wait for remote pin == 0 157 | self.rem.irq(handler=self._handler, trigger=machine.Pin.IRQ_RISING) 158 | 159 | # Request was received: immediately read payload size, then payload 160 | # On Pyboard blocks for 380μs to 1.2ms for small amounts of data 161 | def _handler(self, _, sn=bytearray(2), txnull=bytearray(2)): 162 | addr = Responder.addr 163 | self.rem.irq(handler=None) 164 | utime.sleep_us(_DELAY) # Ensure Initiator has set up to write. 165 | self.i2c.readfrom_into(addr, sn) 166 | self.own(1) 167 | self.waitfor(0) 168 | self.own(0) 169 | n = sn[0] + ((sn[1] & 0x7f) << 8) # no of bytes to receive 170 | if n > self.rxbufsize: 171 | raise ValueError('Receive data too large for buffer.') 172 | self.cantx = not bool(sn[1] & 0x80) # Can Initiator accept a payload? 173 | if n: 174 | self.waitfor(1) 175 | utime.sleep_us(_DELAY) 176 | mv = memoryview(self.rx_mv[0: n]) # allocates 177 | self.i2c.readfrom_into(addr, mv) 178 | self.own(1) 179 | self.waitfor(0) 180 | self.own(0) 181 | self._handle_rxd(mv) 182 | 183 | self.own(1) # Request to send 184 | self.waitfor(1) 185 | utime.sleep_us(_DELAY) 186 | dtx = self.txbyt != b'' and self.cantx # Data to send 187 | siz = self.txsiz if dtx else txnull 188 | if self.rxbyt: 189 | siz[1] |= 0x80 # Hold off Initiator TX 190 | else: 191 | siz[1] &= 0x7f 192 | self.i2c.writeto(addr, siz) # Was getting ENODEV occasionally on Pyboard 193 | self.own(0) 194 | self.waitfor(0) 195 | if dtx: 196 | self.own(1) 197 | self.waitfor(1) 198 | utime.sleep_us(_DELAY) 199 | self.i2c.writeto(addr, self.txbyt) 200 | self.own(0) 201 | self.waitfor(0) 202 | self._txdone() # Invalidate source 203 | self.rem.irq(handler=self._handler, trigger=machine.Pin.IRQ_RISING) 204 | -------------------------------------------------------------------------------- /esp_link/esp_link.py: -------------------------------------------------------------------------------- 1 | # esp_link.py Run on ESP8266. Provides a link between Pyboard/STM device and 2 | # IOT server. 3 | 4 | # Copyright (c) Peter Hinch 2018-2020 5 | # Released under the MIT licence. Full text in root of this repository. 6 | 7 | import gc 8 | import uasyncio as asyncio 9 | import time 10 | gc.collect() 11 | import ujson 12 | from micropython import const 13 | from machine import Pin, I2C 14 | gc.collect() 15 | 16 | from . import asi2c 17 | from iot import client 18 | gc.collect() 19 | 20 | _ID = const(0) # Config list index 21 | _PORT = const(1) 22 | _SERVER = const(2) 23 | _TIMEOUT = const(3) 24 | _REPORT = const(4) 25 | _SSID = const(5) 26 | _PW = const(6) 27 | 28 | class LinkClient(client.Client): 29 | def __init__(self, config, swriter, verbose): 30 | super().__init__(config[_ID], config[_SERVER], config[_PORT], 31 | config[_SSID], config[_PW], config[_TIMEOUT], 32 | conn_cb=self.conn_cb, verbose=verbose) 33 | self.config = config 34 | self.swriter = swriter 35 | 36 | # Initial connection to stored network failed. Try to connect using config 37 | async def bad_wifi(self): 38 | try: 39 | await asyncio.wait_for(super().bad_wifi(), 20) 40 | except asyncio.TimeoutError: 41 | self.swriter.write('b\n') 42 | await self.swriter.drain() 43 | # Message to Pyboard and REPL. Crash the board. Pyboard 44 | # detects, can reboot and retry, change config, or whatever 45 | raise ValueError("Can't connect to {}".format(self.config[_SSID])) # croak... 46 | 47 | async def bad_server(self): 48 | self.swriter.write('s\n') 49 | await self.swriter.drain() 50 | raise ValueError("Server {} port {} is down.".format( 51 | self.config[_SERVER], self.config[_PORT])) # As per bad_wifi: croak... 52 | 53 | # Callback when connection status changes 54 | async def conn_cb(self, status): 55 | self.swriter.write('u\n' if status else 'd\n') 56 | await self.swriter.drain() 57 | 58 | 59 | class App: 60 | def __init__(self, verbose): 61 | self.verbose = verbose 62 | self.cl = None # Client instance for server comms. 63 | # Instantiate a Pyboard Channel 64 | i2c = I2C(scl=Pin(0), sda=Pin(2)) # software I2C 65 | syn = Pin(5) 66 | ack = Pin(4) 67 | self.chan = asi2c.Responder(i2c, syn, ack) # Channel to Pyboard 68 | self.sreader = asyncio.StreamReader(self.chan) 69 | self.swriter = asyncio.StreamWriter(self.chan, {}) 70 | 71 | async def start(self): 72 | await self.chan.ready() # Wait for sync 73 | self.verbose and print('awaiting config') 74 | line = await self.sreader.readline() 75 | config = ujson.loads(line) 76 | 77 | self.verbose and print('Setting client config', config) 78 | self.cl = LinkClient(config, self.swriter, self.verbose) 79 | self.verbose and print('App awaiting connection.') 80 | await self.cl 81 | asyncio.create_task(self.to_server()) 82 | asyncio.create_task(self.from_server()) 83 | t_rep = config[_REPORT] # Reporting interval (s) 84 | if t_rep: 85 | asyncio.create_task(self.report(t_rep)) 86 | await self.crashdet() 87 | 88 | async def to_server(self): 89 | self.verbose and print('Started to_server task.') 90 | while True: 91 | line = await self.sreader.readline() 92 | line = line.decode() 93 | n = ord(line[0]) - 0x30 # Decode header to bitfield 94 | # Implied copy at start of write() 95 | # If the following pauses for an outage, the Pyboard may write 96 | # one more line. Subsequent calls to channel.write pause pending 97 | # resumption of communication with the server. 98 | await self.cl.write(line[1:], qos=n & 2, wait=n & 1) 99 | self.verbose and print('Sent', line[1:].rstrip(), 'to server app') 100 | 101 | async def from_server(self): 102 | self.verbose and print('Started from_server task.') 103 | while True: 104 | line = await self.cl.readline() 105 | # Implied copy 106 | self.swriter.write('n{}'.format(line)) 107 | await self.swriter.drain() 108 | self.verbose and print('Sent', line.encode('utf8'), 'to Pyboard app\n') 109 | 110 | async def crashdet(self): 111 | while True: 112 | await asyncio.sleep(2) 113 | self.swriter.write('k\n') 114 | await self.swriter.drain() 115 | gc.collect() 116 | 117 | async def report(self, time): 118 | data = [0, 0, 0] 119 | count = 0 120 | while True: 121 | await asyncio.sleep(time) 122 | data[0] = self.cl.connects # For diagnostics 123 | data[1] = count 124 | count += 1 125 | gc.collect() 126 | data[2] = gc.mem_free() 127 | line = 'r{}\n'.format(ujson.dumps(data)) 128 | self.swriter.write(line) 129 | await self.swriter.drain() 130 | 131 | def close(self): 132 | self.verbose and print('Closing interfaces') 133 | if self.cl is not None: 134 | self.cl.close() 135 | self.chan.close() 136 | 137 | async def main(): 138 | app = App(True) 139 | await app.start() 140 | 141 | try: 142 | asyncio.run(main()) 143 | finally: 144 | app.close() # e.g. ctrl-c at REPL 145 | asyncio.new_event_loop() 146 | -------------------------------------------------------------------------------- /esp_link/inisetup.py: -------------------------------------------------------------------------------- 1 | import uos 2 | from flashbdev import bdev 3 | 4 | def check_bootsec(): 5 | buf = bytearray(bdev.SEC_SIZE) 6 | bdev.readblocks(0, buf) 7 | empty = True 8 | for b in buf: 9 | if b != 0xff: 10 | empty = False 11 | break 12 | if empty: 13 | return True 14 | fs_corrupted() 15 | 16 | def fs_corrupted(): 17 | import time 18 | while 1: 19 | print("""\ 20 | The FAT filesystem starting at sector %d with size %d sectors appears to 21 | be corrupted. If you had important data there, you may want to make a flash 22 | snapshot to try to recover it. Otherwise, perform factory reprogramming 23 | of MicroPython firmware (completely erase flash, followed by firmware 24 | programming). 25 | """ % (bdev.START_SEC, bdev.blocks)) 26 | time.sleep(3) 27 | 28 | def setup(): 29 | check_bootsec() 30 | print("Performing initial setup") 31 | uos.VfsLfs2.mkfs(bdev) 32 | vfs = uos.VfsLfs2(bdev) 33 | uos.mount(vfs, "/") 34 | with open("boot.py", "w") as f: 35 | f.write( 36 | """\ 37 | # This file is executed on every boot (including wake-boot from deepsleep) 38 | #import esp 39 | #esp.osdebug(None) 40 | import uos, machine 41 | #uos.dupterm(None, 1) # disable REPL on UART(0) 42 | import gc 43 | #import webrepl 44 | #webrepl.start() 45 | gc.collect() 46 | """ 47 | ) 48 | return vfs 49 | -------------------------------------------------------------------------------- /firmware-combined.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/firmware-combined.bin -------------------------------------------------------------------------------- /images/block diagram.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/images/block diagram.odg -------------------------------------------------------------------------------- /images/block_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/images/block_diagram.png -------------------------------------------------------------------------------- /images/block_diagram_orig.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/images/block_diagram_orig.odg -------------------------------------------------------------------------------- /images/block_diagram_orig.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/images/block_diagram_orig.png -------------------------------------------------------------------------------- /images/block_diagram_pyboard.odg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/images/block_diagram_pyboard.odg -------------------------------------------------------------------------------- /images/block_diagram_pyboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/images/block_diagram_pyboard.png -------------------------------------------------------------------------------- /iot/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /iot/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py Common utility functions for micropython-iot 2 | 3 | # Released under the MIT licence. 4 | # Copyright (C) Peter Hinch 2019-2020 5 | 6 | # Now uses and requires uasyncio V3. This is incorporated in daily builds 7 | # and release builds later than V1.12 8 | # Under CPython requires CPython 3.8 or later. 9 | 10 | # Create message ID's. Initially 0 then 1 2 ... 254 255 1 2 11 | def gmid(): 12 | mid = 0 13 | while True: 14 | yield mid 15 | mid = (mid + 1) & 0xff 16 | mid = mid if mid else 1 17 | 18 | # Return True if a message ID has not already been received 19 | def isnew(mid, lst=bytearray(32)): 20 | if mid == -1: 21 | for idx in range(32): 22 | lst[idx] = 0 23 | return 24 | idx = mid >> 3 25 | bit = 1 << (mid & 7) 26 | res = not(lst[idx] & bit) 27 | lst[idx] |= bit 28 | lst[(idx + 16 & 0x1f)] = 0 29 | return res 30 | -------------------------------------------------------------------------------- /iot/client.mpy: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/iot/client.mpy -------------------------------------------------------------------------------- /iot/client.py: -------------------------------------------------------------------------------- 1 | # client.py Client class for resilient asynchronous IOT communication link. 2 | 3 | # Released under the MIT licence. 4 | # Copyright (C) Peter Hinch, Kevin Köck 2019-2020 5 | 6 | # Now uses and requires uasyncio V3. This is incorporated in daily builds 7 | # and release builds later than V1.12 8 | 9 | # After sending ID now pauses before sending further data to allow server to 10 | # initiate read task. 11 | 12 | import gc 13 | 14 | gc.collect() 15 | import usocket as socket 16 | import uasyncio as asyncio 17 | 18 | gc.collect() 19 | from sys import platform 20 | import network 21 | import utime 22 | import machine 23 | import uerrno as errno 24 | from . import gmid, isnew # __init__.py 25 | from .primitives import launch 26 | from .primitives.queue import Queue, QueueFull 27 | gc.collect() 28 | from micropython import const 29 | 30 | WDT_CANCEL = const(-2) 31 | WDT_CB = const(-3) 32 | 33 | # Message ID generator. Only need one instance on client. 34 | getmid = gmid() 35 | gc.collect() 36 | 37 | # Minimal implementation of set for integers in range 0-255 38 | # Asynchronous version has efficient wait_empty and has_not methods 39 | # based on Events rather than polling. 40 | 41 | 42 | class ASetByte: 43 | def __init__(self): 44 | self._ba = bytearray(32) 45 | self._evdis = asyncio.Event() # Discard event 46 | 47 | def __bool__(self): 48 | return any(self._ba) 49 | 50 | def __contains__(self, i): 51 | return (self._ba[i >> 3] & 1 << (i & 7)) > 0 52 | 53 | def add(self, i): 54 | self._ba[i >> 3] |= 1 << (i & 7) 55 | 56 | def discard(self, i): 57 | self._ba[i >> 3] &= ~(1 << (i & 7)) 58 | self._evdis.set() 59 | 60 | async def has_not(self, i): # Pause until i not in set 61 | while i in self: 62 | await self._evdis.wait() # Pause until something is discarded 63 | self._evdis.clear() 64 | 65 | 66 | class Client: 67 | def __init__(self, my_id, server, port=8123, 68 | ssid='', pw='', timeout=2000, 69 | conn_cb=None, conn_cb_args=None, 70 | verbose=False, led=None, wdog=False): 71 | self._my_id = '{}{}'.format(my_id, '\n') # Ensure >= 1 newline 72 | self._server = server 73 | self._ssid = ssid 74 | self._pw = pw 75 | self._port = port 76 | self._to = timeout # Client and server timeout 77 | self._tim_ka = timeout // 4 # Keepalive interval 78 | self._concb = conn_cb 79 | self._concbargs = () if conn_cb_args is None else conn_cb_args 80 | self._verbose = verbose 81 | self._led = led 82 | 83 | if wdog: 84 | if platform == 'pyboard': 85 | self._wdt = machine.WDT(0, 20000) 86 | 87 | def wdt(): 88 | def inner(feed=0): # Ignore control values 89 | if not feed: 90 | self._wdt.feed() 91 | 92 | return inner 93 | 94 | self._feed = wdt() 95 | else: 96 | def wdt(secs=0): 97 | timer = machine.Timer(-1) 98 | timer.init(period=1000, mode=machine.Timer.PERIODIC, 99 | callback=lambda t: self._feed()) 100 | cnt = secs 101 | run = False # Disable until 1st feed 102 | 103 | def inner(feed=WDT_CB): 104 | nonlocal cnt, run, timer 105 | if feed == 0: # Fixed timeout 106 | cnt = secs 107 | run = True 108 | elif feed < 0: # WDT control/callback 109 | if feed == WDT_CANCEL: 110 | timer.deinit() # Permanent cancellation 111 | elif feed == WDT_CB and run: # Timer callback and is running. 112 | cnt -= 1 113 | if cnt <= 0: 114 | machine.reset() 115 | 116 | return inner 117 | 118 | self._feed = wdt(20) 119 | else: 120 | self._feed = lambda x: None 121 | 122 | self._sta_if = network.WLAN(network.STA_IF) 123 | ap = network.WLAN(network.AP_IF) # create access-point interface 124 | ap.active(False) # deactivate the interface 125 | self._sta_if.active(True) 126 | gc.collect() 127 | if platform == 'esp8266': 128 | import esp 129 | # Improve connection integrity at cost of power consumption. 130 | esp.sleep_type(esp.SLEEP_NONE) 131 | 132 | self._evfail = asyncio.Event() # Set by any comms failure 133 | self._evok = asyncio.Event() # Set by 1st successful read 134 | self._s_lock = asyncio.Lock() # For internal send conflict. 135 | self._w_lock = asyncio.Lock() # For .write rate limit 136 | self._last_wr = utime.ticks_ms() 137 | self._lineq = Queue(20) # 20 entries 138 | self.connects = 0 # Connect count for test purposes/app access 139 | self._sock = None 140 | self._acks_pend = ASetByte() # ACKs which are expected to be received 141 | gc.collect() 142 | asyncio.create_task(self._run()) 143 | 144 | # **** API **** 145 | def __iter__(self): # Await a connection 146 | yield from self._evok.wait() # V3 note: this syntax works. 147 | 148 | def status(self): 149 | return self._evok.is_set() 150 | 151 | __call__ = status 152 | 153 | async def readline(self): 154 | return await self._lineq.get() 155 | 156 | async def write(self, buf, qos=True, wait=True): 157 | if qos and wait: # Disallow concurrent writes 158 | await self._w_lock.acquire() 159 | try: # In case of cancellation/timeout 160 | # Prepend message ID to a copy of buf 161 | fstr = '{:02x}{}' if buf.endswith('\n') else '{:02x}{}\n' 162 | mid = next(getmid) 163 | self._acks_pend.add(mid) 164 | buf = fstr.format(mid, buf) 165 | await self._write(buf) 166 | if qos: # Return when an ACK received 167 | await self._do_qos(mid, buf) 168 | finally: 169 | if qos and wait: 170 | self._w_lock.release() 171 | 172 | def close(self): 173 | self._close() # Close socket and WDT 174 | self._feed(WDT_CANCEL) 175 | 176 | # **** For subclassing **** 177 | 178 | # May be overridden e.g. to provide timeout (asyncio.wait_for) 179 | async def bad_wifi(self): 180 | if not self._ssid: 181 | raise OSError('No initial WiFi connection.') 182 | s = self._sta_if 183 | if s.isconnected(): 184 | return 185 | while True: # For the duration of an outage 186 | s.connect(self._ssid, self._pw) 187 | if await self._got_wifi(s): 188 | break 189 | 190 | async def bad_server(self): 191 | await asyncio.sleep(0) 192 | raise OSError('No initial server connection.') 193 | 194 | # **** API end **** 195 | 196 | def _close(self): 197 | self._verbose and print('Closing sockets.') 198 | if self._sock is not None: # ESP32 issue #4514 199 | self._sock.close() 200 | 201 | # Await a WiFi connection for 10 secs. 202 | async def _got_wifi(self, s): 203 | for _ in range(20): # Wait t s for connect. If it fails assume an outage 204 | await asyncio.sleep_ms(500) 205 | self._feed(0) 206 | if s.isconnected(): 207 | return True 208 | return False 209 | 210 | async def _write(self, line): 211 | while True: 212 | # After an outage wait until something is received from server 213 | # before we send. 214 | await self._evok.wait() 215 | if await self._send(line): 216 | return 217 | 218 | # send fail. _send has triggered _evfail. .run clears _evok. 219 | await asyncio.sleep_ms(0) # Ensure .run is scheduled 220 | 221 | # Handle qos. Retransmit until matching ACK received. 222 | # ACKs typically take 200-400ms to arrive. 223 | async def _do_qos(self, mid, line): 224 | while True: 225 | # Wait for any outage to clear 226 | await self._evok.wait() 227 | # Wait for the matching ACK. 228 | try: 229 | await asyncio.wait_for_ms(self._acks_pend.has_not(mid), self._to) 230 | except asyncio.TimeoutError: # Ack was not received - re-send 231 | await self._write(line) 232 | self._verbose and print('Repeat', line, 'to server app') 233 | else: 234 | return # Got ack 235 | 236 | # Make an attempt to connect to WiFi. May not succeed. 237 | async def _connect(self, s): 238 | self._verbose and print('Connecting to WiFi') 239 | if platform == 'esp8266': 240 | s.connect() 241 | elif self._ssid: 242 | s.connect(self._ssid, self._pw) 243 | else: 244 | raise ValueError('No WiFi credentials available.') 245 | 246 | # Break out on success (or fail after 10s). 247 | await self._got_wifi(s) 248 | self._verbose and print('Checking WiFi stability for 3s') 249 | # Timeout ensures stable WiFi and forces minimum outage duration 250 | await asyncio.sleep(3) 251 | self._feed(0) 252 | 253 | async def _run(self): 254 | # ESP8266 stores last good connection. Initially give it time to re-establish 255 | # that link. On fail, .bad_wifi() allows for user recovery. 256 | await asyncio.sleep(1) # Didn't always start after power up 257 | s = self._sta_if 258 | if platform == 'esp8266': 259 | s.connect() 260 | for _ in range(4): 261 | await asyncio.sleep(1) 262 | if s.isconnected(): 263 | break 264 | else: 265 | await self.bad_wifi() 266 | else: 267 | await self.bad_wifi() 268 | init = True 269 | while True: 270 | while not s.isconnected(): # Try until stable for 2*.timeout 271 | await self._connect(s) 272 | self._verbose and print('WiFi OK') 273 | self._sock = socket.socket() 274 | self._evfail.clear() 275 | try: 276 | serv = socket.getaddrinfo(self._server, self._port)[ 277 | 0][-1] # server read 278 | # If server is down OSError e.args[0] = 111 ECONNREFUSED 279 | self._sock.connect(serv) 280 | except OSError as e: 281 | if e.args[0] in (errno.ECONNABORTED, errno.ECONNRESET, errno.ECONNREFUSED): 282 | if init: 283 | await self.bad_server() 284 | else: 285 | self._sock.setblocking(False) 286 | # Start reading before server can send: can't send until it 287 | # gets ID. 288 | tsk_reader = asyncio.create_task(self._reader()) 289 | # Server reads ID immediately, but a brief pause is probably wise. 290 | await asyncio.sleep_ms(50) 291 | if await self._send(self._my_id): 292 | tsk_ka = asyncio.create_task(self._keepalive()) 293 | if self._concb is not None: 294 | # apps might need to know connection to the server acquired 295 | launch(self._concb, True, *self._concbargs) 296 | await self._evfail.wait() # Pause until something goes wrong 297 | self._evok.clear() 298 | tsk_reader.cancel() 299 | tsk_ka.cancel() 300 | await asyncio.sleep_ms(0) # wait for cancellation 301 | self._feed(0) # _concb might block (I hope not) 302 | if self._concb is not None: 303 | # apps might need to know if they lost connection to the server 304 | launch(self._concb, False, *self._concbargs) 305 | elif init: 306 | await self.bad_server() 307 | finally: 308 | init = False 309 | self._close() # Close socket but not wdt 310 | s.disconnect() 311 | self._feed(0) 312 | # Ensure server detects outage 313 | await asyncio.sleep_ms(self._to * 2) 314 | while s.isconnected(): 315 | await asyncio.sleep(1) 316 | 317 | async def _reader(self): # Entry point is after a (re) connect. 318 | c = self.connects # Count successful connects 319 | to = 2 * self._to # Extend timeout on 1st pass for slow server 320 | while True: 321 | try: 322 | line = await self._readline(to) # OSError on fail 323 | except OSError: 324 | self._verbose and print('reader fail') 325 | self._evfail.set() # ._run cancels other coros 326 | return 327 | 328 | to = self._to 329 | mid = int(line[0:2], 16) 330 | if len(line) == 3: # Got ACK: remove from expected list 331 | self._acks_pend.discard(mid) # qos0 acks are ignored 332 | continue # All done 333 | # Message received & can be passed to user: send ack. 334 | asyncio.create_task(self._sendack(mid)) 335 | # Discard dupes. mid == 0 : Server has power cycled 336 | if not mid: 337 | isnew(-1) # Clear down rx message record 338 | if isnew(mid): 339 | try: 340 | self._lineq.put_nowait(line[2:].decode()) 341 | except QueueFull: 342 | self._verbose and print('_reader fail. Overflow.') 343 | self._evfail.set() 344 | return 345 | if c == self.connects: 346 | self.connects += 1 # update connect count 347 | 348 | async def _sendack(self, mid): 349 | await self._send('{:02x}\n'.format(mid)) 350 | 351 | async def _keepalive(self): 352 | while True: 353 | due = self._tim_ka - \ 354 | utime.ticks_diff(utime.ticks_ms(), self._last_wr) 355 | if due <= 0: 356 | # error sets ._evfail, .run cancels this coro 357 | await self._send(b'\n') 358 | else: 359 | await asyncio.sleep_ms(due) 360 | 361 | # Read a line from nonblocking socket: reads can return partial data which 362 | # are joined into a line. Blank lines are keepalive packets which reset 363 | # the timeout: _readline() pauses until a complete line has been received. 364 | async def _readline(self, to): 365 | led = self._led 366 | line = b'' 367 | start = utime.ticks_ms() 368 | while True: 369 | if line.endswith(b'\n'): 370 | self._evok.set() # Got at least 1 packet after an outage. 371 | if len(line) > 1: 372 | return line 373 | # Got a keepalive: discard, reset timers, toggle LED. 374 | self._feed(0) 375 | line = b'' 376 | if led is not None: 377 | if isinstance(led, machine.Pin): 378 | led(not led()) 379 | else: # On Pyboard D 380 | led.toggle() 381 | try: 382 | d = self._sock.readline() 383 | except Exception as e: 384 | self._verbose and print('_readline exception', d) 385 | raise 386 | if d == b'': 387 | self._verbose and print('_readline peer disconnect') 388 | raise OSError 389 | if d is None: # Nothing received: wait on server 390 | if utime.ticks_diff(utime.ticks_ms(), start) > to: 391 | self._verbose and print('_readline timeout') 392 | raise OSError 393 | await asyncio.sleep_ms(0) 394 | else: # Something received: reset timer 395 | start = utime.ticks_ms() 396 | line = b''.join((line, d)) if line else d 397 | 398 | async def _send(self, d): # Write a line to socket. 399 | async with self._s_lock: 400 | start = utime.ticks_ms() 401 | while d: 402 | try: 403 | ns = self._sock.send(d) # OSError if client closes socket 404 | except OSError as e: 405 | err = e.args[0] 406 | if err == errno.EAGAIN: # Would block: await server read 407 | await asyncio.sleep_ms(100) 408 | else: 409 | self._verbose and print('_send fail. Disconnect') 410 | self._evfail.set() 411 | return False # peer disconnect 412 | else: 413 | d = d[ns:] 414 | if d: # Partial write: pause 415 | await asyncio.sleep_ms(20) 416 | if utime.ticks_diff(utime.ticks_ms(), start) > self._to: 417 | self._verbose and print('_send fail. Timeout.') 418 | self._evfail.set() 419 | return False 420 | 421 | self._last_wr = utime.ticks_ms() 422 | return True 423 | -------------------------------------------------------------------------------- /iot/examples/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /iot/examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/iot/examples/__init__.py -------------------------------------------------------------------------------- /iot/examples/c_app.py: -------------------------------------------------------------------------------- 1 | # c_app.py Client-side application demo 2 | 3 | # Released under the MIT licence. See LICENSE. 4 | # Copyright (C) Peter Hinch 2018-2020 5 | 6 | # Now uses and requires uasyncio V3. This is incorporated in daily builds 7 | # and release builds later than V1.12 8 | 9 | import gc 10 | import uasyncio as asyncio 11 | gc.collect() 12 | from iot import client 13 | gc.collect() 14 | import ujson 15 | # Optional LED. led=None if not required 16 | from sys import platform 17 | if platform == 'pyboard': # D series 18 | from pyb import LED 19 | led = LED(1) 20 | else: 21 | from machine import Pin 22 | led = Pin(2, Pin.OUT, value=1) # Optional LED 23 | # End of optional LED 24 | 25 | from . import local 26 | gc.collect() 27 | 28 | 29 | class App(client.Client): 30 | def __init__(self, verbose): 31 | self.verbose = verbose 32 | self.cl = client.Client(local.MY_ID, local.SERVER, local.PORT, local.SSID, local.PW, 33 | local.TIMEOUT, conn_cb=self.constate, verbose=verbose, 34 | led=led, wdog=False) 35 | 36 | async def start(self): 37 | self.verbose and print('App awaiting connection.') 38 | await self.cl 39 | asyncio.create_task(self.reader()) 40 | await self.writer() 41 | 42 | def constate(self, state): 43 | print("Connection state:", state) 44 | 45 | async def reader(self): 46 | self.verbose and print('Started reader') 47 | while True: 48 | # Attempt to read data: in the event of an outage, .readline() 49 | # pauses until the connection is re-established. 50 | line = await self.cl.readline() 51 | data = ujson.loads(line) 52 | # Receives [restart count, uptime in secs] 53 | print('Got', data, 'from server app') 54 | 55 | # Send [approx application uptime in secs, (re)connect count] 56 | async def writer(self): 57 | self.verbose and print('Started writer') 58 | data = [0, 0, 0] 59 | count = 0 60 | while True: 61 | data[0] = self.cl.connects 62 | data[1] = count 63 | count += 1 64 | gc.collect() 65 | data[2] = gc.mem_free() 66 | print('Sent', data, 'to server app\n') 67 | # .write() behaves as per .readline() 68 | await self.cl.write(ujson.dumps(data)) 69 | await asyncio.sleep(5) 70 | 71 | def shutdown(self): 72 | self.cl.close() # Shuts down WDT (but not on Pyboard D). 73 | 74 | app = None 75 | async def main(): 76 | global app # For finally clause 77 | app = App(verbose=True) 78 | await app.start() 79 | 80 | try: 81 | asyncio.run(main()) 82 | finally: 83 | app.shutdown() 84 | asyncio.new_event_loop() 85 | -------------------------------------------------------------------------------- /iot/examples/local.py: -------------------------------------------------------------------------------- 1 | MY_ID = '1' # Client-unique string 2 | SERVER = '192.168.0.10' 3 | SSID = 'use_my_local' # Put in your WiFi credentials 4 | PW = 'PASSWORD' 5 | PORT = 8123 6 | TIMEOUT = 2000 7 | 8 | # The following may be deleted 9 | if SSID == 'use_my_local': 10 | from iot.examples.my_local import * 11 | -------------------------------------------------------------------------------- /iot/examples/s_app_cp.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # Released under the MIT licence. See LICENSE. 5 | # Copyright (C) Peter Hinch 2018-2020 6 | 7 | # s_app_cp.py Server-side application demo 8 | # Run under CPython 3.8 or later or MicroPython Unix build. 9 | # Under MicroPython uses and requires uasyncio V3. 10 | 11 | # The App class emulates a user application intended to service a single 12 | # client. In this case we have four instances of the application servicing 13 | # clients with ID's 1-4. 14 | 15 | try: 16 | import asyncio 17 | except ImportError: 18 | import uasyncio as asyncio 19 | try: 20 | import json 21 | except ImportError: 22 | import ujson as json 23 | 24 | from iot import server 25 | from iot.examples.local import PORT, TIMEOUT 26 | 27 | 28 | class App: 29 | def __init__(self, client_id): 30 | self.client_id = client_id # This instance talks to this client 31 | self.conn = None # Connection instance 32 | self.data = [0, 0, 0] # Exchange a 3-list with remote 33 | asyncio.create_task(self.start()) 34 | 35 | async def start(self): 36 | print('Client {} Awaiting connection.'.format(self.client_id)) 37 | self.conn = await server.client_conn(self.client_id) 38 | asyncio.create_task(self.reader()) 39 | asyncio.create_task(self.writer()) 40 | 41 | async def reader(self): 42 | print('Started reader') 43 | while True: 44 | line = await self.conn.readline() # Pause in event of outage 45 | self.data = json.loads(line) 46 | # Receives [restart count, uptime in secs, mem_free] 47 | print('Got', self.data, 'from remote', self.client_id) 48 | 49 | # Send 50 | # [approx app uptime in secs/5, received client uptime, received mem_free] 51 | async def writer(self): 52 | print('Started writer') 53 | count = 0 54 | while True: 55 | self.data[0] = count 56 | count += 1 57 | print('Sent', self.data, 'to remote', self.client_id, '\n') 58 | # .write() behaves as per .readline() 59 | await self.conn.write(json.dumps(self.data)) 60 | await asyncio.sleep(5) 61 | 62 | async def main(): 63 | clients = {'1', '2', '3', '4'} 64 | apps = [App(n) for n in clients] # Accept 4 clients with ID's 1-4 65 | await server.run(clients, True, port=PORT, timeout=TIMEOUT) 66 | 67 | def run(): 68 | try: 69 | asyncio.run(main()) 70 | except KeyboardInterrupt: 71 | print('Interrupted') 72 | finally: 73 | print('Closing sockets') 74 | server.Connection.close_all() 75 | asyncio.new_event_loop() 76 | 77 | 78 | if __name__ == "__main__": 79 | run() 80 | -------------------------------------------------------------------------------- /iot/examples_server.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import os 6 | 7 | sys.path.insert(0, os.getcwd()) 8 | 9 | if len(sys.argv) == 1 or sys.argv[1] == "example": 10 | print("Standard example server") 11 | from examples import s_app_cp 12 | 13 | s_app_cp.run() 14 | elif sys.argv[1] == "remote_control": 15 | print("Remote control example server") 16 | from example_remote_control import s_comms_cp 17 | 18 | s_comms_cp.run() 19 | elif sys.argv[1] == "qos": 20 | print("QoS example server") 21 | from qos import s_qos_cp 22 | 23 | s_qos_cp.run() 24 | else: 25 | print("Only these options are available:") 26 | print("example") 27 | print("remote_control") 28 | print("qos") 29 | -------------------------------------------------------------------------------- /iot/primitives/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /iot/primitives/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py Common functions for uasyncio primitives 2 | 3 | # Released under the MIT licence. 4 | # Copyright (C) Peter Hinch 2019-2020 5 | 6 | try: 7 | import uasyncio as asyncio 8 | except ImportError: 9 | import asyncio 10 | 11 | type_gen = type((lambda: (yield))()) # Generator type 12 | 13 | # If a callback is passed, run it and return. 14 | # If a coro is passed initiate it and return. 15 | # coros are passed by name i.e. not using function call syntax. 16 | def launch(func, *tup_args): 17 | res = func(*tup_args) 18 | if isinstance(res, type_gen): 19 | loop = asyncio.get_event_loop() 20 | loop.create_task(res) 21 | 22 | def set_global_exception(): 23 | def _handle_exception(loop, context): 24 | import sys 25 | sys.print_exception(context["exception"]) 26 | sys.exit() 27 | loop = asyncio.get_event_loop() 28 | loop.set_exception_handler(_handle_exception) 29 | -------------------------------------------------------------------------------- /iot/primitives/queue.py: -------------------------------------------------------------------------------- 1 | # queue.py: adapted from uasyncio V2 2 | 3 | # Copyright (c) 2018-2020 Peter Hinch 4 | # Released under the MIT License (MIT) - see LICENSE file 5 | 6 | # Code is based on Paul Sokolovsky's work. 7 | # This is a temporary solution until uasyncio V3 gets an efficient official version 8 | 9 | import uasyncio as asyncio 10 | 11 | 12 | # Exception raised by get_nowait(). 13 | class QueueEmpty(Exception): 14 | pass 15 | 16 | 17 | # Exception raised by put_nowait(). 18 | class QueueFull(Exception): 19 | pass 20 | 21 | class Queue: 22 | 23 | def __init__(self, maxsize=0): 24 | self.maxsize = maxsize 25 | self._queue = [] 26 | self._evput = asyncio.Event() # Triggered by put, tested by get 27 | self._evget = asyncio.Event() # Triggered by get, tested by put 28 | 29 | def _get(self): 30 | self._evget.set() # Schedule all tasks waiting on get 31 | self._evget.clear() 32 | return self._queue.pop(0) 33 | 34 | async def get(self): # Usage: item = await queue.get() 35 | while self.empty(): # May be multiple tasks waiting on get() 36 | # Queue is empty, suspend task until a put occurs 37 | # 1st of N tasks gets, the rest loop again 38 | await self._evput.wait() 39 | return self._get() 40 | 41 | def get_nowait(self): # Remove and return an item from the queue. 42 | # Return an item if one is immediately available, else raise QueueEmpty. 43 | if self.empty(): 44 | raise QueueEmpty() 45 | return self._get() 46 | 47 | def _put(self, val): 48 | self._evput.set() # Schedule tasks waiting on put 49 | self._evput.clear() 50 | self._queue.append(val) 51 | 52 | async def put(self, val): # Usage: await queue.put(item) 53 | while self.full(): 54 | # Queue full 55 | await self._evget.wait() 56 | # Task(s) waiting to get from queue, schedule first Task 57 | self._put(val) 58 | 59 | def put_nowait(self, val): # Put an item into the queue without blocking. 60 | if self.full(): 61 | raise QueueFull() 62 | self._put(val) 63 | 64 | def qsize(self): # Number of items in the queue. 65 | return len(self._queue) 66 | 67 | def empty(self): # Return True if the queue is empty, False otherwise. 68 | return len(self._queue) == 0 69 | 70 | def full(self): # Return True if there are maxsize items in the queue. 71 | # Note: if the Queue was initialized with maxsize=0 (the default) or 72 | # any negative number, then full() is never True. 73 | return self.maxsize > 0 and self.qsize() >= self.maxsize 74 | -------------------------------------------------------------------------------- /iot/primitives/switch.py: -------------------------------------------------------------------------------- 1 | # switch.py 2 | 3 | # Copyright (c) 2018-2020 Peter Hinch 4 | # Released under the MIT License (MIT) - see LICENSE file 5 | 6 | import uasyncio as asyncio 7 | import utime as time 8 | from . import launch 9 | 10 | class Switch: 11 | debounce_ms = 50 12 | def __init__(self, pin): 13 | self.pin = pin # Should be initialised for input with pullup 14 | self._open_func = False 15 | self._close_func = False 16 | self.switchstate = self.pin.value() # Get initial state 17 | asyncio.create_task(self.switchcheck()) # Thread runs forever 18 | 19 | def open_func(self, func, args=()): 20 | self._open_func = func 21 | self._open_args = args 22 | 23 | def close_func(self, func, args=()): 24 | self._close_func = func 25 | self._close_args = args 26 | 27 | # Return current state of switch (0 = pressed) 28 | def __call__(self): 29 | return self.switchstate 30 | 31 | async def switchcheck(self): 32 | while True: 33 | state = self.pin.value() 34 | if state != self.switchstate: 35 | # State has changed: act on it now. 36 | self.switchstate = state 37 | if state == 0 and self._close_func: 38 | launch(self._close_func, self._close_args) 39 | elif state == 1 and self._open_func: 40 | launch(self._open_func, self._open_args) 41 | # Ignore further state changes until switch has settled 42 | await asyncio.sleep_ms(Switch.debounce_ms) 43 | -------------------------------------------------------------------------------- /iot/qos/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /iot/qos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/iot/qos/__init__.py -------------------------------------------------------------------------------- /iot/qos/c_qos.py: -------------------------------------------------------------------------------- 1 | # c_qos.py Client-side application demo for Quality of Service 2 | 3 | # Released under the MIT licence. See LICENSE. 4 | # Copyright (C) Peter Hinch 2018-2020 5 | 6 | # Now uses and requires uasyncio V3. This is incorporated in daily builds 7 | # and release builds later than V1.12 8 | 9 | import gc 10 | import uasyncio as asyncio 11 | 12 | gc.collect() 13 | import ujson 14 | from machine import Pin 15 | import time 16 | from . import local 17 | gc.collect() 18 | from iot import client 19 | import urandom 20 | from .check_mid import CheckMid 21 | 22 | # Optional LED. led=None if not required 23 | from sys import platform, exit, print_exception 24 | if platform == 'pyboard': # D series 25 | from pyb import LED 26 | led = LED(1) 27 | else: 28 | from machine import Pin 29 | led = Pin(2, Pin.OUT, value=1) # Optional LED 30 | # End of optionalLED 31 | 32 | def _handle_exception(loop, context): 33 | print_exception(context["exception"]) 34 | exit() 35 | 36 | class App: 37 | def __init__(self, verbose): 38 | self.verbose = verbose 39 | self.cl = client.Client(local.MY_ID, local.SERVER, 40 | local.PORT, local.SSID, local.PW, 41 | local.TIMEOUT, verbose=verbose, led=led) 42 | self.tx_msg_id = 0 43 | self.cm = CheckMid() # Check message ID's for dupes, missing etc. 44 | 45 | async def start(self): 46 | self.verbose and print('App awaiting connection.') 47 | await self.cl 48 | asyncio.create_task(self.writer()) 49 | await self.reader() 50 | 51 | async def reader(self): 52 | self.verbose and print('Started reader') 53 | while True: 54 | line = await self.cl.readline() 55 | data = ujson.loads(line) 56 | self.cm(data[0]) # Update statistics 57 | print('Got', data, 'from server app') 58 | 59 | # Send [ID, (re)connect count, free RAM, duplicate message count, missed msgcount] 60 | async def writer(self): 61 | self.verbose and print('Started writer') 62 | st = time.time() 63 | while True: 64 | gc.collect() 65 | ut = time.time() - st # Uptime in secs 66 | data = [self.tx_msg_id, self.cl.connects, gc.mem_free(), 67 | self.cm.dupe, self.cm.miss, self.cm.oord, ut] 68 | self.tx_msg_id += 1 69 | print('Sent', data, 'to server app\n') 70 | dstr = ujson.dumps(data) 71 | await self.cl.write(dstr) # Wait out any outage 72 | await asyncio.sleep_ms(7000 + urandom.getrandbits(10)) 73 | 74 | def close(self): 75 | self.cl.close() 76 | 77 | 78 | app = None 79 | async def main(): 80 | global app # For finally clause 81 | loop = asyncio.get_event_loop() 82 | loop.set_exception_handler(_handle_exception) 83 | app = App(verbose=True) 84 | await app.start() 85 | 86 | try: 87 | asyncio.run(main()) 88 | finally: 89 | app.close() 90 | asyncio.new_event_loop() 91 | -------------------------------------------------------------------------------- /iot/qos/c_qos_fast.py: -------------------------------------------------------------------------------- 1 | # c_qos_fast.py Client-side application demo for Quality of Service 2 | # Tests rapid send and receive of qos messages 3 | 4 | # Released under the MIT licence. See LICENSE. 5 | # Copyright (C) Peter Hinch 2018-2020 6 | 7 | # Now uses and requires uasyncio V3. This is incorporated in daily builds 8 | # and release builds later than V1.12 9 | 10 | import gc 11 | import uasyncio as asyncio 12 | 13 | gc.collect() 14 | import ujson 15 | from machine import Pin 16 | from . import local 17 | gc.collect() 18 | from iot import client 19 | from .check_mid import CheckMid 20 | 21 | # Optional LED. led=None if not required 22 | from sys import platform, exit, print_exception 23 | if platform == 'pyboard': # D series 24 | from pyb import LED 25 | led = LED(1) 26 | else: 27 | from machine import Pin 28 | led = Pin(2, Pin.OUT, value=1) # Optional LED 29 | # End of optionalLED 30 | 31 | def _handle_exception(loop, context): 32 | print_exception(context["exception"]) 33 | exit() 34 | 35 | 36 | class App: 37 | def __init__(self, verbose): 38 | self.verbose = verbose 39 | self.cl = client.Client(local.MY_ID, local.SERVER, 40 | local.PORT, local.SSID, local.PW, 41 | local.TIMEOUT, verbose=verbose, led=led) 42 | self.tx_msg_id = 0 43 | self.cm = CheckMid() # Check message ID's for dupes, missing etc. 44 | 45 | async def start(self): 46 | self.verbose and print('App awaiting connection.') 47 | await self.cl 48 | asyncio.create_task(self.reader()) 49 | await self.writer() 50 | 51 | async def reader(self): 52 | self.verbose and print('Started reader') 53 | while True: 54 | line = await self.cl.readline() 55 | data = ujson.loads(line) 56 | self.cm(data[0]) # Update statistics 57 | print('Got', data, 'from server app') 58 | 59 | # Send [ID, (re)connect count, free RAM, duplicate message count, missed msgcount] 60 | async def writer(self): 61 | self.verbose and print('Started writer') 62 | while True: 63 | for _ in range(4): 64 | gc.collect() 65 | data = [self.tx_msg_id, self.cl.connects, gc.mem_free(), 66 | self.cm.dupe, self.cm.miss] 67 | self.tx_msg_id += 1 68 | await self.cl # Only launch write if link is up 69 | print('Sent', data, 'to server app\n') 70 | dstr = ujson.dumps(data) 71 | asyncio.create_task(self.cl.write(dstr, wait=False)) 72 | await asyncio.sleep(5) 73 | 74 | def close(self): 75 | self.cl.close() 76 | 77 | 78 | app = None 79 | async def main(): 80 | global app # For finally clause 81 | loop = asyncio.get_event_loop() 82 | loop.set_exception_handler(_handle_exception) 83 | app = App(verbose=True) 84 | await app.start() 85 | 86 | try: 87 | asyncio.run(main()) 88 | finally: 89 | app.close() 90 | asyncio.new_event_loop() 91 | -------------------------------------------------------------------------------- /iot/qos/check_mid.py: -------------------------------------------------------------------------------- 1 | # check_mid.py Check a sequence of incrementing message ID's. 2 | 3 | # Released under the MIT licence. See LICENSE. 4 | # Copyright (C) Peter Hinch 2020 5 | 6 | # For use in test scripts: message ID's increment without bound rather 7 | # than modulo N. Assumes message ID's start with 0 or 1. 8 | 9 | # Missing and duplicate message counter. Handles out-of-order messages. 10 | # Out of order messages will initially be missing to arrive later. 11 | # The most recent n message ID's are therefore not checked. If a 12 | # message is missing after n have been received, it is assumed lost. 13 | 14 | class CheckMid: 15 | def __init__(self, buff=15): 16 | self._buff = buff 17 | self._mids = set() 18 | self._miss = 0 # Count missing message ID's 19 | self._dupe = 0 # Duplicates 20 | self._oord = 0 # Received out of order 21 | self.bcnt = 0 # Client reboot count. Running totals over reboots: 22 | self._tot_miss = 0 # Missing 23 | self._tot_dupe = 0 # Dupes 24 | self._tot_oord = 0 # Out of order 25 | 26 | @property 27 | def miss(self): 28 | return self._miss + self._tot_miss 29 | 30 | @property 31 | def dupe(self): 32 | return self._dupe + self._tot_dupe 33 | 34 | @property 35 | def oord(self): 36 | return self._oord + self._tot_oord 37 | 38 | def __call__(self, mid): 39 | mids = self._mids 40 | if mid <= 1 and len(mids) > 1: # Target has rebooted 41 | self._mids.clear() 42 | self._tot_miss += self._miss 43 | self._tot_dupe += self._dupe 44 | self._tot_oord += self._oord 45 | self._miss = 0 46 | self._dupe = 0 47 | self._oord = 0 48 | self.bcnt += 1 49 | if mid in mids: 50 | self._dupe += 1 51 | elif mids and mid < max(mids): 52 | self._oord += 1 53 | mids.add(mid) 54 | if len(mids) > self._buff: 55 | oldest = min(mids) 56 | mids.remove(oldest) 57 | self._miss += min(mids) - oldest - 1 58 | 59 | # Usage/demo 60 | #cm = CheckMid(5) 61 | #s1 = (1,2,3,4,5,8,9,10,11,12,13,17,17,16,18,19,20,21,22,23,24,29,28,27,26,30,31,32,33,34,35,36,1,2,3,4,5,6,7,8) 62 | #for x in s1: 63 | #cm(x) 64 | #print(cm.dupe, cm.miss, cm.oord, cm.bcnt) 65 | -------------------------------------------------------------------------------- /iot/qos/local.py: -------------------------------------------------------------------------------- 1 | MY_ID = 'qos' # Client-unique string 2 | SERVER = '192.168.0.42' 3 | SSID = 'use_my_local' # Put in your WiFi credentials 4 | PW = 'PASSWORD' 5 | PORT = 8123 6 | TIMEOUT = 2000 7 | 8 | # The following may be deleted 9 | if SSID == 'use_my_local': 10 | from iot.qos.my_local import * 11 | -------------------------------------------------------------------------------- /iot/qos/s_qos_cp.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # s_app_cp.py Server-side application demo 5 | # Run under CPython 3.8 or later or MicroPython Unix build. 6 | # Under MicroPython uses and requires uasyncio V3. 7 | 8 | # Released under the MIT licence. See LICENSE. 9 | # Copyright (C) Peter Hinch 2018-2020 10 | 11 | # The App class emulates a user application intended to service a single 12 | # client. 13 | 14 | try: 15 | import asyncio 16 | except ImportError: 17 | import uasyncio as asyncio 18 | try: 19 | import json 20 | except ImportError: 21 | import ujson as json 22 | import time 23 | from iot import server 24 | from .local import PORT, TIMEOUT 25 | from .check_mid import CheckMid 26 | 27 | import sys 28 | def _handle_exception(loop, context): 29 | print('Global handler') 30 | sys.print_exception(context["exception"]) 31 | sys.exit() # Drastic - loop.stop() does not work when used this way 32 | 33 | class App: 34 | def __init__(self, client_id): 35 | self.client_id = client_id # This instance talks to this client 36 | self.conn = None # Connection instance 37 | self.tx_msg_id = 0 38 | self.cm = CheckMid() # Check message ID's for dupes, missing etc. 39 | self.data = [0, 0, 0, 0, 0, 0] # Data from remote 40 | asyncio.create_task(self.start()) 41 | 42 | async def start(self): 43 | print('Client {} Awaiting connection.'.format(self.client_id)) 44 | self.conn = await server.client_conn(self.client_id) 45 | asyncio.create_task(self.reader()) 46 | asyncio.create_task(self.writer()) 47 | st = time.time() 48 | while True: # Peiodic reports 49 | await asyncio.sleep(30) 50 | data = self.data 51 | cm = self.cm 52 | outages = self.conn.nconns - 1 53 | ut = (time.time() - st) / 3600 # Uptime in hrs 54 | print('Uptime {:6.2f}hr outages {}'.format(ut, outages)) 55 | print('Dupes ignored {} local {} remote. '.format(cm.dupe, data[3]), end='') 56 | print('Missed msg {} local {} remote.'.format(cm.miss, data[4]), end='') 57 | print('Out of order msg {} local {} remote.'.format(cm.oord, data[5])) 58 | print('Client reboots {} Client uptime {:6.2f}hr'.format(cm.bcnt, data[6]/3600)) 59 | 60 | async def reader(self): 61 | print('Started reader') 62 | while True: 63 | line = await self.conn.readline() # Pause in event of outage 64 | data = json.loads(line) 65 | self.cm(data[0]) 66 | print('Got {} from remote {}'.format(data, self.client_id)) 67 | self.data = data 68 | 69 | # Send [ID, message count since last outage] 70 | async def writer(self): 71 | print('Started writer') 72 | count = 0 73 | while True: 74 | data = [self.tx_msg_id, count] 75 | self.tx_msg_id += 1 76 | count += 1 77 | print('Sent {} to remote {}\n'.format(data, self.client_id)) 78 | await self.conn.write(json.dumps(data)) 79 | await asyncio.sleep(5) 80 | 81 | async def main(): 82 | app = App('qos') 83 | await server.run({'qos'}, True, port=PORT, timeout=TIMEOUT) 84 | 85 | def run(): 86 | try: 87 | asyncio.run(main()) 88 | except KeyboardInterrupt: 89 | print('Interrupted') 90 | finally: 91 | print('Closing sockets') 92 | server.Connection.close_all() 93 | asyncio.new_event_loop() 94 | 95 | 96 | if __name__ == "__main__": 97 | run() 98 | -------------------------------------------------------------------------------- /iot/qos/s_qos_fast.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # s_app_cp.py Server-side application demo 5 | # Tests rapid send and receive of qos messages 6 | # Run under CPython 3.8 or later or MicroPython Unix build. 7 | # Under MicroPython uses and requires uasyncio V3. 8 | 9 | # Released under the MIT licence. See LICENSE. 10 | # Copyright (C) Peter Hinch 2018-2020 11 | 12 | # The App class emulates a user application intended to service a single 13 | # client. 14 | 15 | try: 16 | import asyncio 17 | except ImportError: 18 | import uasyncio as asyncio 19 | try: 20 | import json 21 | except ImportError: 22 | import ujson as json 23 | import time 24 | from iot import server 25 | from .local import PORT, TIMEOUT 26 | from .check_mid import CheckMid 27 | 28 | import sys 29 | def _handle_exception(loop, context): 30 | print('Global handler') 31 | sys.print_exception(context["exception"]) 32 | sys.exit() 33 | 34 | 35 | class App: 36 | def __init__(self, client_id): 37 | self.client_id = client_id # This instance talks to this client 38 | self.conn = None # Connection instance 39 | self.tx_msg_id = 0 40 | self.cm = CheckMid() # Check message ID's for dupes, missing etc. 41 | self.data = [0, 0, 0, 0, 0] # Data from remote 42 | asyncio.create_task(self.start()) 43 | 44 | async def start(self): 45 | print('Client {} Awaiting connection.'.format(self.client_id)) 46 | self.conn = await server.client_conn(self.client_id) 47 | asyncio.create_task(self.reader()) 48 | asyncio.create_task(self.writer()) 49 | st = time.time() 50 | while True: 51 | await asyncio.sleep(30) 52 | data = self.data 53 | cm = self.cm 54 | outages = self.conn.nconns - 1 55 | ut = (time.time() - st) / 3600 # Uptime in hrs 56 | print('Uptime {:6.2f}hr outages {}'.format(ut, outages)) 57 | print('Dupes ignored {} local {} remote. '.format(cm.dupe, data[3]), end='') 58 | print('Missed msg {} local {} remote.'.format(cm.miss, data[4]), end='') 59 | print('Out of order msg {} Client reboots {}'.format(cm.oord, cm.bcnt)) 60 | 61 | async def reader(self): 62 | print('Started reader') 63 | while True: 64 | line = await self.conn.readline() # Pause in event of outage 65 | data = json.loads(line) 66 | self.cm(data[0]) 67 | print('Got {} from remote {}'.format(data, self.client_id)) 68 | self.data = data 69 | 70 | # Send [ID, message count since last outage] 71 | async def writer(self): 72 | print('Started writer') 73 | count = 0 74 | while True: 75 | for _ in range(4): 76 | data = [self.tx_msg_id, count] 77 | self.tx_msg_id += 1 78 | count += 1 79 | await self.conn # Only launch write if link is up 80 | print('Sent {} to remote {}\n'.format(data, self.client_id)) 81 | asyncio.create_task(self.conn.write(json.dumps(data), wait=False)) 82 | await asyncio.sleep(3.95) 83 | 84 | async def main(): 85 | app = App('qos') 86 | await server.run({'qos'}, True, port=PORT, timeout=TIMEOUT) 87 | 88 | def run(): 89 | try: 90 | asyncio.run(main()) 91 | except KeyboardInterrupt: 92 | print('Interrupted') 93 | finally: 94 | print('Closing sockets') 95 | server.Connection.close_all() 96 | asyncio.new_event_loop() 97 | 98 | 99 | if __name__ == "__main__": 100 | run() 101 | -------------------------------------------------------------------------------- /iot/remote/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /iot/remote/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/iot/remote/__init__.py -------------------------------------------------------------------------------- /iot/remote/c_comms_rx.py: -------------------------------------------------------------------------------- 1 | # c_comms_rx.py Client-side application demo reads data sent by another client 2 | 3 | # Released under the MIT licence. See LICENSE. 4 | # Copyright (C) Peter Hinch 2018-2020 5 | 6 | import gc 7 | import uasyncio as asyncio 8 | 9 | gc.collect() 10 | import ujson 11 | from machine import Pin 12 | 13 | from . import local 14 | from iot import client 15 | 16 | 17 | class App: 18 | def __init__(self, verbose): 19 | self.verbose = verbose 20 | self.led = Pin(2, Pin.OUT, value=1) # LED for received data 21 | self.cl = client.Client('rx', local.SERVER, local.PORT, 22 | local.SSID, local.PW, timeout=local.TIMEOUT, 23 | verbose=verbose) 24 | 25 | async def start(self): 26 | self.verbose and print('App awaiting connection.') 27 | await self.cl 28 | print('Got connection') 29 | await self.reader() 30 | 31 | async def reader(self): 32 | self.verbose and print('Started reader') 33 | while True: 34 | line = await self.cl.readline() 35 | data = ujson.loads(line) 36 | self.led.value(data[0]) 37 | print('Got', data, 'from server app') 38 | 39 | def close(self): 40 | self.cl.close() 41 | 42 | 43 | app = App(True) 44 | try: 45 | asyncio.run(app.start()) 46 | finally: 47 | app.close() 48 | asyncio.new_event_loop() 49 | -------------------------------------------------------------------------------- /iot/remote/c_comms_tx.py: -------------------------------------------------------------------------------- 1 | # c_comms_tx.py Client-side app demo. Sends switch state to "rx" device. 2 | 3 | # Released under the MIT licence. See LICENSE. 4 | # Copyright (C) Peter Hinch 2018-2020 5 | 6 | # Outage handling: switch chnages during the outage are ignored. When the 7 | # outage ends, the switch state at that time is transmitted. 8 | 9 | import gc 10 | import uasyncio as asyncio 11 | 12 | gc.collect() 13 | from iot import client 14 | from iot.primitives.switch import Switch 15 | 16 | gc.collect() 17 | import ujson 18 | from machine import Pin 19 | from . import local 20 | 21 | gc.collect() 22 | 23 | 24 | class App: 25 | def __init__(self, verbose): 26 | self.verbose = verbose 27 | led = Pin(2, Pin.OUT, value=1) # Optional LED 28 | # Pushbutton on Cockle board from shrimping.it 29 | self.switch = Switch(Pin(0, Pin.IN)) 30 | self.switch.close_func(lambda _ : self.must_send.set()) 31 | self.switch.open_func(lambda _ : self.must_send.set()) 32 | self.must_send = asyncio.Event() 33 | self.cl = client.Client('tx', local.SERVER, local.PORT, 34 | local.SSID, local.PW, local.TIMEOUT, 35 | verbose=verbose, led=led) 36 | 37 | async def start(self): 38 | self.verbose and print('App awaiting connection.') 39 | await self.cl 40 | self.verbose and print('Got connection') 41 | while True: 42 | await self.must_send.wait() 43 | await self.cl.write(ujson.dumps([self.switch()]), False) 44 | self.must_send.clear() 45 | 46 | def close(self): 47 | self.cl.close() 48 | 49 | 50 | app = App(True) 51 | try: 52 | asyncio.run(app.start()) 53 | finally: 54 | app.close() 55 | asyncio.new_event_loop() 56 | -------------------------------------------------------------------------------- /iot/remote/local.py: -------------------------------------------------------------------------------- 1 | # ID defined in code 2 | SERVER = '192.168.0.35' 3 | SSID = 'use_my_local' # Put in your WiFi credentials 4 | PW = 'PASSWORD' 5 | PORT = 8123 6 | TIMEOUT = 2000 7 | 8 | # The following may be deleted 9 | if SSID == 'use_my_local': 10 | from iot.remote.my_local import * 11 | -------------------------------------------------------------------------------- /iot/remote/s_comms_cp.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # s_comms_cp.py Server-side application demo. Accepts data from one client and 5 | # sends it to another 6 | # Run under CPython 3.8 or later. 7 | 8 | # Released under the MIT licence. See LICENSE. 9 | # Copyright (C) Peter Hinch 2018-2020 10 | 11 | try: 12 | import uasyncio as asyncio 13 | import ujson as json 14 | except ImportError: #CPython 15 | import asyncio 16 | import json 17 | 18 | from iot import server 19 | 20 | from .local import PORT, TIMEOUT 21 | 22 | 23 | class App: 24 | data = None 25 | trig_send = asyncio.Event() 26 | 27 | def __init__(self, client_id): 28 | self.client_id = client_id # This instance talks to this client 29 | self.conn = None # Connection instance 30 | asyncio.create_task(self.start()) 31 | 32 | async def start(self): 33 | my_id = self.client_id 34 | print('Client {} Awaiting connection.'.format(my_id)) 35 | # Wait for all clients to connect 36 | self.conn = await server.wait_all(my_id) 37 | print('Message from {}: all peers are connected.'.format(my_id)) 38 | 39 | if my_id == 'tx': # Client is sending 40 | asyncio.create_task(self.reader()) 41 | else: 42 | asyncio.create_task(self.writer()) 43 | 44 | async def reader(self): 45 | print('Started reader') 46 | while True: 47 | line = await self.conn.readline() # Pause in event of outage 48 | App.data = json.loads(line) 49 | print('Got', App.data, 'from remote', self.client_id) 50 | App.trig_send.set() 51 | 52 | async def writer(self): 53 | print('Started writer') 54 | data = None 55 | while True: 56 | await App.trig_send.wait() 57 | App.trig_send.clear() 58 | data = App.data 59 | await self.conn.write(json.dumps(data), False) # Reduce latency 60 | print('Sent', data, 'to remote', self.client_id, '\n') 61 | 62 | 63 | def run(): 64 | clients = {'rx', 'tx'} # Expected clients 65 | apps = [App(name) for name in clients] # Accept 2 clients 66 | try: 67 | asyncio.run(server.run(clients, verbose=True, port=PORT, timeout=TIMEOUT)) 68 | except KeyboardInterrupt: 69 | print('Interrupted') 70 | finally: 71 | print('Closing sockets') 72 | server.Connection.close_all() 73 | asyncio.new_event_loop() 74 | 75 | 76 | if __name__ == "__main__": 77 | run() 78 | -------------------------------------------------------------------------------- /iot/server.py: -------------------------------------------------------------------------------- 1 | # server_cp.py Server for IOT communications. 2 | 3 | # Released under the MIT licence. 4 | # Copyright (C) Peter Hinch 2019-2020 5 | 6 | # Maintains bidirectional full-duplex links between server applications and 7 | # multiple WiFi connected clients. Each application instance connects to its 8 | # designated client. Connections are resilient and recover from outages of WiFi 9 | # and of the connected endpoint. 10 | # This server and the server applications are assumed to reside on a device 11 | # with a wired interface on the local network. 12 | 13 | # Now uses and requires uasyncio V3. This is incorporated in daily builds 14 | # and release builds later than V1.12 15 | # Under CPython requires CPython 3.8 or later. 16 | 17 | import sys 18 | from . import gmid, isnew # __init__.py 19 | 20 | upython = sys.implementation.name == 'micropython' 21 | if upython: 22 | import usocket as socket 23 | import uasyncio as asyncio 24 | import utime as time 25 | import uselect as select 26 | import uerrno as errno 27 | else: 28 | import socket 29 | import asyncio 30 | import time 31 | import select 32 | import errno 33 | 34 | Lock = asyncio.Lock 35 | 36 | TIM_TINY = 0.05 # Short delay avoids 100% CPU utilisation in busy-wait loops 37 | 38 | # Read the node ID. There isn't yet a Connection instance. 39 | # CPython does not have socket.readline. Return 1st string received 40 | # which starts with client_id. 41 | 42 | # Note re OSError: did detect errno.EWOULDBLOCK. Not supported in MicroPython. 43 | # In cpython EWOULDBLOCK == EAGAIN == 11. 44 | async def _readid(s, to_secs): 45 | data = '' 46 | start = time.time() 47 | while True: 48 | try: 49 | d = s.recv(4096).decode() 50 | except OSError as e: 51 | err = e.args[0] 52 | if err == errno.EAGAIN: 53 | if (time.time() - start) > to_secs: 54 | raise OSError # Timeout waiting for data 55 | else: 56 | # Waiting for data from client. Limit CPU overhead. 57 | await asyncio.sleep(TIM_TINY) 58 | else: 59 | raise OSError # Reset by peer 104 60 | else: 61 | if d == '': 62 | raise OSError # Reset by peer or t/o 63 | data = '{}{}'.format(data, d) 64 | if data.find('\n') != -1: # >= one line 65 | return data 66 | 67 | 68 | # API: application calls server.run() 69 | # Allow 2 extra connections. This is to cater for error conditions like 70 | # duplicate or unexpected clients. Accept the connection and have the 71 | # Connection class produce a meaningful error message. 72 | async def run(expected, verbose=False, port=8123, timeout=2000): 73 | addr = socket.getaddrinfo('0.0.0.0', port, 0, socket.SOCK_STREAM)[0][-1] 74 | s_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # server socket 75 | s_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 76 | s_sock.bind(addr) 77 | s_sock.listen(len(expected) + 2) 78 | verbose and print('Awaiting connection.', port) 79 | poller = select.poll() 80 | poller.register(s_sock, select.POLLIN) 81 | to_secs = timeout / 1000 # ms -> secs 82 | while True: 83 | res = poller.poll(1) # 1ms block 84 | if res: # Only s_sock is polled 85 | c_sock, _ = s_sock.accept() # get client socket 86 | c_sock.setblocking(False) 87 | try: 88 | data = await _readid(c_sock, to_secs) 89 | except OSError: 90 | c_sock.close() 91 | else: 92 | Connection.go(to_secs, data, verbose, c_sock, s_sock, 93 | expected) 94 | await asyncio.sleep(0.2) 95 | 96 | 97 | # A Connection persists even if client dies (minimise object creation). 98 | # If client dies Connection is closed: ._close() flags this state by closing its 99 | # socket and setting .sock to None (.status() == False). 100 | class Connection: 101 | _conns = {} # index: client_id. value: Connection instance 102 | _expected = set() # Expected client_id's 103 | _server_sock = None 104 | 105 | @classmethod 106 | def go(cls, to_secs, data, verbose, c_sock, s_sock, expected): 107 | client_id, init_str = data.split('\n', 1) 108 | verbose and print('Got connection from client', client_id) 109 | if cls._server_sock is None: # 1st invocation 110 | cls._server_sock = s_sock 111 | cls._expected.update(expected) 112 | if client_id in cls._conns: # Old client, new socket 113 | if cls._conns[client_id].status(): 114 | print('Duplicate client {} ignored.'.format(client_id)) 115 | c_sock.close() 116 | else: # Reconnect after failure 117 | cls._conns[client_id]._reconnect(c_sock) 118 | else: # New client: instantiate Connection 119 | Connection(to_secs, c_sock, client_id, init_str, verbose) 120 | 121 | # Server-side app waits for a working connection 122 | @classmethod 123 | async def client_conn(cls, client_id): 124 | while True: 125 | if client_id in cls._conns: 126 | c = cls._conns[client_id] 127 | # await c 128 | # works but under CPython produces runtime warnings. So do: 129 | await c._status_coro() 130 | return c 131 | await asyncio.sleep(0.5) 132 | 133 | # App waits for all expected clients to connect. 134 | @classmethod 135 | async def wait_all(cls, client_id=None, peers=None): 136 | conn = None 137 | if client_id is not None: 138 | conn = await client_conn(client_id) 139 | if peers is None: # Wait for all expected clients 140 | while cls._expected: 141 | await asyncio.sleep(0.5) 142 | else: 143 | while not set(cls._conns.keys()).issuperset(peers): 144 | await asyncio.sleep(0.5) 145 | return conn 146 | 147 | @classmethod 148 | def close_all(cls): 149 | for conn in cls._conns.values(): 150 | conn._close('Connection {} closed by application'.format(conn._cl_id)) 151 | if cls._server_sock is not None: 152 | cls._server_sock.close() 153 | 154 | def __init__(self, to_secs, c_sock, client_id, init_str, verbose): 155 | self._to_secs = to_secs 156 | self._tim_short = self._to_secs / 10 157 | self._tim_short_ms = int(self._to_secs * 100) # MicroPython only! 158 | self._tim_ka = self._to_secs / 4 # Keepalive interval 159 | self._sock = c_sock # Socket 160 | self._cl_id = client_id 161 | self._verbose = verbose 162 | self._newlist = bytearray(32) # Per-client de-dupe list 163 | self.nconns = 0 # Reconnect count (information only) 164 | Connection._conns[client_id] = self 165 | try: 166 | Connection._expected.remove(client_id) 167 | except KeyError: 168 | print('Unknown client {} has connected. Expected {}.'.format( 169 | client_id, Connection._expected)) 170 | 171 | self._getmid = gmid() # Message ID generator 172 | # ._wr_pause set after initial or subsequent client connection. Cleared 173 | # after 1st keepalive received. We delay sending anything other than 174 | # keepalives while ._wr_pause is set 175 | self._wr_pause = True 176 | self._await_client = True # Waiting for 1st received line. 177 | self._wlock = Lock() # Write lock 178 | self._lines = [] # Buffer of received lines 179 | self._acks_pend = set() # ACKs which are expected to be received 180 | asyncio.create_task(self._read(init_str)) 181 | asyncio.create_task(self._keepalive()) 182 | 183 | def _reconnect(self, c_sock): 184 | self._sock = c_sock 185 | self._wr_pause = True 186 | self._await_client = True 187 | 188 | # Have received 1st data packet from client. Launched by ._read 189 | async def _client_active(self): 190 | await asyncio.sleep(0.2) # Let ESP get out of bed. 191 | self._wr_pause = False 192 | 193 | def status(self): 194 | return self._sock is not None 195 | 196 | __call__ = status 197 | 198 | def __await__(self): 199 | if upython: 200 | while not self(): 201 | yield from asyncio.sleep_ms(self._tim_short_ms) 202 | else: 203 | # CPython: Meet requirement for generator in __await__ 204 | # https://github.com/python/asyncio/issues/451 205 | yield from self._status_coro().__await__() 206 | 207 | __iter__ = __await__ # MicroPython compatibility. 208 | 209 | async def _status_coro(self): 210 | while not self(): 211 | await asyncio.sleep(self._tim_short) 212 | 213 | async def readline(self): 214 | l = self._readline() 215 | if l is not None: 216 | return l 217 | # Must wait for data 218 | while True: 219 | if not self(): # Outage 220 | self._verbose and print('Client:', self._cl_id, 'awaiting connection') 221 | await self._status_coro() 222 | self._verbose and print('Client:', self._cl_id, 'connected') 223 | while self(): 224 | l = self._readline() 225 | if l is not None: 226 | return l 227 | await asyncio.sleep(TIM_TINY) # Limit CPU utilisation 228 | 229 | # Immediate return. If a non-duplicate line is ready return it. 230 | def _readline(self): 231 | while self._lines: 232 | line = self._lines.pop(0) 233 | # Discard dupes: get message ID 234 | mid = int(line[0:2], 16) 235 | # mid == 0 : client has power cycled. Clear list of mid's. 236 | if not mid: 237 | isnew(-1, self._newlist) 238 | if isnew(mid, self._newlist): 239 | return '{}{}'.format(line[2:], '\n') 240 | 241 | async def _read(self, istr): 242 | while True: 243 | # Start (or restart after outage). Do this promptly. 244 | # Fast version of await self._status_coro() 245 | while self._sock is None: 246 | await asyncio.sleep(TIM_TINY) 247 | self.nconns += 1 # For test scripts 248 | start = time.time() 249 | while self(): 250 | try: 251 | d = self._sock.recv(4096) # bytes object 252 | #print('TEST', d) 253 | except OSError as e: 254 | err = e.args[0] 255 | if err == errno.EAGAIN: # Would block: try later 256 | if time.time() - start > self._to_secs: 257 | self._close('_read timeout') # Unless it timed out. 258 | else: 259 | # Waiting for data from client. Limit CPU overhead. 260 | await asyncio.sleep(TIM_TINY) 261 | else: 262 | self._close('_read reset by peer 104') 263 | else: 264 | start = time.time() # Something was received 265 | if self._await_client: # 1st item after (re)start 266 | self._await_client = False # Enable write after delay 267 | asyncio.create_task(self._client_active()) 268 | if d == b'': # Reset by peer 269 | self._close('_read reset by peer') 270 | continue 271 | d = d.lstrip(b'\n') # Discard leading KA's 272 | if d == b'': # Only KA's 273 | continue 274 | 275 | istr += d.decode() # Add to any partial message 276 | # Strings from this point 277 | l = istr.split('\n') 278 | istr = l.pop() # '' unless partial line 279 | self._process_str(l) 280 | 281 | # Given a list of received lines remove any ka's from middle. Split into 282 | # messages and ACKs. Put messages into ._lines and remove ACKs from 283 | # ._acks_pend. Note messages in ._lines have no trailing \n. 284 | def _process_str(self, l): 285 | l = [x for x in l if x] # Discard ka's 286 | self._acks_pend -= {int(x, 16) for x in l if len(x) == 2} 287 | lines = [x for x in l if len(x) != 2] # Lines received 288 | if lines: 289 | self._lines.extend(lines) 290 | for line in lines: 291 | asyncio.create_task(self._sendack(int(line[0:2], 16))) 292 | 293 | async def _sendack(self, mid): 294 | async with self._wlock: 295 | await self._send('{:02x}\n'.format(mid)) 296 | 297 | async def _keepalive(self): 298 | while True: 299 | await self._vwrite(None) 300 | await asyncio.sleep(self._tim_ka) 301 | 302 | async def write(self, line, qos=True, wait=True): 303 | if qos and wait: 304 | while self._acks_pend: 305 | await asyncio.sleep(TIM_TINY) 306 | fstr = '{:02x}{}' if line.endswith('\n') else '{:02x}{}\n' 307 | mid = next(self._getmid) 308 | self._acks_pend.add(mid) 309 | # ACK will be removed from ._acks_pend by ._read 310 | line = fstr.format(mid, line) # Local copy 311 | await self._vwrite(line) # Write verbatim 312 | if not qos: # Don't care about ACK. All done. 313 | return 314 | # qos: pause until ACK received 315 | while True: 316 | await self._status_coro() # Wait for outage to clear 317 | if await self._waitack(mid): 318 | return # Got ack, removed from ._acks_pend, all done 319 | # Either timed out or an outage started 320 | await self._vwrite(line) # Waits for outage to clear 321 | self._verbose and print('Repeat', line[2:], 'to server app') 322 | 323 | # When ._read receives an ACK it is discarded from ._acks_pend. Wait for 324 | # this to occur (or an outage to start). Currently use system timeout. 325 | async def _waitack(self, mid): 326 | tstart = time.time() # Wait for ACK 327 | while mid in self._acks_pend: 328 | await asyncio.sleep(TIM_TINY) 329 | if not self() or ((time.time() - tstart) > self._to_secs): 330 | self._verbose and print('waitack timeout', mid) 331 | return False # Outage or ACK not received in time 332 | return True 333 | 334 | # Verbatim write: add no message ID. 335 | async def _vwrite(self, line): 336 | ok = False 337 | while not ok: 338 | if self._verbose and not self(): 339 | print('Writer Client:', self._cl_id, 'awaiting OK status') 340 | await self._status_coro() 341 | if line is None: 342 | line = '\n' # Keepalive. Send now: don't care about loss 343 | else: 344 | # Aawait client ready after initial or subsequent connection 345 | while self._wr_pause: 346 | await asyncio.sleep(self._tim_short) 347 | 348 | async with self._wlock: # >1 writing task? 349 | ok = await self._send(line) # Fail clears status 350 | 351 | # Send a string. Return True on apparent success, False on failure. 352 | async def _send(self, d): 353 | if not self(): 354 | return False 355 | d = d.encode('utf8') # Socket requires bytes 356 | start = time.time() 357 | while d: 358 | try: 359 | ns = self._sock.send(d) # Raise OSError if client fails 360 | except OSError as e: 361 | err = e.args[0] 362 | if err == errno.EAGAIN: # Would block: try later 363 | await asyncio.sleep(0.1) 364 | continue 365 | break 366 | else: 367 | d = d[ns:] 368 | if d: 369 | await asyncio.sleep(self._tim_short) 370 | if (time.time() - start) > self._to_secs: 371 | break 372 | else: 373 | # The 0.2s delay is necessary otherwise messages can be lost if the 374 | # app attempts to send them in quick succession. Also occurs on 375 | # Pyboard D despite completely different hardware. 376 | # Is it better to return immediately and delay subsequent writes? 377 | # Should the delay be handled at a higher level? 378 | #await asyncio.sleep(0.2) # Disallow rapid writes: result in data loss 379 | return True # Success 380 | self._close('Write fail: closing connection.') 381 | return False 382 | 383 | def __getitem__(self, client_id): # Return a Connection of another client 384 | return Connection._conns[client_id] 385 | 386 | def _close(self, reason=''): 387 | if self._sock is not None: 388 | self._verbose and print('fail detected') 389 | self._verbose and reason and print('Reason:', reason) 390 | self._sock.close() 391 | self._sock = None 392 | 393 | # API aliases 394 | client_conn = Connection.client_conn 395 | wait_all = Connection.wait_all 396 | -------------------------------------------------------------------------------- /pb_link/.rshell-ignore: -------------------------------------------------------------------------------- 1 | README.md 2 | LICENSE 3 | docs 4 | private 5 | __pycache__ 6 | 7 | -------------------------------------------------------------------------------- /pb_link/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-iot/94e71d909eb7c1f4f1352b68eaeaf858c68d5e2a/pb_link/__init__.py -------------------------------------------------------------------------------- /pb_link/app_base.py: -------------------------------------------------------------------------------- 1 | # pb_client.py Run on Pyboard/STM device. Communicate with IOT server via an 2 | # ESP8266 running esp_link.py 3 | 4 | # Copyright (c) Peter Hinch 2018-2020 5 | # Released under the MIT licence. Full text in root of this repository. 6 | 7 | # Communication uses I2C slave mode. 8 | 9 | import uasyncio as asyncio 10 | import ujson 11 | from . import asi2c_i 12 | from primitives.delay_ms import Delay_ms 13 | from primitives.message import Message 14 | 15 | 16 | class AppBase: 17 | def __init__(self, conn_id, config, hardware, verbose): 18 | self.verbose = verbose 19 | self.initial = True 20 | self._status = False # Server status 21 | self.wlock = asyncio.Lock() 22 | self.rxmsg = Message() # rx data ready 23 | self.tim_boot = Delay_ms(func=self.reboot) 24 | config.insert(0, conn_id) 25 | config.append('cfg') # Marker defines a config list 26 | self.cfg = ''.join((ujson.dumps(config), '\n')) 27 | i2c, syn, ack, rst = hardware 28 | self.chan = asi2c_i.Initiator(i2c, syn, ack, rst, verbose, self._go, (), self.reboot) 29 | self.sreader = asyncio.StreamReader(self.chan) 30 | self.swriter = asyncio.StreamWriter(self.chan, {}) 31 | self.lqueue = [] # Outstanding lines 32 | 33 | # Runs after sync acquired on 1st or subsequent ESP8266 boots. 34 | async def _go(self): 35 | self.verbose and print('Sync acquired, sending config') 36 | if not self.wlock.locked(): # May have been acquired in .reboot 37 | await self.wlock.acquire() 38 | self.verbose and print('Got lock, sending config', self.cfg) 39 | self.swriter.write(self.cfg) 40 | await self.swriter.drain() # 1st message is config 41 | while self.lqueue: 42 | self.swriter.write(self.lqueue.pop(0)) 43 | await self.swriter.drain() 44 | self.wlock.release() 45 | # At this point ESP8266 can handle the Pyboard interface but may not 46 | # yet be connected to the server 47 | if self.initial: 48 | self.initial = False 49 | self.start() # User starts read and write tasks 50 | 51 | # **** API **** 52 | async def await_msg(self): 53 | while True: 54 | line = await self.sreader.readline() 55 | h, p = chr(line[0]), line[1:] # Header char, payload 56 | if h == 'n': # Normal message 57 | self.rxmsg.set(p) 58 | elif h == 'b': 59 | asyncio.create_task(self.bad_wifi()) 60 | elif h == 's': 61 | asyncio.create_task(self.bad_server()) 62 | elif h == 'r': 63 | asyncio.create_task(self.report(ujson.loads(p))) 64 | elif h == 'k': 65 | self.tim_boot.trigger(4000) # hold off reboot (4s) 66 | elif h in ('u', 'd'): 67 | up = h == 'u' 68 | self._status = up 69 | asyncio.create_task(self.server_ok(up)) 70 | else: 71 | raise ValueError('Unknown header:', h) 72 | 73 | async def write(self, line, qos=True, wait=True): 74 | ch = chr(0x30 + ((qos << 1) | wait)) # Encode args 75 | fstr = '{}{}' if line.endswith('\n') else '{}{}\n' 76 | line = fstr.format(ch, line) 77 | try: 78 | await asyncio.wait_for(self.wlock.acquire(), 1) 79 | self.swriter.write(line) 80 | await self.swriter.drain() 81 | except asyncio.TimeoutError: # Lock is set because ESP has crashed 82 | self.verbose and print('Timeout getting lock: queueing line', line) 83 | # Send line later. Can avoid message loss, but this 84 | self.lqueue.append(line) # isn't a bomb-proof guarantee 85 | finally: 86 | if self.wlock.locked(): 87 | self.wlock.release() 88 | 89 | async def readline(self): 90 | await self.rxmsg 91 | line = self.rxmsg.value() 92 | self.rxmsg.clear() 93 | return line 94 | 95 | # Stopped getting keepalives. ESP8266 crash: prevent user code from writing 96 | # until reboot sequence complete 97 | async def reboot(self): 98 | self.verbose and print('AppBase reboot') 99 | if self.chan.reset is None: # No config for reset 100 | raise OSError('Cannot reset ESP8266.') 101 | asyncio.create_task(self.chan.reboot()) # Hardware reset board 102 | self.tim_boot.stop() # No more reboots 103 | if not self.wlock.locked(): # Prevent user writes 104 | await self.wlock.acquire() 105 | 106 | def close(self): 107 | self.verbose and print('Closing channel.') 108 | self.chan.close() 109 | 110 | def status(self): # Server status 111 | return self._status 112 | 113 | # **** For subclassing **** 114 | 115 | async def bad_wifi(self): 116 | await asyncio.sleep(0) 117 | raise OSError('No initial WiFi connection.') 118 | 119 | async def bad_server(self): 120 | await asyncio.sleep(0) 121 | raise OSError('No initial server connection.') 122 | 123 | async def report(self, data): 124 | await asyncio.sleep(0) 125 | print('Connects {} Count {} Mem free {}'.format(data[0], data[1], data[2])) 126 | 127 | async def server_ok(self, up): 128 | await asyncio.sleep(0) 129 | print('Server is {}'.format('up' if up else 'down')) 130 | -------------------------------------------------------------------------------- /pb_link/asi2c.py: -------------------------------------------------------------------------------- 1 | # asi2c.py A communications link using I2C slave mode on Pyboard. 2 | # Channel and Responder classes. Adapted for uasyncio V3, WBUS DIP28. 3 | 4 | # The MIT License (MIT) 5 | # 6 | # Copyright (c) 2018-2020 Peter Hinch 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | import uasyncio as asyncio 27 | import machine 28 | import utime 29 | from micropython import const 30 | import io 31 | 32 | _MP_STREAM_POLL_RD = const(1) 33 | _MP_STREAM_POLL_WR = const(4) 34 | _MP_STREAM_POLL = const(3) 35 | _MP_STREAM_ERROR = const(-1) 36 | # Delay compensates for short Responder interrupt latency. Must be >= max delay 37 | # between Initiator setting a pin and initiating an I2C transfer: ensure 38 | # Initiator sets up first. 39 | _DELAY = const(20) # μs 40 | 41 | 42 | # Base class provides user interface and send/receive object buffers 43 | class Channel(io.IOBase): 44 | def __init__(self, i2c, own, rem, verbose, rxbufsize): 45 | self.rxbufsize = rxbufsize 46 | self.verbose = verbose 47 | self.synchronised = False 48 | # Hardware 49 | self.i2c = i2c 50 | self.own = own 51 | self.rem = rem 52 | own.init(mode=machine.Pin.OUT, value=1) 53 | rem.init(mode=machine.Pin.IN, pull=machine.Pin.PULL_UP) 54 | # I/O 55 | self.txbyt = b'' # Data to send 56 | self.txsiz = bytearray(2) # Size of .txbyt encoded as 2 bytes 57 | self.rxbyt = b'' 58 | self.rxbuf = bytearray(rxbufsize) 59 | self.rx_mv = memoryview(self.rxbuf) 60 | self.cantx = True # Remote can accept data 61 | 62 | async def _sync(self): 63 | self.verbose and print('Synchronising') 64 | self.own(0) 65 | while self.rem(): 66 | await asyncio.sleep_ms(100) 67 | # Both pins are now low 68 | await asyncio.sleep(0) 69 | self.verbose and print('Synchronised') 70 | self.synchronised = True 71 | 72 | def waitfor(self, val): # Initiator overrides 73 | while not self.rem() == val: 74 | pass 75 | 76 | # Get incoming bytes instance from memoryview. 77 | def _handle_rxd(self, msg): 78 | self.rxbyt = bytes(msg) 79 | 80 | def _txdone(self): 81 | self.txbyt = b'' 82 | self.txsiz[0] = 0 83 | self.txsiz[1] = 0 84 | 85 | # Stream interface 86 | 87 | def ioctl(self, req, arg): 88 | ret = _MP_STREAM_ERROR 89 | if req == _MP_STREAM_POLL: 90 | ret = 0 91 | if self.synchronised: 92 | if arg & _MP_STREAM_POLL_RD: 93 | if self.rxbyt: 94 | ret |= _MP_STREAM_POLL_RD 95 | if arg & _MP_STREAM_POLL_WR: 96 | if (not self.txbyt) and self.cantx: 97 | ret |= _MP_STREAM_POLL_WR 98 | return ret 99 | 100 | def readline(self): 101 | n = self.rxbyt.find(b'\n') 102 | if n == -1: 103 | t = self.rxbyt[:] 104 | self.rxbyt = b'' 105 | else: 106 | t = self.rxbyt[: n + 1] 107 | self.rxbyt = self.rxbyt[n + 1:] 108 | return t.decode() 109 | 110 | def read(self, n): 111 | t = self.rxbyt[:n] 112 | self.rxbyt = self.rxbyt[n:] 113 | return t.decode() 114 | 115 | # Set .txbyt to the required data. Return its size. So awrite returns 116 | # with transmission occurring in tha background. 117 | # uasyncio V3: Stream.drain() calls write with buf being a memoryview 118 | # and no off or sz args. 119 | def write(self, buf): 120 | if self.synchronised: 121 | if self.txbyt: # Initial call from awrite 122 | return 0 # Waiting for existing data to go out 123 | l = len(buf) 124 | self.txbyt = buf 125 | self.txsiz[0] = l & 0xff 126 | self.txsiz[1] = l >> 8 127 | return l 128 | return 0 129 | 130 | # User interface 131 | 132 | # Wait for sync 133 | async def ready(self): 134 | while not self.synchronised: 135 | await asyncio.sleep_ms(100) 136 | 137 | # Leave pin high in case we run again 138 | def close(self): 139 | self.own(1) 140 | 141 | 142 | # Responder is I2C master. It is cross-platform and uses machine. 143 | # It does not handle errors: if I2C fails it dies and awaits reset by initiator. 144 | # send_recv is triggered by Interrupt from Initiator. 145 | 146 | class Responder(Channel): 147 | addr = 0x12 148 | rxbufsize = 200 149 | 150 | def __init__(self, i2c, pin, pinack, verbose=True): 151 | super().__init__(i2c, pinack, pin, verbose, self.rxbufsize) 152 | loop = asyncio.get_event_loop() 153 | loop.create_task(self._run()) 154 | 155 | async def _run(self): 156 | await self._sync() # own pin ->0, wait for remote pin == 0 157 | self.rem.irq(handler=self._handler, trigger=machine.Pin.IRQ_RISING) 158 | 159 | # Request was received: immediately read payload size, then payload 160 | # On Pyboard blocks for 380μs to 1.2ms for small amounts of data 161 | def _handler(self, _, sn=bytearray(2), txnull=bytearray(2)): 162 | addr = Responder.addr 163 | self.rem.irq(handler=None) 164 | utime.sleep_us(_DELAY) # Ensure Initiator has set up to write. 165 | self.i2c.readfrom_into(addr, sn) 166 | self.own(1) 167 | self.waitfor(0) 168 | self.own(0) 169 | n = sn[0] + ((sn[1] & 0x7f) << 8) # no of bytes to receive 170 | if n > self.rxbufsize: 171 | raise ValueError('Receive data too large for buffer.') 172 | self.cantx = not bool(sn[1] & 0x80) # Can Initiator accept a payload? 173 | if n: 174 | self.waitfor(1) 175 | utime.sleep_us(_DELAY) 176 | mv = memoryview(self.rx_mv[0: n]) # allocates 177 | self.i2c.readfrom_into(addr, mv) 178 | self.own(1) 179 | self.waitfor(0) 180 | self.own(0) 181 | self._handle_rxd(mv) 182 | 183 | self.own(1) # Request to send 184 | self.waitfor(1) 185 | utime.sleep_us(_DELAY) 186 | dtx = self.txbyt != b'' and self.cantx # Data to send 187 | siz = self.txsiz if dtx else txnull 188 | if self.rxbyt: 189 | siz[1] |= 0x80 # Hold off Initiator TX 190 | else: 191 | siz[1] &= 0x7f 192 | self.i2c.writeto(addr, siz) # Was getting ENODEV occasionally on Pyboard 193 | self.own(0) 194 | self.waitfor(0) 195 | if dtx: 196 | self.own(1) 197 | self.waitfor(1) 198 | utime.sleep_us(_DELAY) 199 | self.i2c.writeto(addr, self.txbyt) 200 | self.own(0) 201 | self.waitfor(0) 202 | self._txdone() # Invalidate source 203 | self.rem.irq(handler=self._handler, trigger=machine.Pin.IRQ_RISING) 204 | -------------------------------------------------------------------------------- /pb_link/asi2c_i.py: -------------------------------------------------------------------------------- 1 | # asi2c_i.py A communications link using I2C slave mode on Pyboard. 2 | # Initiator class. Adapted for uasyncio V3, WBUS DIP28. 3 | 4 | # The MIT License (MIT) 5 | # 6 | # Copyright (c) 2018-2020 Peter Hinch 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | import uasyncio as asyncio 27 | import machine 28 | import utime 29 | import gc 30 | from .asi2c import Channel 31 | 32 | 33 | # The initiator is an I2C slave. It runs on a Pyboard. I2C uses pyb for slave 34 | # mode, but pins are instantiated using machine. 35 | # reset (if provided) is a means of resetting Responder in case of error: it 36 | # is (pin, active_level, ms) 37 | class Initiator(Channel): 38 | t_poll = 100 # ms between Initiator polling Responder 39 | rxbufsize = 200 40 | 41 | def __init__(self, i2c, pin, pinack, reset=None, verbose=True, 42 | cr_go=False, go_args=(), cr_fail=False, f_args=()): 43 | super().__init__(i2c, pin, pinack, verbose, self.rxbufsize) 44 | self.reset = reset 45 | self.cr_go = cr_go 46 | self.go_args = go_args 47 | self.cr_fail = cr_fail 48 | self.f_args = f_args 49 | if reset is not None: 50 | reset[0].init(mode=machine.Pin.OUT, value=not (reset[1])) 51 | # Self measurement 52 | self.nboots = 0 # No. of reboots of Responder 53 | self.block_max = 0 # Blocking times: max 54 | self.block_sum = 0 # Total 55 | self.block_cnt = 0 # Count 56 | asyncio.create_task(self._run()) 57 | 58 | def waitfor(self, val): # Wait for response for 1 sec 59 | tim = utime.ticks_ms() 60 | while not self.rem() == val: 61 | if utime.ticks_diff(utime.ticks_ms(), tim) > 1000: 62 | raise OSError 63 | 64 | async def reboot(self): 65 | self.close() # Leave own pin high 66 | if self.reset is not None: 67 | rspin, rsval, rstim = self.reset 68 | self.verbose and print('Resetting target.') 69 | rspin(rsval) # Pulse reset line 70 | await asyncio.sleep_ms(rstim) 71 | rspin(not rsval) 72 | 73 | async def _run(self): 74 | while True: 75 | # If hardware link exists reboot Responder 76 | await self.reboot() 77 | self.txbyt = b'' 78 | self.rxbyt = b'' 79 | await self._sync() 80 | await asyncio.sleep(1) # Ensure Responder is ready 81 | if self.cr_go: 82 | asyncio.create_task(self.cr_go(*self.go_args)) 83 | while True: 84 | gc.collect() 85 | try: 86 | tstart = utime.ticks_us() 87 | self._sendrx() 88 | t = utime.ticks_diff(utime.ticks_us(), tstart) 89 | except OSError: # Reboot remote. 90 | break 91 | await asyncio.sleep_ms(Initiator.t_poll) 92 | self.block_max = max(self.block_max, t) # self measurement 93 | self.block_cnt += 1 94 | self.block_sum += t 95 | self.nboots += 1 96 | if self.cr_fail: 97 | await self.cr_fail(*self.f_args) 98 | if self.reset is None: # No means of recovery 99 | raise OSError('Responder fail.') 100 | 101 | def _send(self, d): 102 | # CRITICAL TIMING. Trigger interrupt on responder immediately before 103 | # send. Send must start before RX begins. Fast responders may need to 104 | # do a short blocking wait to guarantee this. 105 | self.own(1) # Trigger interrupt. 106 | self.i2c.send(d) # Blocks until RX complete. 107 | self.waitfor(1) 108 | self.own(0) 109 | self.waitfor(0) 110 | 111 | # Send payload length (may be 0) then payload (if any) 112 | def _sendrx(self, sn=bytearray(2), txnull=bytearray(2)): 113 | siz = self.txsiz if self.cantx else txnull 114 | if self.rxbyt: 115 | siz[1] |= 0x80 # Hold off further received data 116 | else: 117 | siz[1] &= 0x7f 118 | self._send(siz) 119 | if self.txbyt and self.cantx: 120 | self._send(self.txbyt) 121 | self._txdone() # Invalidate source 122 | # Send complete 123 | self.waitfor(1) # Wait for responder to request send 124 | self.own(1) # Acknowledge 125 | self.i2c.recv(sn) 126 | self.waitfor(0) 127 | self.own(0) 128 | n = sn[0] + ((sn[1] & 0x7f) << 8) # no of bytes to receive 129 | if n > self.rxbufsize: 130 | raise ValueError('Receive data too large for buffer.') 131 | self.cantx = not bool(sn[1] & 0x80) 132 | if n: 133 | self.waitfor(1) # Wait for responder to request send 134 | self.own(1) # Acknowledge 135 | mv = self.rx_mv[0: n] # mv is a memoryview instance 136 | self.i2c.recv(mv) 137 | self.waitfor(0) 138 | self.own(0) 139 | self._handle_rxd(mv) 140 | -------------------------------------------------------------------------------- /pb_link/config.py: -------------------------------------------------------------------------------- 1 | # config.py Config file for Pyboard IOT client 2 | 3 | # Copyright (c) Peter Hinch 2018 4 | # Released under the MIT licence. Full text in root of this repository. 5 | 6 | # Modify this file for local network, server, pin usage 7 | 8 | # config list is shared by Pyboard and by server 9 | # config elements by list index: 10 | # 0. Port (integer). Default 8123. 11 | # 1. Server IP (string). 12 | # 2. Server timeout in ms (int). Default 1500. 13 | # 3. Send reports every N seconds (0: never) (int). 14 | # 4. SSID (str). 15 | # 5. Password (str). 16 | # Use empty string ('') in SSID and PW to only connect to the WLAN which the 17 | # ESP8266 already "knows". 18 | # If Port or Timeout are changed, these values must be passed to server.run() 19 | config = [8123, '192.168.0.42', 2000, 10, 'MY_WIFI_SSID', 'PASSWORD'] 20 | 21 | try: 22 | from pyb import I2C # Only pyb supports slave mode 23 | from machine import Pin # rst Pin must be instantiated by machine 24 | except ImportError: # Running on server 25 | pass 26 | else: # Pyboard configuration 27 | # I2C instance may be hard or soft. 28 | _i2c = I2C(1, mode=I2C.SLAVE) 29 | # Pins are arbitrary. 30 | _syn = Pin('X11') 31 | _ack = Pin('Y8') 32 | # Reset tuple (Pin, level, pulse length in ms). 33 | # Reset ESP8266 with a 0 level for 200ms. 34 | _rst = (Pin('X12'), 0, 200) 35 | hardware = [_i2c, _syn, _ack, _rst] 36 | -------------------------------------------------------------------------------- /pb_link/pb_client.py: -------------------------------------------------------------------------------- 1 | # pb_client.py Run on Pyboard/STM device. Communicate with IOT server via an 2 | # ESP8266 running esp_link.py 3 | 4 | # Copyright (c) Peter Hinch 2018-2020 5 | # Released under the MIT licence. Full text in root of this repository. 6 | 7 | # Communication uses I2C slave mode. 8 | 9 | import uasyncio as asyncio 10 | import ujson 11 | from . import app_base 12 | try: 13 | from . import my_config as cfg # My private config 14 | except ImportError: 15 | from . import config as cfg 16 | 17 | # Server-side connection ID: any newline-terminated string not containing an 18 | # internal newline. 19 | CONN_ID = '1\n' 20 | 21 | 22 | # User application: must be class subclassed from AppBase 23 | class App(app_base.AppBase): 24 | def __init__(self, conn_id, config, hardware, verbose): 25 | super().__init__(conn_id, config, hardware, verbose) 26 | 27 | def start(self): # Apps must implement a synchronous start method 28 | asyncio.create_task(self.receiver()) 29 | asyncio.create_task(self.sender()) 30 | 31 | # If server is running s_app_cp.py it sends 32 | # [approx app uptime in secs/5, echoed count, echoed 99] 33 | async def receiver(self): 34 | self.verbose and print('Starting receiver.') 35 | while True: 36 | line = await self.readline() 37 | data = ujson.loads(line) 38 | self.verbose and print('Received', data) 39 | 40 | async def sender(self): 41 | self.verbose and print('Starting sender.') 42 | data = [42, 0, 99] # s_app_cp.py expects a 3-list 43 | while True: 44 | await asyncio.sleep(5) 45 | data[1] += 1 46 | await self.write(ujson.dumps(data)) 47 | self.verbose and print('Sent', data) 48 | 49 | app = None 50 | 51 | async def main(): 52 | global app 53 | app = App(CONN_ID, cfg.config, cfg.hardware, True) 54 | await app.await_msg() # Run forever awaiting messages from server 55 | 56 | try: 57 | asyncio.run(main()) 58 | finally: 59 | app.close() # for subsequent runs 60 | asyncio.new_event_loop() 61 | -------------------------------------------------------------------------------- /pb_link/s_app.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | 4 | # s_app_cp.py Server-side application demo 5 | # Run under CPython 3.8 or later. 6 | # MicroPython daily build or V1.13 or later. 7 | 8 | # Released under the MIT licence. 9 | # Copyright (C) Peter Hinch 2018-2020 10 | 11 | # The App class emulates a user application intended to service a single 12 | # client. In this case we have four instances of the application servicing 13 | # clients with ID's 1-4. 14 | 15 | try: 16 | import asyncio 17 | except ImportError: 18 | import uasyncio as asyncio 19 | try: 20 | import json 21 | except ImportError: 22 | import ujson as json 23 | 24 | from iot import server 25 | 26 | PORT = 8123 27 | TIMEOUT = 2000 28 | 29 | 30 | class App: 31 | def __init__(self, client_id): 32 | self.client_id = client_id # This instance talks to this client 33 | self.conn = None # Connection instance 34 | self.data = [0, 0, 0] # Exchange a 3-list with remote 35 | asyncio.create_task(self.start()) 36 | 37 | async def start(self): 38 | print('Client {} Awaiting connection.'.format(self.client_id)) 39 | self.conn = await server.client_conn(self.client_id) 40 | asyncio.create_task(self.reader()) 41 | asyncio.create_task(self.writer()) 42 | 43 | async def reader(self): 44 | print('Started reader') 45 | while True: 46 | line = await self.conn.readline() # Pause in event of outage 47 | try: 48 | self.data = json.loads(line) 49 | except ValueError: 50 | print('discarding line', line) # Defensive. I think it never happens. 51 | continue 52 | # Receives [restart count, uptime in secs, mem_free] 53 | print('Got', self.data, 'from remote', self.client_id) 54 | 55 | # Send 56 | # [approx app uptime in secs/5, received client uptime, received mem_free] 57 | async def writer(self): 58 | print('Started writer') 59 | count = 0 60 | while True: 61 | self.data[0] = count 62 | count += 1 63 | print('Sent', self.data, 'to remote', self.client_id, '\n') 64 | # .write() behaves as per .readline() 65 | await self.conn.write(json.dumps(self.data)) 66 | await asyncio.sleep(5) 67 | 68 | 69 | async def main(): 70 | clients = {'1', '2', '3', '4'} 71 | apps = [App(n) for n in clients] # Accept 4 clients with ID's 1-4 72 | await server.run(clients, True, port=PORT, timeout=TIMEOUT) 73 | 74 | def run(): 75 | try: 76 | asyncio.run(main()) 77 | except KeyboardInterrupt: 78 | print('Interrupted') 79 | finally: 80 | print('Closing sockets') 81 | server.Connection.close_all() 82 | asyncio.new_event_loop() 83 | 84 | 85 | if __name__ == "__main__": 86 | run() 87 | --------------------------------------------------------------------------------