From 5b50ce8528c1f9a36bc789e096d95740aba9ad52 Mon Sep 17 00:00:00 2001 From: Thomas Farstrike Date: Tue, 17 Mar 2026 15:17:38 +0100 Subject: [PATCH] Add Hotspot configuration --- .../META-INF/MANIFEST.JSON | 23 + .../assets/hotspot.py | 112 +++++ .../res/mipmap-mdpi/icon_64x64.png | Bin 0 -> 6239 bytes .../assets/settings.py | 7 + .../lib/mpos/net/wifi_service.py | 187 +++++++- partitions_with_retro-go.csv | 19 - tests/test_battery_voltage.py | 85 +--- tests/test_connectivity_manager.py | 25 +- tests/test_sensor_manager.py | 151 +------ tests/test_wifi_service.py | 415 +++++++++++++++--- 10 files changed, 717 insertions(+), 307 deletions(-) create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.hotspot/META-INF/MANIFEST.JSON create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.hotspot/assets/hotspot.py create mode 100644 internal_filesystem/builtin/apps/com.micropythonos.hotspot/res/mipmap-mdpi/icon_64x64.png delete mode 100644 partitions_with_retro-go.csv diff --git a/internal_filesystem/builtin/apps/com.micropythonos.hotspot/META-INF/MANIFEST.JSON b/internal_filesystem/builtin/apps/com.micropythonos.hotspot/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..a4866909 --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.hotspot/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Hotspot", +"publisher": "MicroPythonOS", +"short_description": "Configure Wi-Fi hotspot settings.", +"long_description": "Configure and toggle the device Wi-Fi hotspot, including SSID, security, and network options.", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.hotspot/icons/com.micropythonos.hotspot_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.hotspot/mpks/com.micropythonos.hotspot_0.1.0.mpk", +"fullname": "com.micropythonos.hotspot", +"version": "0.1.0", +"category": "networking", +"activities": [ + { + "entrypoint": "assets/hotspot.py", + "classname": "Hotspot", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/builtin/apps/com.micropythonos.hotspot/assets/hotspot.py b/internal_filesystem/builtin/apps/com.micropythonos.hotspot/assets/hotspot.py new file mode 100644 index 00000000..3148ea8a --- /dev/null +++ b/internal_filesystem/builtin/apps/com.micropythonos.hotspot/assets/hotspot.py @@ -0,0 +1,112 @@ +import lvgl as lv + +from mpos import Activity, Intent, SettingsActivity, SharedPreferences, WifiService + + +class Hotspot(SettingsActivity): + """ + Hotspot configuration app. + + Uses SettingsActivity to render and edit hotspot preferences stored under + com.micropythonos.system.hotspot. + """ + + DEFAULTS = { + "enabled": False, + "ssid": "MicroPythonOS", + "password": "", + "channel": 1, + "hidden": False, + "max_clients": 4, + "authmode": None, + "ip": "192.168.4.1", + "netmask": "255.255.255.0", + "gateway": "192.168.4.1", + "dns": "8.8.8.8", + } + + def getIntent(self): + prefs = SharedPreferences("com.micropythonos.system.hotspot", defaults=self.DEFAULTS) + intent = Intent() + intent.putExtra("prefs", prefs) + intent.putExtra( + "settings", + [ + { + "title": "Hotspot Enabled", + "key": "enabled", + "ui": "radiobuttons", + "ui_options": [("On", "True"), ("Off", "False")], + "changed_callback": self.toggle_hotspot, + }, + { + "title": "Network Name (SSID)", + "key": "ssid", + "placeholder": "Hotspot SSID", + }, + { + "title": "Password", + "key": "password", + "placeholder": "Leave empty for open network", + }, + { + "title": "Channel", + "key": "channel", + "placeholder": "Wi-Fi channel, e.g. 1", + }, + { + "title": "Hidden Network", + "key": "hidden", + "ui": "radiobuttons", + "ui_options": [("Visible", "False"), ("Hidden", "True")], + "changed_callback": self.toggle_hotspot, + }, + { + "title": "Max Clients", + "key": "max_clients", + "placeholder": "Max connections, e.g. 4", + }, + { + "title": "Auth Mode", + "key": "authmode", + "ui": "dropdown", + "ui_options": [ + ("Auto", None), + ("Open", "open"), + ("WPA", "wpa"), + ("WPA2", "wpa2"), + ("WPA/WPA2", "wpa_wpa2"), + ], + "changed_callback": self.toggle_hotspot, + }, + { + "title": "IP Address", + "key": "ip", + "placeholder": "Hotspot IP, e.g. 192.168.4.1", + }, + { + "title": "Netmask", + "key": "netmask", + "placeholder": "Netmask, e.g. 255.255.255.0", + }, + { + "title": "Gateway", + "key": "gateway", + "placeholder": "Gateway, e.g. 192.168.4.1", + }, + { + "title": "DNS", + "key": "dns", + "placeholder": "DNS, e.g. 8.8.8.8", + }, + ], + ) + return intent + + def toggle_hotspot(self, new_value): + enabled_value = self.prefs.get_string("enabled", "False") + should_enable = str(enabled_value).lower() in ("true", "1", "yes", "on") + if should_enable: + WifiService.enable_hotspot() + else: + WifiService.disable_hotspot() diff --git a/internal_filesystem/builtin/apps/com.micropythonos.hotspot/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/builtin/apps/com.micropythonos.hotspot/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 0000000000000000000000000000000000000000..497e0f0b92d558bf9c6b606e4253706e6ed8ebb9 GIT binary patch literal 6239 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 Sdl(oP7(8A5T-G@yGywp{3j4MI literal 0 HcmV?d00001 diff --git a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py index 74c5526a..1088fa9e 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -12,6 +12,12 @@ class LaunchWiFi(Activity): AppManager.start_app("com.micropythonos.wifi") +class LaunchHotspot(Activity): + + def onCreate(self): + AppManager.start_app("com.micropythonos.hotspot") + + class Settings(SettingsActivity): """Override getIntent to provide prefs and settings via Intent extras""" @@ -46,6 +52,7 @@ class Settings(SettingsActivity): intent.putExtra("prefs", SharedPreferences("com.micropythonos.settings")) intent.putExtra("settings", [ {"title": "Wi-Fi", "key": "wifi_settings", "ui": "activity", "activity_class": LaunchWiFi}, + {"title": "Hotspot", "key": "hotspot_settings", "ui": "activity", "activity_class": LaunchHotspot}, # Basic settings, alphabetically: {"title": "Light/Dark Theme", "key": "theme_light_dark", "ui": "radiobuttons", "ui_options": [("Light", "light"), ("Dark", "dark")], "changed_callback": self.theme_changed}, {"title": "Theme Color", "key": "theme_primary_color", "placeholder": "HTML hex color, like: EC048C", "ui": "dropdown", "ui_options": theme_colors, "changed_callback": self.theme_changed, "default_value": AppearanceManager.DEFAULT_PRIMARY_COLOR}, diff --git a/internal_filesystem/lib/mpos/net/wifi_service.py b/internal_filesystem/lib/mpos/net/wifi_service.py index 07cb9cec..41f6f4bf 100644 --- a/internal_filesystem/lib/mpos/net/wifi_service.py +++ b/internal_filesystem/lib/mpos/net/wifi_service.py @@ -46,6 +46,129 @@ class WifiService: # Desktop mode: simulated connected SSID (None = not connected) _desktop_connected_ssid = None + # Hotspot state tracking + hotspot_enabled = False + _temp_disable_state = None + _needs_hotspot_restore = False + + @staticmethod + def _get_hotspot_config(): + prefs = mpos.config.SharedPreferences("com.micropythonos.system.hotspot") + return { + "enabled": prefs.get_bool("enabled", False), + "ssid": prefs.get_string("ssid", "MicroPythonOS"), + "password": prefs.get_string("password", ""), + "channel": prefs.get_int("channel", 1), + "hidden": prefs.get_bool("hidden", False), + "max_clients": prefs.get_int("max_clients", 4), + "authmode": prefs.get_string("authmode", None), + "ip": prefs.get_string("ip", "192.168.4.1"), + "netmask": prefs.get_string("netmask", "255.255.255.0"), + "gateway": prefs.get_string("gateway", "192.168.4.1"), + "dns": prefs.get_string("dns", "8.8.8.8"), + } + + @staticmethod + def _resolve_hotspot_authmode(net, password, authmode_value): + if authmode_value is None: + if password: + return net.AUTH_WPA_WPA2_PSK + return net.AUTH_OPEN + if isinstance(authmode_value, int): + return authmode_value + if isinstance(authmode_value, str): + authmode_key = authmode_value.lower().strip() + mapping = { + "open": net.AUTH_OPEN, + "wpa": net.AUTH_WPA_PSK, + "wpa2": net.AUTH_WPA2_PSK, + "wpa_wpa2": net.AUTH_WPA_WPA2_PSK, + "wpa-wpa2": net.AUTH_WPA_WPA2_PSK, + } + return mapping.get(authmode_key, net.AUTH_WPA_WPA2_PSK) + return net.AUTH_WPA_WPA2_PSK + + @staticmethod + def enable_hotspot(network_module=None): + if WifiService.wifi_busy: + print("WifiService: Cannot enable hotspot, WiFi is busy") + return False + + if not HAS_NETWORK_MODULE and network_module is None: + WifiService.hotspot_enabled = True + print("WifiService: Desktop mode, hotspot enabled (simulated)") + return True + + net = network_module if network_module else network + config = WifiService._get_hotspot_config() + + try: + sta = net.WLAN(net.STA_IF) + if sta.active() or sta.isconnected(): + sta.disconnect() + sta.active(False) + + ap = net.WLAN(net.AP_IF) + ap.active(True) + + authmode = WifiService._resolve_hotspot_authmode( + net, config.get("password"), config.get("authmode") + ) + + ap_config = { + "essid": config.get("ssid"), + "channel": config.get("channel"), + "hidden": config.get("hidden"), + "max_clients": config.get("max_clients"), + "authmode": authmode, + } + if config.get("password"): + ap_config["password"] = config.get("password") + + ap.config(**ap_config) + ap.ifconfig( + ( + config.get("ip"), + config.get("netmask"), + config.get("gateway"), + config.get("dns"), + ) + ) + + WifiService.hotspot_enabled = True + print("WifiService: Hotspot enabled") + return True + except Exception as e: + print(f"WifiService: Failed to enable hotspot: {e}") + return False + + @staticmethod + def disable_hotspot(network_module=None): + if not HAS_NETWORK_MODULE and network_module is None: + WifiService.hotspot_enabled = False + print("WifiService: Desktop mode, hotspot disabled (simulated)") + return + + try: + net = network_module if network_module else network + ap = net.WLAN(net.AP_IF) + ap.active(False) + WifiService.hotspot_enabled = False + print("WifiService: Hotspot disabled") + except Exception: + WifiService.hotspot_enabled = False + + @staticmethod + def is_hotspot_enabled(network_module=None): + if not HAS_NETWORK_MODULE and network_module is None: + return WifiService.hotspot_enabled + try: + net = network_module if network_module else network + ap = net.WLAN(net.AP_IF) + return ap.active() + except Exception: + return WifiService.hotspot_enabled + @staticmethod def connect(network_module=None, time_module=None): """ @@ -141,7 +264,16 @@ class WifiService: net = network_module if network_module else network + def _restore_hotspot_if_needed(): + if WifiService._needs_hotspot_restore: + WifiService._needs_hotspot_restore = False + WifiService.enable_hotspot(network_module=network_module) + try: + if WifiService.is_hotspot_enabled(network_module=network_module): + WifiService._needs_hotspot_restore = True + WifiService.disable_hotspot(network_module=network_module) + wlan = net.WLAN(net.STA_IF) wlan.connect(ssid, password) @@ -156,21 +288,25 @@ class WifiService: except Exception as e: print(f"WifiService: Could not sync time: {e}") + WifiService._needs_hotspot_restore = False return True elif not wlan.active(): # WiFi was disabled during connection attempt print("WifiService: WiFi disabled during connection, aborting") + _restore_hotspot_if_needed() return False print(f"WifiService: Waiting for connection, attempt {i+1}/10") time_mod.sleep(1) print(f"WifiService: Connection timeout for '{ssid}'") + _restore_hotspot_if_needed() return False except Exception as e: print(f"WifiService: Connection error: {e}") + _restore_hotspot_if_needed() return False @staticmethod @@ -187,21 +323,37 @@ class WifiService: """ print("WifiService: Auto-connect thread starting") + hotspot_config = WifiService._get_hotspot_config() + if hotspot_config.get("enabled"): + print("WifiService: Hotspot enabled, skipping STA auto-connect") + WifiService.enable_hotspot(network_module=network_module) + return + if WifiService.is_hotspot_enabled(network_module=network_module): + WifiService._needs_hotspot_restore = True + WifiService.disable_hotspot(network_module=network_module) + # Load saved access points from config WifiService.access_points = mpos.config.SharedPreferences( "com.micropythonos.system.wifiservice" ).get_dict("access_points") if not len(WifiService.access_points): + if WifiService._needs_hotspot_restore: + WifiService._needs_hotspot_restore = False + WifiService.enable_hotspot(network_module=network_module) print("WifiService: No access points configured, exiting") return # Check if WiFi is busy (e.g., WiFi app is scanning) if WifiService.wifi_busy: + if WifiService._needs_hotspot_restore: + WifiService._needs_hotspot_restore = False + WifiService.enable_hotspot(network_module=network_module) print("WifiService: WiFi busy, auto-connect aborted") return WifiService.wifi_busy = True + connected = False try: if not HAS_NETWORK_MODULE and network_module is None: @@ -209,6 +361,7 @@ class WifiService: print("WifiService: Desktop mode, simulating connection...") time_mod = time_module if time_module else time time_mod.sleep(2) + connected = True print("WifiService: Simulated connection complete") else: # Attempt to connect to saved networks @@ -216,6 +369,7 @@ class WifiService: network_module=network_module, time_module=time_module, ): + connected = True print("WifiService: Auto-connect successful") else: print("WifiService: Auto-connect failed") @@ -231,6 +385,9 @@ class WifiService: print("WifiService: WiFi disabled to conserve power") finally: + if not connected and WifiService._needs_hotspot_restore: + WifiService._needs_hotspot_restore = False + WifiService.enable_hotspot(network_module=network_module) WifiService.wifi_busy = False print("WifiService: Auto-connect thread finished") @@ -254,16 +411,23 @@ class WifiService: if WifiService.wifi_busy: raise RuntimeError("Cannot disable WiFi: WifiService is already busy") - # Check actual connection status BEFORE setting wifi_busy was_connected = False + hotspot_was_enabled = False if HAS_NETWORK_MODULE or network_module: try: net = network_module if network_module else network wlan = net.WLAN(net.STA_IF) was_connected = wlan.isconnected() + ap = net.WLAN(net.AP_IF) + hotspot_was_enabled = ap.active() except Exception as e: print(f"WifiService: Error checking connection: {e}") + WifiService._temp_disable_state = { + "was_connected": was_connected, + "hotspot_was_enabled": hotspot_was_enabled, + } + # Now set busy flag and disconnect WifiService.wifi_busy = True WifiService.disconnect(network_module=network_module) @@ -283,6 +447,12 @@ class WifiService: """ WifiService.wifi_busy = False + state = WifiService._temp_disable_state or {} + WifiService._temp_disable_state = None + + if state.get("hotspot_was_enabled"): + WifiService.enable_hotspot(network_module=network_module) + # Only reconnect if WiFi was connected before we disabled it if was_connected: try: @@ -313,9 +483,11 @@ class WifiService: if not HAS_NETWORK_MODULE and network_module is None: return True - # Check actual connection status try: net = network_module if network_module else network + if WifiService.is_hotspot_enabled(network_module=network_module): + ap = net.WLAN(net.AP_IF) + return ap.active() wlan = net.WLAN(net.STA_IF) return wlan.isconnected() except Exception as e: @@ -333,9 +505,11 @@ class WifiService: if not HAS_NETWORK_MODULE and network_module is None: return "123.456.789.000" - # Check actual connection status try: net = network_module if network_module else network + if WifiService.is_hotspot_enabled(network_module=network_module): + ap = net.WLAN(net.AP_IF) + return ap.ifconfig()[0] wlan = net.WLAN(net.STA_IF) return wlan.ipconfig("addr4") except Exception as e: @@ -352,9 +526,11 @@ class WifiService: if not HAS_NETWORK_MODULE and network_module is None: return "000.123.456.789" - # Check actual connection status try: net = network_module if network_module else network + if WifiService.is_hotspot_enabled(network_module=network_module): + ap = net.WLAN(net.AP_IF) + return ap.ifconfig()[2] wlan = net.WLAN(net.STA_IF) return wlan.ipconfig("gw4") except Exception as e: @@ -378,6 +554,9 @@ class WifiService: wlan = net.WLAN(net.STA_IF) wlan.disconnect() wlan.active(False) + ap = net.WLAN(net.AP_IF) + ap.active(False) + WifiService.hotspot_enabled = False print("WifiService: Disconnected and WiFi disabled") except Exception as e: #print(f"WifiService: Error disconnecting: {e}") # probably "Wifi Not Started" so harmless diff --git a/partitions_with_retro-go.csv b/partitions_with_retro-go.csv deleted file mode 100644 index 00c66f8a..00000000 --- a/partitions_with_retro-go.csv +++ /dev/null @@ -1,19 +0,0 @@ -# Partition table for Fri3D Camp 2024 Badge with ESP-IDF OTA support using 16MB flash -# -# Also present in flash: -# 0x0 images/bootloader.bin -# 0x8000 images/partition-table.bin -# -# Notes: -# - app partitions should be aligned at 0x10000 (64k block) -# - otadata size should be 0x2000 -# -# Name, Type, SubType, Offset, Size, Flags -otadata, data, ota, 0x9000, 0x2000, -nvs, data, nvs, 0xb000, 0x5000, -ota_0,app,ota_0,0x20000,0x400000 -ota_1,app,ota_1,0x420000,0x400000 -launcher, app, ota_2, 0x820000, 0x100000, -retro-core, app, ota_3, 0x930000, 0xd0000 -prboom-go, app, ota_4, 0xa00000, 0xe0000, -vfs, data, fat, 0xae0000, 0x520000 diff --git a/tests/test_battery_voltage.py b/tests/test_battery_voltage.py index 9e1367ac..17d87d60 100644 --- a/tests/test_battery_voltage.py +++ b/tests/test_battery_voltage.py @@ -7,86 +7,17 @@ Tests ADC1/ADC2 detection, caching, WiFi coordination, and voltage calculations. import unittest import sys +# Allow importing shared test mocks +sys.path.insert(0, "../tests") + +from mocks import MockADC, MockMachineADC, MockWifiService + # Add parent directory to path for imports -sys.path.insert(0, '../internal_filesystem') - -# Mock modules before importing BatteryManager -class MockADC: - """Mock ADC for testing.""" - ATTN_11DB = 3 - - def __init__(self, pin): - self.pin = pin - self._atten = None - self._read_value = 2048 # Default mid-range value - - def atten(self, value): - self._atten = value - - def read(self): - return self._read_value - - def set_read_value(self, value): - """Test helper to set ADC reading.""" - self._read_value = value - - -class MockPin: - """Mock Pin for testing.""" - def __init__(self, pin_num): - self.pin_num = pin_num - - -class MockMachine: - """Mock machine module.""" - ADC = MockADC - Pin = MockPin - - -class MockWifiService: - """Mock WifiService for testing.""" - wifi_busy = False - _connected = False - _temporarily_disabled = False - - @classmethod - def is_connected(cls): - return cls._connected - - @classmethod - def disconnect(cls): - cls._connected = False - - @classmethod - def temporarily_disable(cls): - """Temporarily disable WiFi and return whether it was connected.""" - if cls.wifi_busy: - raise RuntimeError("Cannot disable WiFi: WifiService is already busy") - was_connected = cls._connected - cls.wifi_busy = True - cls._connected = False - cls._temporarily_disabled = True - return was_connected - - @classmethod - def temporarily_enable(cls, was_connected): - """Re-enable WiFi and reconnect if it was connected before.""" - cls.wifi_busy = False - cls._temporarily_disabled = False - if was_connected: - cls._connected = True # Simulate reconnection - - @classmethod - def reset(cls): - """Test helper to reset state.""" - cls.wifi_busy = False - cls._connected = False - cls._temporarily_disabled = False - +sys.path.insert(0, "../internal_filesystem") # Inject mocks -sys.modules['machine'] = MockMachine -sys.modules['mpos.net.wifi_service'] = type('module', (), {'WifiService': MockWifiService})() +sys.modules["machine"] = MockMachineADC +sys.modules["mpos.net.wifi_service"] = type("module", (), {"WifiService": MockWifiService})() # Now import BatteryManager from mpos.battery_manager import BatteryManager diff --git a/tests/test_connectivity_manager.py b/tests/test_connectivity_manager.py index a73f66ee..d49a3b06 100644 --- a/tests/test_connectivity_manager.py +++ b/tests/test_connectivity_manager.py @@ -1,31 +1,18 @@ import unittest import sys -# Add parent directory to path so we can import network_test_helper +# Add parent directory to path so we can import shared mocks/network_test_helper # When running from unittest.sh, we're in internal_filesystem/, so tests/ is ../tests/ -sys.path.insert(0, '../tests') +sys.path.insert(0, "../tests") + +from mocks import make_machine_timer_module, make_usocket_module # Import our network test helpers from network_test_helper import MockNetwork, MockTimer, MockTime, MockRequests, MockSocket -# Mock machine module with Timer -class MockMachine: - """Mock machine module.""" - Timer = MockTimer - -# Mock usocket module -class MockUsocket: - """Mock usocket module.""" - AF_INET = MockSocket.AF_INET - SOCK_STREAM = MockSocket.SOCK_STREAM - - @staticmethod - def socket(af, sock_type): - return MockSocket(af, sock_type) - # Inject mocks into sys.modules BEFORE importing connectivity_manager -sys.modules['machine'] = MockMachine -sys.modules['usocket'] = MockUsocket +sys.modules["machine"] = make_machine_timer_module(MockTimer) +sys.modules["usocket"] = make_usocket_module(MockSocket) # Mock requests module mock_requests = MockRequests() diff --git a/tests/test_sensor_manager.py b/tests/test_sensor_manager.py index d93caa87..d35b5850 100644 --- a/tests/test_sensor_manager.py +++ b/tests/test_sensor_manager.py @@ -2,91 +2,17 @@ import unittest import sys +# Allow importing shared test mocks +sys.path.insert(0, "../tests") -# Mock hardware before importing SensorManager -class MockI2C: - """Mock I2C bus for testing.""" - def __init__(self, bus_id, sda=None, scl=None): - self.bus_id = bus_id - self.sda = sda - self.scl = scl - self.memory = {} # addr -> {reg -> value} - - def readfrom_mem(self, addr, reg, nbytes): - """Read from memory (simulates I2C read).""" - if addr not in self.memory: - raise OSError("I2C device not found") - if reg not in self.memory[addr]: - return bytes([0] * nbytes) - return bytes(self.memory[addr][reg]) - - def writeto_mem(self, addr, reg, data): - """Write to memory (simulates I2C write).""" - if addr not in self.memory: - self.memory[addr] = {} - self.memory[addr][reg] = list(data) - - -class MockQMI8658: - """Mock QMI8658 IMU sensor.""" - def __init__(self, i2c_bus, address=0x6B, accel_scale=0b10, gyro_scale=0b100): - self.i2c = i2c_bus - self.address = address - self.accel_scale = accel_scale - self.gyro_scale = gyro_scale - - @property - def temperature(self): - """Return mock temperature.""" - return 25.5 # Mock temperature in °C - - @property - def acceleration(self): - """Return mock acceleration (in G).""" - return (0.0, 0.0, 1.0) # At rest, Z-axis = 1G - - @property - def gyro(self): - """Return mock gyroscope (in deg/s).""" - return (0.0, 0.0, 0.0) # Stationary - - -class MockWsenIsds: - """Mock WSEN_ISDS IMU sensor.""" - def __init__(self, i2c, address=0x6B, acc_range="8g", acc_data_rate="104Hz", - gyro_range="500dps", gyro_data_rate="104Hz"): - self.i2c = i2c - self.address = address - self.acc_range = acc_range - self.gyro_range = gyro_range - self.acc_sensitivity = 0.244 # mg/digit for 8g - self.gyro_sensitivity = 17.5 # mdps/digit for 500dps - self.acc_offset_x = 0 - self.acc_offset_y = 0 - self.acc_offset_z = 0 - self.gyro_offset_x = 0 - self.gyro_offset_y = 0 - self.gyro_offset_z = 0 - - def get_chip_id(self): - """Return WHO_AM_I value.""" - return 0x6A - - def _read_raw_accelerations(self): - """Return mock acceleration (in mg).""" - return (0.0, 0.0, 1000.0) # At rest, Z-axis = 1000 mg - - def read_angular_velocities(self): - """Return mock gyroscope (in mdps).""" - return (0.0, 0.0, 0.0) - - def acc_calibrate(self, samples=None): - """Mock calibration.""" - pass - - def gyro_calibrate(self, samples=None): - """Mock calibration.""" - pass +from mocks import ( + MockI2C, + MockQMI8658, + MockSharedPreferences, + MockWsenIsds, + make_config_module, + make_machine_i2c_module, +) # Mock constants from drivers @@ -96,57 +22,20 @@ _ACCELSCALE_RANGE_8G = 0b10 _GYROSCALE_RANGE_256DPS = 0b100 -# Mock SharedPreferences to prevent loading real calibration -class MockSharedPreferences: - """Mock SharedPreferences for testing.""" - def __init__(self, package, filename=None): - self.package = package - self.filename = filename - self.data = {} - - def get_list(self, key): - """Get list value.""" - return self.data.get(key) - - def edit(self): - """Return editor.""" - return MockEditor(self.data) - -class MockEditor: - """Mock SharedPreferences editor.""" - def __init__(self, data): - self.data = data - - def put_list(self, key, value): - """Put list value.""" - self.data[key] = value - return self - - def commit(self): - """Commit changes.""" - pass - -mock_config = type('module', (), { - 'SharedPreferences': MockSharedPreferences -})() +mock_config = make_config_module(MockSharedPreferences) # Create mock modules -mock_machine = type('module', (), { - 'I2C': MockI2C, - 'Pin': type('Pin', (), {}) +mock_machine = make_machine_i2c_module(MockI2C) + +mock_qmi8658 = type("module", (), { + "QMI8658": MockQMI8658, + "_QMI8685_PARTID": _QMI8685_PARTID, + "_REG_PARTID": _REG_PARTID, + "_ACCELSCALE_RANGE_8G": _ACCELSCALE_RANGE_8G, + "_GYROSCALE_RANGE_256DPS": _GYROSCALE_RANGE_256DPS, })() -mock_qmi8658 = type('module', (), { - 'QMI8658': MockQMI8658, - '_QMI8685_PARTID': _QMI8685_PARTID, - '_REG_PARTID': _REG_PARTID, - '_ACCELSCALE_RANGE_8G': _ACCELSCALE_RANGE_8G, - '_GYROSCALE_RANGE_256DPS': _GYROSCALE_RANGE_256DPS -})() - -mock_wsen_isds = type('module', (), { - 'Wsen_Isds': MockWsenIsds -})() +mock_wsen_isds = type("module", (), {"Wsen_Isds": MockWsenIsds})() # Mock esp32 module def _mock_mcu_temperature(*args, **kwargs): diff --git a/tests/test_wifi_service.py b/tests/test_wifi_service.py index be4cf493..aed75abf 100644 --- a/tests/test_wifi_service.py +++ b/tests/test_wifi_service.py @@ -2,70 +2,20 @@ import unittest import sys # Add tests directory to path for network_test_helper -sys.path.insert(0, '../tests') +sys.path.insert(0, "../tests") # Import network test helpers from network_test_helper import MockNetwork, MockTime -# Mock config classes -class MockSharedPreferences: - """Mock SharedPreferences for testing.""" - _all_data = {} # Class-level storage - - def __init__(self, app_id): - self.app_id = app_id - if app_id not in MockSharedPreferences._all_data: - MockSharedPreferences._all_data[app_id] = {} - - def get_dict(self, key): - return MockSharedPreferences._all_data.get(self.app_id, {}).get(key, {}) - - def edit(self): - return MockEditor(self) - - @classmethod - def reset_all(cls): - cls._all_data = {} - - -class MockEditor: - """Mock editor for SharedPreferences.""" - - def __init__(self, prefs): - self.prefs = prefs - self.pending = {} - - def put_dict(self, key, value): - self.pending[key] = value - - def commit(self): - if self.prefs.app_id not in MockSharedPreferences._all_data: - MockSharedPreferences._all_data[self.prefs.app_id] = {} - MockSharedPreferences._all_data[self.prefs.app_id].update(self.pending) - - -# Create mock mpos module -class MockMpos: - """Mock mpos module with config and time.""" - - class config: - @staticmethod - def SharedPreferences(app_id): - return MockSharedPreferences(app_id) - - class time: - @staticmethod - def sync_time(): - pass # No-op for testing - +from mocks import HotspotMockNetwork, MockMpos, MockSharedPreferences # Inject mocks before importing WifiService -sys.modules['mpos'] = MockMpos -sys.modules['mpos.config'] = MockMpos.config -sys.modules['mpos.time'] = MockMpos.time +sys.modules["mpos"] = MockMpos +sys.modules["mpos.config"] = MockMpos.config +sys.modules["mpos.time"] = MockMpos.time # Add path to wifi_service.py -sys.path.append('lib/mpos/net') +sys.path.append("lib/mpos/net") # Import WifiService from wifi_service import WifiService @@ -331,7 +281,9 @@ class TestWifiServiceIsConnected(unittest.TestCase): def test_is_connected_when_disconnected(self): """Test is_connected returns False when WiFi is disconnected.""" - mock_network = MockNetwork(connected=False) + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(False) result = WifiService.is_connected(network_module=mock_network) @@ -347,6 +299,17 @@ class TestWifiServiceIsConnected(unittest.TestCase): # Should return False even though connected self.assertFalse(result) + def test_is_connected_when_hotspot_enabled(self): + """Test is_connected checks AP state when hotspot is enabled.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + result = WifiService.is_connected(network_module=mock_network) + + self.assertTrue(result) + def test_is_connected_desktop_mode(self): """Test is_connected in desktop mode.""" result = WifiService.is_connected(network_module=None) @@ -424,6 +387,329 @@ class TestWifiServiceNetworkManagement(unittest.TestCase): self.assertEqual(len(saved), 0) +class TestWifiServiceHotspot(unittest.TestCase): + """Test hotspot configuration and mode switching.""" + + def setUp(self): + """Set up test fixtures.""" + MockSharedPreferences.reset_all() + WifiService.hotspot_enabled = False + WifiService.wifi_busy = False + WifiService._needs_hotspot_restore = False + + def tearDown(self): + """Clean up after test.""" + WifiService.hotspot_enabled = False + WifiService.wifi_busy = False + WifiService._needs_hotspot_restore = False + MockSharedPreferences.reset_all() + + def test_enable_hotspot_applies_config(self): + """Test enable_hotspot reads config and configures AP.""" + prefs = MockSharedPreferences("com.micropythonos.system.hotspot") + editor = prefs.edit() + editor.put_bool("enabled", True) + editor.put_string("ssid", "MyAP") + editor.put_string("password", "ap-pass") + editor.put_int("channel", 6) + editor.put_bool("hidden", True) + editor.put_int("max_clients", 3) + editor.put_string("authmode", "wpa2") + editor.put_string("ip", "192.168.4.2") + editor.put_string("netmask", "255.255.255.0") + editor.put_string("gateway", "192.168.4.1") + editor.put_string("dns", "1.1.1.1") + editor.commit() + + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + sta_wlan.active(True) + sta_wlan._connected = True + + result = WifiService.enable_hotspot(network_module=mock_network) + + self.assertTrue(result) + self.assertTrue(WifiService.hotspot_enabled) + self.assertTrue(ap_wlan.active()) + self.assertFalse(sta_wlan.active()) + self.assertEqual(ap_wlan._config.get("essid"), "MyAP") + self.assertEqual(ap_wlan._config.get("channel"), 6) + self.assertTrue(ap_wlan._config.get("hidden")) + self.assertEqual(ap_wlan._config.get("max_clients"), 3) + self.assertEqual(ap_wlan._config.get("authmode"), mock_network.AUTH_WPA2_PSK) + self.assertEqual(ap_wlan._config.get("password"), "ap-pass") + self.assertEqual( + ap_wlan.ifconfig(), + ("192.168.4.2", "255.255.255.0", "192.168.4.1", "1.1.1.1"), + ) + + def test_enable_hotspot_respects_busy_flag(self): + """Test enable_hotspot returns False when WiFi is busy.""" + WifiService.wifi_busy = True + mock_network = HotspotMockNetwork() + + result = WifiService.enable_hotspot(network_module=mock_network) + + self.assertFalse(result) + self.assertFalse(WifiService.hotspot_enabled) + + def test_disable_hotspot_deactivates_ap(self): + """Test disable_hotspot turns off AP and updates flag.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + WifiService.disable_hotspot(network_module=mock_network) + + self.assertFalse(ap_wlan.active()) + self.assertFalse(WifiService.hotspot_enabled) + + def test_enable_hotspot_desktop_mode(self): + """Test enable_hotspot in desktop mode uses simulated flag.""" + result = WifiService.enable_hotspot(network_module=None) + + self.assertTrue(result) + self.assertTrue(WifiService.hotspot_enabled) + + def test_disable_hotspot_desktop_mode(self): + """Test disable_hotspot in desktop mode uses simulated flag.""" + WifiService.hotspot_enabled = True + + WifiService.disable_hotspot(network_module=None) + + self.assertFalse(WifiService.hotspot_enabled) + + def test_auto_connect_with_hotspot_enabled_prefers_ap_mode(self): + """Test auto_connect uses hotspot mode when enabled in config.""" + prefs = MockSharedPreferences("com.micropythonos.system.hotspot") + editor = prefs.edit() + editor.put_bool("enabled", True) + editor.commit() + + mock_network = HotspotMockNetwork() + + WifiService.auto_connect(network_module=mock_network, time_module=MockTime()) + + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + self.assertTrue(ap_wlan.active()) + self.assertTrue(WifiService.hotspot_enabled) + + def test_attempt_connecting_temporarily_disables_hotspot(self): + """Test STA connect disables hotspot and leaves it off on success.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + call_count = [0] + + def mock_isconnected(): + call_count[0] += 1 + return call_count[0] >= 1 + + sta_wlan.isconnected = mock_isconnected + + result = WifiService.attempt_connecting( + "TestSSID", + "pass", + network_module=mock_network, + time_module=MockTime(), + ) + + self.assertTrue(result) + self.assertFalse(WifiService.hotspot_enabled) + self.assertFalse(ap_wlan.active()) + + def test_attempt_connecting_restores_hotspot_on_timeout(self): + """Test STA connect restores hotspot when connection times out.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + + def mock_isconnected(): + return False + + sta_wlan.isconnected = mock_isconnected + + result = WifiService.attempt_connecting( + "TestSSID", + "pass", + network_module=mock_network, + time_module=MockTime(), + ) + + self.assertFalse(result) + self.assertTrue(WifiService.hotspot_enabled) + self.assertTrue(ap_wlan.active()) + + def test_attempt_connecting_restores_hotspot_on_abort(self): + """Test STA connect restores hotspot if WiFi is disabled mid-try.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + + def mock_isconnected(): + return False + + def mock_active(state=None): + if state is not None: + sta_wlan._active = state + return None + return False + + sta_wlan.isconnected = mock_isconnected + sta_wlan.active = mock_active + + result = WifiService.attempt_connecting( + "TestSSID", + "pass", + network_module=mock_network, + time_module=MockTime(), + ) + + self.assertFalse(result) + self.assertTrue(WifiService.hotspot_enabled) + self.assertTrue(ap_wlan.active()) + + +class TestWifiServiceTemporaryDisable(unittest.TestCase): + """Test temporarily_disable/temporarily_enable behavior.""" + + def setUp(self): + """Set up test fixtures.""" + WifiService.wifi_busy = False + WifiService._temp_disable_state = None + WifiService.hotspot_enabled = False + + def tearDown(self): + """Clean up after test.""" + WifiService.wifi_busy = False + WifiService._temp_disable_state = None + WifiService.hotspot_enabled = False + + def test_temporarily_disable_raises_when_busy(self): + """Test temporarily_disable raises if wifi_busy is set.""" + WifiService.wifi_busy = True + + with self.assertRaises(RuntimeError): + WifiService.temporarily_disable(network_module=HotspotMockNetwork()) + + def test_temporarily_disable_disconnects_and_tracks_state(self): + """Test temporarily_disable stores state and disconnects.""" + mock_network = HotspotMockNetwork() + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + sta_wlan._connected = True + ap_wlan.active(True) + WifiService.hotspot_enabled = True + + disconnect_called = [False] + + def mock_disconnect(network_module=None): + disconnect_called[0] = True + + original_disconnect = WifiService.disconnect + WifiService.disconnect = mock_disconnect + try: + was_connected = WifiService.temporarily_disable(network_module=mock_network) + finally: + WifiService.disconnect = original_disconnect + + self.assertTrue(was_connected) + self.assertTrue(WifiService.wifi_busy) + self.assertEqual( + WifiService._temp_disable_state, + {"was_connected": True, "hotspot_was_enabled": True}, + ) + self.assertTrue(disconnect_called[0]) + + def test_temporarily_enable_restores_hotspot_and_reconnects(self): + """Test temporarily_enable restores hotspot and triggers reconnect.""" + mock_network = HotspotMockNetwork() + WifiService._temp_disable_state = {"was_connected": True, "hotspot_was_enabled": True} + WifiService.wifi_busy = True + + thread_calls = [] + + class MockThreadModule: + @staticmethod + def start_new_thread(func, args): + thread_calls.append((func, args)) + + original_thread = sys.modules.get("_thread") + sys.modules["_thread"] = MockThreadModule + + try: + WifiService.temporarily_enable(True, network_module=mock_network) + finally: + if original_thread is not None: + sys.modules["_thread"] = original_thread + else: + sys.modules.pop("_thread", None) + + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + self.assertFalse(WifiService.wifi_busy) + self.assertIsNone(WifiService._temp_disable_state) + self.assertTrue(ap_wlan.active()) + self.assertTrue(WifiService.hotspot_enabled) + self.assertEqual(thread_calls[0][0], WifiService.auto_connect) + + +class TestWifiServiceIPv4Info(unittest.TestCase): + """Test IPv4 info accessors for AP/STA modes.""" + + def setUp(self): + """Set up test fixtures.""" + WifiService.wifi_busy = False + WifiService.hotspot_enabled = False + + def tearDown(self): + """Clean up after test.""" + WifiService.wifi_busy = False + WifiService.hotspot_enabled = False + + def test_get_ipv4_info_from_ap_when_hotspot_enabled(self): + """Test IPv4 getters use AP info when hotspot is enabled.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + ap_wlan.active(True) + ap_wlan.ifconfig(("192.168.4.1", "255.255.255.0", "192.168.4.1", "8.8.4.4")) + WifiService.hotspot_enabled = True + + address = WifiService.get_ipv4_address(network_module=mock_network) + gateway = WifiService.get_ipv4_gateway(network_module=mock_network) + + self.assertEqual(address, "192.168.4.1") + self.assertEqual(gateway, "192.168.4.1") + + def test_get_ipv4_info_returns_none_when_busy(self): + """Test IPv4 getters return None when wifi_busy is set.""" + WifiService.wifi_busy = True + + address = WifiService.get_ipv4_address(network_module=HotspotMockNetwork()) + gateway = WifiService.get_ipv4_gateway(network_module=HotspotMockNetwork()) + + self.assertIsNone(address) + self.assertIsNone(gateway) + + def test_get_ipv4_info_desktop_mode(self): + """Test IPv4 getters return simulated values in desktop mode.""" + address = WifiService.get_ipv4_address(network_module=None) + gateway = WifiService.get_ipv4_gateway(network_module=None) + + self.assertEqual(address, "123.456.789.000") + self.assertEqual(gateway, "000.123.456.789") + + class TestWifiServiceDisconnect(unittest.TestCase): """Test WifiService.disconnect() method.""" @@ -453,6 +739,21 @@ class TestWifiServiceDisconnect(unittest.TestCase): self.assertTrue(disconnect_called[0]) self.assertTrue(active_false_called[0]) + def test_disconnect_disables_ap(self): + """Test disconnect also disables AP and clears hotspot flag.""" + mock_network = HotspotMockNetwork() + ap_wlan = mock_network.WLAN(mock_network.AP_IF) + sta_wlan = mock_network.WLAN(mock_network.STA_IF) + ap_wlan.active(True) + sta_wlan._connected = True + + WifiService.hotspot_enabled = True + + WifiService.disconnect(network_module=mock_network) + + self.assertFalse(ap_wlan.active()) + self.assertFalse(WifiService.hotspot_enabled) + def test_disconnect_desktop_mode(self): """Test disconnect in desktop mode.""" # Should not raise an error