MicroPython Compatibility Layer#
The pymcu-micropython package lets you write firmware using the familiar MicroPython
APIs — machine, utime, micropython, and the AVR-specific avr port module — and
compile it directly to native AVR machine code with zero runtime overhead.
Important
Compiled, not interpreted
There is no MicroPython interpreter on the device. Every machine.* or avr.* call is a
compile-time shim that expands directly to HAL instructions. The MCU receives only the
resulting machine code — typically a few hundred bytes of flash, 0 bytes SRAM overhead.
The layer ships board definitions for arduino_uno, arduino_nano,
arduino_micro, arduino_mega, digispark, and the ATtiny family
(attiny13/13a, attiny24/44/84, attiny25/45/85, attiny2313/4313).
Select one with board = "…" in [tool.pymcu]. The examples below target the
ATmega328P (Arduino Uno / Nano). The pin numbering in machine.Pin follows the
Arduino integer convention (D0–D13, A0–A5); port strings such as "PB5" are also
accepted directly.
Quick start#
pip install pymcu-compiler pymcu-micropython
Note
The compiler/CLI is currently published as pymcu-compiler on PyPI (the
pymcu command comes from it). A PEP 541 request for the shorter
pymcu name is in progress — the original owner has agreed to transfer it —
so a future release may install simply as pip install pymcu.
# pyproject.toml
[tool.pymcu]
stdlib = ["micropython"]
board = "arduino_uno" # implies the chip (atmega328p) and pin map
frequency = 16000000
sources = "src"
entry = "main.py"
# src/main.py — identical to a real MicroPython script
from machine import Pin
from utime import sleep_ms
led = Pin(13, Pin.OUT) # D13 = PB5
while True:
led.toggle()
sleep_ms(500)
pymcu build # → dist/firmware.hex (blink: ~130 bytes flash, 0 bytes SRAM)
pymcu flash
Top-level statements are automatically wrapped in a main() entry point — no if __name__ == "__main__": needed.
Supported modules#
Module |
API surface |
Status |
|---|---|---|
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
Returns CPU Hz |
✅ Complete |
|
Software reset (via watchdog) |
✅ Complete |
|
Sleep modes |
✅ Complete |
|
IRQ control |
✅ Complete |
|
Pulse measurement |
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
|
✅ Complete |
|
UART output — auto-injects UART init |
✅ Complete |
|
UART input — reads line from UART |
✅ Complete |
|
Real-time clock |
✗ Not planned |
Module reference#
machine.Pin#
from machine import Pin
led = Pin(13, Pin.OUT) # Arduino D13 = PB5
btn = Pin(2, Pin.IN, Pin.PULL_UP)
led2 = Pin("PB5", Pin.OUT) # port-string also accepted
led.high() # or led.on()
led.low() # or led.off()
led.toggle()
v = led.value() # read → uint8
led.value(1) # write
# Pin is callable (MicroPython shortcut)
led(1) # same as led.value(1)
v = led() # same as led.value()
Pin number mapping (Arduino Uno)#
Arduino pin |
Port/bit |
AVR register |
|---|---|---|
D0 |
PD0 |
PORTD bit 0 |
D1 |
PD1 |
PORTD bit 1 |
D2 |
PD2 |
PORTD bit 2 (INT0) |
D3 |
PD3 |
PORTD bit 3 (INT1, OC2B) |
D4 |
PD4 |
PORTD bit 4 |
D5 |
PD5 |
PORTD bit 5 (OC0B) |
D6 |
PD6 |
PORTD bit 6 (OC0A) |
D7 |
PD7 |
PORTD bit 7 |
D8 |
PB0 |
PORTB bit 0 |
D9 |
PB1 |
PORTB bit 1 (OC1A) |
D10 |
PB2 |
PORTB bit 2 (OC1B, SS) |
D11 |
PB3 |
PORTB bit 3 (OC2A, MOSI) |
D12 |
PB4 |
PORTB bit 4 (MISO) |
D13 |
PB5 |
PORTB bit 5 (SCK, LED) |
Constant |
Value |
Description |
|---|---|---|
|
1 |
Input (DDRx bit cleared) |
|
0 |
Output (DDRx bit set) |
|
1 |
Enable internal pull-up (PORTx = 1 when IN) |
|
2 |
No hardware pull-down on AVR — stub |
|
1 |
Falling edge trigger |
|
2 |
Rising edge trigger |
Hardware interrupt configuration via Pin.irq(). Pass a handler directly — the
build driver registers it as the hardware ISR for the corresponding INT vector.
from machine import Pin
from pymcu.types import uint8
count: uint8 = 0
def on_press():
global count
count += 1
def main():
btn = Pin(2, Pin.IN, Pin.PULL_UP)
btn.irq(handler=on_press, trigger=Pin.IRQ_FALLING) # INT0, falling edge
while True:
pass
The handler is passed as a keyword argument (MicroPython style) or positional.
Under the hood, Pin.irq() calls pymcu.hal.gpio.Pin.irq(trigger, handler) which
configures EICRA/EIMSK and registers the ISR at the correct AVR vector.
@interrupt is still available for low-level control, but Pin.irq(handler=cb) is
the idiomatic MicroPython-compatible form.
# Both forms produce identical firmware:
btn.irq(handler=on_press, trigger=Pin.IRQ_FALLING) # MicroPython style
btn.irq(Pin.IRQ_FALLING, on_press) # positional (HAL order)
machine.UART#
Constructor:
UART(id=0, baudrate=9600)
The ATmega328P has one hardware UART (USART0). id is accepted for API
compatibility but ignored — 0 is the only meaningful value. The baudrate is
resolved at compile time against frequency in pyproject.toml; only standard
baud rates (9600, 19200, 38400, 57600, 115200) are guaranteed to produce exact
UBRR values with less than 0.5% error.
from machine import UART
from pymcu.types import uint8
uart = UART(0, 9600) # id ignored → USART0; baud rate set at compile time
uart.write(65) # send single byte (ASCII 'A')
uart.write_str("hello\n") # send string literal from PROGMEM
uart.println("ready") # write_str + newline
b: uint8 = uart.read() # blocking read — spins until RXC flag is set
uart.print_byte(42) # sends "42\n" as decimal ASCII digits
Method |
Signature |
Description |
|---|---|---|
|
|
Send a single byte |
|
|
Send a compile-time string literal — |
|
|
Blocking read — spins until RXC flag is set |
|
|
Read until |
|
|
Read until |
|
|
Read exactly |
|
|
Returns |
|
|
PyMCU extension — prefer |
|
|
PyMCU extension — |
|
|
PyMCU extension — decimal ASCII digits + newline |
Note
print() — the Python built-in — is the simplest way to write to the UART from
MicroPython-style code. The build driver detects print( in your source and
automatically injects uart_init() before main(). No manual UART(0, ...) required.
See Built-in functions below.
machine.ADC#
from machine import ADC, Pin
from pymcu.types import uint16
adc = ADC(Pin("A0"))
raw: uint16 = adc.read() # 0–1023 (10-bit, ATmega328P native)
val: uint16 = adc.read_u16() # 0–65472 (scaled ×64 to approximate MicroPython 0–65535)
The ATmega328P ADC is 10-bit. read_u16() scales the result by 64 to match MicroPython’s
convention of returning a 16-bit value; the maximum is 65472 (1023 × 64), not 65535.
machine.PWM#
from machine import PWM, Pin
pwm = PWM(Pin("PD6"), freq=1000, duty_u16=32768) # D6, 50% at 1 kHz
pwm.freq(490) # change frequency
pwm.duty_u16(49152) # 75% (16-bit, 0–65535 range)
pwm.duty(200) # 78% (8-bit OCR value, 0–255)
pwm.deinit() # stop and detach timer
Note
freq() sets the Timer0 prescaler. D5 (OC0B) and D6 (OC0A) share Timer0; changing
frequency on one affects both. For independent frequency control, use D9/D10 (Timer1)
or D11 (Timer2) via the HAL directly.
AVR PWM pins#
Arduino pin |
Timer |
Channel |
Notes |
|---|---|---|---|
D5 |
Timer0 |
OC0B |
Shares freq with D6 |
D6 |
Timer0 |
OC0A |
Shares freq with D5 |
D9 |
Timer1 |
OC1A |
16-bit, independent |
D10 |
Timer1 |
OC1B |
16-bit, independent |
D11 |
Timer2 |
OC2A |
8-bit, independent |
machine.SPI#
Constructor:
SPI()
Hardware SPI on the ATmega328P uses fixed pins: D13 (SCK), D11 (MOSI), D12 (MISO),
configured as controller (master) inside the constructor. The chip-select pin must
be managed manually with a Pin — this matches standard MicroPython behaviour.
from machine import SPI, Pin
from pymcu.types import uint8
spi = SPI() # hardware SPI, controller mode
cs = Pin(10, Pin.OUT)
# Single-byte variants (MicroPython-compatible signatures)
cs.low()
spi.write(0x9F) # send command byte (discard MISO)
device_id: uint8 = spi.read(0xFF) # send 0xFF dummy, return MISO byte
cs.high()
# Multi-byte variants — same API as MicroPython
buf: uint8[3] = [0xAA, 0xBB, 0xCC]
rxbuf: uint8[3] = [0, 0, 0]
cs.low()
spi.write(buf) # send len(buf) bytes, discard MISO
spi.readinto(rxbuf) # receive len(rxbuf) bytes (clocks 0xFF as dummy)
spi.readinto(rxbuf, 0x00) # receive len(rxbuf) bytes, clock 0x00 as dummy
spi.write_readinto(buf, rxbuf) # full-duplex: send/receive len(buf) bytes
cs.high()
Method |
Signature |
Description |
|---|---|---|
|
|
Send one byte, discard MISO |
|
|
Send |
|
|
Send |
|
|
Receive |
|
|
Receive |
|
|
Full-duplex single-byte exchange |
|
|
Full-duplex |
|
|
No-ops (SPI is set up in the constructor) |
Note
The constructor takes no arguments — the SPI clock divider, polarity, and bit order
use the HAL defaults (controller mode, MSB-first, f_osc/4). For a configurable clock
or arbitrary pins, use avr.SoftSPI (see below).
machine.I2C#
Constructor:
I2C(scl=None, sda=None, freq=100000)
Hardware I2C (TWI) uses fixed pins: A4 (SDA = PC4) and A5 (SCL = PC5). The scl
and sda arguments are accepted for MicroPython API compatibility but ignored —
the hardware pins are fixed on the ATmega328P. Requires 4.7 kΩ external pull-up
resistors on both lines.
from machine import I2C
from pymcu.types import uint8
i2c = I2C(freq=100000) # 100 kHz standard mode (scl/sda are fixed)
count: uint8 = i2c.scan() # count of responding devices (not a list)
# Single-byte variants (MicroPython-compatible signatures)
i2c.writeto(0x3C, 0x00) # write command byte to SSD1306
val: uint8 = i2c.readfrom(0x3C) # read one byte
# Multi-byte variants — same API as MicroPython
buf: uint8[3] = [0, 0, 0]
i2c.writeto(0x48, buf) # write len(buf) bytes to 0x48
n: uint8 = i2c.readfrom_into(0x48, buf) # read len(buf) bytes; returns 1=ok, 0=NACK
Method |
Signature |
Description |
|---|---|---|
|
|
Count of responding devices (not a list) |
|
|
Store addresses into caller buffer; returns count |
|
|
Write one byte to address |
|
|
Write |
|
|
Read one byte from address |
|
|
Read |
Note
scan() returns a device count rather than a list of addresses — returning a list[uint8]
would pull in GC infrastructure for a result most programs discard immediately. Use
scan(buf, max_count) to capture addresses into a caller-owned buffer at zero GC cost,
or pymcu.hal.i2c.I2C.ping(addr) to probe a specific address directly.
For bit-bang I2C with arbitrary GPIO pins, use avr.SoftI2C (see below).
machine.Timer#
from machine import Timer
from pymcu.types import uint8
ticks: uint8 = 0
def on_tick():
global ticks
ticks += 1
def main():
# MicroPython style: configure by frequency
t = Timer(1, freq=10, callback=on_tick) # 10 Hz == 100 ms
# Alternative: configure by period in ms
t = Timer(1, period=100, callback=on_tick) # fires every 100 ms
# Low-level style (still works):
# t = Timer(1, prescaler=64)
# t.irq(on_tick, Timer.IRQ_COMPA)
# t.start()
while True:
pass
Timer(id, freq=Hz) and Timer(id, period=ms) both auto-select a prescaler and OCR
value and register the callback in one step — identical to the MicroPython API.
Timer.init(freq=Hz) prescaler selection for AVR @ 16 MHz (Timer1, 16-bit):
|
Prescaler |
OCR formula |
|---|---|---|
≥ 245 |
1 |
|
≥ 31 |
8 |
|
≥ 4 |
64 |
|
≥ 1 |
1024 |
|
Timer.init(period=ms) prescaler selection:
|
Prescaler |
OCR formula |
|---|---|---|
≤ 262 |
64 |
|
≤ 4194 |
1024 |
|
Constant |
Value |
Description |
|---|---|---|
|
0 |
Fire once (stop after first interrupt) |
|
1 |
Reload automatically (default) |
|
1 |
Overflow interrupt |
|
2 |
Compare-match A interrupt |
AVR Timer resources#
Timer id |
Width |
Vectors |
Default use |
|---|---|---|---|
0 |
8-bit |
OVF, COMPA, COMPB |
|
1 |
16-bit |
OVF, COMPA, COMPB |
Best for precise intervals |
2 |
8-bit |
OVF, COMPA, COMPB |
PWM on D11 |
Warning
Timer(id=0) shares resources with delay_ms() and PWM on D5/D6. Prefer Timer(1) for
general-purpose periodic interrupts.
machine.WDT#
Constructor:
WDT(id=0, timeout=5000)
The id argument is accepted for API compatibility and ignored (single watchdog
on AVR). Configures the ATmega328P hardware watchdog. The MCU resets if feed() is not called
within the configured window. Useful as a fault-recovery safety net.
from machine import WDT
wdt = WDT(timeout=2000) # 2-second watchdog window
while True:
wdt.feed() # must be called within 2 s or MCU resets
do_work()
The timeout is in milliseconds and is rounded to the nearest ATmega328P prescaler step:
|
Prescaler |
Actual window |
|---|---|---|
≤ 16 |
WDP0 |
16 ms |
≤ 32 |
WDP1 |
32 ms |
≤ 64 |
WDP2 |
64 ms |
≤ 125 |
WDP1+WDP0 |
125 ms |
≤ 250 |
WDP2+WDP0 |
250 ms |
≤ 500 |
WDP2+WDP1 |
500 ms |
≤ 1000 |
WDP2+WDP1+WDP0 |
1 s |
≤ 2000 |
WDP3 |
2 s |
≤ 4000 |
WDP3+WDP0 |
4 s |
≤ 8000 |
WDP3+WDP1 |
8 s |
Method |
Description |
|---|---|
|
Reset the watchdog counter (must be called periodically) |
machine.Signal#
Active-high / active-low pin abstraction. Useful for active-low LEDs or relay modules:
from machine import Pin, Signal
relay = Signal(Pin(8, Pin.OUT), invert=True) # active-low relay board
relay.on() # drives pin LOW (activates relay)
relay.off() # drives pin HIGH (deactivates relay)
relay.value(1) # logical ON → LOW
machine.mem8 / machine.mem16#
Direct register access, identical syntax to real MicroPython:
from machine import mem8, mem16
# Toggle the LED on D13 (PB5) by writing PORTB directly
mem8[0x25] = mem8[0x24] | 0x20 # PORTB = PINB | PB5
# Read/write a 16-bit SFR (e.g., Timer1 counter TCNT1 at 0x84)
mem16[0x84] = 0 # reset Timer1 counter
Tip
For new code, prefer from pymcu.types import ptr — it is typed and verified at
compile time. mem8 / mem16 exist purely for MicroPython source compatibility.
Commonly used ATmega328P register addresses#
Address |
Register |
Description |
|---|---|---|
|
|
Port B input pins |
|
|
Port B direction |
|
|
Port B output |
|
|
Port C input pins |
|
|
Port C direction |
|
|
Port C output |
|
|
Port D input pins |
|
|
Port D direction |
|
|
Port D output |
|
|
ADC low byte |
|
|
ADC high byte |
|
|
ADC control/status |
|
|
Timer1 counter (16-bit) |
machine — IRQ and sleep#
from machine import disable_irq, enable_irq, idle, lightsleep, deepsleep, reset
# Atomic / critical section
state = disable_irq() # CLI instruction — disables global interrupts
# ... atomic operation ...
enable_irq(state) # SEI instruction — restores interrupts
# Sleep modes (wake on any enabled interrupt)
idle() # Idle: CPU halted, all peripherals running (~70% power reduction)
lightsleep() # Power-save: async timer kept alive; I/O and Timer2 active
deepsleep() # Power-down: only INT0/INT1 or WDT can wake (~99% reduction)
# Software reset (triggers a watchdog reset to restart the MCU)
reset()
machine.time_pulse_us#
Measure the duration of a pulse on a pin. Mirrors MicroPython’s machine.time_pulse_us:
from machine import Pin, time_pulse_us
from pymcu.types import int16
echo = Pin(7, Pin.IN)
duration: int16 = time_pulse_us(echo, 1, timeout_us=30000)
if duration == -1:
pass # timeout — no pulse detected
else:
distance_cm: int = duration // 58 # HC-SR04: 58 µs ≈ 1 cm
Returns the pulse width in microseconds, or -1 on timeout.
machine.freq#
from machine import freq
from pymcu.types import uint32
clk: uint32 = freq() # returns 16000000 for a 16 MHz Arduino Uno
The value is a compile-time constant derived from frequency in pyproject.toml.
utime#
from utime import sleep_ms, sleep_us, sleep, ticks_ms, ticks_us, ticks_cpu, ticks_diff, ticks_add
from pymcu.types import uint32
sleep_ms(500) # busy-wait 500 ms (uses _delay_ms loop)
sleep_us(100) # busy-wait 100 µs
sleep(1) # 1 second (integer only — no float on AVR)
t0: uint32 = ticks_ms()
sleep_ms(200)
elapsed: uint32 = ticks_diff(ticks_ms(), t0) # elapsed ≈ 200
# Deadline pattern (identical to real MicroPython):
deadline: uint32 = ticks_add(ticks_ms(), 500) # 500 ms from now
us: uint32 = ticks_us() # microseconds (4 µs resolution at 16 MHz)
cpu: uint32 = ticks_cpu() # alias for ticks_us() on AVR
Note
ticks_ms() requires the millis counter to be running. The pymcu build driver
detects ticks_ms() usage and automatically injects millis_init() before your code
runs — no manual setup needed. This is the same auto-injection pattern used for
print() / UART initialisation.
millis_init() configures Timer0 in normal overflow mode at prescaler 64 (~1 ms
resolution at 16 MHz). Do not use Timer0 for PWM or CTC in the same project when
ticks_ms() is active. delay_ms() / delay_us() are unaffected (software busy-loop).
Function |
Notes |
|---|---|
|
Busy-wait via |
|
Busy-wait via |
|
Integer seconds ( |
|
Milliseconds since boot — Timer0 counter ( |
|
Microseconds since boot — |
|
Alias for |
|
|
|
|
micropython#
import micropython
BAUD = micropython.const(9600) # compile-time constant — same as writing 9600
@micropython.native # silently ignored — PyMCU always emits native code
def fast():
pass
@micropython.viper # silently ignored — use @inline for zero-cost inlining
def also_fast():
pass
micropython.const() is an identity function at the PyMCU level. All integer literals
annotated as const[T] are already compile-time folded by the optimizer.
Built-in functions#
PyMCU supports a subset of Python’s built-in functions. Functions that require dynamic
memory allocation or a runtime type system are not available, but the two most common
I/O built-ins — print() and input() — are fully supported via the UART.
print()#
print("hello, world") # sends "hello, world\n" to UART0
print("value:", 42) # multiple arguments — space-separated
print() is the simplest way to emit text to the serial monitor. It behaves identically
to Python’s built-in print() for string and integer arguments.
Auto-injection: The pymcu build driver scans your source for print( and
automatically injects uart_init(baud) before main() runs. You do not need to
initialise the UART manually when print() is the only UART user.
Behaviour |
Python / MicroPython |
PyMCU |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
runtime format |
compile-time if both constant |
|
empty line |
empty |
keyword args ( |
supported |
❌ not supported |
input()#
Reads a line of text from UART0. The line is terminated by a newline character (\n).
Windows-style CRLF sequences (\r\n) are handled transparently — the \r is silently
stripped.
Syntax:
line: bytearray = input()
line: bytearray = input("prompt")
line: bytearray = input("prompt", maxlen)
prompt— optional compile-time string literal, sent to UART before readingmaxlen— optional integer; default 64. The buffer is allocated asuint8[maxlen]on the stack — it is a compile-time constant, not a heap allocation.Return type must be annotated as
bytearray.
from pymcu.types import uint8
def main():
name: bytearray = input("Enter name: ") # prints prompt, reads until newline
print("Hello, ")
print(name)
# Read with explicit buffer limit
cmd: bytearray = input("> ", 16) # max 16 characters
Auto-injection: Like print(), the driver detects input( and injects
the UART initialisation automatically. The output device and baud rate come from
the optional stdout / stdout_baud keys in [tool.pymcu] (defaults: uart0
at 115200 baud).
Under the hood: input("prompt") lowers to:
uart_write_str("prompt")— sends the prompt string from PROGMEMuart_read_line(buf, maxlen)— reads bytes until\normaxlen−1bytes consumedReturns a
bytearraypointing to the stack buffer
Note
input() is a blocking call — execution pauses until the user presses Enter.
There is no timeout. If your application must remain responsive, read the
receive-complete flag yourself (e.g. via machine.mem8 on UCSR0A) and
accumulate bytes one at a time with machine.UART.read().
Warning
The buffer lives on the AVR stack frame. Do not let maxlen exceed the available
stack space (typically ~200–400 bytes on an ATmega328P with the default 2 KB SRAM).
Example — UART command loop:
from pymcu.types import uint8
def handle(cmd: bytearray) -> uint8:
# compare against known commands
return 0
def main():
while True:
cmd: bytearray = input("> ", 32)
handle(cmd)
avr — AVR port module#
The avr module exposes AVR-specific peripherals not covered by the machine module:
non-volatile EEPROM storage, bit-bang SPI, and bit-bang I2C. Import from avr (no
package prefix needed when stdlib = ["micropython"] is set).
from avr import EEPROM, SoftSPI, SoftI2C
avr.EEPROM#
The ATmega328P has 1 KB of EEPROM at addresses 0x000–0x3FF, retained across resets and
power cycles. Reads and writes go through the EEAR/EEDR/EECR register set.
from avr import EEPROM
from pymcu.types import uint8, uint16
ee = EEPROM()
# Write a calibration constant at address 0
ee.write(0, 42)
# Read it back after a reset
val: uint8 = ee.read(0) # → 42
# Store a 16-bit value across two bytes
high: uint8 = 0xAB
low: uint8 = 0xCD
ee.write(10, high)
ee.write(11, low)
Method |
Signature |
Description |
|---|---|---|
|
|
Read one byte from EEPROM address |
|
|
Write one byte; blocks until complete |
Warning
EEPROM write cycles are rated at 100,000 minimum. Avoid writing in tight loops. Each write blocks for approximately 3.4 ms (EEPROM busy-wait).
avr.SoftSPI#
Bit-bang SPI using arbitrary GPIO pins. Use when the hardware SPI pins (D11/D12/D13) are occupied or when multiple SPI devices with separate CS lines are needed.
from machine import Pin
from avr import SoftSPI
from pymcu.types import uint8
sck = Pin(13, Pin.OUT)
mosi = Pin(11, Pin.OUT)
miso = Pin(12, Pin.IN)
cs = Pin(10, Pin.OUT)
spi = SoftSPI(sck, mosi, miso, baudrate=500) # 500 kHz
cs.low()
spi.select()
received: uint8 = spi.transfer(0x9F) # full-duplex byte
spi.write(0x00) # send, discard MISO
spi.deselect()
cs.high()
Constant |
Value |
Description |
|---|---|---|
|
0 |
Master mode (drives SCK) |
|
1 |
Slave mode |
Method |
Signature |
Description |
|---|---|---|
|
|
Full-duplex byte exchange |
|
|
Send byte, discard received |
|
|
Assert CS low |
|
|
Release CS high |
avr.SoftI2C#
Bit-bang I2C using arbitrary GPIO pins. Requires external 4.7 kΩ pull-up resistors on both SDA and SCL lines.
from machine import Pin
from avr import SoftI2C
from pymcu.types import uint8
scl = Pin(5, Pin.OUT)
sda = Pin(4, Pin.OUT)
i2c = SoftI2C(scl, sda, freq=100000) # 100 kHz standard mode
# Scan for devices
count: uint8 = i2c.scan() # returns number of responding addresses (not a list)
# Probe a specific address
if i2c.ping(0x3C): # SSD1306 OLED at 0x3C
i2c.writeto(0x3C, 0x00) # write command byte
val: uint8 = i2c.readfrom(0x3C)
Method |
Signature |
Description |
|---|---|---|
|
|
Count of responding I2C devices |
|
|
Returns 1 if device ACKs |
|
|
Write one byte |
|
|
Read one byte |
Note
scan() returns a count rather than a list — returning a list[uint8] would
pull in GC infrastructure for a result most programs discard. Use ping(addr) to
probe a known address directly.
freq is converted to a bit-bang half-period: half_us = 500_000 // freq.
At 100 kHz this gives 5 µs half-period; at 400 kHz (“fast mode”), 1 µs.
Porting guide#
Add type annotations to every variable#
count = 0 # MicroPython — runtime type inference
count: int = 0 # PyMCU — static annotation required
Replace float sleep with integer milliseconds#
utime.sleep(0.5) # MicroPython — float seconds
utime.sleep_ms(500) # PyMCU — integer milliseconds
Replace dynamic bytearray with fixed-size array#
buf = bytearray(8) # MicroPython — heap allocation
buf: uint8[8] = [0,0,0,0,0,0,0,0] # PyMCU — SRAM fixed array
Replace machine.mem8 with typed ptr (optional but safer)#
machine.mem8[addr] works in PyMCU, but the ptr[T] type
is preferred for memory-mapped I/O — it is compile-time checked and documents the intent
of each register access.
machine.mem8[0x25] = 0xFF # works in PyMCU — raw mem access
from pymcu.types import ptr, uint8
PORTB: ptr[uint8] = ptr(0x25) # typed, compile-time checked
PORTB.value = 0xFF
Replace Timer callback lambdas with named functions#
# MicroPython — lambdas work here
tim = Timer(period=100, mode=Timer.PERIODIC, callback=lambda t: led.toggle())
# PyMCU — named function required (no lambda support)
def on_tick():
led.toggle()
tim = Timer(1, period=100, callback=on_tick) # identical API otherwise
Note
Lambda expressions are not supported in PyMCU. Use a named function instead.
The Timer(period=ms, callback=fn) syntax is otherwise identical to MicroPython.
Differences from real MicroPython#
These are the actual gaps and trade-offs — things that work differently or are unavailable. Anything not listed here behaves identically to standard MicroPython.
Feature |
MicroPython |
PyMCU |
|---|---|---|
Execution model |
Bytecode interpreter |
Native compiled — zero runtime overhead |
RAM overhead |
~10–40 KB for the VM |
~0 bytes (static dispatch, no GC) |
|
Full hardware/soft-float |
Soft-float (~200–400 cycles per op) |
|
Runtime evaluation |
Compile-time constants only |
|
Supported (heap-based) |
✅ Supported — zero-cost T-flag error ABI, no heap |
|
Dynamic heap allocation |
Fixed-size |
|
Number of bytes available |
Returns |
|
Optional |
Single-byte blocking read only; use |
|
Returns |
|
|
Fills up to |
✅ |
|
Returns list of addresses |
|
|
|
✅ |
|
Returns |
|
|
|
✅ |
|
Fills |
✅ |
|
Buffer lengths inferred |
✅ |
|
|
✅ Supported — ZCA synthesis passes Timer instance |
|
Hz-based config |
✅ Supported — auto-selects prescaler for 1 Hz – MHz range |
|
|
✅ Supported — |
|
String pin name |
✅ Supported — bypasses Arduino integer mapping |
Lambda expressions |
Supported |
❌ Not available — use named functions |
Target hardware |
STM32, RP2040, ESP32, … |
ATmega328P (Arduino Uno / Nano) |