├── .gitignore ├── ANALYSER.md ├── LICENSE ├── README.md ├── analyser ├── analyser.py └── hardware_setup.py ├── images ├── full_test.jpg ├── la_hw.jpg ├── la_ui.jpg ├── latency.jpg ├── monitor.jpg ├── monitor_gc.jpg ├── monitor_hw.JPG ├── syn_test.jpg ├── syn_time.jpg ├── uart_problem.png └── utx0.png ├── monitor.py ├── monitor_pico.py └── tests ├── full_test.py ├── isr.py ├── latency.py ├── looping.py ├── quick_test.py ├── syn_test.py ├── syn_time.py └── two_core.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /ANALYSER.md: -------------------------------------------------------------------------------- 1 | # Displaying monitor information 2 | 3 | The monitor project enables the study of running realtime systems with ways of 4 | triggering on infrequent fault conditions. Up to now it has required a logic 5 | analyser or a scope (preferably with more than two channels) to display the 6 | results. 7 | 8 | This alternative back-end uses a Pico and an inexpensive display to show up to 9 | eight data channels. Component cost should be under $20. This solution has no 10 | benefits compared to a logic analyser with pre-trigger data capture: it is a 11 | low-cost alternative. 12 | 13 | ![Image](./images/la_hw.jpg) 14 | 15 | The device under test runs `monitor.py` as per the docs. 16 | 17 | Key features are: 18 | 1. Flexible triggering options allow detection of rare events in a realtime 19 | system including slow running code or CPU hogging. 20 | 2. Substantial data capture pre- and post-tigger enabling the circumstances 21 | leading to the trigger event to be studied. 22 | 3. Timing measurements may be made using a pair of cursors. 23 | 4. Data may be sideways-scrolled using the encoder. 24 | 5. Zoom in and out buttons. 25 | 26 | Limitations: 27 | 1. No realtime display: the device captures a set of samples which may then be 28 | displayed. 29 | 2. Timing measurements are relatively imprecise (tens of μs). 30 | 31 | User interface: 32 | ![Image](./images/la_ui.jpg) 33 | 34 | This shows a capture of data from a realtime system with two measurement 35 | cursors visible. 36 | 37 | # Project status 38 | 39 | I developed this from a sense of curiosity as to whether it could be done on a 40 | Pico. It works but with some rough edges around the user interface. I remain 41 | unconvinced if it has any practical application: its only merit compared with a 42 | logic analyser is low cost. Saleae clones can be bought cheaply however the 43 | example I have cannot display pre-trigger information making it almost useless. 44 | If a device crashes you want to see events in the run-up to the crash. This 45 | project does provide pre-trigger data. A genuine Saleae is a highly capable 46 | device - if you can justfy the cost. 47 | 48 | I don't intend to put any more effort into this unless serious interest is 49 | expressed. 50 | 51 | # Hardware 52 | 53 | The display is a 2.4" 320x240 ILI9341 unit from eBay. A Pico, two pushbuttons, 54 | and an incremental encoder with pushbutton complete the unit. Wiring is as 55 | below. Power is via USB to the Pico. 56 | 57 | | Pin | GPIO | Signal | Device | 58 | |:---:|:----:|:-------:|:-------:| 59 | | 9 | 6 | Sck | Display | 60 | | 10 | 7 | Mosi | " | 61 | | 11 | 8 | d/c | " | 62 | | 12 | 9 | Rst | " | 63 | | 13 | Gnd | Gnd | " | 64 | | 14 | 10 | cs/ | " | 65 | | 36 | 3V3 | V+ | " | 66 | | 21 | 16 | Select | Button | 67 | | 22 | 17 | X | Encoder | 68 | | 24 | 18 | Prev | Button | 69 | | 25 | 19 | Next | Button | 70 | | 26 | 20 | Y | Encoder | 71 | 72 | Pushbuttons and encoder are connected to Gnd. I use 1KΩ pullups to 3.3V but you 73 | may use the internal pin pullups. In my example the Select button is 74 | incorporated in the encoder as a push to select button. 75 | 76 | ## UART connection 77 | 78 | Wiring: 79 | 80 | | DUT | GPIO | Pin | 81 | |:---:|:----:|:---:| 82 | | Gnd | Gnd | 3 | 83 | | txd | 1 | 2 | 84 | 85 | 86 | ## SPI connection 87 | 88 | Wiring: 89 | 90 | | DUT | GPIO | Pin | 91 | |:-----:|:----:|:---:| 92 | | Gnd | Gnd | 3 | 93 | | mosi | 0 | 1 | 94 | | sck | 1 | 2 | 95 | | cs | 2 | 4 | 96 | 97 | # Installation 98 | 99 | The first step is to install micro gui according to 100 | [the docs](https://github.com/peterhinch/micropython-micro-gui). Copy 101 | `hardware_setup.py` and `analyser.py` from the `analyser` directory to the root 102 | of the Pico's filesystem. Run one or more micro gui test scripts and verify 103 | that the buttons and encoder work correctly. The module can be tested with 104 | simulated data by issuing the following at the REPL: 105 | 106 | ``` 107 | import analyser 108 | analyser.run() 109 | ``` 110 | 111 | ## Running 112 | 113 | The `run` command takes one optional arg: 114 | 115 | `device="demo"` Alternatively "spi" or "uart". In demo mode it generates 116 | synthetic random data, otherwise expects data on the specified interface. 117 | 118 | # GUI guide 119 | 120 | The analyser operates in two modes: acquisition and display. When the `acquire` 121 | button is pressed it enters acquisition mode and the GUI becomes inoperative. 122 | When a trigger condition occurs it enters display mode and the data may be 123 | examined. 124 | 125 | ## Main window 126 | 127 | When this has focus (white border) the data may be scrolled horizontally with 128 | the encoder. A long press on the encoder changes the border to yellow and gives 129 | finer control. Pressing the `Home` button centres visible data on the trigger. 130 | Individual signals may have values 0, 1 or unknown: the state of a signal is 131 | regarded as unknown until a value arrives from the DUT. This convention causes 132 | unused signals to appear blank improving the clarity of the display. It also 133 | enables determination of the arrival time of the first messaged from the DUT. 134 | 135 | ## Zoom 136 | 137 | The main window may be zoomed using the `+` and `-` buttons. The labels 138 | immediately beneath the main window indicate the start and end times of the 139 | visible data. These are in ms relative to the first event in the buffer. 140 | 141 | There is a limit to zooming out, at which point the `-` button will become 142 | greyed-out and unavailable. 143 | 144 | ## Acquisition and Triggering 145 | 146 | Trigger conditions are associated with channel 0. 147 | 148 | When the `Acquire` button is pressed, incoming events are buffered until either 149 | a trigger condition occurs or the `Next` physical button is pressed. Thus `Next` 150 | can always be used to terminate acquisition. The buffer is a circular buffer 151 | holding a maximum of 2K events. 152 | 153 | When a trigger condition occurs, further data is acquired for 1 second or until 154 | another half a buffer of data arrives (whichever occurs first). This ensures 155 | that half a buffer full of pre-trigger data is retained. 156 | 157 | Trigger condions are selected by the dropdown and are as follows: 158 | 159 | 1. "Next" Data is acquired until the `Next` hardware button is pressed. 160 | 2. "Ch 0" Trigger if a high level on channel 0 exceeds the duration of the 161 | timer. 162 | 3. "Timer" Trigger if no event occurs on channel 0 for a period exceeding the 163 | timer duration. 164 | 4. "Hog end" Trigger if no event occurs on channel 0 for a period exceeding 165 | the timer duration, but only when activity restarts. The trigger point is the 166 | first event after the period of inactivity. 167 | 168 | Modes 3 and 4 are primarily intended for the detection of CPU hogging where 169 | channel 0 on the DUT runs the `hog_detect` task. Mode 3 is particularly useful 170 | to detect long durations of hogging or crashing. Typical use of mode 2 is where 171 | the DUT runs the `trigger` closure to detect sections of slow running code. 172 | 173 | The trigger point is indicated by a vertical blue line in the main window. The 174 | display may be centred on this by pressing the `Home` button. 175 | 176 | ## Timer 177 | 178 | Triggering is controlled by a timer controlled by an `Adjuster` widget to the 179 | right of the timer `Label`. When it has the focus (white border) it is 180 | controlled by the encoder. Fine control may be achieved with a long press on 181 | `Select` when the border turns yellow. Times are in ms in the range 1-1001. 182 | 183 | ## Cursors 184 | 185 | Two cursors may be created, marked by vertical red lines in the main window. 186 | These facilitate "absolute" and relative time measurements. Absolute times are 187 | with reference to the arbitrary start used in the labels below the main screen. 188 | Times are in ms. Accuracy is on the order of tens of μs. 189 | 190 | The cursor `Adjuster` widgets can perform fine control as per the Timer one 191 | above. 192 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 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 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 2 | 3 | # 1. A monitor for realtime MicroPython code 4 | 5 | This library provides a means of examining the behaviour of a running system. 6 | It was initially designed to characterise `asyncio` programs but may also be 7 | used to study any code whose behaviour may change dynamically such as threaded 8 | code or applications using interrupts. 9 | 10 | The device under test (DUT) is linked to a Raspberry Pico. The latter displays 11 | the behaviour of the DUT by pin changes and optional print statements. A logic 12 | analyser or scope provides a view of the realtime behaviour of the code. 13 | Valuable information can also be gleaned at the Pico command line. 14 | 15 | An [analyser back-end](./ANALYSER.md) is provided for users lacking a logic 16 | analyser or multi-channel scope: 17 | ![Image](./images/la_ui.jpg) 18 | 19 | A Pico and a cheap display emulates a logic analyser style display. I'm unsure 20 | whether this has practical application: I suspect most asynchronous coders 21 | already have test gear. The rest of this document describes use with a logic 22 | analyser. 23 | 24 | Where an application runs multiple concurrent tasks it can be difficult to 25 | identify a task which is hogging CPU time. Long blocking periods can also occur 26 | when several tasks each block for a period. If these happen to be scheduled in 27 | succession, the times will add: this may occur at unpredictable, infrequent 28 | intervals. To capture these events the monitor issues a trigger pulse when the 29 | blocking period exceeds a threshold. The threshold can be a fixed time or the 30 | current maximum blocking period. A logic analyser enables the state at the time 31 | of the transient event to be examined. 32 | 33 | This image shows the detection of CPU hogging. In this example a task hogs the 34 | CPU for 500ms, causing the scheduler to be unable to schedule other tasks. A 35 | trigger pulse is generated by the Pico 100ms after hogging started. This script 36 | is discussed in detail in [section 6](./README.md#6-test-and-demo-scripts). 37 | 38 | ![Image](./images/monitor.jpg) 39 | 40 | The following image shows brief (<4ms) hogging while `quick_test.py` ran. The 41 | likely cause is garbage collection on the Pyboard D DUT. The monitor was able 42 | to demonstrate that this never exceeded 5ms. 43 | 44 | ![Image](./images/monitor_gc.jpg) 45 | 46 | ### Threaded and RP2 dual core applications 47 | 48 | The monitor is designed to work with asynchronous systems based on `asyncio`, 49 | threading and interrupts. It also supports dual-core applications on RP2, but 50 | the device under test should run firmware V1.19 or later. 51 | 52 | ## 1.1 Concepts 53 | 54 | Communication with the Pico may be by UART or SPI, and is uni-directional from 55 | DUT to Pico. If a UART is used only one GPIO pin is needed. SPI requires three, 56 | namely `mosi`, `sck` and `cs/`. 57 | 58 | The Pico runs `monitor_pico.py` typically invoked with: 59 | ```python 60 | from monitor_pico import run 61 | run() # or run(device="spi") 62 | ``` 63 | Debug statements are inserted at key points in the DUT code. These cause state 64 | changes on Pico pins. All debug lines are associated with an `ident` which is a 65 | number where `0 <= ident <= 21`. The `ident` value defines a Pico GPIO pin 66 | according to the mapping in [section 5.1](./README.md#51-pico-pin-mapping). 67 | 68 | For example the following will cause a pulse on GPIO6. 69 | ```python 70 | import monitor 71 | trig1 = monitor.trigger(1) # Create a trigger on ident 1 72 | 73 | async def test(): 74 | while True: 75 | await asyncio.sleep_ms(100) 76 | trig1() # Pulse appears now 77 | ``` 78 | A decorator may be inserted prior to a function definition; in `asyncio` code 79 | a coroutine definition may be decorated. These cause a Pico pin to go high for 80 | the duration whenever the function or coroutine runs. Other mechanisms offer 81 | means of measuring cpu hogging. 82 | 83 | The Pico can output a trigger pulse on GPIO28 which may be used to trigger a 84 | scope or logic analyser. This can be configured to occur when excessive latency 85 | arises or when a segment of code runs unusually slowly. This helps identify the 86 | cause of the problem. 87 | 88 | ## 1.2 Pre-requisites 89 | 90 | The DUT and the Pico must run firmware V1.17 or later. 91 | 92 | ## 1.3 Installation 93 | 94 | Copy `monitor.py` to the DUT filesystem. Copy `monitor_pico.py` to the Pico. 95 | 96 | ## 1.4 UART connection 97 | 98 | Wiring. Pins for the DUT are not specified as there are many MicroPython 99 | platforms and any UART may be used. Pins are those on the Pico performing the 100 | monitoring. 101 | 102 | | DUT | Pico GPIO | Pico Pin | Pico signal | 103 | |:---:|:----------|:---------|:------------| 104 | | Gnd | Gnd | 3 | Gnd | 105 | | txd | 1 | 2 | Uart 0 Rxd | 106 | 107 | The DUT is configured to use a UART by passing an initialised UART with 1MHz 108 | baudrate to `monitor.set_device`: 109 | 110 | ```python 111 | from machine import UART 112 | import monitor 113 | monitor.set_device(UART(2, 1_000_000)) # Baudrate MUST be 1MHz. 114 | ``` 115 | The Pico `run()` command assumes a UART by default. 116 | 117 | ## 1.5 SPI connection 118 | 119 | Wiring: 120 | 121 | | DUT | GPIO | Pin | 122 | |:-----:|:----:|:---:| 123 | | Gnd | Gnd | 3 | 124 | | mosi | 0 | 1 | 125 | | sck | 1 | 2 | 126 | | cs | 2 | 4 | 127 | 128 | The DUT is configured to use SPI by passing an initialised SPI instance and a 129 | `cs/` Pin instance to `set_device`: 130 | ```python 131 | from machine import Pin, SPI 132 | import monitor 133 | monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X6', Pin.OUT)) # Device under test SPI 134 | ``` 135 | The SPI instance must have default args; the one exception being baudrate which 136 | may be any value. I have tested up to 30MHz but there is no benefit in running 137 | above 1MHz. Hard or soft SPI may be used. It should be possible to share the 138 | bus with other devices, although I haven't tested this. 139 | 140 | The Pico should be started with 141 | ```python 142 | monitor_pico.run(device="spi") 143 | ``` 144 | 145 | ## 1.6 Quick start 146 | 147 | This example assumes a UART connection. On the Pico issue: 148 | ```python 149 | from monitor_pico import run 150 | run() 151 | ``` 152 | The Pico should issue "Awaiting communication." 153 | 154 | On the DUT some boilerplate code must be added to the application to enable 155 | monitoring. Lines labelled MANDATORY represent this added code. 156 | ```python 157 | import asyncio as asyncio 158 | from machine import UART # MANDATORY Use a UART for monitoring 159 | import monitor # MANDATORY 160 | monitor.set_device(UART(2, 1_000_000)) # MANDATORY Baudrate MUST be 1MHz. 161 | 162 | @monitor.asyn(1) # Assign ident 1 to foo (GPIO 4) 163 | async def foo(): 164 | await asyncio.sleep_ms(100) 165 | 166 | async def main(): 167 | monitor.init() # MANDATORY Initialise Pico state at the start of every run 168 | while True: 169 | await foo() # Pico GPIO4 will go high for duration 170 | await asyncio.sleep_ms(100) 171 | 172 | try: 173 | asyncio.run(main()) 174 | finally: 175 | asyncio.new_event_loop() 176 | ``` 177 | When this runs the Pico should issue "Got communication." and a square wave of 178 | period 200ms should be observed on Pico GPIO 4 (pin 6). 179 | 180 | Example script `quick_test.py` provides a usage example. It may be adapted to 181 | use a UART or SPI interface: see commented-out code. 182 | 183 | # 2. Monitoring 184 | 185 | An application to be monitored should first define the interface: 186 | ```python 187 | from machine import UART # Using a UART for monitoring 188 | import monitor 189 | monitor.set_device(UART(2, 1_000_000)) # Baudrate MUST be 1MHz. 190 | ``` 191 | or 192 | ```python 193 | from machine import Pin, SPI 194 | import monitor 195 | # Pass a configured SPI interface and a cs/ Pin instance. 196 | monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) 197 | ``` 198 | The pin used for `cs/` is arbitrary. 199 | 200 | Each time the application runs it should issue: 201 | ```python 202 | def main(): 203 | monitor.init() 204 | # rest of application code 205 | ``` 206 | This ensures that the Pico code assumes a known state, even if a prior run 207 | crashed, was interrupted or failed. 208 | 209 | ## 2.1 Validation of idents 210 | 211 | Re-using idents would lead to confusing behaviour. If an ident is out of range 212 | or is assigned to more than one coroutine an error message is printed and 213 | execution terminates. 214 | 215 | # 3. Monitoring asyncio code 216 | 217 | ## 3.1 Monitoring coroutines 218 | 219 | Coroutines to be monitored are prefixed with the `@monitor.asyn` decorator: 220 | ```python 221 | @monitor.asyn(2, 3) 222 | async def my_coro(): 223 | # code 224 | ``` 225 | The decorator positional args are as follows: 226 | 1. `ident` A unique number in range `0 <= ident <= 21` for the code being 227 | monitored. Determines the pin number on the Pico. See 228 | [section 5.1](./README.md#51-pico-pin-mapping). 229 | 2. `max_instances=1` Defines the maximum number of concurrent instances of the 230 | task to be independently monitored (default 1). 231 | 3. `verbose=True` If `False` suppress the warning which is printed on the DUT 232 | if the instance count exceeds `max_instances`. 233 | 4. `looping=False` Set `True` if the decorator is called repeatedly e.g. 234 | decorating a nested function or method. The `True` value ensures validation of 235 | the ident occurs once only when the decorator first runs. 236 | 237 | Whenever the coroutine runs, a pin on the Pico will go high, and when the code 238 | terminates it will go low. This enables the behaviour of the system to be 239 | viewed on a logic analyser or via console output on the Pico. This behavior 240 | works whether the code terminates normally, is cancelled or has a timeout. 241 | 242 | In the example above, when `my_coro` starts, the pin defined by `ident==2` 243 | (GPIO 5) will go high. When it ends, the pin will go low. If, while it is 244 | running, a second instance of `my_coro` is launched, the next pin (GPIO 6) will 245 | go high. Pins will go low when the relevant instance terminates, is cancelled, 246 | or times out. If more instances are started than were specified to the 247 | decorator, a warning will be printed on the DUT. All excess instances will be 248 | associated with the final pin (`pins[ident + max_instances - 1]`) which will 249 | only go low when all instances associated with that pin have terminated. 250 | 251 | Consequently if `max_instances=1` and multiple instances are launched, a 252 | warning will appear on the DUT; the pin will go high when the first instance 253 | starts and will not go low until all have ended. The purpose of the warning is 254 | because the existence of multiple instances may be unexpected behaviour in the 255 | application under test - it does not imply a problem with the monitor. 256 | 257 | ## 3.2 Detecting CPU hogging 258 | 259 | A common cause of problems in asynchronous code is the case where a task blocks 260 | for a period, hogging the CPU, stalling the scheduler and preventing other 261 | tasks from running. Determining the task responsible can be difficult, 262 | especially as excessive latency may only occur when several greedy tasks are 263 | scheduled in succession. 264 | 265 | The Pico pin state only indicates that the task is running. A high pin does not 266 | imply CPU hogging. Thus 267 | ```python 268 | @monitor.asyn(3) 269 | async def long_time(): 270 | await asyncio.sleep(30) 271 | ``` 272 | will cause the pin to go high for 30s, even though the task is consuming no 273 | resources for that period. 274 | 275 | To provide a clue about CPU hogging, a `hog_detect` coroutine is provided. This 276 | has `ident=0` and, if used, is monitored on GPIO3. It loops, yielding to the 277 | scheduler. It will therefore be scheduled in round-robin fashion at speed. If 278 | long gaps appear in the pulses on GPIO3, other tasks are hogging the CPU. 279 | Usage of this is optional. To use, issue 280 | ```python 281 | import asyncio as asyncio 282 | import monitor 283 | # code omitted 284 | asyncio.create_task(monitor.hog_detect()) 285 | # code omitted 286 | ``` 287 | To aid in detecting the gaps in execution, in its default mode the Pico code 288 | implements a timer. This is retriggered by activity on `ident=0`. If it times 289 | out, a brief high going pulse is produced on GPIO 28, along with the console 290 | message "Hog". The pulse can be used to trigger a scope or logic analyser. The 291 | duration of the timer may be adjusted. Other modes of hog detection are also 292 | supported, notably producing a trigger pulse only when the prior maximum was 293 | exceeded. See [section 5](./README.md#5-Pico). 294 | 295 | # 4. Monitoring arbitrary code 296 | 297 | The following features may be used to characterise synchronous or asynchronous 298 | applications by causing Pico pin changes at specific points in code execution. 299 | 300 | The following are provided: 301 | * A `sync` decorator for synchronous functions or methods: like `asyn` it 302 | monitors every call to the function. 303 | * A `mon_call` context manager enables function monitoring to be restricted to 304 | specific calls. 305 | * A `trigger` closure which issues a brief pulse on the Pico or can set and 306 | clear the pin on demand. 307 | 308 | ## 4.1 The sync decorator 309 | 310 | This works as per the `@async` decorator, but with no `max_instances` arg. The 311 | following example will activate GPIO 26 (associated with ident 20) for the 312 | duration of every call to `sync_func()`: 313 | ```python 314 | @monitor.sync(20) 315 | def sync_func(): 316 | pass 317 | ``` 318 | Decorator args: 319 | 1. `ident` 320 | 2. `looping=False` Set `True` if the decorator is called repeatedly e.g. in a 321 | nested function or method. The `True` value ensures validation of the ident 322 | occurs once only when the decorator first runs. 323 | 324 | ## 4.2 The mon_call context manager 325 | 326 | This may be used to monitor a function only when called from specific points in 327 | the code. Since context managers may be used in a looping construct the ident 328 | is only checked for conflicts when the CM is first instantiated. 329 | 330 | Usage: 331 | ```python 332 | def another_sync_func(): 333 | pass 334 | 335 | with monitor.mon_call(22): 336 | another_sync_func() 337 | ``` 338 | 339 | It is advisable not to use the context manager with a function having the 340 | `mon_func` decorator. The behaviour of pins and reports are confusing. 341 | 342 | ## 4.3 The trigger timing marker 343 | 344 | The `trigger` closure is intended for timing blocks of code. A closure instance 345 | is created by passing the ident. If the instance is run with no args a brief 346 | (~80μs) pulse will occur on the Pico pin. If `True` is passed, the pin will go 347 | high until `False` is passed. 348 | 349 | The closure should be instantiated in the outermost scope. The instance may be 350 | called in a hard ISR context: this facilitates measuring the effect on system 351 | performance of ISR overhead. Also measuring the time between a hard ISR running 352 | and a section of code responding. See `tests/isr.py`. 353 | ```python 354 | trig = monitor.trigger(10) # Associate trig with ident 10. 355 | 356 | def foo(): 357 | trig() # Pulse ident 10, GPIO 13 358 | 359 | def bar(): 360 | trig(True) # set pin high 361 | # code omitted 362 | trig(False) # set pin low 363 | ``` 364 | ## 4.4 Timing of code segments 365 | 366 | It can be useful to time the execution of a specific block of code especially 367 | if the duration varies in real time. It is possible to cause a message to be 368 | printed and a trigger pulse to be generated whenever the execution time exceeds 369 | the prior maximum. A scope or logic analyser may be triggered by this pulse 370 | allowing the state of other components of the system to be checked. 371 | 372 | This is done by re-purposing ident 0 as follows: 373 | ```python 374 | trig = monitor.trigger(0) 375 | def foo(): 376 | # code omitted 377 | trig(True) # Start of code block 378 | # code omitted 379 | trig(False) 380 | ``` 381 | See [section 5.5](./README.md#55-timing-of-code-segments) for the Pico usage 382 | and demo `syn_time.py`. 383 | 384 | # 5. Pico 385 | 386 | # 5.1 Pico pin mapping 387 | 388 | The Pico GPIO numbers used by idents start at 3 and have a gap where the Pico 389 | uses GPIO's for particular purposes. This is the mapping between `ident` GPIO 390 | no. and Pico PCB pin. Pins for the trigger and the UART/SPI link are also 391 | identified: 392 | 393 | | ident | GPIO | pin | 394 | |:-------:|:----:|:----:| 395 | | nc/mosi | 0 | 1 | 396 | | rxd/sck | 1 | 2 | 397 | | nc/cs/ | 2 | 4 | 398 | | 0 | 3 | 5 | 399 | | 1 | 4 | 6 | 400 | | 2 | 5 | 7 | 401 | | 3 | 6 | 9 | 402 | | 4 | 7 | 10 | 403 | | 5 | 8 | 11 | 404 | | 6 | 9 | 12 | 405 | | 7 | 10 | 14 | 406 | | 8 | 11 | 15 | 407 | | 9 | 12 | 16 | 408 | | 10 | 13 | 17 | 409 | | 11 | 14 | 19 | 410 | | 12 | 15 | 20 | 411 | | 13 | 16 | 21 | 412 | | 14 | 17 | 22 | 413 | | 15 | 18 | 24 | 414 | | 16 | 19 | 25 | 415 | | 17 | 20 | 26 | 416 | | 18 | 21 | 27 | 417 | | 19 | 22 | 29 | 418 | | 20 | 26 | 31 | 419 | | 21 | 27 | 32 | 420 | | trigger | 28 | 34 | 421 | 422 | ## 5.2 The Pico code 423 | 424 | Monitoring via the UART with default behaviour is started as follows: 425 | ```python 426 | from monitor_pico import run 427 | run() 428 | ``` 429 | By default the Pico retriggers a timer every time ident 0 becomes active. If 430 | the timer times out, a pulse appears on GPIO28 which may be used to trigger a 431 | scope or logic analyser. This is intended for use with the `hog_detect` coro, 432 | with the pulse occurring when excessive latency is encountered. 433 | 434 | ## 5.3 The Pico run function 435 | 436 | Arguments to `run()` can select the interface and modify the default behaviour. 437 | 1. `period=100` Define the hog_detect timer period in ms. A 2-tuple may also 438 | be passed for specialised reporting, see below. 439 | 2. `verbose=()` A list or tuple of `ident` values which should produce console 440 | output. A passed ident will produce console output each time that task starts 441 | or ends. 442 | 3. `device="uart"` Set to `"spi"` for an SPI interface. 443 | 4. `vb=True` By default the Pico issues console messages reporting on initial 444 | communication status, repeated each time the application under test restarts. 445 | Set `False` to disable these messages. 446 | 447 | Thus to run such that idents 4 and 7 produce console output, with hogging 448 | reported if blocking is for more than 60ms, issue 449 | ```python 450 | from monitor_pico import run 451 | run(60, (4, 7)) 452 | ``` 453 | Hog reporting is as follows. If ident 0 is inactive for more than the specified 454 | time, "Timeout" is issued. If ident 0 occurs after this, "Hog Nms" is issued 455 | where N is the duration of the outage. If the outage is longer than the prior 456 | maximum, "Max hog Nms" is also issued. 457 | 458 | This means that if the application under test terminates, throws an exception 459 | or crashes, "Timeout" will be issued. 460 | 461 | ## 5.4 Advanced hog detection 462 | 463 | The detection of rare instances of high latency is a key requirement and other 464 | modes are available. There are two aims: providing information to users lacking 465 | test equipment and enhancing the ability to detect infrequent cases. Modes 466 | affect the timing of the trigger pulse and the frequency of reports. 467 | 468 | Modes are invoked by passing a 2-tuple as the `period` arg. 469 | * `period[0]` The period (ms): outages shorter than this time will be ignored. 470 | * `period[1]` is the mode: constants `SOON`, `LATE` and `MAX` are exported. 471 | 472 | The mode has the following effect on the trigger pulse: 473 | * `SOON` Default behaviour: pulse occurs early at time `period[0]` ms after 474 | the last trigger. 475 | * `LATE` Pulse occurs when the outage ends. 476 | * `MAX` Pulse occurs when the outage ends and its duration exceeds the prior 477 | maximum. 478 | 479 | The mode also affects reporting. The effect of mode is as follows: 480 | * `SOON` Default behaviour as described in section 4. 481 | * `LATE` As above, but no "Timeout" message: reporting occurs at the end of an 482 | outage only. 483 | * `MAX` Report at end of outage but only when prior maximum exceeded. This 484 | ensures worst-case is not missed. 485 | 486 | Running the following produce instructive console output: 487 | ```python 488 | from monitor_pico import run, MAX 489 | run((1, MAX)) 490 | ``` 491 | ## 5.5 Timing of code segments 492 | 493 | This may be done by issuing: 494 | ```python 495 | from monitor_pico import run, WIDTH 496 | run((20, WIDTH)) # Ignore widths < 20ms. 497 | ``` 498 | Assuming that ident 0 is used as described in 499 | [section 4.4](./README.md#44-timing-of-code-segments) a trigger pulse on GPIO28 500 | will occur each time the time taken exceeds both 20ms and its prior maximum. A 501 | message with the actual width is also printed whenever this occurs. 502 | 503 | # 6. Test and demo scripts 504 | 505 | The following image shows the `quick_test.py` code being monitored at the point 506 | when a task hogs the CPU. The top line 00 shows the "hog detect" trigger. Line 507 | 01 shows the fast running `hog_detect` task which cannot run at the time of the 508 | trigger because another task is hogging the CPU. Lines 02 and 04 show the `foo` 509 | and `bar` tasks. Line 03 shows the `hog` task and line 05 is a trigger issued 510 | by `hog()` when it starts monopolising the CPU. The Pico issues the "hog 511 | detect" trigger 100ms after hogging starts. 512 | 513 | ![Image](./images/monitor.jpg) 514 | 515 | `full_test.py` Tests task timeout and cancellation, also the handling of 516 | multiple task instances. If the Pico is run with `run((1, MAX))` it reveals 517 | the maximum time the DUT hogs the CPU. On a Pyboard D I measured 5ms. 518 | 519 | The sequence here is a trigger is issued on ident 4. The task on ident 1 is 520 | started, but times out after 100ms. 100ms later, five instances of the task on 521 | ident 1 are started, at 100ms intervals. They are then cancelled at 100ms 522 | intervals. Because 3 idents are allocated for multiple instances, these show up 523 | on idents 1, 2, and 3 with ident 3 representing 3 instances. Ident 3 therefore 524 | only goes low when the last of these three instances is cancelled. 525 | 526 | ![Image](./images/full_test.jpg) 527 | 528 | `latency.py` Measures latency between the start of a monitored task and the 529 | Pico pin going high. In the image below the sequence starts when the DUT 530 | pulses a pin (ident 6). The Pico pin monitoring the task then goes high (ident 531 | 1 after ~20μs). Then the trigger on ident 2 occurs 112μs after the pin pulse. 532 | 533 | ![Image](./images/latency.jpg) 534 | 535 | `syn_test.py` Demonstrates two instances of a bound method along with the ways 536 | of monitoring synchronous code. The trigger on ident 5 marks the start of the 537 | sequence. The `foo1.pause` method on ident 1 starts and runs `foo1.wait1` on 538 | ident 3. 100ms after this ends, `foo.wait2` on ident 4 is triggered. 100ms 539 | after this ends, `foo1.pause` on ident 1 ends. The second instance of `.pause` 540 | (`foo2.pause`) on ident 2 repeats this sequence shifted by 50ms. The 10ms gaps 541 | in `hog_detect` show the periods of deliberate CPU hogging. 542 | 543 | ![Image](./images/syn_test.jpg) 544 | 545 | `syn_time.py` Demonstrates timing of a specific code segment with a trigger 546 | pulse being generated every time the period exceeds its prior maximum. 547 | 548 | ![Image](./images/syn_time.jpg) 549 | 550 | # 7. Internals 551 | 552 | ## 7.1 Performance and design notes 553 | 554 | Using a UART the latency between a monitored coroutine starting to run and the 555 | Pico pin going high is about 23μs. With SPI I measured -12μs. This isn't as 556 | absurd as it sounds: a negative latency is the effect of the decorator which 557 | sends the character before the coroutine starts. These values are small in the 558 | context of `asyncio`: scheduling delays are on the order of 150μs or greater 559 | depending on the platform. See `tests/latency.py` for a way to measure latency. 560 | 561 | The use of decorators eases debugging: they are readily turned on and off by 562 | commenting out. 563 | 564 | The Pico was chosen for extremely low cost. It has plenty of GPIO pins and no 565 | underlying OS to introduce timing uncertainties. The PIO enables a simple SPI 566 | slave. 567 | 568 | Symbols transmitted by the UART are printable ASCII characters to ease 569 | debugging. A single byte protocol simplifies and speeds the Pico code. 570 | 571 | The baudrate of 1Mbps was chosen to minimise latency (10μs per character is 572 | fast in the context of asyncio). It also ensures that tasks like `hog_detect`, 573 | which can be scheduled at a high rate, can't overflow the UART buffer. The 574 | 1Mbps rate seems widely supported. 575 | 576 | ## 7.2 How it works 577 | 578 | This is for anyone wanting to modify the code. Each ident is associated with 579 | two bytes, `0x40 + ident` and `0x60 + ident`. These are upper and lower case 580 | printable ASCII characters (aside from ident 0 which is `@` paired with the 581 | backtick character). When an ident becomes active (e.g. at the start of a 582 | coroutine), uppercase is transmitted, when it becomes inactive lowercase is 583 | sent. 584 | 585 | The Pico maintains a list `pins` indexed by `ident`. Each entry is a 3-list 586 | comprising: 587 | * The `Pin` object associated with that ident. 588 | * An instance counter. 589 | * A `verbose` boolean defaulting `False`. 590 | 591 | When a character arrives, the `ident` value is recovered. If it is uppercase 592 | the pin goes high and the instance count is incremented. If it is lowercase the 593 | instance count is decremented: if it becomes 0 the pin goes low. 594 | 595 | The `init` function on the DUT sends `b"z"` to the Pico. This sets each pin 596 | in `pins` low and clears its instance counter (the program under test may have 597 | previously failed, leaving instance counters non-zero). The Pico also clears 598 | variables used to measure hogging. In the case of SPI communication, before 599 | sending the `b"z"`, a 0 character is sent with `cs/` high. The Pico implements 600 | a basic SPI slave using the PIO. This may have been left in an invalid state by 601 | a crashing DUT. The slave is designed to reset to a "ready" state if it 602 | receives any character with `cs/` high. 603 | 604 | The ident `@` (0x40) is assumed to be used by the `hog_detect()` function. When 605 | the Pico receives it, processing occurs to aid in hog detection and creating a 606 | trigger on GPIO28. Behaviour depends on the mode passed to the `run()` command. 607 | In the following, `thresh` is the time passed to `run()` in `period[0]`. 608 | * `SOON` This retriggers a timer with period `thresh`. Timeout causes a 609 | trigger. 610 | * `LATE` Trigger occurs if the period since the last `@` exceeds `thresh`. The 611 | trigger happens when the next `@` is received. 612 | * `MAX` Trigger occurs if period exceeds `thresh` and also exceeds the prior 613 | maximum. 614 | 615 | ## 7.3 ESP8266 note 616 | 617 | ESP8266 applications can be monitored using the transmit-only UART 1. 618 | 619 | I was expecting problems: on boot the ESP8266 transmits data on both UARTs at 620 | 75Kbaud. In practice `monitor_pico.py` ignores this data for the following 621 | reasons. 622 | 623 | A bit at 75Kbaud corresponds to 13.3 bits at 1Mbaud. The receiving UART will 624 | see a transmitted 1 as 13 consecutive 1 bits. In the absence of a start bit, it 625 | will ignore the idle level. An incoming 0 will be interpreted as a framing 626 | error because of the absence of a stop bit. In practice the Pico UART returns 627 | `b'\x00'` when this occurs; `monitor.py` ignores such characters. A monitored 628 | ESP8266 behaves identically to other platforms and can be rebooted at will. 629 | 630 | # 8. A hardware implementation 631 | 632 | I expect to use this a great deal, so I designed a PCB. In the image below the 633 | device under test is on the right, linked to the Pico board by means of a UART. 634 | 635 | ![Image](./images/monitor_hw.JPG) 636 | 637 | I can supply a schematic and PCB details if anyone is interested. 638 | 639 | This project was inspired by 640 | [this GitHub thread](https://github.com/micropython/micropython/issues/7456). 641 | -------------------------------------------------------------------------------- /analyser/analyser.py: -------------------------------------------------------------------------------- 1 | # analyser.py Low cost back end for micropython-monitor 2 | 3 | import hardware_setup # Create a display instance 4 | from array import array 5 | import gc 6 | gc.collect() 7 | EVLEN = const(2048) # Must be 2**N for modulo with EVMSK 8 | TAIL = const(1024) # No of events to store after trigger 9 | EVMSK = const(EVLEN - 1) 10 | evt_buf = array('I', (0 for _ in range(EVLEN))) 11 | 12 | from gui.core.ugui import Screen, ssd, LinearIO 13 | from gui.widgets.label import Label 14 | from gui.widgets.buttons import Button, CloseButton, CIRCLE 15 | from gui.widgets.dropdown import Dropdown 16 | from gui.widgets.adjuster import Adjuster, FloatAdj 17 | 18 | from gui.core.writer import CWriter 19 | # Font for CWriter 20 | import gui.fonts.arial10 as arial10 21 | from gui.core.colors import * 22 | 23 | from time import ticks_us, ticks_diff 24 | import rp2 25 | from machine import UART, Pin, Timer 26 | from random import getrandbits 27 | import uasyncio as asyncio 28 | gc.collect() 29 | 30 | dolittle = lambda *_ : None 31 | GRIDCOLOR = create_color(12, 50, 150, 50) 32 | 33 | # ****** SPI support ****** 34 | @rp2.asm_pio(autopush=True, in_shiftdir=rp2.PIO.SHIFT_LEFT, push_thresh=8) 35 | def spi_in(): 36 | label("escape") 37 | set(x, 0) 38 | mov(isr, x) # Zero after DUT crash 39 | wrap_target() 40 | wait(1, pins, 2) # CS/ False 41 | wait(0, pins, 2) # CS/ True 42 | set(x, 7) 43 | label("bit") 44 | wait(0, pins, 1) 45 | wait(1, pins, 1) 46 | in_(pins, 1) 47 | jmp(pin, "escape") # DUT crashed. On restart it sends a char with CS high. 48 | jmp(x_dec, "bit") # Post decrement 49 | wrap() 50 | 51 | 52 | class PIOSPI: 53 | def __init__(self): 54 | self._sm = rp2.StateMachine( 55 | 0, 56 | spi_in, 57 | in_shiftdir=rp2.PIO.SHIFT_LEFT, 58 | push_thresh=8, 59 | in_base=Pin(0), 60 | jmp_pin=Pin(2, Pin.IN, Pin.PULL_UP), 61 | ) 62 | self._sm.active(1) 63 | 64 | # Nonblocking read of 1 char. Returns ord(ch) or None. 65 | def read(self): 66 | if self._sm.rx_fifo(): 67 | return self._sm.get() & 0xFF 68 | 69 | 70 | # ****** Monitor ****** 71 | 72 | class Channel: 73 | def __init__(self, graph, nbit): 74 | self.graph = graph 75 | self.row = graph.row + nbit * graph.dr 76 | self.nbit = nbit 77 | self.height = graph.dr - 2 # Allow for spacing between lines 78 | 79 | def show(self): 80 | width = self.graph.width 81 | c = self.graph.fgcolor 82 | x = self.graph.col 83 | ht = self.height 84 | yon = self.row + 2 85 | yoff = self.row + ht - 2 86 | ssd.hline(x, self.row + ht + 1, width, self.graph.gridcolor) 87 | on = self.graph.on 88 | off = self.graph.off 89 | mask = 1 << self.nbit 90 | state = 0 # 0 not yet known, 1 high, 2 low 91 | pstate = 0 92 | for n in range(width): 93 | if on[n] & mask: 94 | state = 1 95 | ssd.pixel(x + n, yon, c) 96 | if off[n] & mask: 97 | state = 2 98 | ssd.pixel(x + n, yoff, c) 99 | # Conditions for a vertical line 100 | # Draw if prior state was known and state has changed. 101 | if (pstate and (state ^ pstate)) or (off[n] & on[n] & mask): 102 | ssd.vline(x + n, yon, ht - 4, c) 103 | pstate = state 104 | 105 | 106 | # The LA class provides a logic analyser display of 8 channels based on two 107 | # bytearrys. Each column maps onto an ON byte and an OFF byte, with each bit 108 | # corresponding to a channel. A bit may be ON, OFF, neither (unknown) or both 109 | # (aliased data). 110 | class LA(LinearIO): 111 | def __init__(self, writer, row, col, height, width, 112 | fgcolor, bgcolor, bdcolor, gridcolor, curs_color, trig_color, 113 | callback, args): 114 | fw = writer.font.max_width() # Width of channel labels 115 | width -= fw 116 | lcol = col 117 | col += fw 118 | super().__init__(writer, row, col, height, width, 119 | fgcolor, bgcolor, bdcolor, 0, True, None) 120 | super()._set_callbacks(callback, args) 121 | self.cpos = [-1, -1] # Cursor position (column) 122 | self.trig_pos = -1 123 | self.ccolor = curs_color if curs_color is not None else self.fgcolor 124 | self.tcolor = trig_color if trig_color is not None else self.fgcolor 125 | self.gridcolor = gridcolor if gridcolor is not None else self.fgcolor 126 | self.on = bytearray(width) # Data arrays: on pixels 127 | self.off = bytearray(width) # Off pixels 128 | self.nrows = 8 129 | self.dr = round(height/self.nrows) 130 | hh = round(self.dr / 3) 131 | for n in range(self.nrows): 132 | Label(writer, row + self.dr * n + hh, lcol, chr(0x30 + n)) 133 | self.lines = [Channel(self, n) for n in range(self.nrows)] 134 | 135 | def show(self): # 56ms regardless of freq 136 | if super().show(): # Clear working area 137 | for line in self.lines: 138 | line.show() 139 | for x in range(2): 140 | if self.cpos[x] >= 0: # Draw cursors if in window 141 | ssd.vline(self.cpos[x], self.row, self.height, self.ccolor) 142 | if self.trig_pos >= 0: 143 | ssd.vline(self.trig_pos, self.row, self.height, self.tcolor) 144 | 145 | def set_point(self, n, onoff): 146 | self.on[n] = onoff & 0xFF 147 | self.off[n] = onoff >> 8 148 | 149 | def clear(self): 150 | for n in range(self.width): 151 | self.on[n] = 0 152 | self.off[n] = 0 153 | 154 | def v_to_col(self, v): # Given value 0..1 return equivalent column 155 | return self.col + round(v * (self.width -1)) 156 | 157 | 158 | class Monitor(LA): 159 | 160 | """An event consists of a 32 bit integer. Bits 31..4 comprise the time in us from 161 | the first sample captured (which may be lost). Bit 3 indicates bit set (1) or 162 | clear (0), bits 0-2 are the channel. This means the LS 4 bits of time are garbage 163 | but 16μs is negligible so time comparisons are done ignoring this. 164 | The onoff value stores the current state of all channels, bits 0-7 indicating ON 165 | channels and 8-15 OFF ones. A channel can be unknown with neither set or aliased 166 | with both set. 167 | sonoff takes current onoff value and modifies it with an event.""" 168 | @staticmethod 169 | def sonoff(onoff, evt): 170 | bitset = 1 << (evt & 7) # ON bit for current channel 171 | bitclr = 1 << ((evt & 7) + 8) # OFF bit for current channel 172 | on = evt & 8 173 | bset = bitset if on else bitclr 174 | bclr = bitset if not on else bitclr 175 | return (onoff | bset) & ~bclr 176 | 177 | @staticmethod 178 | def mod_onoff(onoff, adt): # Set both on and off bits for aliased channels 179 | if adt: 180 | for chan in range(8): 181 | if adt & 1: # Alias detected for this channel 182 | onoff |= (1 << chan) | (1 << (chan + 8)) # Set on and off bits 183 | adt >>= 1 184 | return onoff 185 | 186 | def __init__(self, writer, row, col, *, height, width, evtbuf, 187 | fgcolor=None, bgcolor=None, bdcolor=None, gridcolor=None, 188 | curs_color=None, trig_color=None, callback=dolittle, args=()): 189 | evtbuf.set_monitor(self) 190 | self.evtbuf = evtbuf 191 | # Times in μs from 1st sample. Set by EventBuf.acquire. 192 | self.t_end = 1_000_000 # Time of last sample 193 | self.t_ws = 0 # Display window start 194 | self.t_we = 100_000 # Display window end 195 | self.t_ww = 100_000 # Window width 196 | self.can_zoomout = True 197 | self.ttrig = 0 # Trigger time 198 | self.tcurs = [0, 0] # Cursor times 199 | super().__init__(writer, row, col, height, width, 200 | fgcolor, bgcolor, bdcolor, gridcolor, curs_color, trig_color, 201 | callback, args) 202 | 203 | 204 | # Alias detection can be confusing, notably with trigger events which issue a short pulse. 205 | # This can be aliased if both transitions occur in one pixel. 206 | def redraw(self): 207 | self.draw = True # Ensure physical refresh on return 208 | npoints = self.width 209 | uspp = round(self.t_ww / npoints) # μs per point 210 | onoff = 0 # byte 0 = on bits, byte 1 = off bits 211 | for evt in (get:= self.evtbuf.get_event()): 212 | if evt: # Ignore empty elements 213 | evt_time = (evt & ~0xF) | 8 # Mean time: replace channel and bit with avg 214 | if evt_time >= self.t_ws: 215 | break # Reached the window 216 | onoff = self.sonoff(onoff, evt) 217 | else: # No data 218 | self.clear() # Display an empty graph 219 | self.show() 220 | return 221 | # Reached 1st event in display window. 222 | for point in range(npoints): 223 | tp = self.t_ws + point * uspp # Time corresponding to current point 224 | if evt == 0: # Either reached end of buf or rest of contents are zero 225 | self.set_point(point, 0) # No data 226 | else: 227 | aa = 0 228 | adt = 0 229 | while evt_time <= tp: # Process events at or before current point 230 | # Note that onoff tracks all events, maintaining current state of 231 | # all channels even if aliasing occurs. 232 | onoff = self.sonoff(onoff, evt) # Update onoff and get next event 233 | abit = (1 << (evt & 7)) # Unique bit for channel 234 | if aa & abit: 235 | adt |= abit # Log all channels aliased on this point 236 | aa |= abit 237 | try: 238 | evt = next(get) 239 | evt_time = (evt & ~0xF) | 8 # Mean time 240 | except StopIteration: 241 | evt = 0 # Flag out of data 242 | break 243 | # Set all channels for this point, modifying for aliased channels 244 | self.set_point(point, self.mod_onoff(onoff, adt)) 245 | 246 | for n in range(2): 247 | t = self.tcurs[n] 248 | if self.t_ws <= t <= self.t_we: # Trigger is in window 249 | self.cpos[n] = round(self.v_to_col((t - self.t_ws) / self.t_ww)) 250 | else: 251 | self.cpos[n] = -1 # Not visible 252 | if self.t_ws <= self.ttrig <= self.t_we: # Trigger is in window 253 | self.trig_pos = round(self.v_to_col((self.ttrig - self.t_ws) / self.t_ww)) 254 | else: 255 | self.trig_pos = -1 # Not visible 256 | 257 | def zoom_minus(self): # Widen window 258 | dt = self.t_ww // 2 # Width added at each end 259 | self.t_ww *= 2 # New width 260 | if (self.t_ws - dt) < 0: 261 | self.value(0) 262 | self.can_zoomout = False 263 | elif (self.t_we + dt) > self.t_end: 264 | self.value(1) 265 | self.can_zoomout = False 266 | else: 267 | self.scroll() 268 | 269 | def zoom_plus(self): # Narrow window 270 | dt = self.t_ww // 4 271 | self.t_ws += dt 272 | self.t_we -= dt 273 | self.t_ww = self.t_we - self.t_ws 274 | self.can_zoomout = True 275 | 276 | def scroll(self): # Move window 277 | self.t_ws = round(self.value() * (self.t_end - self.t_ww)) # Value expressed as a time 278 | self.t_we = self.t_ws + self.t_ww 279 | self.can_zoomout = True 280 | 281 | def home(self): # Attempt to place trigger at screen centre 282 | t_ws = max(0, self.ttrig - round(self.t_ww / 2)) 283 | t_we = t_ws + self.t_ww 284 | if t_we > self.t_end: 285 | t_ws = self.t_end - self.t_ww 286 | self.value(t_ws / (self.t_end - self.t_ww)) 287 | 288 | def cursor(self, x, v): # Adjuster has moved 289 | t = round(v * self.t_end) # Target time 290 | if self.tcurs[x] == -1 and not (self.t_ws <= t <= self.t_we): 291 | # Moved for first time: put in middle of window 292 | t = self.t_ws + (self.t_we - self.t_ws) // 2 293 | return t / self.t_end 294 | self.tcurs[x] = t 295 | return self.tcurs 296 | 297 | def kill_cursors(self): 298 | self.tcurs[0] = -1 299 | self.tcurs[1] = -1 300 | 301 | 302 | # ****** Acquisition ****** 303 | 304 | class EventBuf: 305 | 306 | def __init__(self, screen, device): 307 | self.screen = screen 308 | self.device = device 309 | self.wptr = 0 # Buffer write pointer 310 | self.tim = Timer() 311 | self.run = False 312 | self.monitor = None # Point to Monitor instance 313 | hardware_setup.nxt.irq(handler=self.tcb, trigger=Pin.IRQ_FALLING) # Next button stops capture 314 | if device == "uart" or device == "demo": 315 | uart = UART(0, 1_000_000) # rx on GPIO 1 316 | 317 | def read(): 318 | if uart.any(): # Nonblocking read 319 | return ord(uart.read(1)) 320 | 321 | elif device == "spi": 322 | pio = PIOSPI() 323 | 324 | def read(): # Nonblocking 325 | return pio.read() 326 | 327 | else: 328 | raise ValueError("Unsupported device:", device) 329 | self.read = read 330 | 331 | def set_monitor(self, mon): # Called by Monitor ctor 332 | self.monitor = mon 333 | 334 | def tcb(self, _): # Timer callback: normal termination 335 | self.run = False 336 | 337 | # ***** Process event buffer ***** 338 | # This is a ringbuf. Writing may overwrite old samples until a trigger occurs, 339 | # when the contents are frozen. The oldest sample is then one after the write 340 | # pointer (modulo length). 341 | # Return an event, oldest first. 342 | def get_event(self): 343 | rptr = (self.wptr + 1) & EVMSK 344 | while rptr != self.wptr: 345 | yield evt_buf[rptr] 346 | rptr = (rptr + 1) & EVMSK 347 | 348 | def get_idx(self): # Return index into evt_buf, oldest entry first 349 | rptr = (self.wptr + 1) & EVMSK 350 | while rptr != self.wptr: 351 | yield rptr 352 | rptr = (rptr + 1) & EVMSK 353 | 354 | def clear(self): 355 | for x in range(EVLEN): 356 | evt_buf[x] = 0 357 | self.wptr = 0 358 | 359 | def acquire(self, mode, t_ms): 360 | if self.device == "demo": 361 | self.demo_acquire(mode, t_ms) 362 | else: 363 | self.real_acquire(mode, t_ms) 364 | 365 | # Dummy data acquisition. 366 | def demo_acquire(self, mode, t_ms): 367 | for x in range(EVLEN // 2): 368 | evt_buf[x] = 512 * x + getrandbits(4) 369 | mon = self.monitor 370 | mon.t_end = 512 * x # Time of last sample relative to 1st 371 | mon.t_ws = 0 372 | mon.t_we = mon.t_end // 10 373 | mon.t_ww = mon.t_we - mon.t_ws 374 | mon.can_zoomout = True 375 | mon.ttrig = mon.t_end // 2 376 | mon.kill_cursors() 377 | self.wptr = x # Read from index 0 378 | 379 | # Adjust times of a buffer of samples such that oldest has time 0 380 | def normalise(self): 381 | ts = 0 # Time of oldest sample before normalisation 382 | for tp in self.get_idx(): # Generator returns index into evt_buf 383 | if ts: 384 | evt_buf[tp] -= ts 385 | elif (ts := (evt_buf[tp] & ~0xF)): # Early samples may be 0 (no data) 386 | evt_buf[tp] -= ts 387 | dt = evt_buf[tp] # Time of most recent sample relative to oldest 388 | mon = self.monitor 389 | mon.ttrig -= ts 390 | mon.t_end = dt 391 | mon.t_ws = 0 392 | mon.t_we = dt // 10 # Window 1/10 of data initially 393 | mon.t_ww = mon.t_we - mon.t_ws 394 | mon.can_zoomout = True 395 | mon.kill_cursors() 396 | print('Capture complete.') 397 | 398 | # Incoming data has channels up to 21. Store ch 0-7 in buffer. 399 | def save_event(self, x, tarr_us, t_start): 400 | if (x & 0x1f) < 8: 401 | dt = ticks_diff(tarr_us, t_start) & ~0xF # Ensure no rollover 402 | evt_buf[self.wptr] = dt | x & 7 | (0 if (x & 0x20) else 8) 403 | self.wptr += 1 404 | self.wptr &= EVMSK 405 | 406 | # mode defines stop conditions. In any mode .abrt terminates immediately. 407 | # 0: Run forever (until .abrt) 408 | # 1: When Ch 0 width > t_ms (check program segment duration) 409 | # 2: When Ch 0 inactive for > t_ms (timer timeout sets .run False) 410 | # 3: Ch 0 inactive for > t_ms, stop when it is next active 411 | def real_acquire(self, mode, t_ms): 412 | self.clear() # Zero all samples. 413 | self.run = True 414 | tim = self.tim 415 | t_us = t_ms * 1000 416 | while self.read() is not None: 417 | pass # Discard any buffered characters 418 | h_start = -1 # Absolute time of last Ch0 leading edge: invalidate. 419 | t_start = ticks_us() # Time reference for buffer 420 | tarr_us = t_start # Arrival time of latest byte 421 | while self.run: 422 | if (x := self.read()) is not None: 423 | tarr_us = ticks_us() 424 | if x == 0x7A: # Init: program under test has restarted 425 | print("Got communication.") 426 | h_start = -1 427 | self.clear() 428 | t_start = ticks_us() 429 | continue 430 | 431 | self.save_event(x, tarr_us, t_start) # Store in evt_buf. 432 | if not (x & 0x1F): # Edge on channel 0 433 | # Possible timeout on channel 0 434 | ptout = h_start != -1 and ticks_diff(tarr_us, h_start) > t_us 435 | if x == 0x40: # Leading edge on ch 0. 436 | if mode == 2: # .run = False on absence of activity > t_ms 437 | tim.init(period=t_ms, mode=Timer.ONE_SHOT, callback=self.tcb) 438 | elif mode == 3 and ptout: 439 | break # Ch 0 edge, but inactivity exceeded threshold 440 | h_start = tarr_us 441 | if x == 0x60 and mode == 1: # Ch 0 trailing edge: check pulse width 442 | if ptout: 443 | break # Stop capture 444 | 445 | # Acquistion has ended. Collect 1s/half a buffer full of data. 446 | # Trigger time is arrival time of last byte or timer timeout. 447 | self.monitor.ttrig = ticks_diff(ticks_us(), t_start) & ~0xF 448 | begin = ticks_us() 449 | nch = 0 450 | while (ticks_diff(ticks_us(), begin) < 1_000_000) and (nch < TAIL): 451 | while (x:= self.read()) is not None: 452 | self.save_event(x, ticks_us(), t_start) 453 | nch += 1 454 | self.normalise() 455 | 456 | 457 | # ***** GUI ***** 458 | class BaseScreen(Screen): # Screen is 240*320 459 | 460 | def __init__(self, device): # "uart", "spi" or "demo" 461 | super().__init__() 462 | self.initialised = False 463 | wri = CWriter(ssd, arial10, GREEN, BLACK, False) 464 | self.evtbuf = EventBuf(self, device) # Store incoming data 465 | col = 2 466 | row = 2 467 | lw = 50 # Label width 468 | self.la = Monitor(wri, row, col, height=170, width=316, 469 | bdcolor=GRIDCOLOR, gridcolor=GRIDCOLOR, 470 | curs_color=RED, trig_color=BLUE, 471 | callback=self.cb, evtbuf=self.evtbuf) 472 | 473 | row = self.la.mrow + 5 474 | self.lblstart = Label(wri, row, col, lw, bdcolor=GREEN) 475 | self.lblend = Label(wri, row, ssd.width - lw - 3, lw, bdcolor=GREEN) 476 | self.ba = Button(wri, self.lblstart.mrow + 5, col, height=15, 477 | bgcolor=DARKGREEN, litcolor=WHITE, shape=CLIPPED_RECT, 478 | text='Acquire', callback=self.btncb) 479 | self.bh = Button(wri, self.ba.mrow + 5, col, height=15, 480 | bgcolor=DARKGREEN, litcolor=WHITE, shape=CLIPPED_RECT, 481 | text='Home', callback=self.homecb) 482 | col = self.ba.mcol + 10 483 | self.zp = Button(wri, row, col, shape=CIRCLE, bgcolor=LIGHTRED, 484 | litcolor=WHITE, text='+', callback=self.zplus) 485 | self.zm = Button(wri, row + 25, col, shape=CIRCLE, bgcolor=LIGHTRED, 486 | litcolor=WHITE, text='-', callback=self.zminus) 487 | col = self.zp.mcol + 20 488 | els = ("Next", "Ch 0", "Timer", "Hog end") 489 | self.dd = Dropdown(wri, row, col, elements = els, 490 | bdcolor = BLUE, fgcolor=BLUE, fontcolor=GREEN) 491 | self.lbltim = Label(wri, self.dd.mrow + 3, col, "Duration") 492 | self.fa = FloatAdj(wri, self.lbltim.mrow + 3, col, color=BLUE, lbl_width=lw, 493 | map_func=lambda x: 1 + round(x * 1000), 494 | value=0.5, fstr="{:3d}ms") 495 | col = self.dd.mcol + 20 496 | rowa = row + 15 497 | self.ca0 = Adjuster(wri, rowa, col, fgcolor=CYAN, 498 | value=0.5, callback=self.curs, args=(0,)) 499 | self.lblcurs = Label(wri, row, col, "Cursors") 500 | self.ca1 = Adjuster(wri, self.ca0.mrow + 5, col, fgcolor=CYAN, 501 | value=0.5, callback=self.curs, args=(1,)) 502 | self.lblcursa = Label(wri, rowa, self.ca1.mcol + 5, lw, bdcolor=CYAN) 503 | Label(wri, rowa, self.lblcursa.mcol + 3, "Absolute") 504 | self.lblcursv = Label(wri, self.lblcursa.mrow + 5, self.ca1.mcol + 5, lw, bdcolor=CYAN) 505 | Label(wri, self.lblcursa.mrow + 5, self.lblcursv.mcol + 3, "Diff") 506 | 507 | def cb(self, la): 508 | la.scroll() # Adjust window to match current value 509 | self.refresh(la) 510 | 511 | def curs(self, adj, n): # Args: adjuster, cursor no 512 | if not self.initialised: 513 | return 514 | res = self.la.cursor(n, adj()) 515 | if isinstance(res, float): 516 | adj.value(res) 517 | return # Will re-enter 518 | v0, v1 = res 519 | if v0 >= 0: # Cursor 0 has been set 520 | self.lblcursa.value("{:6.3f}ms".format(v0 / 1000)) 521 | if v1 >= 0: # Both set 522 | self.lblcursv.value("{:6.3f}ms".format((v1 - v0) / 1000)) 523 | self.la.redraw() 524 | 525 | 526 | def refresh(self, la): 527 | la.redraw() 528 | self.lblstart.value(f'{la.t_ws / 1000:5.1f}ms') 529 | self.lblend.value(f'{la.t_we / 1000:5.1f}ms') 530 | if la.tcurs[0] == -1: 531 | self.lblcursv.value("") 532 | self.lblcursa.value("") 533 | self.zm.greyed_out(not self.la.can_zoomout) 534 | 535 | def btncb(self, _): 536 | asyncio.create_task(self.do_get()) 537 | 538 | async def do_get(self): 539 | self.evtbuf.clear() 540 | self.refresh(self.la) 541 | await asyncio.sleep_ms(200) # Physical refresh of blank display 542 | self.evtbuf.acquire(self.dd.value(), self.fa.mapped_value()) # Args: mode and timer value 543 | self.refresh(self.la) 544 | 545 | def homecb(self, _): 546 | self.la.home() 547 | self.refresh(self.la) 548 | 549 | def zplus(self, _): 550 | self.la.zoom_plus() 551 | self.refresh(self.la) 552 | #gc.collect() # TEST 553 | #print(gc.mem_free()) 554 | 555 | def zminus(self, _): 556 | self.la.zoom_minus() 557 | self.refresh(self.la) 558 | 559 | def after_open(self): 560 | self.initialised = True 561 | 562 | # ***** End ***** 563 | def run(device="demo"): # Options "uart" or "spi" 564 | print("Analyser mode:", device) 565 | Screen.change(BaseScreen, args=(device,)) 566 | 567 | # run() 568 | -------------------------------------------------------------------------------- /analyser/hardware_setup.py: -------------------------------------------------------------------------------- 1 | # ili9341_pico.py Customise for your hardware config 2 | 3 | # Released under the MIT License (MIT). See LICENSE. 4 | # Copyright (c) 2021 Peter Hinch 5 | 6 | # As written, supports: 7 | # ili9341 240x320 displays on Pi Pico 8 | # Edit the driver import for other displays. 9 | 10 | # Demo of initialisation procedure designed to minimise risk of memory fail 11 | # when instantiating the frame buffer. The aim is to do this as early as 12 | # possible before importing other modules. 13 | 14 | # WIRING 15 | # Pico Display 16 | # GPIO Pin 17 | # 3v3 36 Vin 18 | # IO6 9 CLK Hardware SPI0 19 | # IO7 10 DATA (AKA SI MOSI) 20 | # IO8 11 DC 21 | # IO9 12 Rst 22 | # Gnd 13 Gnd 23 | # IO10 14 CS 24 | 25 | # Pushbuttons are wired between the pin and Gnd 26 | # Pico pin Meaning 27 | # 16 Operate current control 28 | # 17 Decrease value of current control 29 | # 18 Select previous control 30 | # 19 Select next control 31 | # 20 Increase value of current control 32 | 33 | from machine import Pin, SPI, freq 34 | import gc 35 | 36 | from drivers.ili93xx.ili9341 import ILI9341 as SSD 37 | freq(250_000_000) # RP2 overclock 38 | # Create and export an SSD instance 39 | pdc = Pin(8, Pin.OUT, value=0) # Arbitrary pins 40 | prst = Pin(9, Pin.OUT, value=1) 41 | pcs = Pin(10, Pin.OUT, value=1) 42 | spi = SPI(0, baudrate=30_000_000) 43 | gc.collect() # Precaution before instantiating framebuf 44 | ssd = SSD(spi, pcs, pdc, prst, usd=True) 45 | 46 | from gui.core.ugui import Display 47 | # Create and export a Display instance 48 | # Define control buttons 49 | nxt = Pin(19, Pin.IN, Pin.PULL_UP) # Move to next control 50 | sel = Pin(16, Pin.IN, Pin.PULL_UP) # Operate current control 51 | prev = Pin(18, Pin.IN, Pin.PULL_UP) # Move to previous control 52 | increase = Pin(20, Pin.IN, Pin.PULL_UP) # Increase control's value 53 | decrease = Pin(17, Pin.IN, Pin.PULL_UP) # Decrease control's value 54 | display = Display(ssd, nxt, sel, prev, increase, decrease, 5) 55 | -------------------------------------------------------------------------------- /images/full_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/full_test.jpg -------------------------------------------------------------------------------- /images/la_hw.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/la_hw.jpg -------------------------------------------------------------------------------- /images/la_ui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/la_ui.jpg -------------------------------------------------------------------------------- /images/latency.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/latency.jpg -------------------------------------------------------------------------------- /images/monitor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/monitor.jpg -------------------------------------------------------------------------------- /images/monitor_gc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/monitor_gc.jpg -------------------------------------------------------------------------------- /images/monitor_hw.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/monitor_hw.JPG -------------------------------------------------------------------------------- /images/syn_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/syn_test.jpg -------------------------------------------------------------------------------- /images/syn_time.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/syn_time.jpg -------------------------------------------------------------------------------- /images/uart_problem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/uart_problem.png -------------------------------------------------------------------------------- /images/utx0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/05abb4b2290434972e622652f69b3852e657f692/images/utx0.png -------------------------------------------------------------------------------- /monitor.py: -------------------------------------------------------------------------------- 1 | # monitor.py 2 | # Monitor an asynchronous program by sending single bytes down an interface. 3 | 4 | # Copyright (c) 2021-2024 Peter Hinch 5 | # Released under the MIT License (MIT) - see LICENSE file 6 | # V0.2 Supports monitoring dual-core applications on RP2 7 | 8 | import asyncio 9 | from machine import UART, SPI, Pin, disable_irq, enable_irq 10 | from time import sleep_us 11 | from sys import exit 12 | 13 | _lck = False 14 | try: 15 | import _thread 16 | 17 | _lock = _thread.allocate_lock() 18 | _acquire = _lock.acquire 19 | _release = _lock.release 20 | except ImportError: # Only source of hard concurrency is IRQ 21 | 22 | def _acquire(): # Crude blocking lock. Held only for short periods. 23 | global _lck 24 | istate = disable_irq() 25 | while _lck: 26 | pass 27 | _lck = True 28 | enable_irq(istate) 29 | 30 | def _release(): 31 | global _lck 32 | _lck = False 33 | 34 | 35 | # Quit with an error message rather than throw. 36 | def _quit(s): 37 | print("Monitor", s) 38 | exit(0) 39 | 40 | 41 | _write = lambda _: _quit("must run set_device") 42 | _ifrst = lambda: None # Reset interface. If UART do nothing. 43 | 44 | # For UART pass initialised UART. Baudrate must be 1_000_000. 45 | # For SPI pass initialised instance SPI. Can be any baudrate, but 46 | # must be default in other respects. 47 | def set_device(dev, cspin=None): 48 | global _write, _ifrst 49 | if isinstance(dev, UART) and cspin is None: # UART 50 | 51 | def uwrite(data, buf=bytearray(1)): 52 | _acquire() 53 | buf[0] = data 54 | dev.write(buf) 55 | _release() 56 | 57 | _write = uwrite 58 | elif isinstance(dev, SPI) and isinstance(cspin, Pin): 59 | cspin(1) 60 | 61 | def spiwrite(data, buf=bytearray(1)): 62 | _acquire() 63 | buf[0] = data 64 | cspin(0) 65 | dev.write(buf) 66 | cspin(1) 67 | _release() 68 | 69 | _write = spiwrite 70 | 71 | def clear_sm(): # Set Pico SM to its initial state 72 | cspin(1) 73 | dev.write(b"\0") # SM is now waiting for CS low. 74 | 75 | _ifrst = clear_sm 76 | else: 77 | _quit("set_device: invalid args.") 78 | 79 | 80 | # Valid idents are 0..21 81 | # Looping: some idents may be repeatedly instantiated. This can occur 82 | # if decorator is run in looping code. A CM is likely to be used in a 83 | # loop. In these cases only validate on first use. 84 | 85 | 86 | def _validate(ident, num=1, looping=False, loopers=set(), available=set(range(0, 22))): 87 | if ident >= 0 and ident + num < 22: 88 | try: 89 | for x in range(ident, ident + num): 90 | if looping: 91 | if x not in loopers: 92 | available.remove(x) 93 | loopers.add(x) 94 | else: 95 | available.remove(x) 96 | except KeyError: 97 | _quit("error - ident {:02d} already allocated.".format(x)) 98 | else: 99 | _quit("error - ident {:02d} out of range.".format(ident)) 100 | 101 | 102 | # /mnt/qnap2/data/Projects/Python/AssortedTechniques/decorators 103 | # asynchronous monitor 104 | def asyn(ident, max_instances=1, verbose=True, looping=False): 105 | def decorator(coro): 106 | _validate(ident, max_instances, looping) 107 | instance = 0 108 | 109 | async def wrapped_coro(*args, **kwargs): 110 | nonlocal instance 111 | d = 0x40 + ident + min(instance, max_instances - 1) 112 | instance += 1 113 | if verbose and instance > max_instances: # Warning only. 114 | print("Monitor ident: {:02d} instances: {}.".format(ident, instance)) 115 | _write(d) 116 | try: 117 | res = await coro(*args, **kwargs) 118 | except asyncio.CancelledError: 119 | raise # Other exceptions produce traceback. 120 | finally: 121 | _write(d | 0x20) 122 | instance -= 1 123 | return res 124 | 125 | return wrapped_coro 126 | 127 | return decorator 128 | 129 | 130 | # If SPI, clears the state machine in case prior test resulted in the DUT 131 | # crashing. It does this by sending a byte with CS\ False (high). 132 | def init(): 133 | _ifrst() # Reset interface. Does nothing if UART. 134 | _write(ord("z")) # Clear Pico's instance counters etc. 135 | 136 | 137 | # Optionally run this to show up periods of blocking behaviour 138 | async def hog_detect(s=(0x40, 0x60)): 139 | while True: 140 | for v in s: 141 | _write(v) 142 | await asyncio.sleep_ms(0) 143 | 144 | 145 | # Monitor a synchronous function definition 146 | def sync(ident, looping=False): 147 | def decorator(func): 148 | _validate(ident, 1, looping) 149 | 150 | def wrapped_func(*args, **kwargs): 151 | _write(0x40 + ident) 152 | res = func(*args, **kwargs) 153 | _write(0x60 + ident) 154 | return res 155 | 156 | return wrapped_func 157 | 158 | return decorator 159 | 160 | 161 | # Monitor a function call 162 | class mon_call: 163 | def __init__(self, ident): 164 | # looping: a CM may be instantiated many times 165 | _validate(ident, 1, True) 166 | self.ident = ident 167 | 168 | def __enter__(self): 169 | _write(0x40 + self.ident) 170 | return self 171 | 172 | def __exit__(self, type, value, traceback): 173 | _write(0x60 + self.ident) 174 | return False # Don't silence exceptions 175 | 176 | 177 | # Either cause pico ident n to produce a brief (~80μs) pulse or turn it 178 | # on or off on demand. No looping: docs suggest instantiating at start. 179 | def trigger(ident): 180 | _validate(ident) 181 | 182 | def wrapped(state=None): 183 | if state is None: 184 | _write(0x40 + ident) 185 | sleep_us(20) 186 | _write(0x60 + ident) 187 | else: 188 | _write(ident + (0x40 if state else 0x60)) 189 | 190 | return wrapped 191 | -------------------------------------------------------------------------------- /monitor_pico.py: -------------------------------------------------------------------------------- 1 | # monitor_pico.py 2 | # Runs on a Raspberry Pico board to receive data from monitor.py 3 | # Firmware should be V1.20 or later 4 | 5 | # Copyright (c) 2021-2024 Peter Hinch 6 | # Released under the MIT License (MIT) - see LICENSE file 7 | 8 | # Device gets a single ASCII byte defining the pin number and whether 9 | # to increment (uppercase) or decrement (lowercase) the use count. 10 | # Pin goes high if use count > 0 else low. 11 | # incoming numbers are 0..21 which map onto 22 GPIO pins 12 | 13 | import rp2 14 | from machine import UART, Pin, Timer, freq 15 | from time import ticks_ms, ticks_diff 16 | 17 | freq(250_000_000) 18 | 19 | # ****** SPI support ****** 20 | @rp2.asm_pio(autopush=True, in_shiftdir=rp2.PIO.SHIFT_LEFT, push_thresh=8) 21 | def spi_in(): 22 | label("escape") 23 | set(x, 0) 24 | mov(isr, x) # Zero after DUT crash 25 | wrap_target() 26 | wait(1, pins, 2) # CS/ False 27 | wait(0, pins, 2) # CS/ True 28 | set(x, 7) 29 | label("bit") 30 | wait(0, pins, 1) 31 | wait(1, pins, 1) 32 | in_(pins, 1) 33 | jmp(pin, "escape") # DUT crashed. On restart it sends a char with CS high. 34 | jmp(x_dec, "bit") # Post decrement 35 | wrap() 36 | 37 | 38 | class PIOSPI: 39 | def __init__(self): 40 | self._sm = rp2.StateMachine( 41 | 0, 42 | spi_in, 43 | in_shiftdir=rp2.PIO.SHIFT_LEFT, 44 | push_thresh=8, 45 | in_base=Pin(0), 46 | jmp_pin=Pin(2, Pin.IN, Pin.PULL_UP), 47 | ) 48 | self._sm.active(1) 49 | 50 | # Blocking read of 1 char. Returns ord(ch). If DUT crashes, worst case 51 | # is where CS is left low. SM will hang until user restarts. On restart 52 | # the app 53 | def read(self): 54 | return self._sm.get() & 0xFF 55 | 56 | 57 | # ****** Define pins ****** 58 | 59 | # Valid GPIO pins 60 | # GPIO 0,1,2 are for interface so pins are 3..22, 26..27 61 | PIN_NOS = list(range(3, 23)) + list(range(26, 28)) 62 | 63 | # Index is incoming ID 64 | # contents [Pin, instance_count, verbose] 65 | pins = [] 66 | for pin_no in PIN_NOS: 67 | pins.append([Pin(pin_no, Pin.OUT), 0, False]) 68 | 69 | # ****** Timing ***** 70 | 71 | pin_t = Pin(28, Pin.OUT) 72 | 73 | 74 | def _cb(_): 75 | pin_t(1) 76 | print("Timeout.") 77 | pin_t(0) 78 | 79 | 80 | tim = Timer() 81 | 82 | # ****** Monitor ****** 83 | 84 | SOON = const(0) 85 | LATE = const(1) 86 | MAX = const(2) 87 | WIDTH = const(3) 88 | # Modes. Pulses and reports only occur if an outage exceeds the threshold. 89 | # SOON: pulse early when timer times out. Report at outage end. 90 | # LATE: pulse when outage ends. Report at outage end. 91 | # MAX: pulse when outage exceeds prior maximum. Report only in that instance. 92 | # WIDTH: for measuring time between arbitrary points in code. When duration 93 | # between 0x40 and 0x60 exceeds previous max, pulse and report. 94 | 95 | # native reduced latency to 10μs 96 | @micropython.native 97 | def run(period=100, verbose=(), device="uart", vb=True): 98 | if isinstance(period, int): 99 | t_ms = period 100 | mode = SOON 101 | else: 102 | t_ms, mode = period 103 | if mode not in (SOON, LATE, MAX, WIDTH): 104 | raise ValueError("Invalid mode.") 105 | for x in verbose: 106 | pins[x][2] = True 107 | # A device must support a blocking read. 108 | if device == "uart": 109 | uart = UART(0, 1_000_000) # rx on GPIO 1 110 | 111 | def read(): 112 | while not uart.any(): # Prevent UART timeouts 113 | pass 114 | return ord(uart.read(1)) 115 | 116 | elif device == "spi": 117 | pio = PIOSPI() 118 | 119 | def read(): 120 | return pio.read() 121 | 122 | else: 123 | raise ValueError("Unsupported device:", device) 124 | 125 | vb and print("Awaiting communication.") 126 | h_max = 0 # Max hog duration (ms) 127 | h_start = -1 # Absolute hog start time: invalidate. 128 | while True: 129 | if x := read(): # Get an initial 0 on UART 130 | tarr = ticks_ms() # Arrival time 131 | if x == 0x7A: # Init: program under test has restarted 132 | vb and print("Got communication.") 133 | h_max = 0 # Restart timing 134 | h_start = -1 135 | for pin in pins: 136 | pin[0](0) # Clear pin 137 | pin[1] = 0 # and instance counter 138 | continue 139 | if mode == WIDTH: 140 | if x == 0x40: # Leading edge on ident 0 141 | h_start = tarr 142 | elif x == 0x60 and h_start != -1: # Trailing edge 143 | dt = ticks_diff(tarr, h_start) 144 | if dt > t_ms and dt > h_max: 145 | h_max = dt 146 | print(f"Max width {dt}ms") 147 | pin_t(1) 148 | pin_t(0) 149 | elif x == 0x40: # hog_detect task has started. 150 | if mode == SOON: # Pulse on absence of activity 151 | tim.init(period=t_ms, mode=Timer.ONE_SHOT, callback=_cb) 152 | if h_start != -1: # There was a prior trigger 153 | dt = ticks_diff(tarr, h_start) 154 | if dt > t_ms: # Delay exceeds threshold 155 | if mode != MAX: 156 | print(f"Hog {dt}ms") 157 | if mode == LATE: 158 | pin_t(1) 159 | pin_t(0) 160 | if dt > h_max: 161 | h_max = dt 162 | print(f"Max hog {dt}ms") 163 | if mode == MAX: 164 | pin_t(1) 165 | pin_t(0) 166 | h_start = tarr 167 | p = pins[x & 0x1F] # Key: 0x40 (ord('@')) is pin ID 0 168 | if x & 0x20: # Going down 169 | if p[1] > 0: # Might have restarted this script with a running client. 170 | p[1] -= 1 # or might have sent trig(False) before True. 171 | if not p[1]: # Instance count is zero 172 | p[0](0) 173 | else: 174 | p[0](1) 175 | p[1] += 1 176 | if p[2]: 177 | print(f"ident {i} count {p[1]}") 178 | -------------------------------------------------------------------------------- /tests/full_test.py: -------------------------------------------------------------------------------- 1 | # full_test.py 2 | 3 | # Copyright (c) 2021 Peter Hinch 4 | # Released under the MIT License (MIT) - see LICENSE file 5 | 6 | # Tests monitoring of timeout, task cancellation and multiple instances. 7 | 8 | import uasyncio as asyncio 9 | from machine import Pin, UART, SPI 10 | import monitor 11 | 12 | trig = monitor.trigger(4) 13 | # Define interface to use 14 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 15 | # monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) # SPI suggest >= 1MHz 16 | 17 | 18 | @monitor.asyn(1, 3) 19 | async def forever(): 20 | while True: 21 | await asyncio.sleep_ms(100) 22 | 23 | 24 | async def main(): 25 | monitor.init() 26 | asyncio.create_task(monitor.hog_detect()) # Watch for gc dropouts on ID0 27 | while True: 28 | trig() 29 | try: 30 | await asyncio.wait_for_ms(forever(), 100) # 100ms pulse on ID1 31 | except asyncio.TimeoutError: # Mandatory error trapping 32 | pass 33 | # Task has now timed out 34 | await asyncio.sleep_ms(100) 35 | tasks = [] 36 | for _ in range(5): # ID 1, 2, 3 go high, then 500ms pause 37 | tasks.append(asyncio.create_task(forever())) 38 | await asyncio.sleep_ms(100) 39 | while tasks: # ID 3, 2, 1 go low 40 | tasks.pop().cancel() 41 | await asyncio.sleep_ms(100) 42 | await asyncio.sleep_ms(100) 43 | 44 | 45 | try: 46 | asyncio.run(main()) 47 | finally: 48 | asyncio.new_event_loop() 49 | -------------------------------------------------------------------------------- /tests/isr.py: -------------------------------------------------------------------------------- 1 | # isr.py 2 | # Tests case where a trigger is operated in a hard ISR. 3 | # This test is Pyboard specific. 4 | 5 | # Copyright (c) 2021-2022 Peter Hinch 6 | # Released under the MIT License (MIT) - see LICENSE file 7 | 8 | 9 | import uasyncio as asyncio 10 | from machine import Pin, UART, SPI 11 | from pyb import Timer 12 | import monitor 13 | 14 | 15 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz. O/P on X3 16 | trig1 = monitor.trigger(1) 17 | trig2 = monitor.trigger(2) 18 | 19 | tim = Timer(1) 20 | tsf = asyncio.ThreadSafeFlag() 21 | 22 | 23 | def tcb(_): 24 | trig1() 25 | tsf.set() 26 | 27 | 28 | async def main(): 29 | monitor.init() 30 | tim.init(freq=50, callback=tcb) 31 | while True: 32 | await tsf.wait() 33 | trig2() # Latency 276us on Pyboad D SF2W 34 | 35 | 36 | try: 37 | asyncio.run(main()) 38 | finally: 39 | asyncio.new_event_loop() 40 | -------------------------------------------------------------------------------- /tests/latency.py: -------------------------------------------------------------------------------- 1 | # latency.py 2 | # Measure the time between a task starting and the Pico pin going high. 3 | # Also the delay before a trigger occurs. 4 | 5 | # Copyright (c) 2021 Peter Hinch 6 | # Released under the MIT License (MIT) - see LICENSE file 7 | 8 | import uasyncio as asyncio 9 | from machine import Pin, UART, SPI 10 | import monitor 11 | 12 | # Pin on host: modify for other platforms 13 | test_pin = Pin("X6", Pin.OUT) 14 | trig = monitor.trigger(2) 15 | 16 | # Define interface to use 17 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 18 | # monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) # SPI suggest >= 1MHz 19 | 20 | 21 | @monitor.asyn(1) 22 | async def pulse(pin): 23 | pin(1) # Pulse pin 24 | pin(0) 25 | trig() # Pulse Pico pin ident 2 26 | await asyncio.sleep_ms(30) 27 | 28 | 29 | async def main(): 30 | monitor.init() 31 | while True: 32 | await pulse(test_pin) 33 | await asyncio.sleep_ms(100) 34 | 35 | 36 | try: 37 | asyncio.run(main()) 38 | finally: 39 | asyncio.new_event_loop() 40 | -------------------------------------------------------------------------------- /tests/looping.py: -------------------------------------------------------------------------------- 1 | # looping.py 2 | # Tests case where a decorator is called repeatedly. 3 | 4 | # Copyright (c) 2021 Peter Hinch 5 | # Released under the MIT License (MIT) - see LICENSE file 6 | 7 | import uasyncio as asyncio 8 | import time 9 | from machine import Pin, UART, SPI 10 | import monitor 11 | 12 | # Define interface to use 13 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 14 | # monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) # SPI suggest >= 1MHz 15 | trig = monitor.trigger(5) 16 | 17 | 18 | class Foo: 19 | def __init__(self): 20 | pass 21 | 22 | @monitor.asyn(1, 2) # ident 1/2 high 23 | async def pause(self): 24 | while True: 25 | trig() 26 | self.wait1() # ident 3 10ms pulse 27 | await asyncio.sleep_ms(100) 28 | with monitor.mon_call(4): # ident 4 10ms pulse 29 | self.wait2() 30 | await asyncio.sleep_ms(100) 31 | # ident 1/2 low 32 | 33 | async def bar(self): 34 | @monitor.asyn(3, looping=True) 35 | async def wait1(): 36 | await asyncio.sleep_ms(100) 37 | 38 | @monitor.sync(4, True) 39 | def wait2(): 40 | time.sleep_ms(10) 41 | 42 | trig() 43 | await wait1() 44 | trig() 45 | wait2() 46 | 47 | 48 | async def main(): 49 | monitor.init() 50 | asyncio.create_task(monitor.hog_detect()) # Make 10ms waitx gaps visible 51 | foo = Foo() 52 | while True: 53 | await foo.bar() 54 | await asyncio.sleep_ms(100) 55 | await foo.pause() 56 | 57 | 58 | try: 59 | asyncio.run(main()) 60 | finally: 61 | asyncio.new_event_loop() 62 | -------------------------------------------------------------------------------- /tests/quick_test.py: -------------------------------------------------------------------------------- 1 | # quick_test.py 2 | # Tests the monitoring of deliberate CPU hogging. 3 | 4 | # Copyright (c) 2021 Peter Hinch 5 | # Released under the MIT License (MIT) - see LICENSE file 6 | 7 | import uasyncio as asyncio 8 | import time 9 | from machine import Pin, UART, SPI 10 | import monitor 11 | 12 | # Define interface to use 13 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 14 | # monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) # SPI suggest >= 1MHz 15 | 16 | trig = monitor.trigger(4) 17 | 18 | 19 | @monitor.asyn(1) 20 | async def foo(t): 21 | await asyncio.sleep_ms(t) 22 | 23 | 24 | @monitor.asyn(2) 25 | async def hog(): 26 | await asyncio.sleep(5) 27 | trig() # Hog start 28 | time.sleep_ms(500) 29 | 30 | 31 | @monitor.asyn(3) 32 | async def bar(t): 33 | await asyncio.sleep_ms(t) 34 | 35 | 36 | async def main(): 37 | monitor.init() 38 | asyncio.create_task(monitor.hog_detect()) 39 | asyncio.create_task(hog()) # Will hog for 500ms after 5 secs 40 | while True: 41 | asyncio.create_task(foo(100)) 42 | await bar(150) 43 | await asyncio.sleep_ms(50) 44 | 45 | 46 | try: 47 | asyncio.run(main()) 48 | finally: 49 | asyncio.new_event_loop() 50 | -------------------------------------------------------------------------------- /tests/syn_test.py: -------------------------------------------------------------------------------- 1 | # syn_test.py 2 | # Tests the monitoring synchronous code and of an async method. 3 | 4 | # Copyright (c) 2021 Peter Hinch 5 | # Released under the MIT License (MIT) - see LICENSE file 6 | 7 | import uasyncio as asyncio 8 | import time 9 | from machine import Pin, UART, SPI 10 | import monitor 11 | 12 | # Define interface to use 13 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 14 | # monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) # SPI suggest >= 1MHz 15 | trig = monitor.trigger(5) 16 | 17 | 18 | class Foo: 19 | def __init__(self): 20 | pass 21 | 22 | @monitor.asyn(1, 2) # ident 1/2 high 23 | async def pause(self): 24 | self.wait1() # ident 3 10ms pulse 25 | await asyncio.sleep_ms(100) 26 | with monitor.mon_call(4): # ident 4 10ms pulse 27 | self.wait2() 28 | await asyncio.sleep_ms(100) 29 | # ident 1/2 low 30 | 31 | @monitor.sync(3) 32 | def wait1(self): 33 | time.sleep_ms(10) 34 | 35 | def wait2(self): 36 | time.sleep_ms(10) 37 | 38 | 39 | async def main(): 40 | monitor.init() 41 | asyncio.create_task(monitor.hog_detect()) # Make 10ms waitx gaps visible 42 | foo1 = Foo() 43 | foo2 = Foo() 44 | while True: 45 | trig() # Mark start with pulse on ident 5 46 | # Create two instances of .pause separated by 50ms 47 | asyncio.create_task(foo1.pause()) 48 | await asyncio.sleep_ms(50) 49 | await foo2.pause() 50 | await asyncio.sleep_ms(50) 51 | 52 | 53 | try: 54 | asyncio.run(main()) 55 | finally: 56 | asyncio.new_event_loop() 57 | -------------------------------------------------------------------------------- /tests/syn_time.py: -------------------------------------------------------------------------------- 1 | # syn_time.py 2 | # Tests the monitoring synchronous code. 3 | # Can run with run((1, monitor.WIDTH)) to check max width detection. 4 | 5 | # Copyright (c) 2021 Peter Hinch 6 | # Released under the MIT License (MIT) - see LICENSE file 7 | 8 | import uasyncio as asyncio 9 | import time 10 | from machine import Pin, UART, SPI 11 | import monitor 12 | 13 | # Define interface to use 14 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 15 | # monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) # SPI suggest >= 1MHz 16 | 17 | trig0 = monitor.trigger(0) 18 | twait = 20 19 | 20 | 21 | async def test(): 22 | while True: 23 | await asyncio.sleep_ms(100) 24 | trig0(True) 25 | await asyncio.sleep_ms(twait) 26 | trig0(False) 27 | 28 | 29 | async def lengthen(): 30 | global twait 31 | while twait < 200: 32 | twait += 1 33 | await asyncio.sleep(1) 34 | 35 | 36 | async def main(): 37 | monitor.init() 38 | asyncio.create_task(lengthen()) 39 | await test() 40 | 41 | 42 | try: 43 | asyncio.run(main()) 44 | finally: 45 | asyncio.new_event_loop() 46 | -------------------------------------------------------------------------------- /tests/two_core.py: -------------------------------------------------------------------------------- 1 | # two_core.py Test monitoring dual-core code on RP2. 2 | # Firmware should have patch 3 | # https://github.com/micropython/micropython/issues/7977 4 | # Should see hog detect on 0, approximate square waves on 1-3 5 | 6 | import _thread 7 | from time import sleep_ms 8 | import uasyncio as asyncio 9 | from machine import UART, SPI, Pin 10 | import gc 11 | import monitor 12 | 13 | # monitor.set_device(UART(0, 1_000_000)) # UART must be 1MHz. O/P on GPIO 0 14 | monitor.set_device(SPI(0, baudrate=5_000_000), Pin(5, Pin.OUT)) 15 | trig1 = monitor.trigger(1) 16 | trig2 = monitor.trigger(2) 17 | 18 | def other(): 19 | while True: 20 | trig1(True) 21 | sleep_ms(10) 22 | trig1(False) 23 | sleep_ms(9) 24 | 25 | @monitor.asyn(3) 26 | async def bar(): 27 | await asyncio.sleep_ms(10) 28 | 29 | async def foo(): 30 | while True: 31 | trig2(True) 32 | await asyncio.sleep_ms(10) 33 | trig2(False) 34 | await asyncio.sleep_ms(10) 35 | 36 | async def main(): 37 | monitor.init() 38 | print("Running...") 39 | asyncio.create_task(monitor.hog_detect()) 40 | asyncio.create_task(foo()) 41 | _thread.start_new_thread(other, ()) # Tuple of args 42 | while True: 43 | await bar() 44 | await asyncio.sleep_ms(11) 45 | 46 | asyncio.run(main()) 47 | --------------------------------------------------------------------------------