Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions AMY_AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,44 @@ amy.send(synth=1, osc=3, wave=amy.PULSE,
mod_source=1)
```

## Rotary encoders: one `amyboard.encoder()` for all hardware — never the per-device helpers

There are three encoder accessories (Adafruit single = 1 knob, Adafruit quad = 4,
M5Stack 8Encoder = 8) plus the simulator's single emulated encoder. **Prefer the
unified `amyboard.encoder()` over the legacy `read_encoder()`/`read_buttons()` or the
`m5_8encoder` module**, because those tie a sketch to one device and a hardcoded
encoder count/I2C address — a sketch that indexes encoder 3 silently breaks on the
single-knob hardware and in the simulator.

Build it once at top level, then read it in `loop()`. Drive parameters from the
**delta** of `read(i)` (not its absolute value) and clamp to `range(enc.encoders)` so
the same sketch scales from 1 to 8 knobs and degrades to a no-op when nothing is
plugged in (`enc.encoders == 0`). Guard LED writes with `enc.leds`.

```python
import amyboard, amy

amy.send(synth=1, patch=0, num_voices=4)
enc = amyboard.encoder() # autodetect; works on any device or the simulator
_last = [enc.read(i) for i in range(enc.encoders)]

def loop():
for i in range(enc.encoders):
pos = enc.read(i)
delta = pos - _last[i]
_last[i] = pos
if delta:
# encoder 0 -> cutoff; spread the rest across other params as you like
if i == 0:
amy.send(synth=1, filter_freq=max(50, min(8000, 1000 + pos * 50)))
if i < enc.leds:
enc.led(i, 0, 40 if enc.button(i) else 0, 40)
```

`enc.read(i)` is 0-based and starts at 0; `enc.button(i)` is True while held on every
device; `enc.led(i, r, g, b)` takes 0..255 and applies immediately. Never reference an
encoder index `>= enc.encoders` or an LED index `>= enc.leds`.

## MicroPython lacks some Python functions

The script environment is MicroPython which attempts to match regular Python but does not
Expand Down
34 changes: 32 additions & 2 deletions docs/amyboard/accessories.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,34 @@ amyboard.draw_waveform()

### Rotary encoders

AMYboard supports three different rotary-encoder accessories (described below). They
have different encoder counts, LED layouts, and I2C protocols, so the easiest way to
use any of them — and to write a sketch that runs unchanged on whichever one you have
(or in the web simulator) — is the unified `amyboard.encoder()` API. It autodetects
the connected device and gives you one consistent interface:

```python
import amyboard

enc = amyboard.encoder() # autodetect whatever is connected
print(enc.type) # "adafruit_single", "adafruit_quad", "m5stack", "web", or None
print(enc.encoders) # how many encoders this device has (1, 4, or 8)
print(enc.leds) # how many addressable LEDs it has

for i in range(enc.encoders):
pos = enc.read(i) # cumulative position, starts at 0
pressed = enc.button(i) # True while the push button is held
if i < enc.leds:
enc.led(i, 0, 64, 0) # light LED i dim green (r, g, b, each 0..255)

enc.reset() # zero every encoder back to 0 (or enc.reset(i) for just one)
enc.switch() # M5Stack toggle-switch state (False on other devices)
```

If no encoder is attached `enc.encoders` is 0 and every method returns a safe default,
so sketches still run. The per-device functions documented below still work for
advanced use, but new code should prefer `amyboard.encoder()`.

- ![Adafruit STEMMA QT Rotary Encoder](img/accessory_adafruit_encoder.jpg)
[**Adafruit I2C STEMMA QT Rotary Encoder Breakout**](https://www.adafruit.com/product/5880) -- A single rotary encoder with push button and NeoPixel LED, running seesaw firmware over I2C. Supports up to 8 on one I2C bus via address jumpers. AMYboard has built-in Python support for reading encoder position and button state.

Expand Down Expand Up @@ -116,15 +144,17 @@ amyboard.patch_selector()
```

- ![M5Stack 8-Encoder Unit](img/accessory_m5_8encoder.jpg)
[**M5Stack 8-Encoder Unit (STM32F030)**](https://shop.m5stack.com/products/8-encoder-unit-stm32f030) -- Eight rotary encoders with RGB LEDs and a toggle switch, all on one I2C unit. Great for controlling multiple synth parameters at once.
[**M5Stack 8-Encoder Unit (STM32F030)**](https://shop.m5stack.com/products/8-encoder-unit-stm32f030) -- Eight rotary encoders with RGB LEDs and a toggle switch, all on one I2C unit. Great for controlling multiple synth parameters at once. Use `amyboard.encoder()` (above) for portable code, or the lower-level `m5_8encoder` module directly:

```python
import m5_8encoder

# Cumulative position of each encoder (-2**31 to +2**31)
positions = m5_8encoder.read_all_counters()

# Push-button state for each encoder (0 = up, 1 = pressed)
# Push-button state for each encoder. The register is active-low:
# 0 = pressed, 1 = up. (amyboard.encoder().button(i) normalizes this to
# True == pressed.)
buttons = m5_8encoder.read_all_buttons()

# Toggle switch on the side (0 or 1)
Expand Down
13 changes: 9 additions & 4 deletions docs/amyboard/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,15 @@ import amyboard
amyboard.cv_out(5.0, channel=0) # Output 5V on CV out 1
volts = amyboard.cv_in(channel=0) # Read CV in 1

# Rotary encoders. The defaults are for the Adafruit single-encoder https://www.adafruit.com/product/5880
print(amyboard.read_encoder())
amyboard.init_buttons()
print(amyboard.read_buttons())
# Rotary encoders. amyboard.encoder() autodetects whichever accessory is
# connected (Adafruit single/quad or M5Stack 8Encoder) and gives one API for all.
enc = amyboard.encoder()
print(enc.type, enc.encoders) # e.g. "m5stack" 8
print(enc.read(0)) # position of encoder 0 (starts at 0)
print(enc.button(0)) # True while held
if enc.leds:
enc.led(0, 0, 64, 0) # light encoder 0's LED dim green
# See accessories.md for the legacy per-device read_encoder()/read_buttons() helpers.

# OLED display (if connected)
amyboard.init_display()
Expand Down
9 changes: 8 additions & 1 deletion tulip/server/amyboardworld_db_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,14 @@ def publish_shared_generations(
THE amyboard MODULE (import amyboard) -- physical I/O (present on hardware; safe to call in the simulator)
- amyboard.cv_in(channel) -> volts (-10..10). channel 0 = CV1, 1 = CV2.
- amyboard.cv_out(volts, channel): write a CV output.
- amyboard.read_encoder(), amyboard.init_buttons(), amyboard.read_buttons(): rotary encoder + buttons.
- Rotary encoders: ALWAYS use the unified, hardware-agnostic API so the sketch works on any encoder accessory (Adafruit single/quad or M5Stack 8Encoder) and in the simulator. Do NOT call the legacy read_encoder()/read_buttons()/m5_8encoder helpers, and never hardcode an encoder count or I2C address.
enc = amyboard.encoder() # autodetects whatever is connected (or the simulator's one encoder)
enc.encoders # how many encoders exist (1, 4, or 8); loop over range(enc.encoders)
enc.read(i) # cumulative position of encoder i (0-based), starts at 0
enc.button(i) # True while encoder i's push button is held
enc.led(i, r, g, b) # light encoder i's LED (0..255 each); skip if i >= enc.leds
enc.reset(i) # zero encoder i (omit i to zero all)
Build the encoder once at top level (enc = amyboard.encoder()) and read it inside loop(). If enc.encoders == 0 no hardware is present; guard LED writes with enc.leds. Use relative motion (track the previous enc.read(i) and act on the delta) so any number of encoders maps onto your parameters.
- amyboard.init_display(); amyboard.display.fill(0); amyboard.display.text("hi", 0, 0, 255); amyboard.display_refresh(): optional 128x128 grayscale OLED. Color is 0-255. The ONLY drawing methods are: fill(col), fill_rect(x,y,w,h,col), rect(x,y,w,h,col), line(x1,y1,x2,y2,col), hline(x,y,w,col), vline(x,y,h,col), pixel(x,y,col), text(str,x,y,col), scroll(dx,dy). There is no circle, ellipse, hline-only-via-line, or print method -- use only the methods listed.

OTHER MODULES
Expand Down
190 changes: 190 additions & 0 deletions tulip/shared/amyboard-py/amyboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,6 +1006,196 @@ def show_neopixels(seesaw_dev=0x49):
except OSError:
_seesaw_missing.add(seesaw_dev)

# ---------------------------------------------------------------------------
# Unified rotary-encoder API
# ---------------------------------------------------------------------------
# AMYboard works with three different rotary-encoder accessories on the I2C bus,
# each with its own wire protocol, encoder count, and LED layout:
#
# "adafruit_single" - Adafruit I2C QT Rotary Encoder (0x36): 1 encoder, 1 LED
# "adafruit_quad" - Adafruit Quad Rotary Encoder Breakout (0x49): 4 enc, 4 LED
# "m5stack" - M5Stack Unit 8Encoder (0x41): 8 encoders, 8 LEDs + a toggle
# "web" - the in-browser simulator's single emulated encoder
#
# Rather than make every sketch (and the sketch generator) special-case each one,
# amyboard.encoder() autodetects whichever is connected and returns an Encoder
# whose API is identical across all of them:
#
# enc = amyboard.encoder() # autodetect
# enc.type # "adafruit_quad" | "m5stack" | "web" | ... | None
# enc.encoders # number of encoders (int)
# enc.leds # number of addressable LEDs (int)
# enc.read(i) # cumulative position of encoder i, starts at 0
# enc.button(i) # True while encoder i's push button is held
# enc.led(i, r, g, b) # light encoder i's LED (0..255 each), applied now
# enc.reset(i) # zero encoder i (omit i to zero them all)
# enc.switch() # M5Stack toggle (always False on other devices)
#
# If no encoder is attached (type is None) or the I2C bus errors mid-read, every
# method returns a safe default, so a sketch written for one device runs unchanged
# on another device, on the web simulator, or on no hardware at all.

_M5_8ENCODER_ADDR = 0x41
_ADAFRUIT_QUAD_ADDR = 0x49
_ADAFRUIT_SINGLE_ADDR = 0x36

# Fixed per-device config. button_pins / neopixel_pin only apply to the seesaw
# (Adafruit) devices; the M5Stack unit exposes everything through one register map.
_ENCODER_PROFILES = {
"adafruit_single": {"addr": _ADAFRUIT_SINGLE_ADDR, "encoders": 1, "leds": 1,
"button_pins": (24,), "neopixel_pin": 6},
"adafruit_quad": {"addr": _ADAFRUIT_QUAD_ADDR, "encoders": 4, "leds": 4,
"button_pins": (12, 14, 17, 9), "neopixel_pin": 18},
"m5stack": {"addr": _M5_8ENCODER_ADDR, "encoders": 8, "leds": 8},
}


def _detect_encoder_type():
"""Probe the I2C bus and return the connected encoder's type string, or None.

M5Stack is checked first since it has a distinct address; the two Adafruit
seesaw devices use different default addresses too."""
if web():
return "web"
try:
present = set(get_i2c().scan())
except Exception:
return None
if _M5_8ENCODER_ADDR in present:
return "m5stack"
if _ADAFRUIT_QUAD_ADDR in present:
return "adafruit_quad"
if _ADAFRUIT_SINGLE_ADDR in present:
return "adafruit_single"
return None


class Encoder:
"""One interface to whichever rotary-encoder accessory is connected.

Build via amyboard.encoder() (autodetects), or pass type= to force a specific
device: "adafruit_single", "adafruit_quad", or "m5stack".

Encoder positions returned by read() are zeroed at construction time, so the
first read() of an untouched encoder is 0 no matter what its raw hardware
counter happened to be. Buttons are normalized so button(i) is True while
held, regardless of each device's underlying active-high/active-low wiring."""

def __init__(self, type=None):
if type is None:
type = _detect_encoder_type()
self.type = type

if type == "web":
self.encoders = 1
self.leds = 0 # no LED in the simulator
self._addr = None
elif type in _ENCODER_PROFILES:
p = _ENCODER_PROFILES[type]
self.encoders = p["encoders"]
self.leds = p["leds"]
self._addr = p["addr"]
self._button_pins = p.get("button_pins")
self._neopixel_pin = p.get("neopixel_pin")
if type in ("adafruit_single", "adafruit_quad"):
init_buttons(pins=self._button_pins, seesaw_dev=self._addr)
init_neopixels(num=self.leds, pin=self._neopixel_pin, seesaw_dev=self._addr)
else:
# No encoder detected — a 0-encoder device whose methods all no-op.
self.type = None
self.encoders = 0
self.leds = 0
self._addr = None

# Snapshot each encoder's raw counter so read() starts at 0.
self._offset = [0] * max(self.encoders, 1)
for i in range(self.encoders):
self._offset[i] = self._raw_read(i)

# -- raw, offset-free per-device reads --

def _raw_read(self, i):
if self.type == "web":
return _web_encoder_pos
if self.type in ("adafruit_single", "adafruit_quad"):
return read_encoder(i, seesaw_dev=self._addr)
if self.type == "m5stack":
try:
i2c = get_i2c()
i2c.writeto(self._addr, bytes([4 * i])) # counter reg = 4*i, <i (LE)
return struct.unpack("<i", i2c.readfrom(self._addr, 4))[0]
except OSError:
return 0
return 0

# -- public API --

def read(self, i=0):
"""Cumulative position of encoder i (0-based), starting at 0."""
if not (0 <= i < self.encoders):
return 0
return self._raw_read(i) - self._offset[i]

def button(self, i=0):
"""True while encoder i's push button is held down."""
if not (0 <= i < self.encoders):
return False
if self.type == "web":
return _web_encoder_button
if self.type in ("adafruit_single", "adafruit_quad"):
return read_buttons(pins=(self._button_pins[i],), seesaw_dev=self._addr)[0]
if self.type == "m5stack":
try:
i2c = get_i2c()
i2c.writeto(self._addr, bytes([0x50 + i])) # button reg, active-low
return i2c.readfrom(self._addr, 1)[0] == 0 # 0 == pressed
except OSError:
return False
return False

def led(self, i, r, g, b):
"""Set encoder i's LED to (r, g, b), each 0..255. Applied immediately."""
if not (0 <= i < self.leds):
return
if self.type in ("adafruit_single", "adafruit_quad"):
set_neopixel(i, r, g, b, seesaw_dev=self._addr)
show_neopixels(seesaw_dev=self._addr)
elif self.type == "m5stack":
try:
get_i2c().writeto_mem(self._addr, 0x70 + 3 * i, bytes([r, g, b])) # RGB
except OSError:
pass

def reset(self, i=None):
"""Zero encoder i back to 0 (omit i to zero every encoder)."""
if i is None:
for j in range(self.encoders):
self._offset[j] = self._raw_read(j)
elif 0 <= i < self.encoders:
self._offset[i] = self._raw_read(i)

def switch(self):
"""M5Stack 8Encoder toggle-switch state (always False on other devices)."""
if self.type == "m5stack":
try:
i2c = get_i2c()
i2c.writeto(self._addr, bytes([0x60])) # switch reg
return i2c.readfrom(self._addr, 1)[0] != 0
except OSError:
return False
return False


def encoder(type=None):
"""Autodetect the connected rotary-encoder accessory and return an Encoder.

Detects the Adafruit single (0x36) or quad (0x49) seesaw breakouts and the
M5Stack 8Encoder unit (0x41), or the web simulator's emulated encoder. Pass
type= ("adafruit_single", "adafruit_quad", "m5stack") to force a device.
See the Encoder class for the unified read()/button()/led()/reset() API."""
return Encoder(type=type)


def monitor_encoders():
"""Show status of encoders on display."""
# You can run this in a loop to get continuous display of encoders
Expand Down
Loading