From c0ef5bffdd74940a1a8050cade9259ed64572ce4 Mon Sep 17 00:00:00 2001 From: Richard Taylor Date: Fri, 27 Mar 2026 16:14:31 +0000 Subject: [PATCH 1/2] Fix desktop run: handle missing _webrepl module gracefully MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WebServer import chain (mpos/__init__.py → webserver.py → webrepl_http.py) requires the _webrepl C module which is only available on ESP32 builds. This causes an ImportError on desktop/Unix systems, preventing the entire mpos package from loading. Wrap the import in try/except so desktop builds can still run. main.py already has a similar guard around WebServer.auto_start(). Co-Authored-By: Claude Opus 4.6 --- internal_filesystem/lib/mpos/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 82ce41c0..4b6520f5 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -19,7 +19,10 @@ # Battery manager (imported early for UI dependencies) from .battery_manager import BatteryManager -from .webserver.webserver import WebServer +try: + from .webserver.webserver import WebServer +except ImportError: + WebServer = None # _webrepl not available on desktop/Unix builds # Common activities from .app.activities.chooser import ChooserActivity From feb841d87db1e735c923958748707446ae37d53c Mon Sep 17 00:00:00 2001 From: Richard Taylor Date: Fri, 27 Mar 2026 17:56:37 +0000 Subject: [PATCH 2/2] Add international number format setting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a system-wide number format preference to the Settings app, allowing users to choose their preferred decimal and thousands separators. Follows the TimeZone pattern with a NumberFormat utility class that any app can import. Available formats: - 1,234.56 (US/UK) - 1.234,56 (Europe) - 1 234,56 (French) - 1'234.56 (Swiss) - 1_234.56 (Tech) - 1234.56 / 1234,56 (No thousands separator) New files: - lib/mpos/number_format.py — NumberFormat class with format_number() - tests/test_number_format.py — 14 unit tests Co-Authored-By: Claude Opus 4.6 --- .../assets/settings.py | 3 +- internal_filesystem/lib/mpos/__init__.py | 1 + internal_filesystem/lib/mpos/number_format.py | 111 ++++++++++++++++++ tests/test_number_format.py | 82 +++++++++++++ 4 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 internal_filesystem/lib/mpos/number_format.py create mode 100644 tests/test_number_format.py 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 9af397f4..84837dbe 100644 --- a/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py +++ b/internal_filesystem/builtin/apps/com.micropythonos.settings/assets/settings.py @@ -1,6 +1,6 @@ import lvgl as lv -from mpos import Activity, Intent, AppearanceManager, AppManager, SettingActivity, SettingsActivity, TimeZone +from mpos import Activity, Intent, AppearanceManager, AppManager, NumberFormat, SettingActivity, SettingsActivity, TimeZone from bootloader import ResetIntoBootloader from calibrate_imu import CalibrateIMUActivity @@ -82,6 +82,7 @@ def getIntent(self): {"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}, {"title": "Timezone", "key": "timezone", "ui": "dropdown", "ui_options": [(tz, tz) for tz in TimeZone.get_timezones()], "changed_callback": lambda *args: TimeZone.refresh_timezone_preference()}, + {"title": "Number Format", "key": "number_format", "ui": "dropdown", "ui_options": NumberFormat.get_format_options(), "changed_callback": lambda *args: NumberFormat.refresh_preference(), "default_value": "comma_dot"}, # Advanced settings, alphabetically: {"title": "Auto Start App", "key": "auto_start_app", "ui": "radiobuttons", "ui_options": [(app.name, app.fullname) for app in AppManager.get_app_list()]}, {"title": "Check IMU Calibration", "key": "check_imu_calibration", "ui": "activity", "activity_class": CheckIMUCalibrationActivity}, diff --git a/internal_filesystem/lib/mpos/__init__.py b/internal_filesystem/lib/mpos/__init__.py index 4b6520f5..71a2193f 100644 --- a/internal_filesystem/lib/mpos/__init__.py +++ b/internal_filesystem/lib/mpos/__init__.py @@ -14,6 +14,7 @@ from .camera_manager import CameraManager from .sensor_manager import SensorManager from .time_zone import TimeZone +from .number_format import NumberFormat from .device_info import DeviceInfo from .build_info import BuildInfo diff --git a/internal_filesystem/lib/mpos/number_format.py b/internal_filesystem/lib/mpos/number_format.py new file mode 100644 index 00000000..a4829a5b --- /dev/null +++ b/internal_filesystem/lib/mpos/number_format.py @@ -0,0 +1,111 @@ +from . import config + + +NUMBER_FORMAT_MAP = { + "comma_dot": (".", ","), # 1,234.56 US/UK + "dot_comma": (",", "."), # 1.234,56 Europe + "space_comma": (",", " "), # 1 234,56 French + "apos_dot": (".", "'"), # 1'234.56 Swiss + "under_dot": (".", "_"), # 1_234.56 Tech + "none_dot": (".", ""), # 1234.56 No thousands + "none_comma": (",", ""), # 1234,56 No thousands +} + +DEFAULT_FORMAT = "comma_dot" + + +class NumberFormat: + """Number formatting utility using the system number format preference.""" + + number_format_preference = None + + @staticmethod + def refresh_preference(): + """Refresh the number format preference from SharedPreferences.""" + NumberFormat.number_format_preference = config.SharedPreferences( + "com.micropythonos.settings" + ).get_string("number_format") + if not NumberFormat.number_format_preference: + NumberFormat.number_format_preference = DEFAULT_FORMAT + + @staticmethod + def get_separators(): + """Return (decimal_sep, thousands_sep) for the current preference.""" + if NumberFormat.number_format_preference is None: + NumberFormat.refresh_preference() + return NUMBER_FORMAT_MAP.get( + NumberFormat.number_format_preference, + NUMBER_FORMAT_MAP[DEFAULT_FORMAT], + ) + + @staticmethod + def format_number(value, decimals=None): + """Format a number using the current number format preference. + + Args: + value: int or float to format. + decimals: number of decimal places (None = auto for ints, strip trailing zeros for floats). + + Returns: + Formatted string. + """ + dec_sep, thou_sep = NumberFormat.get_separators() + + if isinstance(value, int) and decimals is None: + negative = value < 0 + s = str(abs(value)) + s = _insert_thousands(s, thou_sep) + return ("-" + s) if negative else s + + # Float formatting + if decimals is None: + decimals = 2 + s = "{:.{}f}".format(float(value), decimals) + + negative = s.startswith("-") + if negative: + s = s[1:] + + # Split on the Python decimal point + if "." in s: + int_part, frac_part = s.split(".") + # Strip trailing zeros from fractional part + frac_part = frac_part.rstrip("0") + else: + int_part = s + frac_part = "" + + int_part = _insert_thousands(int_part, thou_sep) + + if frac_part: + result = int_part + dec_sep + frac_part + else: + result = int_part + + return ("-" + result) if negative else result + + @staticmethod + def get_format_options(): + """Return a list of (label, key) tuples for the settings dropdown.""" + return [ + ("1,234.56 (US/UK)", "comma_dot"), + ("1.234,56 (Europe)", "dot_comma"), + ("1 234,56 (French)", "space_comma"), + ("1'234.56 (Swiss)", "apos_dot"), + ("1_234.56 (Tech)", "under_dot"), + ("1234.56 (No separator)", "none_dot"), + ("1234,56 (No separator)", "none_comma"), + ] + + +def _insert_thousands(int_str, separator): + """Insert thousands separator into an integer string.""" + if not separator or len(int_str) <= 3: + return int_str + parts = [] + while len(int_str) > 3: + parts.append(int_str[-3:]) + int_str = int_str[:-3] + parts.append(int_str) + parts.reverse() + return separator.join(parts) diff --git a/tests/test_number_format.py b/tests/test_number_format.py new file mode 100644 index 00000000..f557e371 --- /dev/null +++ b/tests/test_number_format.py @@ -0,0 +1,82 @@ +import unittest +from mpos.number_format import NumberFormat, NUMBER_FORMAT_MAP, DEFAULT_FORMAT + + +class TestNumberFormat(unittest.TestCase): + + def setUp(self): + NumberFormat.number_format_preference = DEFAULT_FORMAT + + def test_default_is_us_format(self): + self.assertEqual(NumberFormat.get_separators(), (".", ",")) + + def test_format_int_default(self): + self.assertEqual(NumberFormat.format_number(1234), "1,234") + self.assertEqual(NumberFormat.format_number(0), "0") + self.assertEqual(NumberFormat.format_number(999), "999") + self.assertEqual(NumberFormat.format_number(1000), "1,000") + self.assertEqual(NumberFormat.format_number(1234567), "1,234,567") + + def test_format_negative_int(self): + self.assertEqual(NumberFormat.format_number(-1234), "-1,234") + self.assertEqual(NumberFormat.format_number(-5), "-5") + + def test_format_float_default(self): + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1,234.56") + self.assertEqual(NumberFormat.format_number(1234.50, 2), "1,234.5") + self.assertEqual(NumberFormat.format_number(1234.00, 2), "1,234") + self.assertEqual(NumberFormat.format_number(0.5, 2), "0.5") + + def test_format_europe(self): + NumberFormat.number_format_preference = "dot_comma" + self.assertEqual(NumberFormat.format_number(1234), "1.234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1.234,56") + + def test_format_french(self): + NumberFormat.number_format_preference = "space_comma" + self.assertEqual(NumberFormat.format_number(1234), "1 234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1 234,56") + + def test_format_swiss(self): + NumberFormat.number_format_preference = "apos_dot" + self.assertEqual(NumberFormat.format_number(1234), "1'234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1'234.56") + + def test_format_tech(self): + NumberFormat.number_format_preference = "under_dot" + self.assertEqual(NumberFormat.format_number(1234), "1_234") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1_234.56") + + def test_format_no_thousands_dot(self): + NumberFormat.number_format_preference = "none_dot" + self.assertEqual(NumberFormat.format_number(1234567), "1234567") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1234.56") + + def test_format_no_thousands_comma(self): + NumberFormat.number_format_preference = "none_comma" + self.assertEqual(NumberFormat.format_number(1234567), "1234567") + self.assertEqual(NumberFormat.format_number(1234.56, 2), "1234,56") + + def test_strip_trailing_zeros(self): + NumberFormat.number_format_preference = DEFAULT_FORMAT + self.assertEqual(NumberFormat.format_number(1.10, 2), "1.1") + self.assertEqual(NumberFormat.format_number(1.00, 2), "1") + self.assertEqual(NumberFormat.format_number(1.00, 8), "1") + + def test_large_number(self): + self.assertEqual(NumberFormat.format_number(100000000), "100,000,000") + + def test_get_format_options_returns_list(self): + options = NumberFormat.get_format_options() + self.assertIsInstance(options, list) + self.assertTrue(len(options) == len(NUMBER_FORMAT_MAP)) + for label, key in options: + self.assertIn(key, NUMBER_FORMAT_MAP) + + def test_unknown_preference_falls_back_to_default(self): + NumberFormat.number_format_preference = "nonexistent" + self.assertEqual(NumberFormat.get_separators(), (".", ",")) + + +if __name__ == "__main__": + unittest.main()