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
+102
View File
@@ -0,0 +1,102 @@
# Hardware Mocks for Testing AudioFlinger and LightsManager
# Provides mock implementations of PWM, I2S, NeoPixel, and Pin classes
class MockPin:
"""Mock machine.Pin for testing."""
IN = 0
OUT = 1
PULL_UP = 2
def __init__(self, pin_number, mode=None, pull=None):
self.pin_number = pin_number
self.mode = mode
self.pull = pull
self._value = 0
def value(self, val=None):
if val is not None:
self._value = val
return self._value
class MockPWM:
"""Mock machine.PWM for testing buzzer."""
def __init__(self, pin, freq=0, duty=0):
self.pin = pin
self.last_freq = freq
self.last_duty = duty
self.freq_history = []
self.duty_history = []
def freq(self, value=None):
"""Set or get frequency."""
if value is not None:
self.last_freq = value
self.freq_history.append(value)
return self.last_freq
def duty_u16(self, value=None):
"""Set or get duty cycle (0-65535)."""
if value is not None:
self.last_duty = value
self.duty_history.append(value)
return self.last_duty
class MockI2S:
"""Mock machine.I2S for testing audio playback."""
TX = 0
MONO = 1
STEREO = 2
def __init__(self, id, sck, ws, sd, mode, bits, format, rate, ibuf):
self.id = id
self.sck = sck
self.ws = ws
self.sd = sd
self.mode = mode
self.bits = bits
self.format = format
self.rate = rate
self.ibuf = ibuf
self.written_bytes = []
self.total_bytes_written = 0
def write(self, buf):
"""Simulate writing to I2S hardware."""
self.written_bytes.append(bytes(buf))
self.total_bytes_written += len(buf)
return len(buf)
def deinit(self):
"""Deinitialize I2S."""
pass
class MockNeoPixel:
"""Mock neopixel.NeoPixel for testing LEDs."""
def __init__(self, pin, num_leds):
self.pin = pin
self.num_leds = num_leds
self.pixels = [(0, 0, 0)] * num_leds
self.write_count = 0
def __setitem__(self, index, value):
"""Set LED color (R, G, B) tuple."""
if 0 <= index < self.num_leds:
self.pixels[index] = value
def __getitem__(self, index):
"""Get LED color."""
if 0 <= index < self.num_leds:
return self.pixels[index]
return (0, 0, 0)
def write(self):
"""Update hardware (mock - just increment counter)."""
self.write_count += 1
+243
View File
@@ -0,0 +1,243 @@
# Unit tests for AudioFlinger service
import unittest
import sys
# Mock hardware before importing
class MockPWM:
def __init__(self, pin, freq=0, duty=0):
self.pin = pin
self.last_freq = freq
self.last_duty = duty
def freq(self, value=None):
if value is not None:
self.last_freq = value
return self.last_freq
def duty_u16(self, value=None):
if value is not None:
self.last_duty = value
return self.last_duty
class MockPin:
IN = 0
OUT = 1
def __init__(self, pin_number, mode=None):
self.pin_number = pin_number
self.mode = mode
# Inject mocks
class MockMachine:
PWM = MockPWM
Pin = MockPin
sys.modules['machine'] = MockMachine()
class MockLock:
def acquire(self):
pass
def release(self):
pass
class MockThread:
@staticmethod
def allocate_lock():
return MockLock()
@staticmethod
def start_new_thread(func, args, **kwargs):
pass # No-op for testing
@staticmethod
def stack_size(size=None):
return 16384 if size is None else None
sys.modules['_thread'] = MockThread()
class MockMposApps:
@staticmethod
def good_stack_size():
return 16384
sys.modules['mpos.apps'] = MockMposApps()
# Now import the module to test
import mpos.audio.audioflinger as AudioFlinger
class TestAudioFlinger(unittest.TestCase):
"""Test cases for AudioFlinger service."""
def setUp(self):
"""Initialize AudioFlinger before each test."""
self.buzzer = MockPWM(MockPin(46))
self.i2s_pins = {'sck': 2, 'ws': 47, 'sd': 16}
# Reset volume to default before each test
AudioFlinger.set_volume(70)
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_BOTH,
i2s_pins=self.i2s_pins,
buzzer_instance=self.buzzer
)
def tearDown(self):
"""Clean up after each test."""
AudioFlinger.stop()
def test_initialization(self):
"""Test that AudioFlinger initializes correctly."""
self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH)
self.assertEqual(AudioFlinger._i2s_pins, self.i2s_pins)
self.assertEqual(AudioFlinger._buzzer_instance, self.buzzer)
def test_device_types(self):
"""Test device type constants."""
self.assertEqual(AudioFlinger.DEVICE_NULL, 0)
self.assertEqual(AudioFlinger.DEVICE_I2S, 1)
self.assertEqual(AudioFlinger.DEVICE_BUZZER, 2)
self.assertEqual(AudioFlinger.DEVICE_BOTH, 3)
def test_stream_types(self):
"""Test stream type constants and priority order."""
self.assertEqual(AudioFlinger.STREAM_MUSIC, 0)
self.assertEqual(AudioFlinger.STREAM_NOTIFICATION, 1)
self.assertEqual(AudioFlinger.STREAM_ALARM, 2)
# Higher number = higher priority
self.assertTrue(AudioFlinger.STREAM_MUSIC < AudioFlinger.STREAM_NOTIFICATION)
self.assertTrue(AudioFlinger.STREAM_NOTIFICATION < AudioFlinger.STREAM_ALARM)
def test_volume_control(self):
"""Test volume get/set operations."""
# Set volume
AudioFlinger.set_volume(50)
self.assertEqual(AudioFlinger.get_volume(), 50)
# Test clamping to 0-100 range
AudioFlinger.set_volume(150)
self.assertEqual(AudioFlinger.get_volume(), 100)
AudioFlinger.set_volume(-10)
self.assertEqual(AudioFlinger.get_volume(), 0)
def test_device_null_rejects_playback(self):
"""Test that DEVICE_NULL rejects all playback requests."""
# Re-initialize with no device
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_NULL,
i2s_pins=None,
buzzer_instance=None
)
# WAV should be rejected
result = AudioFlinger.play_wav("test.wav")
self.assertFalse(result)
# RTTTL should be rejected
result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c")
self.assertFalse(result)
def test_device_i2s_only_rejects_rtttl(self):
"""Test that DEVICE_I2S rejects buzzer playback."""
# Re-initialize with I2S only
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_I2S,
i2s_pins=self.i2s_pins,
buzzer_instance=None
)
# RTTTL should be rejected (no buzzer)
result = AudioFlinger.play_rtttl("Test:d=4,o=5,b=120:c")
self.assertFalse(result)
def test_device_buzzer_only_rejects_wav(self):
"""Test that DEVICE_BUZZER rejects I2S playback."""
# Re-initialize with buzzer only
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_BUZZER,
i2s_pins=None,
buzzer_instance=self.buzzer
)
# WAV should be rejected (no I2S)
result = AudioFlinger.play_wav("test.wav")
self.assertFalse(result)
def test_missing_i2s_pins_rejects_wav(self):
"""Test that missing I2S pins rejects WAV playback."""
# Re-initialize with I2S device but no pins
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_I2S,
i2s_pins=None,
buzzer_instance=None
)
result = AudioFlinger.play_wav("test.wav")
self.assertFalse(result)
def test_is_playing_initially_false(self):
"""Test that is_playing() returns False initially."""
self.assertFalse(AudioFlinger.is_playing())
def test_stop_with_no_playback(self):
"""Test that stop() can be called when nothing is playing."""
# Should not raise exception
AudioFlinger.stop()
self.assertFalse(AudioFlinger.is_playing())
def test_get_device_type(self):
"""Test that get_device_type() returns correct value."""
# Test DEVICE_BOTH
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_BOTH,
i2s_pins=self.i2s_pins,
buzzer_instance=self.buzzer
)
self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BOTH)
# Test DEVICE_I2S
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_I2S,
i2s_pins=self.i2s_pins,
buzzer_instance=None
)
self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_I2S)
# Test DEVICE_BUZZER
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_BUZZER,
i2s_pins=None,
buzzer_instance=self.buzzer
)
self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_BUZZER)
# Test DEVICE_NULL
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_NULL,
i2s_pins=None,
buzzer_instance=None
)
self.assertEqual(AudioFlinger.get_device_type(), AudioFlinger.DEVICE_NULL)
def test_audio_focus_check_no_current_stream(self):
"""Test audio focus allows playback when no stream is active."""
result = AudioFlinger._check_audio_focus(AudioFlinger.STREAM_MUSIC)
self.assertTrue(result)
def test_init_creates_lock(self):
"""Test that initialization creates a stream lock."""
self.assertIsNotNone(AudioFlinger._stream_lock)
def test_volume_default_value(self):
"""Test that default volume is reasonable."""
# After init, volume should be at default (70)
AudioFlinger.init(
device_type=AudioFlinger.DEVICE_NULL,
i2s_pins=None,
buzzer_instance=None
)
self.assertEqual(AudioFlinger.get_volume(), 70)
+126
View File
@@ -0,0 +1,126 @@
# Unit tests for LightsManager service
import unittest
import sys
# Mock hardware before importing LightsManager
class MockPin:
IN = 0
OUT = 1
def __init__(self, pin_number, mode=None):
self.pin_number = pin_number
self.mode = mode
class MockNeoPixel:
def __init__(self, pin, num_leds):
self.pin = pin
self.num_leds = num_leds
self.pixels = [(0, 0, 0)] * num_leds
self.write_count = 0
def __setitem__(self, index, value):
if 0 <= index < self.num_leds:
self.pixels[index] = value
def __getitem__(self, index):
if 0 <= index < self.num_leds:
return self.pixels[index]
return (0, 0, 0)
def write(self):
self.write_count += 1
# Inject mocks
sys.modules['machine'] = type('module', (), {'Pin': MockPin})()
sys.modules['neopixel'] = type('module', (), {'NeoPixel': MockNeoPixel})()
# Now import the module to test
import mpos.lights as LightsManager
class TestLightsManager(unittest.TestCase):
"""Test cases for LightsManager service."""
def setUp(self):
"""Initialize LightsManager before each test."""
LightsManager.init(neopixel_pin=12, num_leds=5)
def test_initialization(self):
"""Test that LightsManager initializes correctly."""
self.assertTrue(LightsManager.is_available())
self.assertEqual(LightsManager.get_led_count(), 5)
def test_set_single_led(self):
"""Test setting a single LED color."""
result = LightsManager.set_led(0, 255, 0, 0)
self.assertTrue(result)
# Verify color was set (via internal _neopixel mock)
neopixel = LightsManager._neopixel
self.assertEqual(neopixel[0], (255, 0, 0))
def test_set_led_invalid_index(self):
"""Test that invalid LED indices are rejected."""
# Negative index
result = LightsManager.set_led(-1, 255, 0, 0)
self.assertFalse(result)
# Index too large
result = LightsManager.set_led(10, 255, 0, 0)
self.assertFalse(result)
def test_set_all_leds(self):
"""Test setting all LEDs to same color."""
result = LightsManager.set_all(0, 255, 0)
self.assertTrue(result)
# Verify all LEDs were set
neopixel = LightsManager._neopixel
for i in range(5):
self.assertEqual(neopixel[i], (0, 255, 0))
def test_clear(self):
"""Test clearing all LEDs."""
# First set some colors
LightsManager.set_all(255, 255, 255)
# Then clear
result = LightsManager.clear()
self.assertTrue(result)
# Verify all LEDs are black
neopixel = LightsManager._neopixel
for i in range(5):
self.assertEqual(neopixel[i], (0, 0, 0))
def test_write(self):
"""Test that write() updates hardware."""
neopixel = LightsManager._neopixel
initial_count = neopixel.write_count
result = LightsManager.write()
self.assertTrue(result)
# Verify write was called
self.assertEqual(neopixel.write_count, initial_count + 1)
def test_notification_colors(self):
"""Test convenience notification color method."""
# Valid colors
self.assertTrue(LightsManager.set_notification_color("red"))
self.assertTrue(LightsManager.set_notification_color("green"))
self.assertTrue(LightsManager.set_notification_color("blue"))
# Invalid color
result = LightsManager.set_notification_color("invalid_color")
self.assertFalse(result)
def test_case_insensitive_colors(self):
"""Test that color names are case-insensitive."""
self.assertTrue(LightsManager.set_notification_color("RED"))
self.assertTrue(LightsManager.set_notification_color("Green"))
self.assertTrue(LightsManager.set_notification_color("BLUE"))
+173
View File
@@ -0,0 +1,173 @@
# Unit tests for RTTTL parser (RTTTLStream)
import unittest
import sys
# Mock hardware before importing
class MockPWM:
def __init__(self, pin, freq=0, duty=0):
self.pin = pin
self.last_freq = freq
self.last_duty = duty
self.freq_history = []
self.duty_history = []
def freq(self, value=None):
if value is not None:
self.last_freq = value
self.freq_history.append(value)
return self.last_freq
def duty_u16(self, value=None):
if value is not None:
self.last_duty = value
self.duty_history.append(value)
return self.last_duty
# Inject mock
sys.modules['machine'] = type('module', (), {'PWM': MockPWM, 'Pin': lambda x: x})()
# Now import the module to test
from mpos.audio.stream_rtttl import RTTTLStream
class TestRTTTL(unittest.TestCase):
"""Test cases for RTTTL parser."""
def setUp(self):
"""Create a mock buzzer before each test."""
self.buzzer = MockPWM(46)
def test_parse_simple_rtttl(self):
"""Test parsing a simple RTTTL string."""
rtttl = "Nokia:d=4,o=5,b=225:8e6,8d6,8f#,8g#"
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
self.assertEqual(stream.name, "Nokia")
self.assertEqual(stream.default_duration, 4)
self.assertEqual(stream.default_octave, 5)
self.assertEqual(stream.bpm, 225)
def test_parse_defaults(self):
"""Test parsing default values."""
rtttl = "Test:d=8,o=6,b=180:c"
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
self.assertEqual(stream.default_duration, 8)
self.assertEqual(stream.default_octave, 6)
self.assertEqual(stream.bpm, 180)
# Check calculated msec_per_whole_note
# 240000 / 180 = 1333.33...
self.assertAlmostEqual(stream.msec_per_whole_note, 1333.33, places=1)
def test_invalid_rtttl_format(self):
"""Test that invalid RTTTL format raises ValueError."""
# Missing colons
with self.assertRaises(ValueError):
RTTTLStream("invalid", 0, 100, self.buzzer, None)
# Too many colons
with self.assertRaises(ValueError):
RTTTLStream("a:b:c:d", 0, 100, self.buzzer, None)
def test_note_parsing(self):
"""Test parsing individual notes."""
rtttl = "Test:d=4,o=5,b=120:c,d,e"
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
# Generate notes
notes = list(stream._notes())
# Should have 3 notes
self.assertEqual(len(notes), 3)
# Each note should be a tuple of (frequency, duration)
for freq, duration in notes:
self.assertTrue(freq > 0, "Frequency should be non-zero")
self.assertTrue(duration > 0, "Duration should be non-zero")
def test_sharp_notes(self):
"""Test parsing sharp notes."""
rtttl = "Test:d=4,o=5,b=120:c#,d#,f#"
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
notes = list(stream._notes())
self.assertEqual(len(notes), 3)
# Sharp notes should have different frequencies than natural notes
# (can't test exact values without knowing frequency table)
def test_pause_notes(self):
"""Test parsing pause notes."""
rtttl = "Test:d=4,o=5,b=120:c,p,e"
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
notes = list(stream._notes())
self.assertEqual(len(notes), 3)
# Pause (p) should have frequency 0
freq, duration = notes[1]
self.assertEqual(freq, 0.0)
def test_duration_modifiers(self):
"""Test note duration modifiers (dots)."""
rtttl = "Test:d=4,o=5,b=120:c,c."
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
notes = list(stream._notes())
self.assertEqual(len(notes), 2)
# Dotted note should be 1.5x longer
normal_duration = notes[0][1]
dotted_duration = notes[1][1]
self.assertAlmostEqual(dotted_duration / normal_duration, 1.5, places=1)
def test_octave_variations(self):
"""Test notes with different octaves."""
rtttl = "Test:d=4,o=5,b=120:c4,c5,c6,c7"
stream = RTTTLStream(rtttl, 0, 100, self.buzzer, None)
notes = list(stream._notes())
self.assertEqual(len(notes), 4)
# Higher octaves should have higher frequencies
freqs = [freq for freq, dur in notes]
self.assertTrue(freqs[0] < freqs[1], "c4 should be lower than c5")
self.assertTrue(freqs[1] < freqs[2], "c5 should be lower than c6")
self.assertTrue(freqs[2] < freqs[3], "c6 should be lower than c7")
def test_volume_scaling(self):
"""Test volume to duty cycle conversion."""
# Test various volume levels
for volume in [0, 25, 50, 75, 100]:
stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, volume, self.buzzer, None)
# Volume 0 should result in duty 0
if volume == 0:
# Note: play() method calculates duty, not __init__
pass # Can't easily test without calling play()
else:
# Volume > 0 should result in duty > 0
# (duty calculation happens in play() method)
pass
def test_stream_type(self):
"""Test that stream type is stored correctly."""
stream = RTTTLStream("Test:d=4,o=5,b=120:c", 2, 100, self.buzzer, None)
self.assertEqual(stream.stream_type, 2)
def test_stop_flag(self):
"""Test that stop flag can be set."""
stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None)
self.assertTrue(stream._keep_running)
stream.stop()
self.assertFalse(stream._keep_running)
def test_is_playing_flag(self):
"""Test playing flag is initially false."""
stream = RTTTLStream("Test:d=4,o=5,b=120:c", 0, 100, self.buzzer, None)
self.assertFalse(stream.is_playing())
+78
View File
@@ -0,0 +1,78 @@
import unittest
import sys
import os
class TestSysPathRestore(unittest.TestCase):
"""Test that sys.path is properly restored after execute_script"""
def test_syspath_restored_after_execute_script(self):
"""Test that sys.path is restored to original state after script execution"""
# Import here to ensure we're in the right context
import mpos.apps
# Capture original sys.path
original_path = sys.path[:]
original_length = len(sys.path)
# Create a test directory path that would be added
test_cwd = "apps/com.test.app/assets/"
# Verify the test path is not already in sys.path
self.assertFalse(test_cwd in original_path,
f"Test path {test_cwd} should not be in sys.path initially")
# Create a simple test script
test_script = '''
import sys
# Just a simple script that does nothing
x = 42
'''
# Call execute_script with cwd parameter
# Note: This will fail because there's no Activity to start,
# but that's fine - we're testing the sys.path restoration
result = mpos.apps.execute_script(
test_script,
is_file=False,
cwd=test_cwd,
classname="NonExistentClass"
)
# After execution, sys.path should be restored
current_path = sys.path
current_length = len(sys.path)
# Verify sys.path has been restored to original
self.assertEqual(current_length, original_length,
f"sys.path length should be restored. Original: {original_length}, Current: {current_length}")
# Verify the test directory is not in sys.path anymore
self.assertFalse(test_cwd in current_path,
f"Test path {test_cwd} should not be in sys.path after execution. sys.path={current_path}")
# Verify sys.path matches original
self.assertEqual(current_path, original_path,
f"sys.path should match original.\nOriginal: {original_path}\nCurrent: {current_path}")
def test_syspath_not_affected_when_no_cwd(self):
"""Test that sys.path is unchanged when cwd is None"""
import mpos.apps
# Capture original sys.path
original_path = sys.path[:]
test_script = '''
x = 42
'''
# Call without cwd parameter
result = mpos.apps.execute_script(
test_script,
is_file=False,
cwd=None,
classname="NonExistentClass"
)
# sys.path should be unchanged
self.assertEqual(sys.path, original_path,
"sys.path should be unchanged when cwd is None")