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
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "DJ Addon",
"publisher": "MicroPythonOS",
"short_description": "DJ Addon demo",
"long_description": "Shows off the capabilities of the Fri3d DJ Addon 2026.",
"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.dj_addon/icons/com.micropythonos.dj_addon_0.0.1_64x64.png",
"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.dj_addon/mpks/com.micropythonos.dj_addon_0.0.1.mpk",
"fullname": "com.micropythonos.dj_addon",
"version": "0.0.1",
"category": "development",
"activities": [
{
"entrypoint": "assets/dj_addon.py",
"classname": "DJAddonActivity",
"intent_filters": [
{
"action": "main",
"category": "launcher"
}
]
}
]
}
257 changes: 257 additions & 0 deletions internal_filesystem/apps/com.micropythonos.dj_addon/assets/dj_addon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
import math
import time
from mpos import DeviceManager
import lvgl as lv
from mpos import Activity, DisplayMetrics

_ADC_MAX = 4095

# ADC channel indices in DMA rank order (rank N → adc_channels[N-1])
_CH_PM_LEFT_BOTTOM = 0 # rank 1
_CH_PM_LEFT_MID = 1 # rank 2
_CH_PM_LEFT_TOP = 2 # rank 3
_CH_SLIDER_LEFT = 3 # rank 4
_CH_PM_RIGHT_BOTTOM = 4 # rank 5
_CH_PM_RIGHT_MID = 5 # rank 6
_CH_PM_RIGHT_TOP = 6 # rank 7
_CH_SLIDER_RIGHT = 7 # rank 8
_CH_SLIDER_MID = 8 # rank 9

_MARGIN = 4
_ARC_GAP = 4
_BTN_GAP = 2
_CROSSFADER_H = 22
_REFRESH_MS = 100

# LVGL arc default angles (0°=east, clockwise). In REVERSE mode the needle
# tip sits at _ARC_END_DEG when value=0 and sweeps _ARC_RANGE_DEG CCW to
# _ARC_START_DEG when value=_ADC_MAX.
_ARC_END_DEG = 45
_ARC_RANGE_DEG = 270

# Maps dj.buttons index → pad_buttons index.
# DJ row 0 (indices 0-3) sits at the bottom of the hardware, which corresponds
# to pad row 1 (indices 4-7) at the bottom of the display grid, and vice versa.
_DJ_TO_PAD = (3,7,1,2,0,5,6,4)

class _MockDJAddon:
version = (1, 0, 0)

@property
def analog(self):
t = time.ticks_ms()
result = []
for i in range(9):
phase = (t + i * 222) % 2000
val = phase if phase < 1000 else 2000 - phase
result.append(int(val * 4.095))
return result

@property
def buttons(self):
idx = (time.ticks_ms() // 1000) % 8
return tuple(i == idx for i in range(8))

def set_led(self, idx, r, g, b):
pass


class DJAddonActivity(Activity):

def __init__(self):
super().__init__()
self.dj = None
self.timer = None
self.arcs_left = [] # list of (arc, needle, cx, cy, r)
self.arcs_right = [] # list of (arc, needle, cx, cy, r)
self.bar_left = None
self.bar_right = None
self.slider_mid = None
self.pad_buttons = []
self.pad_button_states = [] # 0=off, 1=R, 2=G, 3=B cycles on each click

def onCreate(self):
screen = lv.obj()
screen.set_style_bg_color(lv.color_black(), lv.PART.MAIN)
screen.set_style_border_width(0, lv.PART.MAIN)
screen.set_style_pad_all(0, lv.PART.MAIN)

try:
from drivers.fri3d.dj import DJAddon
i2c_bus = DeviceManager.getBus(type="i2c")
self.dj = DJAddon(i2c_bus=i2c_bus)
version = self.dj.version
print("DJ Addon FW version:", ".".join(str(i) for i in version))
if version != (1, 0, 0):
raise ValueError("unexpected firmware version")
except Exception as e:
print("DJ Addon not available, using mock:", e)
self.dj = _MockDJAddon()

self._build_ui(screen)
self.setContentView(screen)

# --- widget factories ---

def _make_arc_at(self, parent, size, x, y):
arc = lv.arc(parent)
arc.set_size(size, size)
arc.set_pos(x, y)
arc.set_range(0, _ADC_MAX)
arc.set_value(0)
arc.set_mode(lv.arc.MODE.REVERSE)
arc.set_style_opa(lv.OPA.TRANSP, lv.PART.INDICATOR)
arc.set_style_opa(lv.OPA.TRANSP, lv.PART.KNOB)
arc.remove_flag(lv.obj.FLAG.CLICKABLE)

cx = x + size // 2
cy = y + size // 2
r = size // 2 - 4

needle = lv.line(parent)
needle.set_style_line_width(4, lv.PART.MAIN)
needle.set_style_line_color(lv.color_white(), lv.PART.MAIN)
needle.set_style_line_rounded(True, lv.PART.MAIN)
self._set_needle(needle, cx, cy, r, 0)

return arc, needle, cx, cy, r

def _make_vbar(self, parent, w, h):
bar = lv.bar(parent)
bar.set_size(w, h)
bar.set_range(0, _ADC_MAX)
bar.set_value(0, False)
bar.remove_flag(lv.obj.FLAG.CLICKABLE)
return bar

def _make_pad_grid(self, parent, x, y, w, h):
btn_w = (w - _BTN_GAP * 3) // 4
btn_h = (h - _BTN_GAP) // 2
self.pad_buttons = []
self.pad_button_states = []
for row in range(2):
for col in range(4):
idx = len(self.pad_buttons)
btn = lv.obj(parent)
btn.set_size(btn_w, btn_h)
btn.set_pos(x + col * (btn_w + _BTN_GAP), y + row * (btn_h + _BTN_GAP))
btn.set_style_bg_color(lv.color_black(), lv.PART.MAIN)
btn.set_style_border_width(2, lv.PART.MAIN)
btn.set_style_border_color(lv.color_hex(0x444444), lv.PART.MAIN)
btn.set_style_radius(3, lv.PART.MAIN)
btn.remove_flag(lv.obj.FLAG.SCROLLABLE)
btn.add_event_cb(lambda _e, i=idx: self._on_pad_click(i), lv.EVENT.CLICKED, None)
self.pad_buttons.append(btn)
self.pad_button_states.append(0)

@staticmethod
def _set_needle(needle, cx, cy, r, value):
angle_rad = math.radians(_ARC_END_DEG - (value / _ADC_MAX) * _ARC_RANGE_DEG)
tx = int(cx + r * math.cos(angle_rad))
ty = int(cy + r * math.sin(angle_rad))
needle.set_points([{'x': cx, 'y': cy}, {'x': tx, 'y': ty}], 2)

def _on_pad_click(self, idx):
_COLORS = ((0, 0, 0), (255, 0, 0), (0, 255, 0), (0, 0, 255))
self.pad_button_states[idx] = (self.pad_button_states[idx] + 1) % 4
r, g, b = _COLORS[self.pad_button_states[idx]]
self.set_button_color(idx, r, g, b)

# --- public API ---

def set_button_color(self, idx: int, r: int, g: int, b: int):
if 0 <= idx < len(self.pad_buttons):
self.pad_buttons[idx].set_style_bg_color(lv.color_make(r, g, b), lv.PART.MAIN)
if self.dj is not None:
self.dj.set_led(idx, r, g, b)

# --- layout ---

def _build_ui(self, screen):
W = DisplayMetrics.width()
H = DisplayMetrics.height()

top_h = H - _CROSSFADER_H - _MARGIN * 3

# Three-zone layout: [left deck | button grid | right deck]
center_w = W // 2
side_w = (W - center_w) // 2
center_x = side_w

# Arcs fill the full side width (no separate slider column)
arc_size = min((top_h - _ARC_GAP * 2) // 3, side_w - _MARGIN * 2)
arc_h = 3 * arc_size + 2 * _ARC_GAP

left_arc_x = _MARGIN
right_arc_x = center_x + center_w + _MARGIN

# Left deck: bar first (background), then arcs on top
self.bar_left = self._make_vbar(screen, arc_size, arc_h)
self.bar_left.set_pos(left_arc_x, _MARGIN)

self.arcs_left = []
for i in range(3):
self.arcs_left.append(
self._make_arc_at(screen, arc_size, left_arc_x, _MARGIN + i * (arc_size + _ARC_GAP))
)

# Center: 2-row × 4-column pad button grid
self._make_pad_grid(screen, center_x, _MARGIN, center_w, top_h)

# Right deck: bar first (background), then arcs on top
self.bar_right = self._make_vbar(screen, arc_size, arc_h)
self.bar_right.set_pos(right_arc_x, _MARGIN)

self.arcs_right = []
for i in range(3):
self.arcs_right.append(
self._make_arc_at(screen, arc_size, right_arc_x, _MARGIN + i * (arc_size + _ARC_GAP))
)

# Crossfader: horizontal, full width, bottom
self.slider_mid = lv.slider(screen)
self.slider_mid.set_size(W - _MARGIN * 2, _CROSSFADER_H - 6)
self.slider_mid.set_range(_ADC_MAX, 0)
self.slider_mid.set_value(_ADC_MAX // 2, False)
self.slider_mid.set_style_opa(lv.OPA.TRANSP, lv.PART.INDICATOR)
self.slider_mid.remove_flag(lv.obj.FLAG.CLICKABLE)
self.slider_mid.set_pos(_MARGIN, H - _MARGIN - _CROSSFADER_H)

# --- data update ---

def _update_ui(self, analog, buttons):
vals_left = (analog[_CH_PM_LEFT_TOP], analog[_CH_PM_LEFT_MID], analog[_CH_PM_LEFT_BOTTOM])
vals_right = (analog[_CH_PM_RIGHT_TOP], analog[_CH_PM_RIGHT_MID], analog[_CH_PM_RIGHT_BOTTOM])

for (_, needle, cx, cy, r), val in zip(self.arcs_left, vals_left):
self._set_needle(needle, cx, cy, r, val)

for (_, needle, cx, cy, r), val in zip(self.arcs_right, vals_right):
self._set_needle(needle, cx, cy, r, val)

for dj_idx, pressed in enumerate(buttons):
color = lv.color_white() if pressed else lv.color_hex(0x444444)
self.pad_buttons[_DJ_TO_PAD[dj_idx]].set_style_border_color(color, lv.PART.MAIN)

self.bar_left.set_value(_ADC_MAX - analog[_CH_SLIDER_LEFT], False)
self.bar_right.set_value(_ADC_MAX - analog[_CH_SLIDER_RIGHT], False)
self.slider_mid.set_value(analog[_CH_SLIDER_MID], False)

# --- lifecycle ---

def onResume(self, screen):
if self.timer is None:
self.timer = lv.timer_create(self.refresh, _REFRESH_MS, None)

def onPause(self, screen):
if self.timer:
self.timer.delete()
self.timer = None

def refresh(self, timer):
if self.dj is None:
return
try:
self._update_ui(self.dj.analog, self.dj.buttons)
except Exception as e:
print("DJ refresh error:", e)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions internal_filesystem/lib/drivers/fri3d/dj.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import struct

from micropython import const
from machine import I2C, UART

from .device import Device

# registers
_DJ_ADDON_REG_BUTTONS = const(0x03)
_DJ_ADDON_REG_ANALOG = const(0x04)
_DJ_ADDON_REG_LEFT_ENCODER = const(0x16)
_DJ_ADDON_REG_RIGHT_ENCODER = const(0x18)
_DJ_ADDON_REG_LEDS = const(0x1A)

_DJ_ADDON_BAUDRATE = const(115200)

_DJ_ADDON_I2CADDR_DEFAULT = const(0x3A)


class DJAddon(Device):
"""Fri3d Badge 2026 expander MCU."""

def __init__(self, i2c_bus: I2C, uart_bus: UART = None, address: int = _DJ_ADDON_I2CADDR_DEFAULT):
"""Read from a sensor on the given I2C bus, at the given address."""
Device.__init__(self, i2c_bus, address)
self.use_uart = False
self.write_idx = 0
self.data_ready = False
if uart_bus:
self.use_uart = True
self.uart = uart_bus
self.uart.init(_DJ_ADDON_BAUDRATE, bits=8, parity=None, stop=1)
self._rx_buf = bytearray(4)
self._rx_mv = memoryview(self._rx_buf)
self.uart.irq(handler=self.uart_handler, trigger=UART.IRQ_RX)

def uart_handler(self, uart):
"""Interrupt handler for incoming UART data"""
while uart.any() and not self.data_ready:
# Calculate how much space is left
space_left = 4 - self.write_idx

# Read directly into the slice of the memoryview
# readinto returns the number of bytes actually read
num_read = uart.readinto(self._rx_mv[self.write_idx :], space_left)

if num_read:
self.write_idx += num_read

if self.write_idx >= 4:
self.data_ready = True

@property
def buttons(self) -> tuple[bool, bool, bool, bool, bool, bool, bool, bool]:
buttons = self._read("B", _DJ_ADDON_REG_BUTTONS, 1)[0]
return tuple([bool(int(digit)) for digit in "{:08b}".format(buttons)])

@property
def analog(self) -> tuple[int, int, int, int, int, int, int, int, int]:
return self._read("<HHHHHHHHH", _DJ_ADDON_REG_ANALOG, 18)

@property
def left_encoder(self) -> int:
return self._read("<H", _DJ_ADDON_REG_LEFT_ENCODER, 2)[0]

@property
def right_encoder(self) -> int:
return self._read("<H", _DJ_ADDON_REG_RIGHT_ENCODER, 2)[0]

def set_led(self, idx: int, r: int, g: int, b: int):
self._write(_DJ_ADDON_REG_LEDS + (idx * 3), struct.pack("BBB", g, r, b))

def send_midi(self, data: bytes):
if self.use_uart and len(data) == 4:
self.uart.write(data)
self.uart.flush()
Loading