diff --git a/CHANGELOG.md b/CHANGELOG.md index 38af4569..e00f3f5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,10 @@ - ActivityNavigator: support pre-instantiated activities to support one activity closing a child activity - Rename PackageManager to AppManager framework - Add new AppearanceManager framework +- Add new BatteryManager framework - Add new DeviceInfo framework - Add new DisplayMetrics framework +- Add new InputManager framework - Add new VersionInfo framework - Additional board support: Fri3d Camp 2026 (untested on real hardware) - Harmonize frameworks to use same coding patterns diff --git a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py index bb940915..d8d043d8 100644 --- a/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py +++ b/internal_filesystem/apps/com.micropythonos.showbattery/assets/hello.py @@ -49,7 +49,7 @@ battery power: import lvgl as lv import time -from mpos import battery_voltage, Activity +from mpos import BatteryManager, Activity class Hello(Activity): @@ -70,9 +70,9 @@ class Hello(Activity): def update_bat(timer): #global l - r = battery_voltage.read_raw_adc() - v = battery_voltage.read_battery_voltage() - percent = battery_voltage.get_battery_percentage() + r = BatteryManager.read_raw_adc() + v = BatteryManager.read_battery_voltage() + percent = BatteryManager.get_battery_percentage() text = f"{time.localtime()}\n{r}\n{v}V\n{percent}%" #text = f"{time.localtime()}: {r}" print(text) diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 770a074c..694d45ad 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -17,6 +17,9 @@ from .time_zone import TimeZone from .device_info import DeviceInfo from .build_info import BuildInfo +# Battery manager (imported early for UI dependencies) +from .battery_manager import BatteryManager + # Common activities from .app.activities.chooser import ChooserActivity from .app.activities.view import ViewActivity @@ -56,7 +59,6 @@ from . import time from . import sensor_manager from . import camera_manager from . import sdcard -from . import battery_voltage from . import audio from . import hardware @@ -66,7 +68,7 @@ __all__ = [ "Activity", "SharedPreferences", "ConnectivityManager", "DownloadManager", "WifiService", "AudioFlinger", "Intent", - "ActivityNavigator", "AppManager", "TaskManager", "CameraManager", + "ActivityNavigator", "AppManager", "TaskManager", "CameraManager", "BatteryManager", # Device and build info "DeviceInfo", "BuildInfo", # Common activities @@ -93,7 +95,7 @@ __all__ = [ "get_all_widgets_with_text", # Submodules "ui", "config", "net", "content", "time", "sensor_manager", - "camera_manager", "sdcard", "battery_voltage", "audio", "hardware", "bootloader", + "camera_manager", "sdcard", "audio", "hardware", "bootloader", # Timezone utilities "TimeZone" ] diff --git a/internal_filesystem/lib/mpos/battery_manager.py b/internal_filesystem/lib/mpos/battery_manager.py new file mode 100644 index 00000000..4849252d --- /dev/null +++ b/internal_filesystem/lib/mpos/battery_manager.py @@ -0,0 +1,175 @@ +""" +BatteryManager - Android-inspired battery and power information API. + +Provides direct query access to battery voltage, charge percentage, and raw ADC values. +Handles ADC1/ADC2 pin differences on ESP32-S3 with adaptive caching to minimize WiFi interference. +""" + +import time + +MIN_VOLTAGE = 3.15 +MAX_VOLTAGE = 4.15 + +# Internal state +_adc = None +_conversion_func = None +_adc_pin = None + +# Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) +_cached_raw_adc = None +_last_read_time = 0 +CACHE_DURATION_ADC1_MS = 30000 # 30 seconds (cheaper: no WiFi interference) +CACHE_DURATION_ADC2_MS = 600000 # 600 seconds (expensive: requires WiFi disable) + + +def _is_adc2_pin(pin): + """Check if pin is on ADC2 (ESP32-S3: GPIO11-20).""" + return 11 <= pin <= 20 + + +class BatteryManager: + """ + Android-inspired BatteryManager for querying battery and power information. + + Provides static methods for battery voltage, percentage, and raw ADC readings. + Automatically handles ADC1/ADC2 differences and WiFi coordination on ESP32-S3. + """ + + @staticmethod + def init_adc(pinnr, adc_to_voltage_func): + """ + Initialize ADC for battery voltage monitoring. + + IMPORTANT for ESP32-S3: ADC2 (GPIO11-20) doesn't work when WiFi is active! + Use ADC1 pins (GPIO1-10) for battery monitoring if possible. + If using ADC2, WiFi will be temporarily disabled during readings. + + Args: + pinnr: GPIO pin number + adc_to_voltage_func: Conversion function that takes raw ADC value (0-4095) + and returns battery voltage in volts + """ + global _adc, _conversion_func, _adc_pin + + _conversion_func = adc_to_voltage_func + _adc_pin = pinnr + + try: + print(f"Initializing ADC pin {pinnr} with conversion function") + if _is_adc2_pin(pinnr): + print(f" WARNING: GPIO{pinnr} is on ADC2 - WiFi will be disabled during readings") + from machine import ADC, Pin + _adc = ADC(Pin(pinnr)) + _adc.atten(ADC.ATTN_11DB) # 0-3.3V range + except Exception as e: + print(f"Info: this platform has no ADC for measuring battery voltage: {e}") + + initial_adc_value = BatteryManager.read_raw_adc() + print(f"Reading ADC at init to fill cache: {initial_adc_value} => {BatteryManager.read_battery_voltage(raw_adc_value=initial_adc_value)}V => {BatteryManager.get_battery_percentage(raw_adc_value=initial_adc_value)}%") + + @staticmethod + def read_raw_adc(force_refresh=False): + """ + Read raw ADC value (0-4095) with adaptive caching. + + On ESP32-S3 with ADC2, WiFi is temporarily disabled during reading. + Raises RuntimeError if WifiService is busy (connecting/scanning) when using ADC2. + + Args: + force_refresh: Bypass cache and force fresh reading + + Returns: + float: Raw ADC value (0-4095) + + Raises: + RuntimeError: If WifiService is busy (only when using ADC2) + """ + global _cached_raw_adc, _last_read_time + + # Desktop mode - return random value in typical ADC range + if not _adc: + import random + return random.randint(1900, 2600) + + # Check if this is an ADC2 pin (requires WiFi disable) + needs_wifi_disable = _adc_pin is not None and _is_adc2_pin(_adc_pin) + + # Use different cache durations based on cost + cache_duration = CACHE_DURATION_ADC2_MS if needs_wifi_disable else CACHE_DURATION_ADC1_MS + + # Check cache + current_time = time.ticks_ms() + if not force_refresh and _cached_raw_adc is not None: + age = time.ticks_diff(current_time, _last_read_time) + if age < cache_duration: + return _cached_raw_adc + + # Import WifiService only if needed + WifiService = None + if needs_wifi_disable: + try: + # Needs actual path, not "from mpos" shorthand because it's mocked by test_battery_voltage.py + from mpos.net.wifi_service import WifiService + except ImportError: + pass + + # Temporarily disable WiFi for ADC2 reading + was_connected = False + if needs_wifi_disable and WifiService: + # This will raise RuntimeError if WiFi is already busy + was_connected = WifiService.temporarily_disable() + time.sleep(0.05) # Brief delay for WiFi to fully disable + + try: + # Read ADC (average of 10 samples) + total = sum(_adc.read() for _ in range(10)) + raw_value = total / 10.0 + + # Update cache + _cached_raw_adc = raw_value + _last_read_time = current_time + + return raw_value + + finally: + # Re-enable WiFi (only if we disabled it) + if needs_wifi_disable and WifiService: + WifiService.temporarily_enable(was_connected) + + @staticmethod + def read_battery_voltage(force_refresh=False, raw_adc_value=None): + """ + Read battery voltage in volts. + + Args: + force_refresh: Bypass cache and force fresh reading + raw_adc_value: Optional pre-computed raw ADC value (for testing) + + Returns: + float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) + """ + raw = raw_adc_value if raw_adc_value else BatteryManager.read_raw_adc(force_refresh) + voltage = _conversion_func(raw) if _conversion_func else 0.0 + return voltage + + @staticmethod + def get_battery_percentage(raw_adc_value=None): + """ + Get battery charge percentage. + + Args: + raw_adc_value: Optional pre-computed raw ADC value (for testing) + + Returns: + float: Battery percentage (0-100) + """ + voltage = BatteryManager.read_battery_voltage(raw_adc_value=raw_adc_value) + percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) + return max(0, min(100.0, percentage)) # limit to 100.0% and make sure it's positive + + @staticmethod + def clear_cache(): + """Clear the battery voltage cache to force fresh reading on next call.""" + global _cached_raw_adc, _last_read_time + _cached_raw_adc = None + _last_read_time = 0 diff --git a/internal_filesystem/lib/mpos/battery_voltage.py b/internal_filesystem/lib/mpos/battery_voltage.py deleted file mode 100644 index 716a8e00..00000000 --- a/internal_filesystem/lib/mpos/battery_voltage.py +++ /dev/null @@ -1,157 +0,0 @@ -import time - -MIN_VOLTAGE = 3.15 -MAX_VOLTAGE = 4.15 - -adc = None -conversion_func = None # Conversion function: ADC value -> voltage -adc_pin = None - -# Cache to reduce WiFi interruptions (ADC2 requires WiFi to be disabled) -_cached_raw_adc = None -_last_read_time = 0 -CACHE_DURATION_ADC1_MS = 30000 # 30 seconds (cheaper: no WiFi interference) -CACHE_DURATION_ADC2_MS = 600000 # 600 seconds (expensive: requires WiFi disable) -#CACHE_DURATION_ADC2_MS = CACHE_DURATION_ADC1_MS # trigger frequent disconnections for debugging OSUpdate resume -# Or at runtime, do: -# import mpos.battery_voltage ; mpos.battery_voltage.CACHE_DURATION_ADC2_MS = 30000 - - -def _is_adc2_pin(pin): - """Check if pin is on ADC2 (ESP32-S3: GPIO11-20).""" - return 11 <= pin <= 20 - - -def init_adc(pinnr, adc_to_voltage_func): - """ - Initialize ADC for battery voltage monitoring. - - IMPORTANT for ESP32-S3: ADC2 (GPIO11-20) doesn't work when WiFi is active! - Use ADC1 pins (GPIO1-10) for battery monitoring if possible. - If using ADC2, WiFi will be temporarily disabled during readings. - - Args: - pinnr: GPIO pin number - adc_to_voltage_func: Conversion function that takes raw ADC value (0-4095) - and returns battery voltage in volts - """ - global adc, conversion_func, adc_pin - - conversion_func = adc_to_voltage_func - adc_pin = pinnr - - try: - print(f"Initializing ADC pin {pinnr} with conversion function") - if _is_adc2_pin(pinnr): - print(f" WARNING: GPIO{pinnr} is on ADC2 - WiFi will be disabled during readings") - from machine import ADC, Pin - adc = ADC(Pin(pinnr)) - adc.atten(ADC.ATTN_11DB) # 0-3.3V range - except Exception as e: - print(f"Info: this platform has no ADC for measuring battery voltage: {e}") - - initial_adc_value = read_raw_adc() - print(f"Reading ADC at init to fill cache: {initial_adc_value} => {read_battery_voltage(raw_adc_value=initial_adc_value)}V => {get_battery_percentage(raw_adc_value=initial_adc_value)}%") - - -def read_raw_adc(force_refresh=False): - """ - Read raw ADC value (0-4095) with adaptive caching. - - On ESP32-S3 with ADC2, WiFi is temporarily disabled during reading. - Raises RuntimeError if WifiService is busy (connecting/scanning) when using ADC2. - - Args: - force_refresh: Bypass cache and force fresh reading - - Returns: - float: Raw ADC value (0-4095) - - Raises: - RuntimeError: If WifiService is busy (only when using ADC2) - """ - global _cached_raw_adc, _last_read_time - - # Desktop mode - return random value in typical ADC range - if not adc: - import random - return random.randint(1900, 2600) - - # Check if this is an ADC2 pin (requires WiFi disable) - needs_wifi_disable = adc_pin is not None and _is_adc2_pin(adc_pin) - - # Use different cache durations based on cost - cache_duration = CACHE_DURATION_ADC2_MS if needs_wifi_disable else CACHE_DURATION_ADC1_MS - - # Check cache - current_time = time.ticks_ms() - if not force_refresh and _cached_raw_adc is not None: - age = time.ticks_diff(current_time, _last_read_time) - if age < cache_duration: - return _cached_raw_adc - - # Import WifiService only if needed - WifiService = None - if needs_wifi_disable: - try: - # Needs actual path, not "from mpos" shorthand because it's mocked by test_battery_voltage.py - from mpos.net.wifi_service import WifiService - except ImportError: - pass - - # Temporarily disable WiFi for ADC2 reading - was_connected = False - if needs_wifi_disable and WifiService: - # This will raise RuntimeError if WiFi is already busy - was_connected = WifiService.temporarily_disable() - time.sleep(0.05) # Brief delay for WiFi to fully disable - - try: - # Read ADC (average of 10 samples) - total = sum(adc.read() for _ in range(10)) - raw_value = total / 10.0 - - # Update cache - _cached_raw_adc = raw_value - _last_read_time = current_time - - return raw_value - - finally: - # Re-enable WiFi (only if we disabled it) - if needs_wifi_disable and WifiService: - WifiService.temporarily_enable(was_connected) - - -def read_battery_voltage(force_refresh=False, raw_adc_value=None): - """ - Read battery voltage in volts. - - Args: - force_refresh: Bypass cache and force fresh reading - - Returns: - float: Battery voltage in volts (clamped to 0-MAX_VOLTAGE) - """ - raw = raw_adc_value if raw_adc_value else read_raw_adc(force_refresh) - voltage = conversion_func(raw) if conversion_func else 0.0 - return voltage - - -def get_battery_percentage(raw_adc_value=None): - """ - Get battery charge percentage. - - Returns: - float: Battery percentage (0-100) - """ - voltage = read_battery_voltage(raw_adc_value=raw_adc_value) - percentage = (voltage - MIN_VOLTAGE) * 100.0 / (MAX_VOLTAGE - MIN_VOLTAGE) - return max(0,min(100.0, percentage)) # limit to 100.0% and make sure it's positive - - -def clear_cache(): - """Clear the battery voltage cache to force fresh reading on next call.""" - global _cached_raw_adc, _last_read_time - _cached_raw_adc = None - _last_read_time = 0 diff --git a/internal_filesystem/lib/mpos/board/fri3d_2024.py b/internal_filesystem/lib/mpos/board/fri3d_2024.py index b0120b9c..953c6346 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2024.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2024.py @@ -264,8 +264,8 @@ InputManager.register_indev(indev) # Battery voltage ADC measuring # NOTE: GPIO13 is on ADC2, which requires WiFi to be disabled during reading on ESP32-S3. -# battery_voltage.py handles this automatically: disables WiFi, reads ADC, reconnects WiFi. -import mpos.battery_voltage +# BatteryManager handles this automatically: disables WiFi, reads ADC, reconnects WiFi. +from mpos import BatteryManager """ best fit on battery power: 2482 is 4.180 @@ -289,7 +289,7 @@ def adc_to_voltage(adc_value): """ return (0.001651* adc_value + 0.08709) -mpos.battery_voltage.init_adc(13, adc_to_voltage) +BatteryManager.init_adc(13, adc_to_voltage) import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) diff --git a/internal_filesystem/lib/mpos/board/fri3d_2026.py b/internal_filesystem/lib/mpos/board/fri3d_2026.py index ee623258..724abe12 100644 --- a/internal_filesystem/lib/mpos/board/fri3d_2026.py +++ b/internal_filesystem/lib/mpos/board/fri3d_2026.py @@ -185,7 +185,7 @@ indev.enable(True) # NOQA InputManager.register_indev(indev) # Battery voltage ADC measuring: sits on PC0 of CH32X035GxUx -import mpos.battery_voltage +from mpos import BatteryManager def adc_to_voltage(adc_value): """ Convert raw ADC value to battery voltage using calibrated linear function. @@ -193,7 +193,7 @@ def adc_to_voltage(adc_value): This is ~10x more accurate than simple scaling (error ~0.01V vs ~0.1V). """ return (0.001651* adc_value + 0.08709) -#mpos.battery_voltage.init_adc(13, adc_to_voltage) # TODO +#BatteryManager.init_adc(13, adc_to_voltage) # TODO import mpos.sdcard mpos.sdcard.init(spi_bus, cs_pin=14) diff --git a/internal_filesystem/lib/mpos/board/linux.py b/internal_filesystem/lib/mpos/board/linux.py index b4fbd1de..4c7df84a 100644 --- a/internal_filesystem/lib/mpos/board/linux.py +++ b/internal_filesystem/lib/mpos/board/linux.py @@ -89,13 +89,13 @@ except Exception as e: # Simulated battery voltage ADC measuring -import mpos.battery_voltage +from mpos import BatteryManager def adc_to_voltage(adc_value): """Convert simulated ADC value to voltage.""" return adc_value * (3.3 / 4095) * 2 -mpos.battery_voltage.init_adc(999, adc_to_voltage) +BatteryManager.init_adc(999, adc_to_voltage) # === AUDIO HARDWARE === from mpos import AudioFlinger diff --git a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py index 6b607083..ee8b8e10 100644 --- a/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py +++ b/internal_filesystem/lib/mpos/board/waveshare_esp32_s3_touch_lcd_2.py @@ -82,7 +82,7 @@ lv.init() mpos.ui.main_display.set_rotation(lv.DISPLAY_ROTATION._90) # must be done after initializing display and creating the touch drivers, to ensure proper handling # Battery voltage ADC measuring -import mpos.battery_voltage +from mpos import BatteryManager def adc_to_voltage(adc_value): """ @@ -95,7 +95,7 @@ def adc_to_voltage(adc_value): """ return adc_value * 0.00262 -mpos.battery_voltage.init_adc(5, adc_to_voltage) +BatteryManager.init_adc(5, adc_to_voltage) # On the Waveshare ESP32-S3-Touch-LCD-2, the camera is hard-wired to power on, # so it needs a software power off to prevent it from staying hot all the time and quickly draining the battery. diff --git a/internal_filesystem/lib/mpos/ui/topmenu.py b/internal_filesystem/lib/mpos/ui/topmenu.py index 40deaf85..8b3ce0aa 100644 --- a/internal_filesystem/lib/mpos/ui/topmenu.py +++ b/internal_filesystem/lib/mpos/ui/topmenu.py @@ -1,7 +1,7 @@ import lvgl as lv import mpos.time -import mpos.battery_voltage +from ..battery_manager import BatteryManager from .display_metrics import DisplayMetrics from .appearance_manager import AppearanceManager from .util import (get_foreground_app) @@ -138,9 +138,9 @@ def create_notification_bar(): def update_battery_icon(timer=None): try: - percent = mpos.battery_voltage.get_battery_percentage() + percent = BatteryManager.get_battery_percentage() except Exception as e: - print(f"battery_voltage.get_battery_percentage got exception, not updating battery_icon: {e}") + print(f"BatteryManager.get_battery_percentage got exception, not updating battery_icon: {e}") return if percent > 80: battery_icon.set_text(lv.SYMBOL.BATTERY_FULL) diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py index 3f3336af..9e1367ac 100644 --- a/tests/test_battery_voltage.py +++ b/tests/test_battery_voltage.py @@ -1,5 +1,5 @@ """ -Unit tests for mpos.battery_voltage module. +Unit tests for mpos.battery_manager.BatteryManager class. Tests ADC1/ADC2 detection, caching, WiFi coordination, and voltage calculations. """ @@ -10,7 +10,7 @@ import sys # Add parent directory to path for imports sys.path.insert(0, '../internal_filesystem') -# Mock modules before importing battery_voltage +# Mock modules before importing BatteryManager class MockADC: """Mock ADC for testing.""" ATTN_11DB = 3 @@ -88,8 +88,8 @@ class MockWifiService: sys.modules['machine'] = MockMachine sys.modules['mpos.net.wifi_service'] = type('module', (), {'WifiService': MockWifiService})() -# Now import battery_voltage -import mpos.battery_voltage as bv +# Now import BatteryManager +from mpos.battery_manager import BatteryManager class TestADC2Detection(unittest.TestCase): @@ -97,20 +97,23 @@ class TestADC2Detection(unittest.TestCase): def test_adc1_pins_detected(self): """Test that ADC1 pins (GPIO1-10) are detected correctly.""" + from mpos.battery_manager import _is_adc2_pin for pin in range(1, 11): - self.assertFalse(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC1") + self.assertFalse(_is_adc2_pin(pin), f"GPIO{pin} should be ADC1") def test_adc2_pins_detected(self): """Test that ADC2 pins (GPIO11-20) are detected correctly.""" + from mpos.battery_manager import _is_adc2_pin for pin in range(11, 21): - self.assertTrue(bv._is_adc2_pin(pin), f"GPIO{pin} should be ADC2") + self.assertTrue(_is_adc2_pin(pin), f"GPIO{pin} should be ADC2") def test_out_of_range_pins(self): """Test pins outside ADC range.""" - self.assertFalse(bv._is_adc2_pin(0)) - self.assertFalse(bv._is_adc2_pin(21)) - self.assertFalse(bv._is_adc2_pin(30)) - self.assertFalse(bv._is_adc2_pin(100)) + from mpos.battery_manager import _is_adc2_pin + self.assertFalse(_is_adc2_pin(0)) + self.assertFalse(_is_adc2_pin(21)) + self.assertFalse(_is_adc2_pin(30)) + self.assertFalse(_is_adc2_pin(100)) class TestInitADC(unittest.TestCase): @@ -118,40 +121,44 @@ class TestInitADC(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.adc = None - bv.conversion_func = None - bv.adc_pin = None + import mpos.battery_manager as bm + bm._adc = None + bm._conversion_func = None + bm._adc_pin = None def test_init_adc1_pin(self): """Test initializing with ADC1 pin.""" def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) + BatteryManager.init_adc(5, adc_to_voltage) - self.assertIsNotNone(bv.adc) - self.assertEqual(bv.conversion_func, adc_to_voltage) - self.assertEqual(bv.adc_pin, 5) - self.assertEqual(bv.adc._atten, MockADC.ATTN_11DB) + import mpos.battery_manager as bm + self.assertIsNotNone(bm._adc) + self.assertEqual(bm._conversion_func, adc_to_voltage) + self.assertEqual(bm._adc_pin, 5) + self.assertEqual(bm._adc._atten, MockADC.ATTN_11DB) def test_init_adc2_pin(self): """Test initializing with ADC2 pin (should warn but work).""" def adc_to_voltage(adc_value): return adc_value * 0.00197 - bv.init_adc(13, adc_to_voltage) + BatteryManager.init_adc(13, adc_to_voltage) - self.assertIsNotNone(bv.adc) - self.assertIsNotNone(bv.conversion_func) - self.assertEqual(bv.adc_pin, 13) + import mpos.battery_manager as bm + self.assertIsNotNone(bm._adc) + self.assertIsNotNone(bm._conversion_func) + self.assertEqual(bm._adc_pin, 13) def test_conversion_func_stored(self): """Test that conversion function is stored correctly.""" def my_conversion(adc_value): return adc_value * 0.12345 - bv.init_adc(5, my_conversion) - self.assertEqual(bv.conversion_func, my_conversion) + BatteryManager.init_adc(5, my_conversion) + import mpos.battery_manager as bm + self.assertEqual(bm._conversion_func, my_conversion) class TestCaching(unittest.TestCase): @@ -159,53 +166,57 @@ class TestCaching(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) # Use ADC1 to avoid WiFi complexity + BatteryManager.init_adc(5, adc_to_voltage) # Use ADC1 to avoid WiFi complexity MockWifiService.reset() def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.clear_cache() def test_cache_hit_on_first_read(self): """Test that first read already has a cache (because of read during init) """ - self.assertIsNotNone(bv._cached_raw_adc) - raw = bv.read_raw_adc() - self.assertIsNotNone(bv._cached_raw_adc) - self.assertEqual(raw, bv._cached_raw_adc) + import mpos.battery_manager as bm + self.assertIsNotNone(bm._cached_raw_adc) + raw = BatteryManager.read_raw_adc() + self.assertIsNotNone(bm._cached_raw_adc) + self.assertEqual(raw, bm._cached_raw_adc) def test_cache_hit_within_duration(self): """Test that subsequent reads use cache within duration.""" - raw1 = bv.read_raw_adc() + raw1 = BatteryManager.read_raw_adc() # Change ADC value but should still get cached value - bv.adc.set_read_value(3000) - raw2 = bv.read_raw_adc() + import mpos.battery_manager as bm + bm._adc.set_read_value(3000) + raw2 = BatteryManager.read_raw_adc() self.assertEqual(raw1, raw2, "Should return cached value") def test_force_refresh_bypasses_cache(self): """Test that force_refresh bypasses cache.""" - bv.adc.set_read_value(2000) - raw1 = bv.read_raw_adc() + import mpos.battery_manager as bm + bm._adc.set_read_value(2000) + raw1 = BatteryManager.read_raw_adc() # Change value and force refresh - bv.adc.set_read_value(3000) - raw2 = bv.read_raw_adc(force_refresh=True) + bm._adc.set_read_value(3000) + raw2 = BatteryManager.read_raw_adc(force_refresh=True) self.assertNotEqual(raw1, raw2, "force_refresh should bypass cache") self.assertEqual(raw2, 3000.0) def test_clear_cache_works(self): """Test that clear_cache() clears the cache.""" - bv.read_raw_adc() - self.assertIsNotNone(bv._cached_raw_adc) + BatteryManager.read_raw_adc() + import mpos.battery_manager as bm + self.assertIsNotNone(bm._cached_raw_adc) - bv.clear_cache() - self.assertIsNone(bv._cached_raw_adc) - self.assertEqual(bv._last_read_time, 0) + BatteryManager.clear_cache() + self.assertIsNone(bm._cached_raw_adc) + self.assertEqual(bm._last_read_time, 0) class TestADC1Reading(unittest.TestCase): @@ -213,23 +224,23 @@ class TestADC1Reading(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 + BatteryManager.init_adc(5, adc_to_voltage) # GPIO5 is ADC1 MockWifiService.reset() MockWifiService._connected = True def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.clear_cache() MockWifiService.reset() def test_adc1_doesnt_disable_wifi(self): """Test that ADC1 reading doesn't disable WiFi.""" MockWifiService._connected = True - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) # WiFi should still be connected self.assertTrue(MockWifiService.is_connected()) @@ -241,7 +252,7 @@ class TestADC1Reading(unittest.TestCase): # Should not raise error try: - raw = bv.read_raw_adc(force_refresh=True) + raw = BatteryManager.read_raw_adc(force_refresh=True) self.assertIsNotNone(raw) except RuntimeError: self.fail("ADC1 should not raise error when WiFi is busy") @@ -252,22 +263,22 @@ class TestADC2Reading(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00197 - bv.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 + BatteryManager.init_adc(13, adc_to_voltage) # GPIO13 is ADC2 MockWifiService.reset() def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.clear_cache() MockWifiService.reset() def test_adc2_disables_wifi_when_connected(self): """Test that ADC2 reading disables WiFi when connected.""" MockWifiService._connected = True - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) # WiFi should be reconnected after reading (if it was connected before) self.assertTrue(MockWifiService.is_connected()) @@ -279,7 +290,7 @@ class TestADC2Reading(unittest.TestCase): # wifi_busy should be False before self.assertFalse(MockWifiService.wifi_busy) - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) # wifi_busy should be False after (cleared in finally) self.assertFalse(MockWifiService.wifi_busy) @@ -289,7 +300,7 @@ class TestADC2Reading(unittest.TestCase): MockWifiService.wifi_busy = True with self.assertRaises(RuntimeError) as ctx: - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) self.assertIn("WifiService is already busy", str(ctx.exception)) @@ -297,13 +308,13 @@ class TestADC2Reading(unittest.TestCase): """Test that ADC2 uses cache even when WiFi is busy.""" # First read to populate cache MockWifiService.wifi_busy = False - raw1 = bv.read_raw_adc(force_refresh=True) + raw1 = BatteryManager.read_raw_adc(force_refresh=True) # Now set WiFi busy MockWifiService.wifi_busy = True # Should return cached value without error - raw2 = bv.read_raw_adc() + raw2 = BatteryManager.read_raw_adc() self.assertEqual(raw1, raw2) def test_adc2_only_reconnects_if_was_connected(self): @@ -311,7 +322,7 @@ class TestADC2Reading(unittest.TestCase): # WiFi is NOT connected MockWifiService._connected = False - bv.read_raw_adc(force_refresh=True) + BatteryManager.read_raw_adc(force_refresh=True) # WiFi should still be disconnected (no unwanted reconnection) self.assertFalse(MockWifiService.is_connected()) @@ -322,58 +333,63 @@ class TestVoltageCalculations(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) # ADC1 pin, scale factor for 2:1 divider - bv.adc.set_read_value(2048) # Mid-range + BatteryManager.init_adc(5, adc_to_voltage) # ADC1 pin, scale factor for 2:1 divider + import mpos.battery_manager as bm + bm._adc.set_read_value(2048) # Mid-range def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.clear_cache() def test_read_battery_voltage_applies_scale_factor(self): """Test that voltage is calculated correctly.""" - bv.adc.set_read_value(2048) # Mid-range - bv.clear_cache() + import mpos.battery_manager as bm + bm._adc.set_read_value(2048) # Mid-range + BatteryManager.clear_cache() - voltage = bv.read_battery_voltage(force_refresh=True) + voltage = BatteryManager.read_battery_voltage(force_refresh=True) expected = 2048 * 0.00161 self.assertAlmostEqual(voltage, expected, places=4) def test_voltage_clamped_to_zero(self): """Test that negative voltage is clamped to 0.""" - bv.adc.set_read_value(0) - bv.clear_cache() + import mpos.battery_manager as bm + bm._adc.set_read_value(0) + BatteryManager.clear_cache() - voltage = bv.read_battery_voltage(force_refresh=True) + voltage = BatteryManager.read_battery_voltage(force_refresh=True) self.assertGreaterEqual(voltage, 0.0) def test_get_battery_percentage_calculation(self): """Test percentage calculation.""" # Set voltage to mid-range between MIN and MAX - mid_voltage = (bv.MIN_VOLTAGE + bv.MAX_VOLTAGE) / 2 + import mpos.battery_manager as bm + mid_voltage = (bm.MIN_VOLTAGE + bm.MAX_VOLTAGE) / 2 # Inverse of conversion function: if voltage = adc * 0.00161, then adc = voltage / 0.00161 raw_adc = mid_voltage / 0.00161 - bv.adc.set_read_value(int(raw_adc)) - bv.clear_cache() + bm._adc.set_read_value(int(raw_adc)) + BatteryManager.clear_cache() - percentage = bv.get_battery_percentage() + percentage = BatteryManager.get_battery_percentage() self.assertAlmostEqual(percentage, 50.0, places=0) def test_percentage_clamped_to_0_100(self): """Test that percentage is clamped to 0-100 range.""" + import mpos.battery_manager as bm # Test minimum - bv.adc.set_read_value(0) - bv.clear_cache() - percentage = bv.get_battery_percentage() + bm._adc.set_read_value(0) + BatteryManager.clear_cache() + percentage = BatteryManager.get_battery_percentage() self.assertGreaterEqual(percentage, 0.0) self.assertLessEqual(percentage, 100.0) # Test maximum - bv.adc.set_read_value(4095) - bv.clear_cache() - percentage = bv.get_battery_percentage() + bm._adc.set_read_value(4095) + BatteryManager.clear_cache() + percentage = BatteryManager.get_battery_percentage() self.assertGreaterEqual(percentage, 0.0) self.assertLessEqual(percentage, 100.0) @@ -383,21 +399,22 @@ class TestAveragingLogic(unittest.TestCase): def setUp(self): """Reset module state.""" - bv.clear_cache() + BatteryManager.clear_cache() def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.init_adc(5, adc_to_voltage) + BatteryManager.init_adc(5, adc_to_voltage) def tearDown(self): """Clean up.""" - bv.clear_cache() + BatteryManager.clear_cache() def test_adc_read_averages_10_samples(self): """Test that 10 samples are averaged.""" - bv.adc.set_read_value(2000) - bv.clear_cache() + import mpos.battery_manager as bm + bm._adc.set_read_value(2000) + BatteryManager.clear_cache() - raw = bv.read_raw_adc(force_refresh=True) + raw = BatteryManager.read_raw_adc(force_refresh=True) # Should be average of 10 reads self.assertEqual(raw, 2000.0) @@ -408,27 +425,28 @@ class TestDesktopMode(unittest.TestCase): def setUp(self): """Disable ADC.""" - bv.adc = None + import mpos.battery_manager as bm + bm._adc = None def adc_to_voltage(adc_value): return adc_value * 0.00161 - bv.conversion_func = adc_to_voltage + bm._conversion_func = adc_to_voltage def test_read_raw_adc_returns_random_value(self): """Test that desktop mode returns random ADC value.""" - raw = bv.read_raw_adc() + raw = BatteryManager.read_raw_adc() self.assertIsNotNone(raw) self.assertTrue(raw > 0, f"Expected raw > 0, got {raw}") self.assertTrue(raw < 4096, f"Expected raw < 4096, got {raw}") def test_read_battery_voltage_works_without_adc(self): """Test that voltage reading works in desktop mode.""" - voltage = bv.read_battery_voltage() + voltage = BatteryManager.read_battery_voltage() self.assertIsNotNone(voltage) self.assertTrue(voltage > 0, f"Expected voltage > 0, got {voltage}") def test_get_battery_percentage_works_without_adc(self): """Test that percentage reading works in desktop mode.""" - percentage = bv.get_battery_percentage() + percentage = BatteryManager.get_battery_percentage() self.assertIsNotNone(percentage) self.assertGreaterEqual(percentage, 0) self.assertLessEqual(percentage, 100)