├── images ├── la_hw.jpg ├── la_ui.jpg ├── utx0.png ├── latency.jpg ├── monitor.jpg ├── schema.png ├── full_test.jpg ├── monitor_gc.jpg ├── monitor_hw.JPG ├── syn_test.jpg ├── syn_time.jpg └── uart_problem.png ├── pico └── package.json ├── package.json ├── pins.py ├── tests ├── isr.py ├── latency.py ├── syn_time.py ├── quick_test.py ├── two_core.py ├── full_test.py ├── syn_test.py ├── looping.py └── lock_event.py ├── LICENSE ├── analyser ├── hardware_setup.py └── analyser.py ├── .gitignore ├── monitor_pico.py ├── monitor.py ├── ANALYSER.md └── README.md /images/la_hw.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/la_hw.jpg -------------------------------------------------------------------------------- /images/la_ui.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/la_ui.jpg -------------------------------------------------------------------------------- /images/utx0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/utx0.png -------------------------------------------------------------------------------- /images/latency.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/latency.jpg -------------------------------------------------------------------------------- /images/monitor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/monitor.jpg -------------------------------------------------------------------------------- /images/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/schema.png -------------------------------------------------------------------------------- /images/full_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/full_test.jpg -------------------------------------------------------------------------------- /images/monitor_gc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/monitor_gc.jpg -------------------------------------------------------------------------------- /images/monitor_hw.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/monitor_hw.JPG -------------------------------------------------------------------------------- /images/syn_test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/syn_test.jpg -------------------------------------------------------------------------------- /images/syn_time.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/syn_time.jpg -------------------------------------------------------------------------------- /images/uart_problem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterhinch/micropython-monitor/HEAD/images/uart_problem.png -------------------------------------------------------------------------------- /pico/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["monitor_pico.py", "github:peterhinch/micropython-monitor/monitor_pico.py"] 4 | ], 5 | "version": "0.1" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "urls": [ 3 | ["monitor.py", "github:peterhinch/micropython-monitor/monitor.py"], 4 | ["pins.py", "github:peterhinch/micropython-monitor/pins.py"] 5 | ], 6 | "version": "0.1" 7 | } 8 | -------------------------------------------------------------------------------- /pins.py: -------------------------------------------------------------------------------- 1 | # pins.py Dicts mapping GP and Pico pin nos onto monitor ID's 2 | 3 | # trig = monitor.trigger(gp(10)) 4 | # trig causes output on Pico GP10 5 | def gp(g): 6 | if 3 <= g <= 22: 7 | return g -3 8 | elif 26 <= g <= 27: 9 | return g -6 10 | raise ValueError("Invalid GPIO no. for monitor") 11 | # trig = monitor.trigger(pico(6) 12 | # Output on Pico PCB pin 6 (GP4) 13 | def pico(p): 14 | pcb = {5:0, 6:1, 7:2, 9:3, 10:4, 11:5, 12:6, 14:7, 15:8, 16:9, 17:10, 19:11} 15 | pcb.update({20:12, 21:13, 22:14, 24:15, 25:16, 26:17, 27:18, 29:19, 31:20, 32:21}) 16 | try: 17 | return pcb[p] 18 | except KeyError: 19 | raise ValueError("Invalid Pico PCB pin no. for monitor") 20 | -------------------------------------------------------------------------------- /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 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 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/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-2024 Peter Hinch 6 | # Released under the MIT License (MIT) - see LICENSE file 7 | 8 | import asyncio 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 | twait = 20 17 | 18 | 19 | async def test(): 20 | while True: 21 | await asyncio.sleep_ms(100) 22 | with monitor.Monitor(0): 23 | await asyncio.sleep_ms(twait) 24 | 25 | 26 | async def lengthen(): 27 | global twait 28 | while twait < 200: 29 | twait += 1 30 | await asyncio.sleep(1) 31 | 32 | 33 | async def main(): 34 | monitor.init() 35 | asyncio.create_task(lengthen()) 36 | await test() 37 | 38 | 39 | try: 40 | asyncio.run(main()) 41 | finally: 42 | asyncio.new_event_loop() 43 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 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(True) # Run hog_detect task 38 | asyncio.create_task(hog()) # Will hog for 500ms after 5 secs 39 | while True: 40 | asyncio.create_task(foo(100)) 41 | await bar(150) 42 | await asyncio.sleep_ms(50) 43 | 44 | 45 | try: 46 | asyncio.run(main()) 47 | finally: 48 | asyncio.new_event_loop() 49 | -------------------------------------------------------------------------------- /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 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 | 19 | def other(): 20 | while True: 21 | trig1(True) 22 | sleep_ms(10) 23 | trig1(False) 24 | sleep_ms(9) 25 | 26 | 27 | @monitor.asyn(3) 28 | async def bar(): 29 | await asyncio.sleep_ms(10) 30 | 31 | 32 | async def foo(): 33 | while True: 34 | trig2(True) 35 | await asyncio.sleep_ms(10) 36 | trig2(False) 37 | await asyncio.sleep_ms(10) 38 | 39 | 40 | async def main(): 41 | monitor.init(True) # Run hog_detect task 42 | print("Running...") 43 | asyncio.create_task(foo()) 44 | _thread.start_new_thread(other, ()) # Tuple of args 45 | while True: 46 | await bar() 47 | await asyncio.sleep_ms(11) 48 | 49 | 50 | asyncio.run(main()) 51 | -------------------------------------------------------------------------------- /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 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(True) # Run hog_detect task 26 | while True: 27 | trig() 28 | try: 29 | await asyncio.wait_for_ms(forever(), 100) # 100ms pulse on ID1 30 | except asyncio.TimeoutError: # Mandatory error trapping 31 | pass 32 | # Task has now timed out 33 | await asyncio.sleep_ms(100) 34 | tasks = [] 35 | for _ in range(5): # ID 1, 2, 3 go high, then 500ms pause 36 | tasks.append(asyncio.create_task(forever())) 37 | await asyncio.sleep_ms(100) 38 | while tasks: # ID 3, 2, 1 go low 39 | tasks.pop().cancel() 40 | await asyncio.sleep_ms(100) 41 | await asyncio.sleep_ms(100) 42 | 43 | 44 | try: 45 | asyncio.run(main()) 46 | finally: 47 | asyncio.new_event_loop() 48 | -------------------------------------------------------------------------------- /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 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(True) # Run hog_detect task. Make 10ms waitx gaps visible. 41 | foo1 = Foo() 42 | foo2 = Foo() 43 | while True: 44 | trig() # Mark start with pulse on ident 5 45 | # Create two instances of .pause separated by 50ms 46 | asyncio.create_task(foo1.pause()) 47 | await asyncio.sleep_ms(50) 48 | await foo2.pause() 49 | await asyncio.sleep_ms(50) 50 | 51 | 52 | try: 53 | asyncio.run(main()) 54 | finally: 55 | asyncio.new_event_loop() 56 | -------------------------------------------------------------------------------- /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 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(True) # Run hog_detect task. Make 10ms waitx gaps visible. 50 | foo = Foo() 51 | while True: 52 | await foo.bar() 53 | await asyncio.sleep_ms(100) 54 | await foo.pause() 55 | 56 | 57 | try: 58 | asyncio.run(main()) 59 | finally: 60 | asyncio.new_event_loop() 61 | -------------------------------------------------------------------------------- /tests/lock_event.py: -------------------------------------------------------------------------------- 1 | # lock_event.py 2 | # Tests the monitoring of Lock and Event objects. 3 | 4 | # Copyright (c) 2025 Peter Hinch 5 | # Released under the MIT License (MIT) - see LICENSE file 6 | 7 | # Host assumed to be a Pico 8 | 9 | import asyncio 10 | from machine import UART 11 | import monitor 12 | from pins import pico # PCB pin nos. 13 | 14 | # Define interface to use 15 | monitor.set_device(UART(0, 1_000_000)) # GPIO 0, pin 1. UART must be 1MHz 16 | 17 | lock = monitor.MonLock(pico(6)) # GPIO4 pin 6 18 | event = monitor.MonEvent(pico(7)) # GPIO 5 pin 7 19 | 20 | 21 | async def locker(n): 22 | print(f"locker {n} awaiting event.") 23 | await event.wait() 24 | await asyncio.sleep(1) 25 | print(f"locker {n} awaiting lock.") 26 | async with lock: 27 | print(f"locker {n} got lock.") 28 | await asyncio.sleep(1) 29 | print(f"locker {n} released lock.") 30 | 31 | 32 | async def start(): 33 | await asyncio.sleep(1) 34 | event.set() 35 | 36 | 37 | async def main(): 38 | monitor.init() 39 | coros = [locker(x) for x in range(4)] 40 | asyncio.create_task(start()) 41 | await asyncio.gather(*coros) 42 | print("Pause 1s") 43 | await asyncio.sleep(1) 44 | event.clear() 45 | print("Event cleared.") 46 | await asyncio.sleep(1) 47 | print("Done") 48 | 49 | 50 | try: 51 | asyncio.run(main()) 52 | finally: 53 | asyncio.new_event_loop() 54 | 55 | # Sequence: 56 | # Lock 0, Event 0 All tasks waiting on event 57 | # Lock 0, Event 1 Tasks got event, paused 58 | # Lock 1, Event 1 Persists for 4 secs. Tasks wait on lock, each runs for 1s 59 | # Lock 0, Event 1 Last task releases lock 60 | # Lock 0, Event 0 Main code clears event. 61 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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(hog_detect=False): 133 | # Optionally run hog detection to show up periods of blocking behaviour 134 | async def hd(s=(0x40, 0x60)): 135 | while True: 136 | for v in s: 137 | _write(v) 138 | await asyncio.sleep_ms(0) 139 | 140 | _ifrst() # Reset interface. Does nothing if UART. 141 | _write(ord("z")) # Clear Pico's instance counters etc. 142 | if hog_detect: 143 | if asyncio.current_task is None: 144 | print("Warning: attempt to run hog_detect before asyncio started.") 145 | asyncio.create_task(hd()) 146 | 147 | 148 | # Monitor a synchronous function definition 149 | def sync(ident, looping=False): 150 | def decorator(func): 151 | _validate(ident, 1, looping) 152 | 153 | def wrapped_func(*args, **kwargs): 154 | _write(0x40 + ident) 155 | res = func(*args, **kwargs) 156 | _write(0x60 + ident) 157 | return res 158 | 159 | return wrapped_func 160 | 161 | return decorator 162 | 163 | 164 | # Monitor using a context manager 165 | class Monitor: 166 | def __init__(self, ident): 167 | # looping: a CM may be instantiated many times 168 | _validate(ident, 1, True) 169 | self.ident = ident 170 | 171 | def __enter__(self): 172 | _write(0x40 + self.ident) 173 | return self 174 | 175 | def __exit__(self, type, value, traceback): 176 | _write(0x60 + self.ident) 177 | return False # Don't silence exceptions 178 | 179 | 180 | mon_call = Monitor # Old version 181 | 182 | # Either cause pico ident n to produce a brief (~80μs) pulse or turn it 183 | # on or off on demand. No looping: docs suggest instantiating at start. 184 | def trigger(ident): 185 | _validate(ident) 186 | 187 | def wrapped(state=None): 188 | if state is None: 189 | _write(0x40 + ident) 190 | sleep_us(20) 191 | _write(0x60 + ident) 192 | else: 193 | _write(ident + (0x40 if state else 0x60)) 194 | 195 | return wrapped 196 | 197 | 198 | # Track the state of a Lock instance 199 | class MonLock(asyncio.Lock): 200 | def __init__(self, ident): 201 | _validate(ident) # Cannot instantiate in a loop 202 | self.ident = ident 203 | super().__init__() 204 | 205 | async def acquire(self): 206 | if not self.state: # Currently unlocked 207 | _write(0x40 + self.ident) # Lock will be set 208 | await super().acquire() 209 | 210 | def release(self): 211 | super().release() # But a pending task may be scheduled 212 | if not self.state: # No pending task: the lock is free. 213 | _write(0x60 + self.ident) 214 | 215 | 216 | # Track the state of an Event instance 217 | class MonEvent(asyncio.Event): 218 | def __init__(self, ident): 219 | _validate(ident) 220 | self.ident = ident 221 | super().__init__() 222 | 223 | def set(self): 224 | if not self.state: # Currently unset 225 | _write(0x40 + self.ident) # Event will be set 226 | super().set() 227 | 228 | def clear(self): 229 | if self.state: # Currently set 230 | _write(0x60 + self.ident) 231 | super().clear() 232 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 2 | 3 | # A monitor for realtime MicroPython code 4 | 5 | This library provides a means of examining the behaviour of a running system. 6 | It is intended for profiling code whose behaviour may change dynamically such as 7 | `asyncio` applications, threaded code or applications using interrupts. 8 | 9 | The device under test (DUT) is linked to a Raspberry Pico. The latter runs a 10 | module from this repo which displays the behaviour of the DUT by pin changes and 11 | optional print statements. A view of the realtime behaviour of the code may be 12 | acquired with a logic analyser or scope; in the absence of test gear valuable 13 | information can be gleaned at the Pico command line. 14 | ![Image](./images/schema.png) 15 | 16 | 1. [Introduction](./README.md#1-introduction) 17 | 1.1 [Concepts](./README.md#11-concepts) 18 | 1.2 [Pre-requisites](./README.md#12-pre-requisites) 19 | 1.3 [Installation](./README.md#13-installation) Using mpremote mip. 20 | 1.4 [UART connection](./README.md#14-uart-connection) Usual way to connect to DUT. 21 | 1.5 [SPI connection](./README.md#15-spi-connection) SPI alternative to a UART. 22 | 1.6 [Quick start](./README.md#16-quick-start) 23 | 1.7 [monitor module methods](./README.md#17-monitor-module-methods) 24 | 2. [Monitoring](./README.md#2-monitoring) 25 | 2.1 [Validation of idents](./README.md#21-validation-of-idents) 26 | 3. [Monitoring arbitrary code](./README.md#3-monitoring-arbitrary-code) 27 | 3.1 [The Monitor context manager](./README.md#31-the-monitor-context-manager) Time a code block. 28 | 3.2 [The trigger timing marker](./README.md#32-the-trigger-timing-marker) Issue timing pulses. 29 | 3.3 [The sync decorator](./README.md#33-the-sync-decorator) Pulse for the duration of a function or method. 30 | 3.4 [Timing of code segments](./README.md#34-timing-of-code-segments) Pulse if a code block runs too slowly. 31 | 4. [Monitoring asyncio code](./README.md#4-monitoring-asyncio-code) 32 | 4.1 [Monitoring coroutines](./README.md#41-monitoring-coroutines) Pulse for the duration of a coroutine. 33 | 4.2 [Detecting CPU hogging](./README.md#42-detecting-cpu-hogging) 34 | 4.3 [Monitoring Lock instances](./README.md#43-monitoring-lock-instances) Pulse while a Lock is held. 35 | 4.4 [Monitoring Event instances](./README.md#44monitoring-event-instances) Pulse while an Event is set. 36 | 5. [The pico](./README.md#5-the-pico) Introduction to the Pico module. 37 | 5.1 [Pico pin mapping](./README.md#51-pico-pin-mapping) 38 | 5.2 [The Pico code](./README.md#52-the-pico-code) 39 | 5.3 [The Pico run function](./README.md#-the-pico-run-function) How to configure the monitoring module. 40 | 5.4 [Advanced hog detection](./README.md#54-advanced-hog-detection) 41 | 5.5 [Timing of code segments](./README.md#55-timing-of-code-segments) 42 | 6. [Test and demo scripts](./README.md#6-test-and-demo-scripts) 43 | 7. [Internals](./README.md#7-internals) 44 | 7.1 [Performance and design notes](./README.md#71-performance-and-design-notes) Design rationale. 45 | 7.2 [How it works](./README.md#72-how-it-works) For those wanting to adapt monitor_pico.py. 46 | 7.3 [ESP8266 note](./README.md#73-esp8266-note) Use of the tx-only UART. 47 | 8. [A hardware implementation](./README.md#8-a-hardware-implementation) A PCB implementation. 48 | 49 | # 1. Introduction 50 | 51 | Code for an [analyser back-end](./ANALYSER.md) is provided for users lacking a 52 | logic analyser or multi-channel scope: 53 | ![Image](./images/la_ui.jpg) 54 | 55 | A Pico and a cheap display emulates a logic analyser style display. I'm unsure 56 | whether this has practical application: I suspect most asynchronous coders 57 | already have test gear. The rest of this document describes use with a logic 58 | analyser. 59 | 60 | Where an application runs multiple concurrent tasks it can be difficult to 61 | identify a task which is hogging CPU time. Long blocking periods can also occur 62 | when several tasks each block for a period. If these happen to be scheduled in 63 | succession, the times will add: this may occur at unpredictable, infrequent 64 | intervals. To capture these events the monitor issues a trigger pulse when the 65 | blocking period exceeds a threshold. The threshold can be a fixed time or the 66 | current maximum blocking period. A logic analyser enables the state at the time 67 | of the transient event to be examined. 68 | 69 | This image shows the detection of CPU hogging. In this example a task hogs the 70 | CPU for 500ms, causing the scheduler to be unable to schedule other tasks. A 71 | trigger pulse is generated by the Pico 100ms after hogging started. This script 72 | is discussed in detail in [section 6](./README.md#6-test-and-demo-scripts). 73 | 74 | ![Image](./images/monitor.jpg) 75 | 76 | The following image shows brief (<4ms) hogging while `quick_test.py` ran. The 77 | likely cause is garbage collection on the Pyboard D DUT. The monitor was able 78 | to demonstrate that this never exceeded 5ms. 79 | 80 | ![Image](./images/monitor_gc.jpg) 81 | 82 | ### Threaded and RP2 dual core applications 83 | 84 | The monitor is designed to work with asynchronous systems based on `asyncio`, 85 | threading and interrupts. It also supports dual-core applications on RP2, but 86 | the device under test should run firmware V1.19 or later. 87 | 88 | ## 1.1 Concepts 89 | 90 | Communication with the Pico may be by UART or SPI, and is uni-directional from 91 | DUT to Pico. If a UART is used only one GPIO pin is needed. SPI requires three, 92 | namely `mosi`, `sck` and `cs/`. 93 | 94 | The Pico runs `monitor_pico.py` typically invoked with: 95 | ```python 96 | from monitor_pico import run 97 | run() # or run(device="spi") 98 | ``` 99 | Debug statements are inserted at key points in the DUT code. These cause state 100 | changes on Pico pins. All debug lines are associated with an `ident` which is a 101 | number where `0 <= ident <= 21`. The `ident` value defines a Pico GPIO pin 102 | according to the mapping in [section 5.1](./README.md#51-pico-pin-mapping). 103 | 104 | For example the following will cause a pulse on GPIO6. 105 | ```python 106 | import monitor 107 | trig1 = monitor.trigger(1) # Create a trigger on ident 1 108 | 109 | async def test(): 110 | while True: 111 | await asyncio.sleep_ms(100) 112 | trig1() # Pulse appears now 113 | ``` 114 | A decorator may be inserted prior to a function definition; in `asyncio` code 115 | a coroutine definition may be decorated. These cause a Pico pin to go high for 116 | the duration whenever the function or coroutine runs. Other mechanisms offer 117 | means of measuring cpu hogging. 118 | 119 | The Pico can output a trigger pulse on GPIO28 which may be used to trigger a 120 | scope or logic analyser. This can be configured to occur when excessive latency 121 | arises or when a segment of code runs unusually slowly. If the LA displays 122 | pre-trigger information the cause of the problem can be isolated. 123 | 124 | ## 1.2 Pre-requisites 125 | 126 | The DUT must run firmware V1.19 or later. The Pico must run V1.20 or later. Pico 127 | hardware may be RP2040 (original Pico) or better. 128 | 129 | ## 1.3 Installation 130 | 131 | On device under test: 132 | ```bash 133 | $ mpremote mip install "github:peterhinch/micropython-monitor" 134 | ``` 135 | On Pico: 136 | ```bash 137 | $ mpremote mip install "github:peterhinch/micropython-monitor/pico" 138 | ``` 139 | 140 | ## 1.4 UART connection 141 | 142 | Wiring. Pins for the DUT are not specified as there are many MicroPython 143 | platforms and any UART may be used. Pins are those on the Pico performing the 144 | monitoring. 145 | 146 | | DUT | Pico GPIO | Pico Pin | Pico signal | 147 | |:---:|:----------|:---------|:------------| 148 | | Gnd | Gnd | 3 | Gnd | 149 | | txd | 1 | 2 | Uart 0 Rxd | 150 | 151 | The DUT is configured to use a UART by passing an initialised UART with 1MHz 152 | baudrate to `monitor.set_device`: 153 | 154 | ```python 155 | from machine import UART 156 | import monitor 157 | monitor.set_device(UART(2, 1_000_000)) # Baudrate MUST be 1MHz. 158 | ``` 159 | The Pico `run()` command assumes a UART by default, invoked as follows: 160 | ```python 161 | from monitor_pico import run 162 | run() 163 | ``` 164 | 165 | ## 1.5 SPI connection 166 | 167 | Wiring: 168 | 169 | | DUT | GPIO | Pin | 170 | |:-----:|:----:|:---:| 171 | | Gnd | Gnd | 3 | 172 | | mosi | 0 | 1 | 173 | | sck | 1 | 2 | 174 | | cs | 2 | 4 | 175 | 176 | The DUT is configured to use SPI by passing an initialised SPI instance and a 177 | `cs/` Pin instance to `set_device`: 178 | ```python 179 | from machine import Pin, SPI 180 | import monitor 181 | monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X6', Pin.OUT)) # Device under test SPI 182 | ``` 183 | The SPI instance must have default args; the one exception being baudrate which 184 | may be any value. I have tested up to 30MHz but there is no benefit in running 185 | above 1MHz. Hard or soft SPI may be used. It should be possible to share the 186 | bus with other devices, although I haven't tested this. 187 | 188 | The Pico should be started with 189 | ```python 190 | from monitor_pico import run 191 | run(device="spi") 192 | ``` 193 | 194 | ## 1.6 Quick start 195 | 196 | This example assumes a UART connection. On the Pico issue: 197 | ```python 198 | from monitor_pico import run 199 | run() 200 | ``` 201 | The Pico should issue "Awaiting communication." 202 | 203 | On the DUT some boilerplate code must be added to the application to enable 204 | monitoring. Lines labelled MANDATORY represent this added code. 205 | ```python 206 | import asyncio as asyncio 207 | from machine import UART # MANDATORY Use a UART for monitoring 208 | import monitor # MANDATORY 209 | monitor.set_device(UART(2, 1_000_000)) # MANDATORY Baudrate MUST be 1MHz. 210 | 211 | @monitor.asyn(1) # Assign ident 1 to foo (GPIO 4) 212 | async def foo(): 213 | await asyncio.sleep_ms(100) 214 | 215 | async def main(): 216 | monitor.init() # MANDATORY Initialise Pico state at the start of every run 217 | while True: 218 | await foo() # Pico GPIO4 will go high for duration 219 | await asyncio.sleep_ms(100) 220 | 221 | try: 222 | asyncio.run(main()) 223 | finally: 224 | asyncio.new_event_loop() 225 | ``` 226 | When this runs the Pico should issue "Got communication." and a square wave of 227 | period 200ms should be observed on Pico GPIO 4 (pin 6). 228 | 229 | Example script `quick_test.py` provides a usage example. It may be adapted to 230 | use a UART or SPI interface: see commented-out code. 231 | 232 | ## 1.7 monitor module methods 233 | 234 | Initialisation: 235 | 236 | * `set_device(dev, cspin=None)` Define interface to Pic: UART or SPI. 237 | * `init(hog_detect=False)` Reset the monitor status. 238 | 239 | Running (`0 <= ident <=21`): 240 | 241 | * `Monitor(ident)` Create a context manager to monitor a code block. 242 | * `trigger(ident)` Create a trigger instance. 243 | * `sync(ident, looping=False)` Decorator for a synchronous function. 244 | * `asyn(ident, max_instances=1, verbose=True, looping=False)` Decorator for a 245 | coroutine. Can monitor multiple task instances. 246 | 247 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 248 | 249 | # 2. Monitoring 250 | 251 | An application to be monitored should first define the interface: 252 | ```python 253 | from machine import UART # Using a UART for monitoring 254 | import monitor 255 | monitor.set_device(UART(2, 1_000_000)) # Baudrate MUST be 1MHz. 256 | ``` 257 | or 258 | ```python 259 | from machine import Pin, SPI 260 | import monitor 261 | # Pass a configured SPI interface and a cs/ Pin instance. 262 | monitor.set_device(SPI(2, baudrate=5_000_000), Pin('X1', Pin.OUT)) 263 | ``` 264 | The pin used for `cs/` is arbitrary. 265 | 266 | Each time the application runs it should issue: 267 | ```python 268 | def main(): 269 | monitor.init() 270 | # rest of application code 271 | ``` 272 | This ensures that the Pico code assumes a known state, even if in a prior run 273 | the DUT crashed, was interrupted or failed. 274 | 275 | ## 2.1 Validation of idents 276 | 277 | Re-using idents would lead to confusing behaviour. If an ident is out of range 278 | or is assigned to more than one coroutine an error message is printed and 279 | execution terminates. 280 | 281 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 282 | 283 | # 3. Monitoring arbitrary code 284 | 285 | The following features may be used to characterise synchronous or asynchronous 286 | applications by causing Pico pin changes at specific points in code execution. 287 | 288 | The following are provided: 289 | * A `Monitor` context manager enables function monitoring to be restricted to 290 | specific calls. 291 | * A `trigger` closure which issues a brief pulse on the Pico or can set and 292 | clear the pin on demand. 293 | * A `sync` decorator for synchronous functions or methods. It monitors every 294 | call to the function. 295 | 296 | ## 3.1 The Monitor context manager 297 | 298 | This may be used to monitor arbitrary segments of code. A context manager may 299 | be used in a looping construct, consequently the ident is only checked for 300 | conflicts when the CM is first instantiated. 301 | 302 | Usage: 303 | ```python 304 | from monitor import Monitor 305 | def foo(): 306 | while True: 307 | # code 308 | with Monitor(5): # ident 5 will be asserted for the duration 309 | # monitored code section 310 | ``` 311 | 312 | It is inadvisable to use the context manager with code that calls a function 313 | having the `mon_func` decorator. Behaviour of pins and reports can be confusing. 314 | 315 | ## 3.2 The trigger timing marker 316 | 317 | The `trigger` closure is intended for timing blocks of code. A closure instance 318 | is created by passing the ident. If the instance is run with no args a brief 319 | (~80μs) pulse will occur on the Pico pin. If `True` is passed, the pin will go 320 | high until `False` is passed. 321 | 322 | The closure should be instantiated in the outermost scope. The instance may be 323 | called in a hard ISR context: this facilitates measuring the effect on system 324 | performance of ISR overhead. Also measuring the time between a hard ISR running 325 | and a section of code responding. See `tests/isr.py`. 326 | ```python 327 | trig = monitor.trigger(10) # Associate trig with ident 10. 328 | 329 | def foo(): 330 | trig() # Pulse ident 10, GPIO 13 331 | 332 | def bar(): 333 | trig(True) # set pin high 334 | # code omitted 335 | trig(False) # set pin low 336 | ``` 337 | If an arg is passed a call setting the pin high must be paired with one setting 338 | it low again otherwise confusing behaviour will occur. See 339 | [Section 7.2](./README.md#72-how-it-works). 340 | 341 | ## 3.3 The sync decorator 342 | 343 | This decorator causes a GPIO to go high when the function is called and to go 344 | low when it terminates. This is intended for synchronous functions: see the 345 | `asyn` decorator for coroutines. The following example will activate GPIO 26 346 | (associated with ident 20) for the duration of every call to `sync_func()`: 347 | ```python 348 | @monitor.sync(20) 349 | def sync_func(): 350 | pass 351 | ``` 352 | Decorator args: 353 | 1. `ident` 354 | 2. `looping=False` Set `True` if the decorator is called repeatedly e.g. in a 355 | nested function or method. The `True` value ensures validation of the ident 356 | occurs once only when the decorator first runs. Usage example: 357 | 358 | ```py 359 | def foo(): 360 | @monitor.sync(20, looping=True) 361 | def bar(): 362 | # code 363 | # 364 | ``` 365 | Without `looping=True`, validation would record a re-use of ident 20 the second 366 | time `foo` was called. 367 | 368 | ## 3.4 Timing of code segments 369 | 370 | The `monitor_pico.py` module running on the attached Pico can detect periods of 371 | inactivity on channel 0. This is primarily intended for the detection of hogging 372 | in asynchronous code, but can be re-purposed to detect slow running of any code 373 | block. This is useful if the duration varies in real time. The Pico can be 374 | configured to cause a message to be printed and a trigger pulse to be generated 375 | whenever the execution time exceeds the prior maximum. A scope or logic analyser 376 | may be triggered by this pulse allowing the state of other components of the 377 | system to be checked. 378 | 379 | This is done by re-purposing ident 0: 380 | ```python 381 | import monitor 382 | monitor.init() # Do not specify hog detection 383 | def foo(): 384 | # code omitted 385 | with monitor.Monitor(0): # Start of code block 386 | # Monitored code omitted 387 | ``` 388 | See [section 5.5](./README.md#55-timing-of-code-segments) for the Pico usage 389 | and demo `syn_time.py`. 390 | 391 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 392 | 393 | # 4. Monitoring asyncio code 394 | 395 | ## 4.1 Monitoring coroutines 396 | 397 | This takes account of the fact that a single coroutine may be used to launch 398 | multiple concurrent tasks. Where this occurs the tasks may be monitored on a set 399 | of consecutive idents. Coroutines to be monitored are prefixed with the 400 | `@monitor.asyn` decorator: 401 | ```python 402 | @monitor.asyn(2, 3) 403 | async def my_coro(): 404 | # code 405 | ``` 406 | The decorator positional args are as follows: 407 | 1. `ident` A unique number in range `0 <= ident <= 21` for the code being 408 | monitored. Determines the pin number on the Pico. See 409 | [section 5.1](./README.md#51-pico-pin-mapping). 410 | 2. `max_instances=1` Defines the maximum number of concurrent instances of the 411 | task to be independently monitored (default 1). 412 | 3. `verbose=True` If `False` suppress the warning which is printed on the DUT 413 | if the instance count exceeds `max_instances`. 414 | 4. `looping=False` Set `True` if the **decorator** is called repeatedly e.g. 415 | when decorating a nested function or method. The `True` value ensures 416 | validation of the ident occurs once only when the decorator first runs. Example 417 | of a case where `looping` should be specified: 418 | 419 | ```py 420 | async def foo(): 421 | @monitor.asyn(20, looping=True) 422 | async def bar(): 423 | # code 424 | # 425 | ``` 426 | Without `looping=True`, validation would record a re-use of ident 20 the second 427 | time `foo` was run. 428 | 429 | Whenever the coroutine runs, a pin on the Pico will go high, and when the code 430 | terminates it will go low. This enables the behaviour of the system to be 431 | viewed on a logic analyser or via console output on the Pico. This behaviour 432 | occurs whether the code terminates normally, is cancelled or has a timeout. 433 | 434 | In the example above, when `my_coro` starts, the pin defined by `ident==2` 435 | (GPIO 5) will go high. When it ends, the pin will go low. If, while it is 436 | running, a second instance of `my_coro` is launched, the next pin (GPIO 6) will 437 | go high. Pins will go low when the relevant instance terminates, is cancelled, 438 | or times out. If more instances are started than were specified to the 439 | decorator, a warning will be printed on the DUT. All excess instances will be 440 | associated with the final pin (`pins[ident + max_instances - 1]`) which will 441 | only go low when all instances associated with that pin have terminated. 442 | 443 | Consequently if `max_instances=1` and multiple instances are launched, a 444 | warning will appear on the DUT; the pin will go high when the first instance 445 | starts and will not go low until all have ended. The purpose of the warning is 446 | because the existence of multiple instances may be unexpected behaviour in the 447 | application under test - it does not imply a problem with the monitor. 448 | 449 | ## 4.2 Detecting CPU hogging 450 | 451 | A common cause of problems in asynchronous code is the case where a task blocks 452 | for a period, hogging the CPU, stalling the scheduler and preventing other 453 | tasks from running. Determining the task responsible can be difficult, 454 | especially as excessive latency may only occur when several greedy tasks are 455 | scheduled in succession. 456 | 457 | The Pico pin state only indicates that the task is running. A high pin does not 458 | imply CPU hogging. Thus 459 | ```python 460 | @monitor.asyn(3) 461 | async def long_time(): 462 | await asyncio.sleep(30) 463 | ``` 464 | will cause the pin to go high for 30s, even though the task is consuming no 465 | resources for that period. 466 | 467 | To provide a clue about CPU hogging, a `hog_detect` coroutine is provided. This 468 | has `ident=0` and, if used, is monitored on GPIO3. It loops, yielding to the 469 | scheduler. It will therefore be scheduled in round-robin fashion at speed. If 470 | long gaps appear in the pulses on GPIO3, other tasks are hogging the CPU. 471 | Usage of this is optional. To use, issue 472 | ```python 473 | import asyncio 474 | import monitor 475 | async def main(): # The application's entry point 476 | monitor.init(True) # True arg initiates hog detection on ident 0 477 | # code omitted 478 | ``` 479 | To aid in detecting the gaps in execution, in its default mode the Pico code 480 | implements a timer. This is retriggered by activity on `ident=0`. If it times 481 | out, a brief high going pulse is produced on GPIO 28, along with the console 482 | message "Hog". The pulse can be used to trigger a scope or logic analyser. The 483 | duration of the timer may be adjusted. Other modes of hog detection are also 484 | supported, notably producing a trigger pulse only when the prior maximum was 485 | exceeded. See [section 5](./README.md#5-Pico). 486 | 487 | # 4.3 Monitoring Lock instances 488 | 489 | Multiple tasks may compete for a `Lock`. A `MonLock`, subclassed from `Lock`, 490 | tracks the `Lock` state setting a Pico pin high when the lock is set, and low 491 | when it is released. In typical use, each competing task uses an asynchronous 492 | context manager to control the common `Lock`. In this case the pin goes low 493 | when the last pending task terminates. 494 | 495 | Usage: 496 | ```python 497 | import asyncio 498 | import monitor 499 | # my_lock = asyncio.Lock() 500 | my_lock = monitor.MonLock(5) # Replace unmonitored Lock with monitor on GPIO 8 501 | # code omitted 502 | async def some_task(): 503 | async with my_lock: # If no other task competes for the Lock, Pico GPIO 8 504 | await asyncio.sleep(1) # will go high for 1s 505 | ``` 506 | 507 | # 4.4 Monitoring Event instances 508 | 509 | An `Event` may be set by multiple tasks and more than one task may wait on it. 510 | A `MonEvent`, subclassed from `Event`, tracks the `Event` state. It sets a Pico 511 | pin high when it is set and low when it is cleared. If a task set an `Event` 512 | which is already set, the pin will remain high; likewise no state change will 513 | occur if an already cleared `Event` is cleared. 514 | 515 | Usage: 516 | ```python 517 | import asyncio 518 | import monitor 519 | monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz 520 | # my_event = asyncio.Event() 521 | my_event = monitor.MonEvent(5) # Replaces normal Event above 522 | # code omitted 523 | async def waiter(): 524 | await my_event.wait() 525 | my_event.clear() 526 | 527 | async def main(): 528 | monitor.init() 529 | asyncio.create_task(waiter()) 530 | await asyncio.sleep(1) 531 | my_event.set() # Pico GPIO 8 goes high, then goes low when waiter runs 532 | await asyncio.sleep(1) 533 | ``` 534 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 535 | 536 | # 5. The Pico 537 | 538 | Pico hardware may be RP2040 (original Pico) or better. Code is `monitor_pico.py` 539 | which is started by the module method `run()` and configured by passing args to 540 | that call. The code runs forever, and may optionally produce printed output. 541 | 542 | # 5.1 Pico pin mapping 543 | 544 | The Pico GPIO numbers used by idents start at 3 and have a gap where the Pico 545 | uses GPIO's for particular purposes. This is the mapping between `ident` GPIO 546 | no. and Pico PCB pin. Pins for the trigger and the UART/SPI link are also 547 | identified: 548 | 549 | | ident | GPIO | pin | 550 | |:-------:|:----:|:----:| 551 | | nc/mosi | 0 | 1 | 552 | | rxd/sck | 1 | 2 | 553 | | nc/cs/ | 2 | 4 | 554 | | 0 | 3 | 5 | 555 | | 1 | 4 | 6 | 556 | | 2 | 5 | 7 | 557 | | 3 | 6 | 9 | 558 | | 4 | 7 | 10 | 559 | | 5 | 8 | 11 | 560 | | 6 | 9 | 12 | 561 | | 7 | 10 | 14 | 562 | | 8 | 11 | 15 | 563 | | 9 | 12 | 16 | 564 | | 10 | 13 | 17 | 565 | | 11 | 14 | 19 | 566 | | 12 | 15 | 20 | 567 | | 13 | 16 | 21 | 568 | | 14 | 17 | 22 | 569 | | 15 | 18 | 24 | 570 | | 16 | 19 | 25 | 571 | | 17 | 20 | 26 | 572 | | 18 | 21 | 27 | 573 | | 19 | 22 | 29 | 574 | | 20 | 26 | 31 | 575 | | 21 | 27 | 32 | 576 | | trigger | 28 | 34 | 577 | 578 | As a convenience the file `pins.py` is provided. This provides two functions: 579 | * `pico(p:int) -> int` Passed a Pico PCB pin no. returns an ident. 580 | * `gp(p:int) -> int` Accepts an RP2xxx GP no, returns an ident. 581 | This allows code such as: 582 | ```python 583 | trig = monitor.trigger(pico(7)) # Monitor on Pico PCB pin 7 584 | ``` 585 | 586 | ## 5.2 The Pico code 587 | 588 | Monitoring via the UART with default behaviour is started as follows: 589 | ```python 590 | from monitor_pico import run 591 | run() 592 | ``` 593 | By default the Pico retriggers a timer every time ident 0 becomes active. If 594 | the timer times out, a pulse appears on GPIO28 which may be used to trigger a 595 | scope or logic analyser. This is intended for use with the `hog_detect` coro, 596 | with the pulse occurring when excessive latency is encountered. 597 | 598 | ## 5.3 The Pico run function 599 | 600 | Arguments to `run()` can select the interface and modify the default behaviour. 601 | 1. `period=100` Define the hog_detect timer period in ms. A 2-tuple may also 602 | be passed for specialised reporting, see below. 603 | 2. `verbose=()` A list or tuple of `ident` values which should produce console 604 | output. A passed ident will produce console output each time that task starts 605 | or ends. 606 | 3. `device="uart"` Set to `"spi"` for an SPI interface. 607 | 4. `vb=True` By default the Pico issues console messages reporting on initial 608 | communication status, repeated each time the application under test restarts. 609 | Set `False` to disable these messages. 610 | 611 | Thus to run such that idents 4 and 7 produce console output, with hogging 612 | reported if blocking is for more than 60ms, issue 613 | ```python 614 | from monitor_pico import run 615 | run(60, (4, 7)) 616 | ``` 617 | Hog reporting is as follows. If ident 0 is inactive for more than the specified 618 | time, "Timeout" is issued. If ident 0 occurs after this, "Hog Nms" is issued 619 | where N is the duration of the outage. If the outage is longer than the prior 620 | maximum, "Max hog Nms" is also issued. 621 | 622 | This means that if the application under test terminates, throws an exception 623 | or crashes, "Timeout" will be issued. 624 | 625 | ## 5.4 Advanced hog detection 626 | 627 | The detection of rare instances of high latency is a key requirement and other 628 | modes are available. There are two aims: providing information to users lacking 629 | test equipment and enhancing the ability to detect infrequent cases. Modes 630 | affect the timing of the trigger pulse and the frequency of reports. 631 | 632 | Modes are invoked by passing a 2-tuple as the `period` arg. 633 | * `period[0]` The period (ms): outages shorter than this time will be ignored. 634 | * `period[1]` is the mode: constants `SOON`, `LATE` and `MAX` are exported. 635 | 636 | The mode has the following effect on the trigger pulse: 637 | * `SOON` Default behaviour: pulse occurs early at time `period[0]` ms after 638 | the last trigger. 639 | * `LATE` Pulse occurs when the outage ends. 640 | * `MAX` Pulse occurs when the outage ends and its duration exceeds the prior 641 | maximum. 642 | 643 | The mode also affects reporting. The effect of mode is as follows: 644 | * `SOON` Default behaviour as described in section 4. 645 | * `LATE` As above, but no "Timeout" message: reporting occurs at the end of an 646 | outage only. 647 | * `MAX` Report at end of outage but only when prior maximum exceeded. This 648 | ensures worst-case is not missed. 649 | 650 | Running the following produce instructive console output: 651 | ```python 652 | from monitor_pico import run, MAX 653 | run((1, MAX)) 654 | ``` 655 | ## 5.5 Timing of code segments 656 | 657 | This may be done by issuing: 658 | ```python 659 | from monitor_pico import run, WIDTH 660 | run((20, WIDTH)) # Ignore widths < 20ms. 661 | ``` 662 | Assuming that ident 0 is used as described in 663 | [section 3.4](./README.md#34-timing-of-code-segments) a trigger pulse on GPIO28 664 | will occur each time the time taken exceeds both 20ms and its prior maximum. A 665 | message with the actual width is also printed whenever this occurs. 666 | 667 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 668 | 669 | # 6. Test and demo scripts 670 | 671 | The following image shows the `quick_test.py` code being monitored at the point 672 | when a task hogs the CPU. The top line 00 shows the "hog detect" trigger. Line 673 | 01 shows the fast running `hog_detect` task which cannot run at the time of the 674 | trigger because another task is hogging the CPU. Lines 02 and 04 show the `foo` 675 | and `bar` tasks. Line 03 shows the `hog` task and line 05 is a trigger issued 676 | by `hog()` when it starts monopolising the CPU. The Pico issues the "hog 677 | detect" trigger 100ms after hogging starts. 678 | 679 | ![Image](./images/monitor.jpg) 680 | 681 | `full_test.py` Tests task timeout and cancellation, also the handling of 682 | multiple task instances. If the Pico is run with `run((1, MAX))` it reveals 683 | the maximum time the DUT hogs the CPU. On a Pyboard D I measured 5ms. 684 | 685 | The sequence here is a trigger is issued on ident 4. The task on ident 1 is 686 | started, but times out after 100ms. 100ms later, five instances of the task on 687 | ident 1 are started, at 100ms intervals. They are then cancelled at 100ms 688 | intervals. Because 3 idents are allocated for multiple instances, these show up 689 | on idents 1, 2, and 3 with ident 3 representing 3 instances. Ident 3 therefore 690 | only goes low when the last of these three instances is cancelled. 691 | 692 | ![Image](./images/full_test.jpg) 693 | 694 | `latency.py` Measures latency between the start of a monitored task and the 695 | Pico pin going high. In the image below the sequence starts when the DUT 696 | pulses a pin (ident 6). The Pico pin monitoring the task then goes high (ident 697 | 1 after ~20μs). Then the trigger on ident 2 occurs 112μs after the pin pulse. 698 | 699 | ![Image](./images/latency.jpg) 700 | 701 | `syn_test.py` Demonstrates two instances of a bound method along with the ways 702 | of monitoring synchronous code. The trigger on ident 5 marks the start of the 703 | sequence. The `foo1.pause` method on ident 1 starts and runs `foo1.wait1` on 704 | ident 3. 100ms after this ends, `foo.wait2` on ident 4 is triggered. 100ms 705 | after this ends, `foo1.pause` on ident 1 ends. The second instance of `.pause` 706 | (`foo2.pause`) on ident 2 repeats this sequence shifted by 50ms. The 10ms gaps 707 | in `hog_detect` show the periods of deliberate CPU hogging. 708 | 709 | ![Image](./images/syn_test.jpg) 710 | 711 | `syn_time.py` Demonstrates timing of a specific code segment with a trigger 712 | pulse being generated every time the period exceeds its prior maximum. 713 | 714 | ![Image](./images/syn_time.jpg) 715 | 716 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 717 | 718 | # 7. Internals 719 | 720 | ## 7.1 Performance and design notes 721 | 722 | Using a UART the latency between a monitored coroutine starting to run and the 723 | Pico pin going high is about 23μs. With SPI I measured -12μs. This isn't as 724 | absurd as it sounds: a negative latency is the effect of the decorator which 725 | sends the character before the coroutine starts. These values are small in the 726 | context of `asyncio`: scheduling delays are on the order of 150μs or greater 727 | depending on the platform. See `tests/latency.py` for a way to measure latency. 728 | 729 | The use of decorators eases debugging: they are readily turned on and off by 730 | commenting out. 731 | 732 | The Pico was chosen for extremely low cost. It has plenty of GPIO pins and no 733 | underlying OS to introduce timing uncertainties. The PIO enables a simple SPI 734 | slave. 735 | 736 | Symbols transmitted by the UART are printable ASCII characters to ease 737 | debugging. A single byte protocol simplifies and speeds the Pico code. 738 | 739 | The baudrate of 1Mbps was chosen to minimise latency (10μs per character is 740 | fast in the context of asyncio). It also ensures that tasks like `hog_detect`, 741 | which can be scheduled at a high rate, can't overflow the UART buffer. The 742 | 1Mbps rate seems widely supported. 743 | 744 | ## 7.2 How it works 745 | 746 | This is for anyone wanting to modify the code. Each ident is associated with 747 | two bytes, `0x40 + ident` and `0x60 + ident`. These are upper and lower case 748 | printable ASCII characters (aside from ident 0 which is `@` paired with the 749 | backtick character). When an ident becomes active (e.g. at the start of a 750 | coroutine), uppercase is transmitted, when it becomes inactive lowercase is 751 | sent. 752 | 753 | The Pico maintains a list `pins` indexed by `ident`. Each entry is a 3-list 754 | comprising: 755 | * The `Pin` object associated with that ident. 756 | * An instance counter. 757 | * A `verbose` boolean defaulting `False`. 758 | 759 | When a character arrives, the `ident` value is recovered. If it is uppercase 760 | the pin goes high and the instance count is incremented. If it is lowercase the 761 | instance count is decremented: if it becomes 0 the pin goes low. 762 | 763 | The `init` function on the DUT sends `b"z"` to the Pico. This sets each pin 764 | in `pins` low and clears its instance counter (the program under test may have 765 | previously failed, leaving instance counters non-zero). The Pico also clears 766 | variables used to measure hogging. In the case of SPI communication, before 767 | sending the `b"z"`, a 0 character is sent with `cs/` high. The Pico implements 768 | a basic SPI slave using the PIO. This may have been left in an invalid state by 769 | a crashing DUT. The slave is designed to reset to a "ready" state if it 770 | receives any character with `cs/` high. 771 | 772 | The ident `@` (0x40) is assumed to be used by the `hog_detect()` function. When 773 | the Pico receives it, processing occurs to aid in hog detection and creating a 774 | trigger on GPIO28. Behaviour depends on the mode passed to the `run()` command. 775 | In the following, `thresh` is the time passed to `run()` in `period[0]`. 776 | * `SOON` This retriggers a timer with period `thresh`. Timeout causes a 777 | trigger. 778 | * `LATE` Trigger occurs if the period since the last `@` exceeds `thresh`. The 779 | trigger happens when the next `@` is received. 780 | * `MAX` Trigger occurs if period exceeds `thresh` and also exceeds the prior 781 | maximum. 782 | 783 | ## 7.3 ESP8266 note 784 | 785 | ESP8266 applications can be monitored using the transmit-only UART 1. 786 | 787 | I was expecting problems: on boot the ESP8266 transmits data on both UARTs at 788 | 75Kbaud. In practice `monitor_pico.py` ignores this data for the following 789 | reasons. 790 | 791 | A bit at 75Kbaud corresponds to 13.3 bits at 1Mbaud. The receiving UART will 792 | see a transmitted 1 as 13 consecutive 1 bits. In the absence of a start bit, it 793 | will ignore the idle level. An incoming 0 will be interpreted as a framing 794 | error because of the absence of a stop bit. In practice the Pico UART returns 795 | `b'\x00'` when this occurs; `monitor.py` ignores such characters. A monitored 796 | ESP8266 behaves identically to other platforms and can be rebooted at will. 797 | 798 | #### [Top](./README.md#a-monitor-for-realtime-micropython-code) 799 | 800 | # 8. A hardware implementation 801 | 802 | I expect to use this a great deal, so I designed a PCB. In the image below the 803 | device under test is on the right, linked to the Pico board by means of a UART. 804 | 805 | ![Image](./images/monitor_hw.JPG) 806 | 807 | I can supply a schematic and PCB details if anyone is interested. 808 | 809 | This project was inspired by 810 | [this GitHub thread](https://github.com/micropython/micropython/issues/7456). 811 | --------------------------------------------------------------------------------