mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-23 13:33:03 +00:00
Gate alpha release on geocaching checks and retry transient Pages downloads
This commit is contained in:
1 parent
6f73515335
commit
f4ee4e9add
5 files changed
+136
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user