diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 527ce8df..035a0d47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -442,7 +442,9 @@ jobs: node-version: "22" - name: Verify locale packs - run: python tools/validate_locale_packs.py --pack-root packs + run: | + python tools/validate_locale_packs.py --pack-root packs + python tests/test_prepare_pages_site.py - name: Generate Pages release metadata and package repository env: @@ -547,6 +549,9 @@ jobs: needs: - boundary - format + - geocaching_web + - reticulum_gateway + - geocaching - build - p4_contract - build_c6 diff --git a/docs/targets/wio_tracker_l2.capabilities.yaml b/docs/targets/wio_tracker_l2.capabilities.yaml index 67d16318..f970e82a 100644 --- a/docs/targets/wio_tracker_l2.capabilities.yaml +++ b/docs/targets/wio_tracker_l2.capabilities.yaml @@ -25,6 +25,8 @@ runtime: ui_thread: "arduino_loop" radio_thread: "existing_esp_mesh_runtime" gps_thread: "existing_esp_gps_runtime" + geocaching_owner: "existing_storage_owner" + note: "Geocaching advances bounded storage/network slices in the existing storage maintenance task; UI calls enqueue work or read mutex-protected snapshots. Runtime shells remain ESP-specific." isr_policy: "defer_to_owner" lock_policy: "separate_i2c_radio_audio_and_filesystem_owners" @@ -86,11 +88,13 @@ capability_bindings: lora: board_provider: "wio_tracker_l2.radio" platform_driver: "SX1262_on_dedicated_SPI" - protocol_consumers: ["core_mesh", "core_chat"] + protocol_consumers: ["core_mesh.meshtastic", "core_mesh.meshcore", "core_chat.reticulum"] gps: board_provider: "wio_tracker_l2.gps" platform_driver: "L76K_UART" - protocol_consumers: ["core_gps"] + protocol_consumers: ["core_gps.nmea", "core_gps.location_service", "core_gps.time_authority"] + ble: + protocol_consumers: ["core_phone.meshtastic", "core_phone.meshcore"] storage: board_provider: "wio_tracker_l2.sdmmc" platform_driver: "ArduinoSdmmcBlockDevice" @@ -101,10 +105,12 @@ capability_bindings: runtime_owner: "single_platform_playback_owner" protocols: - meshtastic: {enabled: true, runtime_owner: "shared_protocol_runtime"} - meshcore: {enabled: true, runtime_owner: "shared_protocol_runtime"} + meshtastic: {enabled: true, runtime_owner: "shared_protocol_runtime", radio_profile_source: "core_mesh", ble_phone_core: "core_phone"} + meshcore: {enabled: true, runtime_owner: "shared_protocol_runtime", radio_profile_source: "core_mesh", ble_phone_core: "core_phone"} reticulum: {enabled: true, runtime_owner: "existing_reticulum_runtime"} - selection: "one_active_product_protocol" + selection: "one_active_chat_protocol" + reticulum_ip_service: "available_independently_of_active_chat_protocol" + note: "Geocaching uses a background IP-only Reticulum service alongside Meshtastic or MeshCore; when Reticulum/RNode chat is active it reuses that backend. Chat/contact protocol partitions remain separate." storage: backend: "existing_SdFat_contract_over_1bit_SDMMC" @@ -112,6 +118,7 @@ storage: messages: "existing_protocol_partitioned_stores_and_ledgers" node_store: "existing_protocol_partitioned_stores" map_storage: "existing_map_runtime" + geocaching: "SD_indexed_journal_and_signed_GPX_details" race_policy: app_to_ui: "snapshot" @@ -126,4 +133,14 @@ build: entrypoint: "platformio.ini" app: "apps/esp32_lvgl" platformio_envs: ["wio_tracker_l2"] + enabled_modules: + - "core_chat" + - "core_device" + - "core_gps" + - "core_phone" + - "core_mesh" + - "core_geocaching" + - "ui_presentation" + - "core_sys" + - "ui_shared" hardware_validation: "pending" diff --git a/scripts/prepare_pages_site.py b/scripts/prepare_pages_site.py index 98c17418..cfcc89a2 100644 --- a/scripts/prepare_pages_site.py +++ b/scripts/prepare_pages_site.py @@ -72,8 +72,12 @@ def open_with_retries( for attempt in range(1, attempts + 1): try: return urllib.request.urlopen(request, timeout=timeout) - except urllib.error.HTTPError: - raise + except urllib.error.HTTPError as exc: + if exc.code not in (408, 429, 500, 502, 503, 504) or attempt >= attempts: + raise + last_error = exc + exc.close() + time.sleep(attempt) except (urllib.error.URLError, TimeoutError, http.client.RemoteDisconnected) as exc: last_error = exc if attempt >= attempts: diff --git a/tests/test_prepare_pages_site.py b/tests/test_prepare_pages_site.py new file mode 100644 index 00000000..7d4a013b --- /dev/null +++ b/tests/test_prepare_pages_site.py @@ -0,0 +1,88 @@ +"""Pages downloads retry transient HTTP errors without hiding permanent failures.""" +import importlib.util +import io +from pathlib import Path +import sys +import unittest +from unittest.mock import call, patch +import urllib.error +import urllib.request + +scripts = Path(__file__).resolve().parents[1] / "scripts" +spec = importlib.util.spec_from_file_location("prepare_pages_site", scripts / "prepare_pages_site.py") +pages = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = pages +sys.path.insert(0, str(scripts)) +try: + spec.loader.exec_module(pages) +finally: + sys.path.pop(0) + + +class PagesDownloadRetryTests(unittest.TestCase): + def setUp(self): + self.request = urllib.request.Request("https://example.invalid/asset.bin") + + def http_error(self, status): + return urllib.error.HTTPError( + self.request.full_url, status, "HTTP failure", {}, io.BytesIO(b"error") + ) + + def test_transient_http_failure_then_success(self): + for status in (408, 429, 500, 502, 503, 504): + with self.subTest(status=status): + error = self.http_error(status) + response = object() + with patch.object(pages.urllib.request, "urlopen", side_effect=[error, response]) as opened, \ + patch.object(pages.time, "sleep") as slept: + self.assertIs(response, pages.open_with_retries(self.request, timeout=7)) + self.assertEqual([call(self.request, timeout=7)] * 2, opened.call_args_list) + slept.assert_called_once_with(1) + self.assertTrue(error.fp.closed) + + def test_transient_http_retries_exhausted(self): + errors = [self.http_error(500) for _ in range(3)] + with patch.object(pages.urllib.request, "urlopen", side_effect=errors) as opened, \ + patch.object(pages.time, "sleep") as slept: + with self.assertRaises(urllib.error.HTTPError) as caught: + pages.open_with_retries(self.request) + self.assertIs(errors[-1], caught.exception) + self.assertEqual(3, opened.call_count) + self.assertEqual([call(1), call(2)], slept.call_args_list) + self.assertTrue(all(error.fp.closed for error in errors[:-1])) + errors[-1].close() + + def test_permanent_http_errors_fail_immediately(self): + for status in (400, 401, 403, 404, 501, 505): + with self.subTest(status=status): + error = self.http_error(status) + with patch.object(pages.urllib.request, "urlopen", side_effect=error) as opened, \ + patch.object(pages.time, "sleep") as slept: + with self.assertRaises(urllib.error.HTTPError) as caught: + pages.open_with_retries(self.request) + self.assertIs(error, caught.exception) + opened.assert_called_once_with(self.request, timeout=30) + slept.assert_not_called() + error.close() + + def test_single_attempt_has_no_retry(self): + error = self.http_error(503) + with patch.object(pages.urllib.request, "urlopen", side_effect=error) as opened, \ + patch.object(pages.time, "sleep") as slept: + with self.assertRaises(urllib.error.HTTPError): + pages.open_with_retries(self.request, attempts=1) + self.assertEqual(1, opened.call_count) + slept.assert_not_called() + error.close() + + def test_network_errors_keep_existing_retry_behavior(self): + response = object() + with patch.object(pages.urllib.request, "urlopen", side_effect=[urllib.error.URLError("reset"), response]) as opened, \ + patch.object(pages.time, "sleep") as slept: + self.assertIs(response, pages.open_with_retries(self.request)) + self.assertEqual(2, opened.call_count) + slept.assert_called_once_with(1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/architecture/KNOWN_PRODUCT_ARCHITECTURE_VIOLATIONS.md b/tools/architecture/KNOWN_PRODUCT_ARCHITECTURE_VIOLATIONS.md index 323d37fd..e9262932 100644 --- a/tools/architecture/KNOWN_PRODUCT_ARCHITECTURE_VIOLATIONS.md +++ b/tools/architecture/KNOWN_PRODUCT_ARCHITECTURE_VIOLATIONS.md @@ -6,6 +6,20 @@ without turning the architecture baseline PR into a behavior-changing rewrite. ## Current Known Categories +### Reticulum and Geocaching compatibility composition + +The ESP Geocaching runtime binds its presentation source through +`modules/ui_shared/include/ui/screens/geocaching/geocaching_page_shell.h`. +This binding belongs in the composition root in a later extraction. The +storage-owner worker publishes snapshots and does not mutate LVGL objects. + +The ESP LXMF/Reticulum compatibility adapters recognize the +`trailmate.geocache` destination and sign Geocaching records with the existing +Reticulum identity. This protocol-facing glue should move behind a shared +protocol/identity port when the adapters are thinned. It does not change board +drivers or radio-driver policy. Geocaching and Reticulum Settings also extend +the historical `core_sys` platform/UI contract layout described below. + ### core_sys still contains platform UI contracts `modules/core_sys/include/platform/ui/*` currently exposes platform-facing UI