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 00000000..497e0f0b Binary files /dev/null and b/internal_filesystem/builtin/apps/com.micropythonos.hotspot/res/mipmap-mdpi/icon_64x64.png differ 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