API: add AudioFlinger for audio playback (i2s DAC and buzzer)

API: add LightsManager for multicolor LEDs
This commit is contained in:
Thomas Farstrike
2025-12-03 22:32:36 +01:00
parent 82f55e0698
commit f37ca70a89
20 changed files with 2016 additions and 140 deletions
@@ -0,0 +1,55 @@
# AudioFlinger - Centralized Audio Management Service for MicroPythonOS
# Android-inspired audio routing with priority-based audio focus
from . import audioflinger
# Re-export main API
from .audioflinger import (
# Device types
DEVICE_NULL,
DEVICE_I2S,
DEVICE_BUZZER,
DEVICE_BOTH,
# Stream types
STREAM_MUSIC,
STREAM_NOTIFICATION,
STREAM_ALARM,
# Core functions
init,
play_wav,
play_rtttl,
stop,
pause,
resume,
set_volume,
get_volume,
get_device_type,
is_playing,
)
__all__ = [
# Device types
'DEVICE_NULL',
'DEVICE_I2S',
'DEVICE_BUZZER',
'DEVICE_BOTH',
# Stream types
'STREAM_MUSIC',
'STREAM_NOTIFICATION',
'STREAM_ALARM',
# Functions
'init',
'play_wav',
'play_rtttl',
'stop',
'pause',
'resume',
'set_volume',
'get_volume',
'get_device_type',
'is_playing',
]
@@ -0,0 +1,330 @@
# AudioFlinger - Core Audio Management Service
# Centralized audio routing with priority-based audio focus (Android-inspired)
# Supports I2S (digital audio) and PWM buzzer (tones/ringtones)
# Device type constants
DEVICE_NULL = 0 # No audio hardware (desktop fallback)
DEVICE_I2S = 1 # Digital audio output (WAV playback)
DEVICE_BUZZER = 2 # PWM buzzer (tones/RTTTL)
DEVICE_BOTH = 3 # Both I2S and buzzer available
# Stream type constants (priority order: higher number = higher priority)
STREAM_MUSIC = 0 # Background music (lowest priority)
STREAM_NOTIFICATION = 1 # Notification sounds (medium priority)
STREAM_ALARM = 2 # Alarms/alerts (highest priority)
# Module-level state (singleton pattern, follows battery_voltage.py)
_device_type = DEVICE_NULL
_i2s_pins = None # I2S pin configuration dict (created per-stream)
_buzzer_instance = None # PWM buzzer instance
_current_stream = None # Currently playing stream
_volume = 70 # System volume (0-100)
_stream_lock = None # Thread lock for stream management
def init(device_type, i2s_pins=None, buzzer_instance=None):
"""
Initialize AudioFlinger with hardware configuration.
Args:
device_type: One of DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH
i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers (for I2S devices)
buzzer_instance: PWM instance for buzzer (for buzzer devices)
"""
global _device_type, _i2s_pins, _buzzer_instance, _stream_lock
_device_type = device_type
_i2s_pins = i2s_pins
_buzzer_instance = buzzer_instance
# Initialize thread lock for stream management
try:
import _thread
_stream_lock = _thread.allocate_lock()
except ImportError:
# Desktop mode - no threading support
_stream_lock = None
device_names = {
DEVICE_NULL: "NULL (no audio)",
DEVICE_I2S: "I2S (digital audio)",
DEVICE_BUZZER: "Buzzer (PWM tones)",
DEVICE_BOTH: "Both (I2S + Buzzer)"
}
print(f"AudioFlinger initialized: {device_names.get(device_type, 'Unknown')}")
def _check_audio_focus(stream_type):
"""
Check if a stream with the given type can start playback.
Implements priority-based audio focus (Android-inspired).
Args:
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
Returns:
bool: True if stream can start, False if rejected
"""
global _current_stream
if not _current_stream:
return True # No stream playing, OK to start
if not _current_stream.is_playing():
return True # Current stream finished, OK to start
# Check priority
if stream_type <= _current_stream.stream_type:
print(f"AudioFlinger: Stream rejected (priority {stream_type} <= current {_current_stream.stream_type})")
return False
# Higher priority stream - interrupt current
print(f"AudioFlinger: Interrupting stream (priority {stream_type} > current {_current_stream.stream_type})")
_current_stream.stop()
return True
def _playback_thread(stream):
"""
Background thread function for audio playback.
Args:
stream: Stream instance (WAVStream or RTTTLStream)
"""
global _current_stream
# Acquire lock and set as current stream
if _stream_lock:
_stream_lock.acquire()
_current_stream = stream
if _stream_lock:
_stream_lock.release()
try:
# Run playback (blocks until complete or stopped)
stream.play()
except Exception as e:
print(f"AudioFlinger: Playback error: {e}")
finally:
# Clear current stream
if _stream_lock:
_stream_lock.acquire()
if _current_stream == stream:
_current_stream = None
if _stream_lock:
_stream_lock.release()
def play_wav(file_path, stream_type=STREAM_MUSIC, volume=None, on_complete=None):
"""
Play WAV file via I2S.
Args:
file_path: Path to WAV file (e.g., "M:/sdcard/music/song.wav")
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
volume: Override volume (0-100), or None to use system volume
on_complete: Callback function(message) called when playback finishes
Returns:
bool: True if playback started, False if rejected or unavailable
"""
if _device_type not in (DEVICE_I2S, DEVICE_BOTH):
print("AudioFlinger: play_wav() failed - no I2S device available")
return False
if not _i2s_pins:
print("AudioFlinger: play_wav() failed - I2S pins not configured")
return False
# Check audio focus
if _stream_lock:
_stream_lock.acquire()
can_start = _check_audio_focus(stream_type)
if _stream_lock:
_stream_lock.release()
if not can_start:
return False
# Create stream and start playback in background thread
try:
from mpos.audio.stream_wav import WAVStream
import _thread
import mpos.apps
stream = WAVStream(
file_path=file_path,
stream_type=stream_type,
volume=volume if volume is not None else _volume,
i2s_pins=_i2s_pins,
on_complete=on_complete
)
_thread.stack_size(mpos.apps.good_stack_size())
_thread.start_new_thread(_playback_thread, (stream,))
return True
except Exception as e:
print(f"AudioFlinger: play_wav() failed: {e}")
return False
def play_rtttl(rtttl_string, stream_type=STREAM_NOTIFICATION, volume=None, on_complete=None):
"""
Play RTTTL ringtone via buzzer.
Args:
rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:8e6,8d6...")
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
volume: Override volume (0-100), or None to use system volume
on_complete: Callback function(message) called when playback finishes
Returns:
bool: True if playback started, False if rejected or unavailable
"""
if _device_type not in (DEVICE_BUZZER, DEVICE_BOTH):
print("AudioFlinger: play_rtttl() failed - no buzzer device available")
return False
if not _buzzer_instance:
print("AudioFlinger: play_rtttl() failed - buzzer not initialized")
return False
# Check audio focus
if _stream_lock:
_stream_lock.acquire()
can_start = _check_audio_focus(stream_type)
if _stream_lock:
_stream_lock.release()
if not can_start:
return False
# Create stream and start playback in background thread
try:
from mpos.audio.stream_rtttl import RTTTLStream
import _thread
import mpos.apps
stream = RTTTLStream(
rtttl_string=rtttl_string,
stream_type=stream_type,
volume=volume if volume is not None else _volume,
buzzer_instance=_buzzer_instance,
on_complete=on_complete
)
_thread.stack_size(mpos.apps.good_stack_size())
_thread.start_new_thread(_playback_thread, (stream,))
return True
except Exception as e:
print(f"AudioFlinger: play_rtttl() failed: {e}")
return False
def stop():
"""Stop current audio playback."""
global _current_stream
if _stream_lock:
_stream_lock.acquire()
if _current_stream:
_current_stream.stop()
print("AudioFlinger: Playback stopped")
else:
print("AudioFlinger: No playback to stop")
if _stream_lock:
_stream_lock.release()
def pause():
"""
Pause current audio playback (if supported by stream).
Note: Most streams don't support pause, only stop.
"""
global _current_stream
if _stream_lock:
_stream_lock.acquire()
if _current_stream and hasattr(_current_stream, 'pause'):
_current_stream.pause()
print("AudioFlinger: Playback paused")
else:
print("AudioFlinger: Pause not supported or no playback active")
if _stream_lock:
_stream_lock.release()
def resume():
"""
Resume paused audio playback (if supported by stream).
Note: Most streams don't support resume, only play.
"""
global _current_stream
if _stream_lock:
_stream_lock.acquire()
if _current_stream and hasattr(_current_stream, 'resume'):
_current_stream.resume()
print("AudioFlinger: Playback resumed")
else:
print("AudioFlinger: Resume not supported or no playback active")
if _stream_lock:
_stream_lock.release()
def set_volume(volume):
"""
Set system volume (affects new streams, not current playback).
Args:
volume: Volume level (0-100)
"""
global _volume
_volume = max(0, min(100, volume))
def get_volume():
"""
Get system volume.
Returns:
int: Current system volume (0-100)
"""
return _volume
def get_device_type():
"""
Get configured audio device type.
Returns:
int: Device type (DEVICE_NULL, DEVICE_I2S, DEVICE_BUZZER, DEVICE_BOTH)
"""
return _device_type
def is_playing():
"""
Check if audio is currently playing.
Returns:
bool: True if playback active, False otherwise
"""
if _stream_lock:
_stream_lock.acquire()
result = _current_stream is not None and _current_stream.is_playing()
if _stream_lock:
_stream_lock.release()
return result
@@ -0,0 +1,231 @@
# RTTTLStream - RTTTL Ringtone Playback Stream for AudioFlinger
# Ring Tone Text Transfer Language parser and player
# Ported from Fri3d Camp 2024 Badge firmware
import math
import time
class RTTTLStream:
"""
RTTTL (Ring Tone Text Transfer Language) parser and player.
Format: "name:defaults:notes"
Example: "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d"
See: https://en.wikipedia.org/wiki/Ring_Tone_Text_Transfer_Language
"""
# Note frequency table (A-G, with sharps)
_NOTES = [
440.0, # A
493.9, # B or H
261.6, # C
293.7, # D
329.6, # E
349.2, # F
392.0, # G
0.0, # pad
466.2, # A#
0.0, # pad
277.2, # C#
311.1, # D#
0.0, # pad
370.0, # F#
415.3, # G#
0.0, # pad
]
def __init__(self, rtttl_string, stream_type, volume, buzzer_instance, on_complete):
"""
Initialize RTTTL stream.
Args:
rtttl_string: RTTTL format string (e.g., "Nokia:d=4,o=5,b=225:...")
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
volume: Volume level (0-100)
buzzer_instance: PWM buzzer instance
on_complete: Callback function(message) when playback finishes
"""
self.stream_type = stream_type
self.volume = volume
self.buzzer = buzzer_instance
self.on_complete = on_complete
self._keep_running = True
self._is_playing = False
# Parse RTTTL format
tune_pieces = rtttl_string.split(':')
if len(tune_pieces) != 3:
raise ValueError('RTTTL should contain exactly 2 colons')
self.name = tune_pieces[0]
self.tune = tune_pieces[2]
self.tune_idx = 0
self._parse_defaults(tune_pieces[1])
def is_playing(self):
"""Check if stream is currently playing."""
return self._is_playing
def stop(self):
"""Stop playback."""
self._keep_running = False
def _parse_defaults(self, defaults):
"""
Parse default values from RTTTL format.
Example: "d=4,o=5,b=140"
"""
self.default_duration = 4
self.default_octave = 5
self.bpm = 120
for item in defaults.split(','):
setting = item.split('=')
if len(setting) != 2:
continue
key = setting[0].strip()
value = int(setting[1].strip())
if key == 'o':
self.default_octave = value
elif key == 'd':
self.default_duration = value
elif key == 'b':
self.bpm = value
# Calculate milliseconds per whole note
# 240000 = 60 sec/min * 4 beats/whole-note * 1000 msec/sec
self.msec_per_whole_note = 240000.0 / self.bpm
def _next_char(self):
"""Get next character from tune string."""
if self.tune_idx < len(self.tune):
char = self.tune[self.tune_idx]
self.tune_idx += 1
if char == ',':
char = ' '
return char
return '|' # End marker
def _notes(self):
"""
Generator that yields (frequency, duration_ms) tuples.
Yields:
tuple: (frequency_hz, duration_ms) for each note
"""
while True:
# Skip blank characters and commas
char = self._next_char()
while char == ' ':
char = self._next_char()
# Parse duration (if present)
# Duration of 1 = whole note, 8 = 1/8 note
duration = 0
while char.isdigit():
duration *= 10
duration += ord(char) - ord('0')
char = self._next_char()
if duration == 0:
duration = self.default_duration
if char == '|': # End of tune
return
# Parse note letter
note = char.lower()
if 'a' <= note <= 'g':
note_idx = ord(note) - ord('a')
elif note == 'h':
note_idx = 1 # H is equivalent to B
elif note == 'p':
note_idx = 7 # Pause
else:
note_idx = 7 # Unknown = pause
char = self._next_char()
# Check for sharp
if char == '#':
note_idx += 8
char = self._next_char()
# Check for duration modifier (dot) before octave
duration_multiplier = 1.0
if char == '.':
duration_multiplier = 1.5
char = self._next_char()
# Check for octave
if '4' <= char <= '7':
octave = ord(char) - ord('0')
char = self._next_char()
else:
octave = self.default_octave
# Check for duration modifier (dot) after octave
if char == '.':
duration_multiplier = 1.5
char = self._next_char()
# Calculate frequency and duration
freq = self._NOTES[note_idx] * (1 << (octave - 4))
msec = (self.msec_per_whole_note / duration) * duration_multiplier
yield freq, msec
def play(self):
"""Play RTTTL tune via buzzer (runs in background thread)."""
self._is_playing = True
# Calculate exponential duty cycle for perceptually linear volume
if self.volume <= 0:
duty = 0
else:
volume = min(100, self.volume)
# Exponential volume curve
# Maximum volume is at 50% duty cycle (32768 when using duty_u16)
# Minimum is 4 (absolute minimum for audible PWM)
divider = 10
duty = int(
((math.exp(volume / divider) - math.exp(0.1)) /
(math.exp(10) - math.exp(0.1)) * (32768 - 4)) + 4
)
print(f"RTTTLStream: Playing '{self.name}' (volume {self.volume}%)")
try:
for freq, msec in self._notes():
if not self._keep_running:
print("RTTTLStream: Playback stopped by user")
break
# Play tone
if freq > 0:
self.buzzer.freq(int(freq))
self.buzzer.duty_u16(duty)
# Play for 90% of duration, silent for 10% (note separation)
time.sleep_ms(int(msec * 0.9))
self.buzzer.duty_u16(0)
time.sleep_ms(int(msec * 0.1))
print(f"RTTTLStream: Finished playing '{self.name}'")
if self.on_complete:
self.on_complete(f"Finished: {self.name}")
except Exception as e:
print(f"RTTTLStream: Error: {e}")
if self.on_complete:
self.on_complete(f"Error: {e}")
finally:
# Ensure buzzer is off
self.buzzer.duty_u16(0)
self._is_playing = False
@@ -0,0 +1,313 @@
# WAVStream - WAV File Playback Stream for AudioFlinger
# Supports 8/16/24/32-bit PCM, mono+stereo, auto-upsampling, volume control
# Ported from MusicPlayer's AudioPlayer class
import machine
import os
import time
import sys
# Volume scaling function - regular Python version
# Note: Viper optimization removed because @micropython.viper decorator
# causes cross-compiler errors on Unix/macOS builds even inside conditionals
def _scale_audio(buf, num_bytes, scale_fixed):
"""Volume scaling for 16-bit audio samples."""
for i in range(0, num_bytes, 2):
lo = buf[i]
hi = buf[i + 1]
sample = (hi << 8) | lo
if hi & 128:
sample -= 65536
sample = (sample * scale_fixed) // 32768
if sample > 32767:
sample = 32767
elif sample < -32768:
sample = -32768
buf[i] = sample & 255
buf[i + 1] = (sample >> 8) & 255
class WAVStream:
"""
WAV file playback stream with I2S output.
Supports 8/16/24/32-bit PCM, mono and stereo, auto-upsampling to >=22050 Hz.
"""
def __init__(self, file_path, stream_type, volume, i2s_pins, on_complete):
"""
Initialize WAV stream.
Args:
file_path: Path to WAV file
stream_type: Stream type (STREAM_MUSIC, STREAM_NOTIFICATION, STREAM_ALARM)
volume: Volume level (0-100)
i2s_pins: Dict with 'sck', 'ws', 'sd' pin numbers
on_complete: Callback function(message) when playback finishes
"""
self.file_path = file_path
self.stream_type = stream_type
self.volume = volume
self.i2s_pins = i2s_pins
self.on_complete = on_complete
self._keep_running = True
self._is_playing = False
self._i2s = None
def is_playing(self):
"""Check if stream is currently playing."""
return self._is_playing
def stop(self):
"""Stop playback."""
self._keep_running = False
# ----------------------------------------------------------------------
# WAV header parser - returns bit-depth and format info
# ----------------------------------------------------------------------
@staticmethod
def _find_data_chunk(f):
"""
Parse WAV header and find data chunk.
Returns:
tuple: (data_start, data_size, sample_rate, channels, bits_per_sample)
"""
f.seek(0)
if f.read(4) != b'RIFF':
raise ValueError("Not a RIFF (standard .wav) file")
file_size = int.from_bytes(f.read(4), 'little') + 8
if f.read(4) != b'WAVE':
raise ValueError("Not a WAVE (standard .wav) file")
pos = 12
sample_rate = None
channels = None
bits_per_sample = None
while pos < file_size:
f.seek(pos)
chunk_id = f.read(4)
if len(chunk_id) < 4:
break
chunk_size = int.from_bytes(f.read(4), 'little')
if chunk_id == b'fmt ':
fmt = f.read(chunk_size)
if len(fmt) < 16:
raise ValueError("Invalid fmt chunk")
if int.from_bytes(fmt[0:2], 'little') != 1:
raise ValueError("Only PCM supported")
channels = int.from_bytes(fmt[2:4], 'little')
if channels not in (1, 2):
raise ValueError("Only mono or stereo supported")
sample_rate = int.from_bytes(fmt[4:8], 'little')
bits_per_sample = int.from_bytes(fmt[14:16], 'little')
if bits_per_sample not in (8, 16, 24, 32):
raise ValueError("Only 8/16/24/32-bit PCM supported")
elif chunk_id == b'data':
return f.tell(), chunk_size, sample_rate, channels, bits_per_sample
pos += 8 + chunk_size
if chunk_size % 2:
pos += 1
raise ValueError("No 'data' chunk found")
# ----------------------------------------------------------------------
# Bit depth conversion functions
# ----------------------------------------------------------------------
@staticmethod
def _convert_8_to_16(buf):
"""Convert 8-bit unsigned PCM to 16-bit signed PCM."""
out = bytearray(len(buf) * 2)
j = 0
for i in range(len(buf)):
u8 = buf[i]
s16 = (u8 - 128) << 8
out[j] = s16 & 0xFF
out[j + 1] = (s16 >> 8) & 0xFF
j += 2
return out
@staticmethod
def _convert_24_to_16(buf):
"""Convert 24-bit PCM to 16-bit PCM."""
samples = len(buf) // 3
out = bytearray(samples * 2)
j = 0
for i in range(samples):
b0 = buf[j]
b1 = buf[j + 1]
b2 = buf[j + 2]
s24 = (b2 << 16) | (b1 << 8) | b0
if b2 & 0x80:
s24 -= 0x1000000
s16 = s24 >> 8
out[i * 2] = s16 & 0xFF
out[i * 2 + 1] = (s16 >> 8) & 0xFF
j += 3
return out
@staticmethod
def _convert_32_to_16(buf):
"""Convert 32-bit PCM to 16-bit PCM."""
samples = len(buf) // 4
out = bytearray(samples * 2)
j = 0
for i in range(samples):
b0 = buf[j]
b1 = buf[j + 1]
b2 = buf[j + 2]
b3 = buf[j + 3]
s32 = (b3 << 24) | (b2 << 16) | (b1 << 8) | b0
if b3 & 0x80:
s32 -= 0x100000000
s16 = s32 >> 16
out[i * 2] = s16 & 0xFF
out[i * 2 + 1] = (s16 >> 8) & 0xFF
j += 4
return out
# ----------------------------------------------------------------------
# Upsampling (zero-order-hold)
# ----------------------------------------------------------------------
@staticmethod
def _upsample_buffer(raw, factor):
"""Upsample 16-bit buffer by repeating samples."""
if factor == 1:
return raw
upsampled = bytearray(len(raw) * factor)
out_idx = 0
for i in range(0, len(raw), 2):
lo = raw[i]
hi = raw[i + 1]
for _ in range(factor):
upsampled[out_idx] = lo
upsampled[out_idx + 1] = hi
out_idx += 2
return upsampled
# ----------------------------------------------------------------------
# Main playback routine
# ----------------------------------------------------------------------
def play(self):
"""Main playback routine (runs in background thread)."""
self._is_playing = True
try:
with open(self.file_path, 'rb') as f:
st = os.stat(self.file_path)
file_size = st[6]
print(f"WAVStream: Playing {self.file_path} ({file_size} bytes)")
# Parse WAV header
data_start, data_size, original_rate, channels, bits_per_sample = \
self._find_data_chunk(f)
# Decide playback rate (force >=22050 Hz)
target_rate = 22050
if original_rate >= target_rate:
playback_rate = original_rate
upsample_factor = 1
else:
upsample_factor = (target_rate + original_rate - 1) // original_rate
playback_rate = original_rate * upsample_factor
print(f"WAVStream: {original_rate} Hz, {bits_per_sample}-bit, {channels}-ch")
print(f"WAVStream: Playback at {playback_rate} Hz (factor {upsample_factor})")
if data_size > file_size - data_start:
data_size = file_size - data_start
# Initialize I2S (always 16-bit output)
try:
i2s_format = machine.I2S.MONO if channels == 1 else machine.I2S.STEREO
self._i2s = machine.I2S(
0,
sck=machine.Pin(self.i2s_pins['sck'], machine.Pin.OUT),
ws=machine.Pin(self.i2s_pins['ws'], machine.Pin.OUT),
sd=machine.Pin(self.i2s_pins['sd'], machine.Pin.OUT),
mode=machine.I2S.TX,
bits=16,
format=i2s_format,
rate=playback_rate,
ibuf=32000
)
except Exception as e:
print(f"WAVStream: I2S init failed: {e}")
return
print(f"WAVStream: Playing {data_size} bytes (volume {self.volume}%)")
f.seek(data_start)
chunk_size = 4096
bytes_per_original_sample = (bits_per_sample // 8) * channels
total_original = 0
while total_original < data_size:
if not self._keep_running:
print("WAVStream: Playback stopped by user")
break
# Read chunk of original data
to_read = min(chunk_size, data_size - total_original)
to_read -= (to_read % bytes_per_original_sample)
if to_read <= 0:
break
raw = bytearray(f.read(to_read))
if not raw:
break
# 1. Convert bit-depth to 16-bit
if bits_per_sample == 8:
raw = self._convert_8_to_16(raw)
elif bits_per_sample == 24:
raw = self._convert_24_to_16(raw)
elif bits_per_sample == 32:
raw = self._convert_32_to_16(raw)
# 16-bit unchanged
# 2. Upsample if needed
if upsample_factor > 1:
raw = self._upsample_buffer(raw, upsample_factor)
# 3. Volume scaling
scale = self.volume / 100.0
if scale < 1.0:
scale_fixed = int(scale * 32768)
_scale_audio(raw, len(raw), scale_fixed)
# 4. Output to I2S
if self._i2s:
self._i2s.write(raw)
else:
# Simulate playback timing if no I2S
num_samples = len(raw) // (2 * channels)
time.sleep(num_samples / playback_rate)
total_original += to_read
print(f"WAVStream: Finished playing {self.file_path}")
if self.on_complete:
self.on_complete(f"Finished: {self.file_path}")
except Exception as e:
print(f"WAVStream: Error: {e}")
if self.on_complete:
self.on_complete(f"Error: {e}")
finally:
self._is_playing = False
if self._i2s:
self._i2s.deinit()
self._i2s = None
@@ -289,4 +289,33 @@ mpos.battery_voltage.init_adc(13, adc_to_voltage)
import mpos.sdcard
mpos.sdcard.init(spi_bus, cs_pin=14)
# === AUDIO HARDWARE ===
from machine import PWM, Pin
import mpos.audio.audioflinger as AudioFlinger
# Initialize buzzer (GPIO 46)
buzzer = PWM(Pin(46), freq=550, duty=0)
# I2S pin configuration (GPIO 2, 47, 16)
# Note: I2S is created per-stream, not at boot (only one instance can exist)
i2s_pins = {
'sck': 2,
'ws': 47,
'sd': 16,
}
# Initialize AudioFlinger (both I2S and buzzer available)
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_BOTH,
i2s_pins=i2s_pins,
buzzer_instance=buzzer
)
# === LED HARDWARE ===
import mpos.lights as LightsManager
# Initialize 5 NeoPixel LEDs (GPIO 12)
LightsManager.init(neopixel_pin=12, num_leds=5)
print("Fri3d hardware: Audio and LEDs initialized")
print("boot.py finished")
@@ -95,6 +95,21 @@ def adc_to_voltage(adc_value):
mpos.battery_voltage.init_adc(999, adc_to_voltage)
# === AUDIO HARDWARE ===
import mpos.audio.audioflinger as AudioFlinger
# Note: Desktop builds have no audio hardware
# AudioFlinger functions will return False (no-op)
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_NULL,
i2s_pins=None,
buzzer_instance=None
)
# === LED HARDWARE ===
# Note: Desktop builds have no LED hardware
# LightsManager will not be initialized (functions will return False)
print("linux.py finished")
@@ -110,4 +110,20 @@ try:
except Exception as e:
print(f"Warning: powering off camera got exception: {e}")
# === AUDIO HARDWARE ===
import mpos.audio.audioflinger as AudioFlinger
# Note: Waveshare board has no buzzer or LEDs, only I2S audio
# I2S pin configuration will be determined by the board's audio hardware
# For now, initialize with I2S only (pins will be configured per-stream if available)
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_I2S,
i2s_pins={'sck': 2, 'ws': 47, 'sd': 16}, # Default ESP32-S3 I2S pins
buzzer_instance=None
)
# === LED HARDWARE ===
# Note: Waveshare board has no NeoPixel LEDs
# LightsManager will not be initialized (functions will return False)
print("boot.py finished")
@@ -0,0 +1,8 @@
# Fri3d Camp 2024 Badge Hardware Drivers
# These are simple wrappers that can be used by services like AudioFlinger
from .buzzer import BuzzerConfig
from .leds import LEDConfig
from .rtttl_data import RTTTL_SONGS
__all__ = ['BuzzerConfig', 'LEDConfig', 'RTTTL_SONGS']
@@ -0,0 +1,11 @@
# Fri3d Camp 2024 Badge - Buzzer Configuration
class BuzzerConfig:
"""Configuration for PWM buzzer hardware."""
# GPIO pin for buzzer
PIN = 46
# Default PWM settings
DEFAULT_FREQ = 550 # Hz
DEFAULT_DUTY = 0 # Off by default
@@ -0,0 +1,10 @@
# Fri3d Camp 2024 Badge - LED Configuration
class LEDConfig:
"""Configuration for NeoPixel RGB LED hardware."""
# GPIO pin for NeoPixel data line
PIN = 12
# Number of NeoPixel LEDs on badge
NUM_LEDS = 5
@@ -0,0 +1,18 @@
# RTTTL Song Catalog
# Ring Tone Text Transfer Language songs for buzzer playback
# Format: "name:defaults:notes"
# Ported from Fri3d Camp 2024 Badge firmware
RTTTL_SONGS = {
"nokia": "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#,8c#6,8b,d,8p,8b,8a,8c#,8e,8a,8p",
"macarena": "Macarena:d=4,o=5,b=180:f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,c,8c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c,p,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8f,8a,p,2c,f,8f,8f,f,8f,8f,8f,8f,8f,8f,8d,8c",
"takeonme": "TakeOnMe:d=4,o=4,b=160:8f#5,8f#5,8f#5,8d5,8p,8b,8p,8e5,8p,8e5,8p,8e5,8g#5,8g#5,8a5,8b5,8a5,8a5,8a5,8e5,8p,8d5,8p,8f#5,8p,8f#5,8p,8f#5,8e5,8e5,8f#5,8e5",
"goodbadugly": "TheGoodTheBad:d=4,o=5,b=160:c,8d,8e,8d,c,8d,8e,8d,c,8d,e,8f,2g,8p,a,b,c6,8b,8a,8g,8f,e,8f,g,8e,8d,8c",
"creeps": "Creeps:d=4,o=5,b=120:8c,8d,8e,8f,g,8e,8f,g,8f,8e,8d,c,8d,8e,f,8d,8e,f,8e,8d,8c,8b4",
"william_tell": "WilliamTell:d=4,o=5,b=125:8e,8e,8e,2p,8e,8e,8e,2p,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,8e,e"
}
+153
View File
@@ -0,0 +1,153 @@
# LightsManager - Simple LED Control Service for MicroPythonOS
# Provides one-shot LED control for NeoPixel RGB LEDs
# Apps implement custom animations using the update_frame() pattern
# Module-level state (singleton pattern)
_neopixel = None
_num_leds = 0
def init(neopixel_pin, num_leds=5):
"""
Initialize NeoPixel LEDs.
Args:
neopixel_pin: GPIO pin number for NeoPixel data line
num_leds: Number of LEDs in the strip (default 5 for Fri3d badge)
"""
global _neopixel, _num_leds
try:
from machine import Pin
from neopixel import NeoPixel
_neopixel = NeoPixel(Pin(neopixel_pin, Pin.OUT), num_leds)
_num_leds = num_leds
# Clear all LEDs on initialization
for i in range(num_leds):
_neopixel[i] = (0, 0, 0)
_neopixel.write()
print(f"LightsManager initialized: {num_leds} LEDs on GPIO {neopixel_pin}")
except Exception as e:
print(f"LightsManager: Failed to initialize LEDs: {e}")
print(" - LED functions will return False (no-op)")
def is_available():
"""
Check if LED hardware is available.
Returns:
bool: True if LEDs are initialized and available
"""
return _neopixel is not None
def get_led_count():
"""
Get the number of LEDs.
Returns:
int: Number of LEDs, or 0 if not initialized
"""
return _num_leds
def set_led(index, r, g, b):
"""
Set a single LED color (buffered until write() is called).
Args:
index: LED index (0 to num_leds-1)
r: Red value (0-255)
g: Green value (0-255)
b: Blue value (0-255)
Returns:
bool: True if successful, False if LEDs unavailable or invalid index
"""
if not _neopixel:
return False
if index < 0 or index >= _num_leds:
print(f"LightsManager: Invalid LED index {index} (valid range: 0-{_num_leds-1})")
return False
_neopixel[index] = (r, g, b)
return True
def set_all(r, g, b):
"""
Set all LEDs to the same color (buffered until write() is called).
Args:
r: Red value (0-255)
g: Green value (0-255)
b: Blue value (0-255)
Returns:
bool: True if successful, False if LEDs unavailable
"""
if not _neopixel:
return False
for i in range(_num_leds):
_neopixel[i] = (r, g, b)
return True
def clear():
"""
Clear all LEDs (set to black, buffered until write() is called).
Returns:
bool: True if successful, False if LEDs unavailable
"""
return set_all(0, 0, 0)
def write():
"""
Update hardware with buffered LED colors.
Must be called after set_led(), set_all(), or clear() to make changes visible.
Returns:
bool: True if successful, False if LEDs unavailable
"""
if not _neopixel:
return False
_neopixel.write()
return True
def set_notification_color(color_name):
"""
Convenience method to set all LEDs to a common color and update immediately.
Args:
color_name: Color name (red, green, blue, yellow, orange, purple, white)
Returns:
bool: True if successful, False if LEDs unavailable or unknown color
"""
colors = {
"red": (255, 0, 0),
"green": (0, 255, 0),
"blue": (0, 0, 255),
"yellow": (255, 255, 0),
"orange": (255, 128, 0),
"purple": (128, 0, 255),
"white": (255, 255, 255),
}
color = colors.get(color_name.lower())
if not color:
print(f"LightsManager: Unknown color '{color_name}'")
print(f" - Available colors: {', '.join(colors.keys())}")
return False
return set_all(*color) and write()