diff --git a/.github/workflows/apply-approved-boundary.yml b/.github/workflows/apply-approved-boundary.yml index 09c10ea..a67e3b0 100644 --- a/.github/workflows/apply-approved-boundary.yml +++ b/.github/workflows/apply-approved-boundary.yml @@ -25,14 +25,14 @@ jobs: steps: - name: Checkout current authority - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 with: ref: main fetch-depth: 0 persist-credentials: false - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: "3.13" @@ -122,7 +122,7 @@ jobs: shell: bash run: | set -euo pipefail - node scripts/validate-regions.js + node scripts/validate-regions.cjs python scripts/verify-region-geometry.py python scripts/verify-region-geometry.py \ --partition docs/assets/regions/canada-region-partition-digital.geojson diff --git a/.github/workflows/deploy-reviewed-site.yml b/.github/workflows/deploy-reviewed-site.yml new file mode 100644 index 0000000..7db3cc5 --- /dev/null +++ b/.github/workflows/deploy-reviewed-site.yml @@ -0,0 +1,57 @@ +name: Deploy reviewed site + +on: + workflow_run: + workflows: ["Validate site quality"] + types: [completed] + branches: [main] + +permissions: + contents: write + +concurrency: + group: meshcore-ca-pages + cancel-in-progress: false + +jobs: + deploy: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout validated revision + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + ref: ${{ github.event.workflow_run.head_sha }} + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-docs.txt + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + + - name: Build the validated revision + env: + MCC_SITE_REVISION: ${{ github.event.workflow_run.head_sha }} + run: | + python -m pip install -r requirements-docs.txt + npm ci + npm run docs:build + node scripts/check-built-links.mjs + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./.tmp/site + cname: meshcore.ca diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml deleted file mode 100644 index 90dfc23..0000000 --- a/.github/workflows/deploy.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Deploy MkDocs site - -on: - push: - branches: [ main ] # change to master if your branch is named master - release: - types: [ published ] # redeploy site when new firmware is released - workflow_dispatch: - -permissions: - contents: write # needed so the action can push to gh-pages - -jobs: - deploy: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.13" - - - name: Install MkDocs + Material - run: | - pip install mkdocs-material - - - name: Install Mkdocs-plugin - run: | - pip install mkdocs-macros-plugin pyyaml mkdocs-glightbox pymdown-extensions shapely==2.0.6 mkdocs-redirects==1.2.2 - - - name: Install proposal gateway dependencies - run: python -m pip install -r tools/region-proposal-gateway/requirements.txt - - - name: Validate MeshCore Canada proposal service - shell: bash - run: | - python -m unittest discover -s tools/region-proposal-gateway/tests -v - node --test tests/editor/*.test.mjs - node --check docs/config/editor/app.js - node --check docs/config/editor/issue.js - node --check docs/javascripts/community-submission.js - node --check docs/javascripts/submission-form.js - if git grep -n -E 'canadaverse\.org|regions-api\.meshcore\.ca|/api/meshcore-regions/proposals|192\.168\.0\.111|/home/neonx|splashpage' -- \ - docs/config/editor docs/javascripts docs/submit-idea.md \ - tools/region-proposal-gateway instructions.md; then - echo 'Production submission files must use only the approved MeshCore Canada endpoint.' >&2 - exit 1 - fi - - - name: Validate exclusive national partition - run: node scripts/validate-regions.js - - - name: Verify generated geometry - run: | - python scripts/verify-region-geometry.py - python scripts/verify-region-geometry.py --partition docs/assets/regions/canada-region-partition-digital.geojson - - - name: Validate community submission helper - run: python scripts/validate_community_submission.py - - - name: Build MkDocs site - run: mkdocs build --strict - - - name: Deploy to GitHub Pages - uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./site - cname: meshcore.ca diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..c838694 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,156 @@ +name: Validate site quality + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: site-quality-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 35 + + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: | + requirements-docs.txt + scripts/requirements-regions.txt + tools/region-proposal-gateway/requirements.txt + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + + - name: Install locked dependencies + run: | + python -m pip install -r requirements-docs.txt + python -m pip install -r scripts/requirements-regions.txt + python -m pip install -r tools/region-proposal-gateway/requirements.txt + npm ci + + - name: Enforce source and production boundaries + shell: bash + run: | + set -euo pipefail + test -z "$(git ls-files site)" + if git grep -n -E 'canadaverse\.org|regions-api\.meshcore\.ca|/api/meshcore-regions/proposals|192\.168\.0\.(24|111)|/home/neonx|splashpage' -- \ + docs/config/editor docs/javascripts docs/submit-idea.md \ + tools/region-proposal-gateway instructions.md; then + echo 'Production submission files contain staging infrastructure.' >&2 + exit 1 + fi + + - name: Validate content and generated directories + run: | + python scripts/validate-content.py + python scripts/validate-communities.py + python scripts/validate_community_submission.py + python -m unittest discover -s tests/content -p "test_*.py" -v + npm run test:content + + - name: Validate region authority and proposal automation + run: | + node scripts/validate-regions.cjs + python scripts/verify-region-geometry.py + python scripts/verify-region-geometry.py --partition docs/assets/regions/canada-region-partition-digital.geojson + python -m unittest discover -s tools/region-proposal-gateway/tests -v + python -m unittest discover -s tests/automation -v + npm run test:editor + + - name: Check browser JavaScript syntax + shell: bash + run: | + set -euo pipefail + for file in docs/config/editor/*.js docs/assets/javascripts/*.js docs/assets/regions/modules/*.js docs/assets/regions/regions.js; do + node --check "$file" + done + + - name: Build and audit local links + run: npm run check:links + + - name: Check patch hygiene + run: git diff --check + + browser: + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 35 + + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-docs.txt + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + + - name: Install site and browser dependencies + run: | + python -m pip install -r requirements-docs.txt + npm ci + npx playwright install --with-deps chromium firefox webkit + + - name: Build site + run: npm run docs:build + + - name: Run critical journeys and accessibility checks + run: npm run test:browser + + lighthouse: + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 25 + + steps: + - name: Checkout repository + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: requirements-docs.txt + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: "22" + cache: npm + + - name: Install locked dependencies + run: | + python -m pip install -r requirements-docs.txt + npm ci + + - name: Enforce Lighthouse budgets + shell: bash + run: | + export CHROME_PATH="$(command -v google-chrome || command -v google-chrome-stable)" + npm run audit:lighthouse diff --git a/.github/workflows/validate-site.yml b/.github/workflows/validate-site.yml deleted file mode 100644 index 61bb7b8..0000000 --- a/.github/workflows/validate-site.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Validate MkDocs site - -on: - pull_request: - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.x" - - - name: Install site dependencies - run: pip install mkdocs-material mkdocs-macros-plugin pyyaml mkdocs-glightbox pymdown-extensions mkdocs-redirects==1.2.2 - - - name: Validate community submission helper - run: python scripts/validate_community_submission.py - - - name: Build site - run: mkdocs build --strict diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml deleted file mode 100644 index 25cb78c..0000000 --- a/.github/workflows/validate.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Validate documentation - -on: - pull_request: - workflow_dispatch: - -permissions: - contents: read - -jobs: - validate: - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - - name: Set up Python - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 - with: - python-version: "3.13" - - - name: Validate anonymous proposal gateway - run: | - python -m pip install -r tools/region-proposal-gateway/requirements.txt - python -m pip install -r scripts/requirements-regions.txt - python -m unittest discover -s tools/region-proposal-gateway/tests -v - python -m unittest discover -s tests/automation -v - - - name: Enforce MeshCore Canada production ownership - shell: bash - run: | - if git grep -n -E 'canadaverse\.org|regions-api\.meshcore\.ca|/api/meshcore-regions/proposals|192\.168\.0\.111|/home/neonx|splashpage' -- \ - docs/config/editor docs/javascripts docs/submit-idea.md \ - tools/region-proposal-gateway instructions.md; then - echo 'Production submission files must use only the approved MeshCore Canada endpoint.' >&2 - exit 1 - fi - - - name: Install documentation dependencies - run: pip install mkdocs-material mkdocs-macros-plugin pyyaml mkdocs-glightbox pymdown-extensions shapely==2.0.6 mkdocs-redirects==1.2.2 - - - name: Validate exclusive national partition - run: node scripts/validate-regions.js - - - name: Verify generated geometry - run: | - python scripts/verify-region-geometry.py - python scripts/verify-region-geometry.py --partition docs/assets/regions/canada-region-partition-digital.geojson - - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version: "22" - - - name: Validate region editor - run: | - node --test tests/editor/*.test.mjs - node --check docs/config/editor/app.js - node --check docs/config/editor/issue.js - node --check docs/javascripts/community-submission.js - node --check docs/javascripts/submission-form.js - - - name: Validate community submission helper - run: python scripts/validate_community_submission.py - - - name: Build documentation - run: mkdocs build --strict diff --git a/.gitignore b/.gitignore index 4c3e0f5..49c115f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ *.swp __pycache__/ *.pyc +site/ docs/assets/regions/*.raw.geojson +node_modules/ +.tmp/ +playwright-report/ +test-results/ +lighthouse-report/ diff --git a/data/communities.fr.json b/data/communities.fr.json new file mode 100644 index 0000000..8caadd1 --- /dev/null +++ b/data/communities.fr.json @@ -0,0 +1,121 @@ +{ + "schema": "meshcore-canada-communities-fr/v1", + "locale": "fr-CA", + "directory_pages": { + "british-columbia": { + "title": "Colombie-Britannique", + "location_phrase": "en Colombie-Britannique" + }, + "alberta": { + "title": "Alberta", + "location_phrase": "en Alberta" + }, + "saskatchewan": { + "title": "Saskatchewan", + "location_phrase": "en Saskatchewan" + }, + "manitoba": { + "title": "Manitoba", + "location_phrase": "au Manitoba" + }, + "ontario": { + "title": "Ontario", + "location_phrase": "en Ontario" + }, + "quebec": { + "title": "Québec", + "location_phrase": "au Québec" + }, + "new-brunswick": { + "title": "Nouveau-Brunswick", + "location_phrase": "au Nouveau-Brunswick" + }, + "nova-scotia": { + "title": "Nouvelle-Écosse", + "location_phrase": "en Nouvelle-Écosse" + }, + "prince-edward-island": { + "title": "Île-du-Prince-Édouard", + "location_phrase": "à l’Île-du-Prince-Édouard" + }, + "newfoundland-and-labrador": { + "title": "Terre-Neuve-et-Labrador", + "location_phrase": "à Terre-Neuve-et-Labrador" + }, + "territories": { + "title": "Territoires", + "location_phrase": "dans les territoires" + } + }, + "communities": { + "salish-mesh": { + "service_area": "Mer des Salish et environs" + }, + "alberta-meshcore-networks": { + "service_area": "Alberta", + "summary": "Développement du réseau maillé LoRa hors réseau de l’Alberta, exploité par la communauté" + }, + "airdrie-meshcore-network": { + "service_area": "Airdrie et Calgary", + "summary": "Utilise l’identifiant régional YYC partagé avec le réseau régional de Calgary" + }, + "calgary-area-meshcore": { + "service_area": "Calgary et environs" + }, + "calgary-meshcore-network": { + "service_area": "Calgary", + "summary": "Première région lancée sur AlbertaMesh.ca" + }, + "edmonton-meshcore-network": { + "service_area": "Edmonton", + "summary": "Communauté régionale d’Edmonton au sein d’Alberta MeshCore" + }, + "yegmesh-ca": { + "service_area": "Edmonton", + "summary": "Communauté du réseau maillé d’Edmonton axée sur MeshCore" + }, + "yqlmesh": { + "service_area": "Lethbridge", + "summary": "Relier Lethbridge, un nœud à la fois" + }, + "southern-alberta": { + "service_area": "Sud de l’Alberta, y compris Cardston, Magrath, Raymond et les environs" + }, + "yyc-meshcore-discord": { + "service_area": "Calgary" + }, + "stoonmesh": { + "service_area": "Centre de la Saskatchewan" + }, + "greater-ottawa-mesh-enthusiasts": { + "service_area": "Est de l’Ontario et ouest du Québec" + }, + "gta-lora-meshes": { + "service_area": "Sud de l’Ontario" + }, + "quinte-mesh-network": { + "service_area": "Région de Quinte, y compris Belleville, Trenton, le comté de Prince Edward et les environs" + }, + "mesh-quebec": { + "service_area": "Québec" + }, + "montreal-mesh": { + "service_area": "Grand Montréal" + }, + "reseau-mesh-capitale-yqb": { + "service_area": "Ville de Québec" + }, + "reseau-mesh-saguenay-lac-saint-jean-ytf": { + "service_area": "Saguenay–Lac-Saint-Jean (YTF)" + }, + "reseau-libre": { + "service_area": "Montréal" + }, + "southern-new-brunswick": { + "service_area": "Sud du Nouveau-Brunswick, y compris Fredericton, Saint John, Moncton et les environs" + }, + "lunenburg-county-mesh": { + "service_area": "Comté de Lunenburg" + } + } +} diff --git a/data/communities.json b/data/communities.json new file mode 100644 index 0000000..4043040 --- /dev/null +++ b/data/communities.json @@ -0,0 +1,1189 @@ +{ + "$schema": "../schemas/community-directory.schema.json", + "schema": "meshcore-canada-communities/v1", + "metadata": { + "owner": "directory-stewards", + "migrated_at": "2026-07-19", + "source_revision": "a47dfbc", + "review_by": "2027-01-15", + "update_route": "../submit-idea.md" + }, + "national_defaults": { + "radio_preset": "USA/Canada (Recommended)", + "raw_radio": { + "frequency_mhz": 910.525, + "bandwidth_khz": 62.5, + "spreading_factor": 7, + "coding_rate": 5 + }, + "path_hash_mode": "3-byte", + "cli_path_setting": "set path.hash.mode 2" + }, + "directory_pages": [ + { + "slug": "british-columbia", + "title": "British Columbia", + "codes": [ + "BC" + ], + "aliases": [ + "BC" + ] + }, + { + "slug": "alberta", + "title": "Alberta", + "codes": [ + "AB" + ], + "aliases": [ + "AB" + ] + }, + { + "slug": "saskatchewan", + "title": "Saskatchewan", + "codes": [ + "SK" + ], + "aliases": [ + "SK" + ] + }, + { + "slug": "manitoba", + "title": "Manitoba", + "codes": [ + "MB" + ], + "aliases": [ + "MB" + ] + }, + { + "slug": "ontario", + "title": "Ontario", + "codes": [ + "ON" + ], + "aliases": [ + "ON" + ] + }, + { + "slug": "quebec", + "title": "Quebec", + "codes": [ + "QC" + ], + "aliases": [ + "Québec", + "QC" + ] + }, + { + "slug": "new-brunswick", + "title": "New Brunswick", + "codes": [ + "NB" + ], + "aliases": [ + "NB" + ] + }, + { + "slug": "nova-scotia", + "title": "Nova Scotia", + "codes": [ + "NS" + ], + "aliases": [ + "NS" + ] + }, + { + "slug": "prince-edward-island", + "title": "Prince Edward Island", + "codes": [ + "PE" + ], + "aliases": [ + "PEI", + "PE" + ] + }, + { + "slug": "newfoundland-and-labrador", + "title": "Newfoundland and Labrador", + "codes": [ + "NL" + ], + "aliases": [ + "Newfoundland", + "Labrador", + "NL" + ] + }, + { + "slug": "territories", + "title": "the territories", + "codes": [ + "YT", + "NT", + "NU" + ], + "aliases": [ + "Yukon", + "Northwest Territories", + "Nunavut", + "YT", + "NT", + "NU" + ] + } + ], + "province_contacts": [ + { + "id": "alberta-meshcore-canada-telegram", + "province": "AB", + "type": "telegram", + "label": "Alberta topic in MeshCore Canada", + "url": "https://t.me/MeshCoreCAN", + "health": "needs-review", + "last_checked": null + } + ], + "communities": [ + { + "id": "salish-mesh", + "name": "Salish Mesh", + "province": "BC", + "service_area": "Salish Sea and surrounding area", + "places": [ + "Salish Sea" + ], + "aliases": [ + "Salish Mesh" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Salish Mesh website", + "url": "https://salishmesh.net/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/british-columbia/#community-salish-mesh" + }, + { + "id": "alberta-meshcore-networks", + "name": "Alberta MeshCore Networks", + "province": "AB", + "service_area": "Alberta", + "summary": "Building Alberta's community-operated off-grid LoRa mesh network", + "places": [ + "Airdrie", + "Calgary", + "Edmonton", + "Lethbridge" + ], + "aliases": [ + "AlbertaMesh", + "AlbertaMesh.ca", + "Alberta MeshCore" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "AlbertaMesh.ca", + "url": "https://albertamesh.ca/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "Alberta MeshCore Discord", + "url": "https://discord.gg/CznDhsRWnJ", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Airdrie regional page", + "url": "https://albertamesh.ca/airdrie/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Calgary regional page", + "url": "https://albertamesh.ca/calgary/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Edmonton regional page", + "url": "https://albertamesh.ca/edmonton/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Lethbridge regional page", + "url": "https://albertamesh.ca/lethbridge/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Why MeshCore?", + "url": "https://albertamesh.ca/why-meshcore/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Monitoring tools", + "url": "https://albertamesh.ca/monitoring-tools/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-alberta-meshcore-networks" + }, + { + "id": "airdrie-meshcore-network", + "name": "Airdrie MeshCore Network", + "province": "AB", + "service_area": "Airdrie and Calgary", + "summary": "Uses the YYC regional identifier shared with the Calgary regional network", + "places": [ + "Airdrie", + "Calgary" + ], + "aliases": [ + "AlbertaMesh Airdrie", + "YYC" + ], + "status": "active", + "languages": [], + "location": { + "latitude": 51.2812, + "longitude": -113.99718, + "precision": "approximate" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Airdrie MeshCore Network", + "url": "https://albertamesh.ca/airdrie/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Airdrie configuration guide", + "url": "https://albertamesh.ca/airdrie/configuration/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "Alberta MeshCore Discord", + "url": "https://discord.gg/CznDhsRWnJ", + "health": "needs-review", + "last_checked": null + }, + { + "type": "meshmapper", + "label": "Airdrie network map", + "url": "https://yyc.meshmapper.net/?lat=51.28120&lon=-113.99718&zoom=13.43", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "WAeV live map", + "url": "https://waev.app/#/live-map/@51.28107,-113.99966,14.47z", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-airdrie-meshcore-network" + }, + { + "id": "calgary-area-meshcore", + "name": "Calgary and Area MeshCore", + "province": "AB", + "service_area": "Calgary and area", + "places": [ + "Calgary" + ], + "aliases": [ + "Calgary and area", + "YYC" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "telegram", + "label": "Calgary topic in Mesh Alberta", + "url": "https://t.me/meshtAlta", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "Canada - Calgary, Alberta & Area regional channel in the MeshCore Discord", + "url": null, + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-calgary-area-meshcore" + }, + { + "id": "calgary-meshcore-network", + "name": "Calgary MeshCore Network", + "province": "AB", + "service_area": "Calgary", + "summary": "Initial AlbertaMesh.ca launch region", + "places": [ + "Calgary" + ], + "aliases": [ + "AlbertaMesh Calgary", + "YYC", + "meshcorecalgary.ca" + ], + "status": "active", + "languages": [], + "location": { + "latitude": 51.01674, + "longitude": -114.00149, + "precision": "approximate" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Calgary MeshCore Network", + "url": "https://meshcorecalgary.ca/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Calgary community guide", + "url": "https://albertamesh.ca/calgary/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "Alberta MeshCore Discord", + "url": "https://discord.gg/CznDhsRWnJ", + "health": "needs-review", + "last_checked": null + }, + { + "type": "meshmapper", + "label": "Calgary network map", + "url": "https://yyc.meshmapper.net/?lat=51.01674&lon=-114.00149&zoom=11.00", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "CoreScope YYC", + "url": "https://corescopeyyc.meshmonitoring.com/#/live", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Recommended Calgary RX channels", + "url": "https://albertamesh.ca/calgary/#rx-channels", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-calgary-meshcore-network" + }, + { + "id": "edmonton-meshcore-network", + "name": "Edmonton MeshCore Network", + "province": "AB", + "service_area": "Edmonton", + "summary": "Edmonton regional community within Alberta MeshCore", + "places": [ + "Edmonton" + ], + "aliases": [ + "AlbertaMesh Edmonton", + "YEG" + ], + "status": "active", + "languages": [], + "location": { + "latitude": 53.45752, + "longitude": -113.5832, + "precision": "approximate" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Edmonton on AlbertaMesh.ca", + "url": "https://albertamesh.ca/edmonton/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Edmonton configuration guide", + "url": "https://albertamesh.ca/edmonton/configuration/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "Alberta MeshCore Discord", + "url": "https://discord.gg/CznDhsRWnJ", + "health": "needs-review", + "last_checked": null + }, + { + "type": "meshmapper", + "label": "Edmonton network map", + "url": "https://yeg.meshmapper.net/?lat=53.45752&lon=-113.58320&zoom=10.03", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-edmonton-meshcore-network" + }, + { + "id": "yegmesh-ca", + "name": "yegmesh.ca", + "province": "AB", + "service_area": "Edmonton", + "summary": "Edmonton mesh network community focused on MeshCore", + "places": [ + "Edmonton" + ], + "aliases": [ + "YEG", + "YEG Mesh", + "yegmesh" + ], + "status": "active", + "languages": [], + "location": { + "latitude": 53.45752, + "longitude": -113.5832, + "precision": "approximate" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Getting started", + "url": "https://yegmesh.ca/p/getting-started", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "MeshCore defaults", + "url": "https://yegmesh.ca/p/meshcore-defaults", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "Alberta MeshCore Discord", + "url": "https://discord.gg/CznDhsRWnJ", + "health": "needs-review", + "last_checked": null + }, + { + "type": "meshmapper", + "label": "Edmonton network map", + "url": "https://yeg.meshmapper.net/?lat=53.45752&lon=-113.58320&zoom=10.03", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-yegmesh-ca" + }, + { + "id": "yqlmesh", + "name": "YQLMesh", + "province": "AB", + "service_area": "Lethbridge", + "summary": "Connecting Lethbridge, one node at a time", + "places": [ + "Lethbridge" + ], + "aliases": [ + "YQL" + ], + "status": "active", + "languages": [], + "location": { + "latitude": 49.69091, + "longitude": -112.86356, + "precision": "approximate" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "YQLMesh website", + "url": "https://www.yqlmesh.com/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Lethbridge regional page", + "url": "https://albertamesh.ca/lethbridge/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "discord", + "label": "YQLMesh Discord", + "url": "https://discord.gg/cFY9GSR37W", + "health": "needs-review", + "last_checked": null + }, + { + "type": "meshmapper", + "label": "Lethbridge network map", + "url": "https://yql.meshmapper.net/?lat=49.69091&lon=-112.86356&zoom=12.14", + "health": "needs-review", + "last_checked": null + }, + { + "type": "facebook", + "label": "YQLMesh Facebook", + "url": "https://facebook.com/groups/YQLMesh", + "health": "needs-review", + "last_checked": null + }, + { + "type": "instagram", + "label": "YQLMesh Instagram", + "url": "https://instagram.com/YQLMesh", + "health": "needs-review", + "last_checked": null + }, + { + "type": "x", + "label": "YQLMesh on X", + "url": "https://x.com/YQLMesh", + "health": "needs-review", + "last_checked": null + }, + { + "type": "reddit", + "label": "YQLMesh subreddit", + "url": "https://www.reddit.com/r/YQLMesh", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-yqlmesh" + }, + { + "id": "southern-alberta", + "name": "Southern Alberta", + "province": "AB", + "service_area": "Southern Alberta, including Cardston, Magrath, Raymond, and nearby areas", + "places": [ + "Cardston", + "Magrath", + "Raymond" + ], + "aliases": [ + "Southern Alberta" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "Canada - Southern Alberta regional channel in the MeshCore Discord", + "url": null, + "health": "needs-review", + "last_checked": null + }, + { + "type": "telegram", + "label": "Cardston topic in Mesh Alberta", + "url": "https://t.me/meshtAlta", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-southern-alberta" + }, + { + "id": "yyc-meshcore-discord", + "name": "YYC MeshCore Discord Group", + "province": "AB", + "service_area": "Calgary", + "places": [ + "Calgary" + ], + "aliases": [ + "YYC" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "YYC MeshCore Discord", + "url": "https://discord.gg/CznDhsRWnJ", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "MeshMonitoring", + "url": "https://meshmonitoring.com/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/alberta/#community-yyc-meshcore-discord" + }, + { + "id": "stoonmesh", + "name": "StoonMesh", + "province": "SK", + "service_area": "Central Saskatchewan", + "places": [], + "aliases": [ + "Central Saskatchewan" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "StoonMesh Discord", + "url": "https://discord.gg/7yGnJuMGkG", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/saskatchewan/#community-stoonmesh" + }, + { + "id": "greater-ottawa-mesh-enthusiasts", + "name": "Greater Ottawa Mesh Enthusiasts", + "province": "ON", + "service_area": "Eastern Ontario and Western Quebec", + "places": [ + "Ottawa" + ], + "aliases": [ + "Eastern Ontario", + "Western Quebec" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "Greater Ottawa Mesh Enthusiasts Discord", + "url": "https://discord.gg/WSyNd8SfNr", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Ottawa Mesh website", + "url": "https://ottawamesh.ca/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/ontario/#community-greater-ottawa-mesh-enthusiasts" + }, + { + "id": "gta-lora-meshes", + "name": "GTA+-Lora-Meshes", + "province": "ON", + "service_area": "Southern Ontario", + "places": [], + "aliases": [ + "GTA", + "Southern Ontario" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "GTA+-Lora-Meshes Discord", + "url": "https://discord.gg/wSHbeb86r4", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/ontario/#community-gta-lora-meshes" + }, + { + "id": "quinte-mesh-network", + "name": "Quinte Mesh Network", + "province": "ON", + "service_area": "Quinte Region, including Belleville, Trenton, Prince Edward County, and nearby areas", + "places": [ + "Belleville", + "Trenton", + "Prince Edward County" + ], + "aliases": [ + "Quinte Region" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "Quinte Mesh Network Discord", + "url": "https://discord.gg/V5esJEP67X", + "health": "needs-review", + "last_checked": null + }, + { + "type": "website", + "label": "Quinte Mesh Network website", + "url": "https://quintemesh.ca/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/ontario/#community-quinte-mesh-network" + }, + { + "id": "mesh-quebec", + "name": "Mesh Quebec", + "province": "QC", + "service_area": "Quebec", + "places": [], + "aliases": [ + "Québec" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Mesh Quebec website", + "url": "https://qcmesh.net", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/quebec/#community-mesh-quebec" + }, + { + "id": "montreal-mesh", + "name": "Montreal Mesh", + "province": "QC", + "service_area": "Greater Montreal", + "places": [ + "Montreal" + ], + "aliases": [ + "Greater Montreal", + "Montréal" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Montreal Mesh website", + "url": "https://www.montrealmesh.ca", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/quebec/#community-montreal-mesh" + }, + { + "id": "reseau-mesh-capitale-yqb", + "name": "Réseau Mesh de la Capitale YQB", + "province": "QC", + "service_area": "Quebec City", + "places": [ + "Quebec City" + ], + "aliases": [ + "YQB", + "Capitale" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "Réseau Mesh de la Capitale YQB Discord", + "url": "https://discord.gg/UhGjTF2MfA", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/quebec/#community-reseau-mesh-capitale-yqb" + }, + { + "id": "reseau-mesh-saguenay-lac-saint-jean-ytf", + "name": "Réseau Mesh du Saguenay Lac st-Jean YTF", + "province": "QC", + "service_area": "Saguenay–Lac-Saint-Jean (YTF)", + "places": [ + "Saguenay", + "Lac-Saint-Jean" + ], + "aliases": [ + "YTF", + "Saguenay Lac st-Jean" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "discord", + "label": "Réseau Mesh du Saguenay Lac st-Jean YTF Discord", + "url": "https://discord.gg/wUR394yXt", + "health": "needs-review", + "last_checked": null + }, + { + "type": "facebook", + "label": "Réseau Mesh du Saguenay Lac st-Jean YTF Facebook", + "url": "https://www.facebook.com/share/g/1GjkHAyZAM/", + "health": "needs-review", + "last_checked": null + }, + { + "type": "meshmapper", + "label": "Réseau Mesh du Saguenay Lac st-Jean YTF MeshMapper", + "url": "https://ytf.meshmapper.net/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/quebec/#community-reseau-mesh-saguenay-lac-saint-jean-ytf" + }, + { + "id": "reseau-libre", + "name": "Réseau Libre", + "province": "QC", + "service_area": "Montreal", + "places": [ + "Montreal" + ], + "aliases": [ + "Montréal" + ], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Réseau Libre website", + "url": "https://lora.reseaulibre.ca/", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/quebec/#community-reseau-libre" + }, + { + "id": "southern-new-brunswick", + "name": "Southern New Brunswick", + "province": "NB", + "service_area": "Fredericton, Saint John, Moncton, and nearby areas", + "places": [ + "Fredericton", + "Saint John", + "Moncton" + ], + "aliases": [ + "Southern New Brunswick" + ], + "status": "forming", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "facebook", + "label": "MESHCORE Saint John, N.B.", + "url": "https://www.facebook.com/groups/613466831163684", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/new-brunswick/#community-southern-new-brunswick" + }, + { + "id": "lunenburg-county-mesh", + "name": "Lunenburg County Mesh", + "province": "NS", + "service_area": "Lunenburg County", + "places": [ + "Lunenburg County" + ], + "aliases": [], + "status": "active", + "languages": [], + "location": { + "latitude": null, + "longitude": null, + "precision": "service-area" + }, + "settings": { + "inherit_national": true, + "overrides": {} + }, + "contacts": [ + { + "type": "website", + "label": "Lunenburg County Mesh website", + "url": "http://www.lunenburgcountymesh.ca", + "health": "needs-review", + "last_checked": null + } + ], + "owner": null, + "verified_at": null, + "verify_by": null, + "canonical_route": "/provinces/nova-scotia/#community-lunenburg-county-mesh" + } + ] +} diff --git a/docs/about.fr.md b/docs/about.fr.md new file mode 100644 index 0000000..8982b69 --- /dev/null +++ b/docs/about.fr.md @@ -0,0 +1,49 @@ +--- +title: À propos de MeshCore Canada +description: Découvrez ce que MeshCore Canada maintient et comment participer. +audience: + - site-visitor + - meshcore-user + - contributor +task: understand-project +scope: canada-baseline +status: verified +owner: site-maintainers +last_reviewed: 2026-07-22 +review_by: 2026-10-22 +evidence: Project scope and contribution routes reviewed on 2026-07-22 +difficulty: beginner +estimated_time: 3 minutes +destructive: false +--- + +# À propos de MeshCore Canada + +MeshCore Canada est un projet communautaire destiné aux personnes qui utilisent +MeshCore au Canada. Nous maintenons des guides de configuration, un répertoire +des communautés, les données des régions canadiennes et des liens vers les +outils partagés. + +MeshCore Canada est indépendant du projet MeshCore officiel. Pour en savoir +plus sur le micrologiciel, les applications et le protocole, consultez la +[documentation officielle de MeshCore](https://docs.meshcore.io/){ target="_blank" rel="noopener" }. + +## Ce que nous maintenons + +- des guides de configuration, de matériel et de dépannage; +- le répertoire des communautés canadiennes et la carte des régions; +- des outils pour les répéteurs et les observateurs; +- le [code source du site Web](https://github.com/MeshCore-ca/MeshCore-Canada){ target="_blank" rel="noopener" }. + +## Participer + +- [Trouvez une communauté canadienne](provinces/index.md) pour connaître ses + paramètres et ses coordonnées. +- [Obtenez de l’aide](start/get-help.md) si une configuration ou une + vérification du réseau échoue. +- [Partagez une idée](submit-idea.md) ou signalez un problème. +- [Contribuez sur GitHub](contributing.md). + +Ne publiez jamais de clés privées, de mots de passe, d’emplacements privés +précis ni d’autres renseignements sensibles dans un formulaire, un billet +GitHub, un forum ou une conversation. diff --git a/docs/about.md b/docs/about.md new file mode 100644 index 0000000..9feb630 --- /dev/null +++ b/docs/about.md @@ -0,0 +1,45 @@ +--- +title: About MeshCore Canada +description: Learn what MeshCore Canada maintains and how to take part. +audience: + - site-visitor + - meshcore-user + - contributor +task: understand-project +scope: canada-baseline +status: verified +owner: site-maintainers +last_reviewed: 2026-07-22 +review_by: 2026-10-22 +evidence: Project scope and contribution routes reviewed on 2026-07-22 +difficulty: beginner +estimated_time: 3 minutes +destructive: false +--- + +# About MeshCore Canada + +MeshCore Canada is a community-run project for people using MeshCore in Canada. +We maintain setup guides, community listings, Canadian region data, and links +to shared tools. + +MeshCore Canada is independent from the official MeshCore project. For +firmware, app, and protocol information, use the +[official MeshCore documentation](https://docs.meshcore.io/){ target="_blank" rel="noopener" }. + +## What the project maintains + +- setup, hardware, and troubleshooting guides; +- the Canadian community directory and region map; +- repeater and observer tools; and +- the [open-source website](https://github.com/MeshCore-ca/MeshCore-Canada){ target="_blank" rel="noopener" }. + +## Get involved + +- [Find a Canadian community](provinces/index.md) for local settings and contacts. +- [Get help](start/get-help.md) when setup or a network check fails. +- [Share an idea](submit-idea.md) or report a problem. +- [Contribute on GitHub](contributing.md). + +Do not publish private keys, passwords, precise private locations, or other +sensitive information in an issue, form, forum post, or chat. diff --git a/docs/analyzer/broker-reference.fr.md b/docs/analyzer/broker-reference.fr.md new file mode 100644 index 0000000..d973755 --- /dev/null +++ b/docs/analyzer/broker-reference.fr.md @@ -0,0 +1,92 @@ +--- +title: Référence des points de terminaison pour observateurs +description: Consultez les paramètres de courtier, de sécurité, de sujet et de paquets utilisés par les observateurs de MeshCore Canada. +audience: + - observer-operators + - service-operators +task: reference-observer-endpoints +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 8 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-broker-reference.js?v=20260722-2 +--- + +# Référence des points de terminaison pour observateurs + +Utilisez cette référence après avoir [choisi une méthode d’observation](intro.md). +Suivez le guide de cette méthode plutôt que de copier séparément les valeurs +présentées ici. + +## Paramètres du courtier + +Ces valeurs proviennent de la [configuration commune des observateurs](observer-config.json). + +
+
+ + + + + + + + + + + + +
UtilisationHôtePortTransportTLSAudience du jeton
+
+

Chargement des valeurs officielles des points de terminaison…

+
+ +Si le tableau ne s’affiche pas, ouvrez [observer-config.json](observer-config.json). + +## Modèles de sujets + +```text +meshcore/{IATA}/{PUBLIC_KEY}/packets +meshcore/{IATA}/{PUBLIC_KEY}/status +``` + +`{IATA}` est le véritable code d’emplacement à trois lettres de l’observateur. +`{PUBLIC_KEY}` est fourni par la radio ou l’intégration. Ne le remplacez jamais +par une clé privée. + +## Authentification et transport + +- Utilisez WebSockets sur le port `443`. +- Exigez TLS et validez les certificats. +- Utilisez l’option de jeton JWT de MeshCore lorsqu’elle est offerte. +- Faites correspondre l’audience de chaque jeton à l’hôte de son point de terminaison. +- Ne mettez jamais un jeton ou un mot de passe dans une URL, une capture d’écran, + un billet ou un ensemble de diagnostics. + +## Mode de paquets selon la méthode + +| Méthode | Paramètre de paquets requis | +|---|---| +| Micrologiciel MQTT | `mqtt.packets on`, `bridge.enabled on`, et `mqtt.rx on` | +| MCtoMQTT / capture d’un compagnon | Configurez le sujet `/packets` | +| PyMC | `format: letsmesh` | +| Home Assistant | **Payload Mode** = `packet` | +| RemoteTerm | Activez le sujet de paquets Community MQTT | + +## Ce que chaque vérification confirme + +| État | Ce qu’il confirme | +|---|---| +| DNS ou port accessible | L’hôte peut joindre le point de terminaison | +| Connexion au courtier | Le transport et l’authentification fonctionnent | +| Observateur visible | Son état s’est rendu au service en direct | +| Paquet récent visible | Le parcours complet de la radio jusqu’à l’affichage fonctionne | + +Seul un paquet récent permet de terminer [la vérification de l’observateur](verify.md). diff --git a/docs/analyzer/broker-reference.md b/docs/analyzer/broker-reference.md index 4278fb5..9ed64c0 100644 --- a/docs/analyzer/broker-reference.md +++ b/docs/analyzer/broker-reference.md @@ -1,50 +1,87 @@ -# Broker Reference +--- +title: Observer endpoint reference +description: Find the broker, security, topic, and packet settings used by MeshCore Canada observers. +audience: + - observer-operators + - service-operators +task: reference-observer-endpoints +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 8 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-broker-reference.js?v=20260722-2 +--- -All MeshCore.ca observer paths publish to the same redundant broker pair. +# Observer endpoint reference -| Broker | Host | Port | Transport | TLS | Token audience | -|--------|------|------|-----------|-----|----------------| -| Primary | `mqtt1.meshcore.ca` | `443` | WebSockets | Enabled + verified | `mqtt1.meshcore.ca` | -| Backup | `mqtt2.meshcore.ca` | `443` | WebSockets | Enabled + verified | `mqtt2.meshcore.ca` | +Use this reference after you have [chosen an observer setup](intro.md). Follow that setup's guide instead of copying fields from here on their own. -Use JWT token authentication where the setup tool exposes it. Do not set a static MQTT password unless a path-specific guide tells you to. +## Broker settings -## Topic Pattern +These values come from the shared [observer configuration](observer-config.json). -Most observer paths publish packet telemetry under this pattern: +
+
+ + + + + + + + + + + + +
UseHostPortTransportTLSToken audience
+
+

Loading canonical endpoint values…

+
-```text -meshcore/{IATA}/{PUBLIC_KEY}/packets -``` +If the table does not load, open [observer-config.json](observer-config.json). -Status topics generally use: +## Topic templates ```text +meshcore/{IATA}/{PUBLIC_KEY}/packets meshcore/{IATA}/{PUBLIC_KEY}/status ``` -`{IATA}` must be a real 3-letter airport code nearest to the observer. `{PUBLIC_KEY}` is supplied by the MeshCore node or integration. +`{IATA}` is the observer's real three-letter location code. `{PUBLIC_KEY}` is supplied by the radio or integration. Never substitute a private key. + +## Authentication and transport -## Payload Mode +- Use WebSockets on port `443`. +- Require TLS and verify certificates. +- Use the MeshCore JWT token option where available. +- Match each token audience to its endpoint host. +- Do not put a token or password into a URL, screenshot, issue, or diagnostic bundle. -MeshCore.ca packet visibility needs packet payload publishing, not only status publishing. +## Packet mode by method -| Path | Required packet setting | -|------|-------------------------| -| MQTT firmware | `set mqtt.packets on`, `set bridge.enabled on`, `set mqtt.rx on`, `set mqtt.tx advert` | -| MCtoMQTT | Broker config created by the MeshCore.ca helper | -| Companion capture | `PACKETCAPTURE_MQTT*_TOPIC_PACKETS` configured | +| Method | Required packet setting | +|---|---| +| MQTT firmware | `mqtt.packets on`, `bridge.enabled on`, and `mqtt.rx on` | +| MCtoMQTT / companion capture | Configure the `/packets` topic | | PyMC | `format: letsmesh` | -| Home Assistant | **Payload Mode** = `packet`, or older **Packets (Lets Mesh)** enabled | -| RemoteTerm | Community MQTT packet topic template enabled | +| Home Assistant | **Payload Mode** = `packet` | +| RemoteTerm | Enable the Community MQTT packet topic | -## Common Broker Mistakes +## What each check tells you -| Symptom | Likely cause | -|---------|--------------| -| Primary connects, backup fails | Backup token audience still set to `mqtt1.meshcore.ca` | -| Observer appears under the wrong city | IATA field is wrong or inconsistent between broker entries | -| Broker connects but no packets appear | Status is publishing, but packet payload mode is not enabled | -| Nothing appears | Radio is off the local mesh, IATA code is invalid, or outbound WSS is blocked | +| State | What it proves | +|---|---| +| DNS or port reachable | The host can reach the endpoint | +| Broker connected | Transport and authentication succeeded | +| Observer visible | Status reached the live service | +| Recent packet visible | The radio-to-viewer path works end to end | -For path-specific commands, use [Troubleshooting](troubleshooting.md). +Only a recent packet completes [the observer check](verify.md). diff --git a/docs/analyzer/builds/mctomqtt.fr.md b/docs/analyzer/builds/mctomqtt.fr.md new file mode 100644 index 0000000..858ada0 --- /dev/null +++ b/docs/analyzer/builds/mctomqtt.fr.md @@ -0,0 +1,231 @@ +--- +title: Observer avec MCtoMQTT +description: Transmettez les paquets d’une radio USB à partir d’un ordinateur Linux ou macOS qui reste en fonction. +audience: + - observer-operators + - service-operators +task: configure-mctomqtt-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 30 minutes +destructive: true +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Observer avec MCtoMQTT + +MCtoMQTT lit les paquets d’une radio MeshCore connectée par USB et les publie à +partir d’un hôte Linux ou macOS qui reste en fonction. + +## Cette méthode vous convient-elle? + +
+
Utilisez MCtoMQTT siUn ordinateur Linux ou macOS reste près d’une radio de journalisation des paquets connectée par USB.
+
Choisissez autre chose siRemoteTerm, Home Assistant ou PyMC gère déjà la radio.
+
Gardez en fonctionLa radio, la connexion USB, le service de l’hôte et la connexion Internet.
+
+ +L’outil d’assistance peut aussi configurer `meshcore-packet-capture` pour un +compagnon relié par BLE, liaison série ou TCP. + +## Avant de commencer { #before-you-start } + +| Exigence | Vérification | +|---|---| +| Radio | La journalisation des paquets fonctionne par USB et les paramètres du réseau local sont exacts | +| Hôte | Linux ou macOS pour `meshcoretomqtt`; Windows est pris en charge uniquement pour la capture d’un compagnon | +| Accès | Vous pouvez lire la configuration actuelle et redémarrer le service | +| Emplacement | Un véritable [code d’emplacement](../iata-codes.md) à trois lettres | +| Outils | `curl`, un afficheur de texte et `systemctl` sous Linux | + +!!! warning "Examinez le script avant de l’exécuter" + Ces outils d’assistance ne sont liés ni à une version précise ni à une somme de contrôle. Téléchargez-les et examinez-les avant de les exécuter. L’option `--no-restart` écrit quand même des fichiers; ce n’est pas une simulation. + +Fichiers à examiner : + +- [Source de l’outil Bash](https://github.com/MeshCore-ca/MeshCore-Canada/blob/main/docs/analyzer/scripts/add-meshcore-ca-broker.sh) +- [Outil Bash publié](https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) +- [Source de l’outil PowerShell pour compagnon](https://github.com/MeshCore-ca/MeshCore-Canada/blob/main/docs/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1) + +## Ce qui sera modifié + +Sur un hôte à liaison série, l’outil : + +- écrit `/etc/mctomqtt/config.d/20-meshcore-ca.toml`; +- crée une copie horodatée `.bak.` si ce fichier existe; +- ajoute les points de terminaison principal et de secours de MeshCore Canada ainsi que le code d’emplacement; +- redémarre `mctomqtt`, sauf si l’option `--no-restart` est utilisée. + +Pour la capture d’un compagnon, il met à jour +`~/.meshcore-packet-capture/.env.local`, crée une sauvegarde horodatée, +configure les emplacements 1 et 2, désactive les emplacements 3 à 6 et peut +redémarrer le service de capture. + +Les options d’installation téléchargent et exécutent des programmes +d’installation distincts provenant des projets d’origine. Ne les utilisez pas +avant d’avoir aussi examiné le programme nommé. + +## Configuration + +### 1. Télécharger et examiner l’outil + +```bash +workdir="$(mktemp -d)" +curl -fsSLo "$workdir/add-meshcore-ca-broker.sh" https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh +less "$workdir/add-meshcore-ca-broker.sh" +``` + +Vérifiez l’URL source, les chemins modifiés, les hôtes des points de +terminaison, le comportement de sauvegarde et le comportement de redémarrage. +Conservez le fichier pour le reste de la configuration. + +### 2. Écrire sans redémarrer + +Remplacez `YOW` par le véritable code d’emplacement le plus près de +l’observateur : + +```bash +bash "$workdir/add-meshcore-ca-broker.sh" --device serial-host --iata YOW --no-restart +``` + +L’outil valide la forme du code, rejette `XXX`, affiche un avertissement pour +les codes absents de sa courte liste et indique les chemins modifiés et +sauvegardés. + +### 3. Examiner le résultat + +```bash +sudo sed -n '1,220p' /etc/mctomqtt/config.d/20-meshcore-ca.toml +``` + +Confirmez que : + +- le code d’emplacement est exact; +- l’hôte principal et son audience sont `mqtt1.meshcore.ca`; +- l’hôte de secours et son audience sont `mqtt2.meshcore.ca`; +- le port est `443`, le transport est WebSockets et la validation TLS est activée. + +Ne collez pas le fichier complet dans un billet public sans l’avoir examiné. + +### 4. Redémarrer volontairement + +```bash +sudo systemctl restart mctomqtt +sudo systemctl status mctomqtt --no-pager +``` + +Le service devrait demeurer actif sans erreurs TLS ou d’authentification +répétées. + +### Exécuter directement après l’examen + +Après avoir examiné la version actuellement publiée de l’outil, vous pouvez +exécuter directement ce même fichier : + +```bash +bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device serial-host --iata YOW +``` + +La méthode de téléchargement présentée plus haut facilite l’examen et la +récupération. + +### Nouvelle installation + +L’option `--install-mctomqtt` télécharge et exécute le programme d’installation +du projet `meshcoretomqtt`. Examinez d’abord ce programme, puis ajoutez +l’option : + +```bash +bash "$workdir/add-meshcore-ca-broker.sh" --device serial-host --iata YOW --install-mctomqtt +``` + +Le programme d’installation du projet d’origine contrôle le choix du port série +et les modifications de paquets. Il n’est lié ici ni à une version ni à une +somme de contrôle. + +## Capture d’un compagnon + +Réglez d’abord la radio du compagnon selon les paramètres du réseau maillé +local. + +=== "Linux ou macOS" + + Examinez le même outil Bash, puis exécutez : + + ```bash + bash "$workdir/add-meshcore-ca-broker.sh" --device companion --iata YOW --no-restart + ``` + + Examinez `~/.meshcore-packet-capture/.env.local`, puis redémarrez votre + processus de capture. + +=== "Windows PowerShell" + + Téléchargez et examinez l’outil avant de l’exécuter : + + ```powershell + $helper = Join-Path $env:TEMP "add-meshcore-ca-packetcapture-broker.ps1" + Invoke-WebRequest https://meshcore.ca/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 -OutFile $helper + Get-Content $helper + powershell -NoProfile -ExecutionPolicy Bypass -File $helper -Iata YOW + ``` + + L’outil PowerShell modifie + `%USERPROFILE%\.meshcore-packet-capture\.env.local` et crée une sauvegarde. + Son option d’installation facultative exécute un programme d’installation + provenant d’un autre projet; examinez-le d’abord. + +Il n’existe pas de programme d’installation Windows documenté pour +`meshcoretomqtt` dans le cas d’un hôte série de journalisation des paquets. + +## Ce que vous devriez voir + +Le service demeure actif, le point de terminaison principal se connecte et son +nombre de paquets change lorsque la radio capte du trafic à proximité. + +## Vérifier dans CoreScope { #check-in-corescope } + +1. Confirmez que le service demeure actif. +2. Trouvez l’observateur dans [CoreScope Observers](https://live.meshcore.ca/#/observers). +3. Attendez de l’activité normale à proximité. +4. Confirmez la présence d’un paquet récent dans [CoreScope Packets](https://live.meshcore.ca/#/packets). + +Terminez avec [Vérifier votre observateur](../verify.md). Un service en fonction +ou une connexion au courtier ne confirme pas que les paquets se sont rendus à +CoreScope. + +## Récupération { #recovery } + +Utilisez le chemin exact de la sauvegarde affiché par l’outil. + +Si un fichier de configuration complémentaire pour l’hôte série existait déjà : + +```bash +sudo cp -- '/etc/mctomqtt/config.d/20-meshcore-ca.toml.bak.' '/etc/mctomqtt/config.d/20-meshcore-ca.toml' +sudo systemctl restart mctomqtt +``` + +Si l’outil a créé un nouveau fichier complémentaire et qu’aucun fichier +n’existait auparavant : + +```bash +sudo rm -- '/etc/mctomqtt/config.d/20-meshcore-ca.toml' +sudo systemctl restart mctomqtt +``` + +Pour la capture d’un compagnon, restaurez la sauvegarde +`.env.local.bak.` indiquée par-dessus `.env.local`, conservez ses +permissions, puis redémarrez le processus de capture. + +L’outil ne désinstalle pas les logiciels ajoutés par une option d’installation +externe. Utilisez la procédure de désinstallation vérifiée du projet d’origine. + +## Si la vérification échoue + +Consultez le [dépannage](../troubleshooting.md). Partagez uniquement un court +état de service caviardé et la liste des chemins modifiés. diff --git a/docs/analyzer/builds/mctomqtt.md b/docs/analyzer/builds/mctomqtt.md index 9e03b13..4baf031 100644 --- a/docs/analyzer/builds/mctomqtt.md +++ b/docs/analyzer/builds/mctomqtt.md @@ -1,157 +1,198 @@ -# MCtoMQTT (USB Serial Host) +--- +title: Observe with MCtoMQTT +description: Send packets from a USB radio through an always-on Linux or macOS computer. +audience: + - observer-operators + - service-operators +task: configure-mctomqtt-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 30 minutes +destructive: true +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- -Bridge a USB-connected MeshCore node to MQTT brokers using `meshcoretomqtt`. This runs on a Linux or macOS host with the device connected over USB serial. +# Observe with MCtoMQTT -!!! info "What it does" - The MCtoMQTT service reads packets from a MeshCore device over its serial port and publishes them to one or more MQTT brokers. A config drop-in file tells it where to send the data. +MCtoMQTT reads packets from a USB-connected MeshCore radio and publishes them from an always-on Linux or macOS host. -## Prerequisites +## Is this method right for you? -| Requirement | Details | -|-------------|---------| -| Device | MeshCore node with packet logging enabled, connected via USB serial | -| Host | Linux or macOS | -| Tools | `curl` installed | -| IATA Code | Your real 3-letter IATA airport code (e.g. `YOW` for Ottawa) | -| Mesh Settings | Connected radio uses `USA/Canada (Recommended)` and 3-byte path hashes | +
+
Use MCtoMQTT ifA Linux or macOS computer stays beside a packet-log radio connected over USB.
+
Use something else ifRemoteTerm, Home Assistant, or PyMC already manages the radio.
+
Keep onlineThe radio, USB connection, host service, and internet connection.
+
-## Quick Setup +The helper can also configure `meshcore-packet-capture` for a companion connected by BLE, serial, or TCP. -If `meshcoretomqtt` is already installed, run the one-liner to add the MeshCore.ca brokers: +## Before you start -```bash -bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device serial-host -``` +| Requirement | Check | +|---|---| +| Radio | Packet logging works over USB and the local mesh settings are correct | +| Host | Linux or macOS for `meshcoretomqtt`; Windows is supported only for companion capture | +| Access | You can read the current config and restart the service | +| Location | A real three-letter [location code](../iata-codes.md) | +| Tools | `curl`, a text viewer, and `systemctl` on Linux | + +!!! warning "Review before running" + These helpers are not pinned to a release or checksum. Download and inspect them before running. `--no-restart` still writes files; it is not a dry run. + +Files to review: + +- [Bash helper source](https://github.com/MeshCore-ca/MeshCore-Canada/blob/main/docs/analyzer/scripts/add-meshcore-ca-broker.sh) +- [Published Bash helper](https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) +- [PowerShell companion helper source](https://github.com/MeshCore-ca/MeshCore-Canada/blob/main/docs/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1) -This creates a config drop-in at `/etc/mctomqtt/config.d/20-meshcore-ca.toml` pointing at the MeshCore.ca broker pair, then restarts the service. +## What this changes -### Fresh Install +On a serial host, the helper: -If `meshcoretomqtt` is not yet installed, add `--install-mctomqtt` and the script will run the upstream installer first: +- writes `/etc/mctomqtt/config.d/20-meshcore-ca.toml`; +- makes a timestamped `.bak.` copy when that file exists; +- adds the primary and backup MeshCore Canada endpoints and location code; and +- restarts `mctomqtt` unless `--no-restart` is used. + +For companion capture, it updates `~/.meshcore-packet-capture/.env.local`, makes a timestamped backup, configures slots 1 and 2, disables slots 3–6, and may restart the capture service. + +Install flags download and run separate upstream installers. Do not use them until you have reviewed the named upstream installer too. + +## Set up + +### 1. Download and inspect the helper ```bash -bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device serial-host --install-mctomqtt +workdir="$(mktemp -d)" +curl -fsSLo "$workdir/add-meshcore-ca-broker.sh" https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh +less "$workdir/add-meshcore-ca-broker.sh" ``` -!!! tip - The upstream installer will walk you through serial port selection. +Check the source URL, changed paths, endpoint hosts, backup behaviour, and restart behaviour. Keep the file for the rest of this setup. -## Specifying Your Region +### 2. Write without restarting -Pass your IATA code via the `--iata` flag or the `MESHCORE_CA_IATA` environment variable. Use the real 3-letter airport code nearest to you: +Replace `YOW` with the real location code nearest the observer: -=== "Flag" +```bash +bash "$workdir/add-meshcore-ca-broker.sh" --device serial-host --iata YOW --no-restart +``` - ```bash - bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device serial-host --iata YOW - ``` +The helper validates the code shape, rejects `XXX`, warns on codes outside its quick list, and prints the changed and backup paths. -=== "Environment Variable" +### 3. Review the result - ```bash - MESHCORE_CA_IATA=YOW bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device serial-host - ``` +```bash +sudo sed -n '1,220p' /etc/mctomqtt/config.d/20-meshcore-ca.toml +``` -If omitted, the script will prompt interactively. +Check that: -!!! warning "Use a real IATA code" - The helper shows a Canadian quick list when it prompts. If your nearest real airport code is not shown, you can still type it, but continue only if it is a real IATA airport code. Do not use `CAN` as shorthand for Canada; it is a real airport code for Guangzhou and will tag your observer to the wrong region. +- the location code is correct; +- primary host and audience are `mqtt1.meshcore.ca`; +- backup host and audience are `mqtt2.meshcore.ca`; +- port is `443`, transport is WebSockets, and TLS verification is enabled. -!!! tip "Check the radio first" - MCtoMQTT can publish packets from a connected radio, but it cannot fix a radio that is listening on the wrong mesh settings. Before troubleshooting MQTT, confirm the MeshCore node is on **USA/Canada (Recommended)** or `910.525 MHz / 62.5 kHz / SF7 / CR5`, and that companion-style radios use 3-byte path hashes. +Do not paste the full file into a public issue without reviewing it. -## What the Script Creates +### 4. Restart deliberately -The drop-in config at `/etc/mctomqtt/config.d/20-meshcore-ca.toml`: +```bash +sudo systemctl restart mctomqtt +sudo systemctl status mctomqtt --no-pager +``` -??? note "View full config" +The service should stay active without repeated TLS or authentication errors. - ```toml - [general] - iata = "YOW" +### Run it directly after review - [[broker]] - name = "meshcore-ca-1" - enabled = true - server = "mqtt1.meshcore.ca" - port = 443 - transport = "websockets" - keepalive = 60 - qos = 0 - retain = true +After reviewing the current published helper, you can run that same file directly: - [broker.tls] - enabled = true - verify = true +```bash +bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device serial-host --iata YOW +``` - [broker.auth] - method = "token" - audience = "mqtt1.meshcore.ca" +The downloaded-file method above is easier to review and recover. - [[broker]] - name = "meshcore-ca-2" - enabled = true - server = "mqtt2.meshcore.ca" - port = 443 - transport = "websockets" - keepalive = 60 - qos = 0 - retain = true +### Fresh installation - [broker.tls] - enabled = true - verify = true +`--install-mctomqtt` downloads and runs the upstream `meshcoretomqtt` installer. Review that installer first, then add the flag: - [broker.auth] - method = "token" - audience = "mqtt2.meshcore.ca" - ``` +```bash +bash "$workdir/add-meshcore-ca-broker.sh" --device serial-host --iata YOW --install-mctomqtt +``` + +The upstream installer controls serial-port selection and package changes, and it is not pinned or checksummed here. -## Companion Devices (BLE / Serial / TCP) +## Companion capture -For companion radios (not packet-log serial hosts), use the companion path instead: +Set the companion radio to the local mesh settings first. -Before running the companion helper, set the companion to **USA/Canada (Recommended)** and set path hash mode to **3-byte** in the companion app or config tool. +=== "Linux or macOS" -=== "Linux / macOS" + Review the same Bash helper, then run: ```bash - bash <(curl -fsSL https://meshcore.ca/analyzer/scripts/add-meshcore-ca-broker.sh) --device companion + bash "$workdir/add-meshcore-ca-broker.sh" --device companion --iata YOW --no-restart ``` - Add `--install-packetcapture` for a fresh install. The upstream installer will walk you through BLE, serial, or TCP connection setup. + Review `~/.meshcore-packet-capture/.env.local`, then restart your capture process. === "Windows PowerShell" + Download and inspect the helper before running: + ```powershell - powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr https://meshcore.ca/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 -UseBasicParsing | iex" + $helper = Join-Path $env:TEMP "add-meshcore-ca-packetcapture-broker.ps1" + Invoke-WebRequest https://meshcore.ca/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 -OutFile $helper + Get-Content $helper + powershell -NoProfile -ExecutionPolicy Bypass -File $helper -Iata YOW ``` - Config is stored under `%USERPROFILE%\.meshcore-packet-capture\.env.local`. + The PowerShell helper changes `%USERPROFILE%\.meshcore-packet-capture\.env.local` and creates a backup. Its optional install switch executes an upstream installer and must be reviewed first. + +There is no documented `meshcoretomqtt` Windows installer for the packet-log serial-host path. + +## What you should see + +The service stays active, the primary endpoint connects, and its packet count changes when the radio hears nearby traffic. + +## Verify in CoreScope -!!! warning "Windows and Serial Hosts" - There is no upstream `meshcoretomqtt` Windows installer. Keep packet-log serial hosts on Linux or macOS. The Windows PowerShell helper is for companion radios only. +1. Confirm the service remains active. +2. Find the observer in [CoreScope Observers](https://live.meshcore.ca/#/observers). +3. Wait for normal nearby activity. +4. Confirm a recent packet in [CoreScope Packets](https://live.meshcore.ca/#/packets). -## Quick Reference +Finish with [Check your observer](../verify.md). A running service or broker connection is not proof that packets reached CoreScope. -| Path | Manager | Connection | Config Location | -|------|---------|------------|-----------------| -| Serial Host | meshcoretomqtt | USB serial | `/etc/mctomqtt/config.d/20-meshcore-ca.toml` | -| Companion | meshcore-packet-capture | BLE, serial, or TCP | `~/.meshcore-packet-capture/.env.local` | +## Recovery + +Use the exact backup path printed by the helper. + +If a serial-host drop-in existed before: + +```bash +sudo cp -- '/etc/mctomqtt/config.d/20-meshcore-ca.toml.bak.' '/etc/mctomqtt/config.d/20-meshcore-ca.toml' +sudo systemctl restart mctomqtt +``` + +If the helper created a new drop-in and no prior file existed: + +```bash +sudo rm -- '/etc/mctomqtt/config.d/20-meshcore-ca.toml' +sudo systemctl restart mctomqtt +``` -## Script Options +For companion capture, restore the printed `.env.local.bak.` over `.env.local`, preserve its permissions, and restart the capture process. -| Flag | Description | -|------|-------------| -| `--iata CODE` | Real 3-letter IATA airport code | -| `--list-iata` | Show the Canadian quick-list choices | -| `--device TYPE` | `serial-host` or `companion` | -| `--install-mctomqtt` | Install meshcoretomqtt if not present | -| `--install-packetcapture` | Install meshcore-packet-capture if not present | -| `--no-restart` | Patch config without restarting the service | -| `--config-dir PATH` | Override config dir (default: `/etc/mctomqtt`) | -| `--service NAME` | Override systemd service name (default: `mctomqtt`) | +The helper does not uninstall software added by an upstream install flag. Use that upstream project's reviewed uninstall process. -## Verify +## If verification fails -Once your service is running, head to [Check Your Observer](../verify.md) to confirm it's reporting correctly. +Use [Troubleshooting](../troubleshooting.md). Share only a short redacted service status and the list of changed paths. diff --git a/docs/analyzer/builds/meshcore-ha.fr.md b/docs/analyzer/builds/meshcore-ha.fr.md new file mode 100644 index 0000000..815380a --- /dev/null +++ b/docs/analyzer/builds/meshcore-ha.fr.md @@ -0,0 +1,121 @@ +--- +title: Observer avec Home Assistant +description: Transmettez à CoreScope les paquets d’une intégration MeshCore déjà configurée dans Home Assistant. +audience: + - observer-operators + - home-assistant-users +task: configure-home-assistant-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: intermediate +estimated_time: 15 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Observer avec Home Assistant + +Utilisez l’intégration MeshCore que vous exploitez déjà dans Home Assistant pour +publier la télémétrie des paquets auprès de MeshCore Canada. + +## Cette méthode vous convient-elle? + +
+
Utilisez Home Assistant siUne intégration MeshCore fonctionnelle et une radio connectée y sont déjà configurées.
+
Choisissez autre chose siVous installeriez Home Assistant uniquement pour observer.
+
Gardez en fonctionHome Assistant, la connexion radio et l’accès Internet.
+
+ +## Vérifier votre écran + +Les intégrations MeshCore actuelles utilisent ces champs : + +| Contrôle des paquets | Contrôle de l’emplacement | +|---|---| +| **Payload Mode** = `packet` | Champ libre **Broker IATA Code** | + +Si votre écran est différent, consultez +[L’écran de Home Assistant ne correspond pas au guide](../troubleshooting.md#home-assistant-screen-does-not-match-the-guide). +Mettez l’intégration à jour si elle n’accepte pas le bon code d’emplacement; ne +choisissez pas un code voisin pour contourner le problème. + +## Avant de commencer + +- [ ] Home Assistant et l’intégration MeshCore fonctionnent correctement. +- [ ] La radio est connectée par une liaison USB, BLE ou TCP compatible. +- [ ] La radio utilise les paramètres du réseau maillé local. +- [ ] Vous avez choisi un véritable [code d’emplacement](../iata-codes.md). +- [ ] Vous avez lu [Données des observateurs et vie privée](../data-collection-access.md). + +## Ce qui sera modifié + +Vous ajouterez deux entrées de courtier MQTT et activerez les paquets dans +**Payload Mode**. N’entrez ni mot de passe Wi-Fi ni mot de passe MQTT statique. + +## Configuration + +Ouvrez : + +**Settings** → **Devices & Services** → **MeshCore** → **Configure** → **Manage MQTT Brokers** + +Ajoutez l’entrée principale : + +| Champ | Valeur | +|---|---| +| Server | `mqtt1.meshcore.ca` | +| Port | `443` | +| Transport | `websockets` | +| Use TLS | Enabled | +| TLS Verify | Enabled | +| Username / Password | Leave blank | +| Use MeshCore Auth Token | Enabled | +| Token Audience | `mqtt1.meshcore.ca` | +| Payload Mode | `packet` | +| Status Topic | `meshcore/{IATA}/{PUBLIC_KEY}/status` | +| Packets Topic | `meshcore/{IATA}/{PUBLIC_KEY}/packets` | + +Réglez le champ d’emplacement de l’intégration au véritable code à trois +lettres le plus près de l’observateur. + +Ajoutez l’entrée de secours avec les mêmes paramètres, en ne modifiant que : + +| Champ | Valeur | +|---|---| +| Server | `mqtt2.meshcore.ca` | +| Token Audience | `mqtt2.meshcore.ca` | + +Laissez les champs facultatifs du responsable vides, sauf s’ils sont +nécessaires. Enregistrez les deux entrées. + +## Ce que vous devriez voir + +Les deux entrées indiquent qu’elles sont connectées, et l’activité radio à +proximité fait changer le nombre de paquets. Si les courtiers se connectent, +mais qu’aucun paquet n’apparaît, le mode de paquets est peut-être désactivé ou +la radio ne capte aucune activité. + +## Vérifier dans CoreScope + +1. Trouvez l’observateur dans [CoreScope Observers](https://live.meshcore.ca/#/observers). +2. Attendez de l’activité MeshCore normale à proximité. +3. Confirmez la présence d’un paquet récent dans [CoreScope Packets](https://live.meshcore.ca/#/packets). + +Terminez avec [Vérifier votre observateur](../verify.md). Le badge de connexion +de Home Assistant ne confirme pas que les paquets se sont rendus à CoreScope. + +## Récupération + +Désactivez ou supprimez seulement les deux entrées de courtier MeshCore Canada +que vous avez ajoutées. Ne modifiez pas les autres intégrations de Home +Assistant ni la connexion radio. Confirmez que l’intégration MeshCore d’origine +fonctionne toujours. + +## Si la vérification échoue + +Consultez le [dépannage](../troubleshooting.md). Indiquez les versions de Home +Assistant et de l’intégration MeshCore, la première étape qui a échoué et une +erreur caviardée. Examinez toute archive de diagnostics avant de la partager. diff --git a/docs/analyzer/builds/meshcore-ha.md b/docs/analyzer/builds/meshcore-ha.md index 934a305..5b265a2 100644 --- a/docs/analyzer/builds/meshcore-ha.md +++ b/docs/analyzer/builds/meshcore-ha.md @@ -1,95 +1,105 @@ -# MeshCore-HA (Home Assistant) +--- +title: Observe with Home Assistant +description: Send packets from an existing Home Assistant MeshCore integration to CoreScope. +audience: + - observer-operators + - home-assistant-users +task: configure-home-assistant-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: intermediate +estimated_time: 15 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- -Add the MeshCore.ca broker pair to your Home Assistant MeshCore integration. This connects your HA instance to the Canadian mesh telemetry network. +# Observe with Home Assistant -## Prerequisites +Use the MeshCore integration you already run in Home Assistant to publish packet telemetry to MeshCore Canada. -| Requirement | Details | -|-------------|---------| -| Home Assistant | With the MeshCore integration installed | -| MeshCore Node | Connected to HA via USB, BLE, or TCP | -| IATA Code | The same real 3-letter IATA airport code used by your observer (e.g. `YOW` for Ottawa) | -| Mesh Settings | Connected node uses `USA/Canada (Recommended)` and 3-byte path hashes | +## Is this method right for you? -## Setup +
+
Use Home Assistant ifIt already has a working MeshCore integration and connected radio.
+
Use something else ifYou would install Home Assistant only for observing.
+
Keep onlineHome Assistant, the radio connection, and internet access.
+
-!!! tip "Known-good setup" - A Home Assistant observer needs three things to line up: both brokers connected, **Payload Mode** set to `packet` (or **Packets (Lets Mesh)** enabled on older screens), and the same real IATA code on both broker entries. +## Check your screen - The connected MeshCore node must also be on the MeshCore Canada network settings: **USA/Canada (Recommended)**, or raw radio values `910.525 MHz / 62.5 kHz / SF7 / CR5`, with 3-byte path hashes. +Current MeshCore integrations use these fields: -### 1. Open Broker Settings +| Packet control | Location control | +|---|---| +| **Payload Mode** = `packet` | Free-text **Broker IATA Code** | -Navigate to: +If your screen looks different, see [Home Assistant screen does not match the guide](../troubleshooting.md#home-assistant-screen-does-not-match-the-guide). Update the integration if it cannot accept the correct location code; do not substitute a nearby code. -**Settings** > **Devices & Services** > **MeshCore** > **Configure** > **Manage MQTT Brokers** +## Before you start -### 2. Configure the Primary Broker +- [ ] Home Assistant and the MeshCore integration are healthy. +- [ ] The radio is connected over a supported USB, BLE, or TCP path. +- [ ] The radio uses the local mesh settings. +- [ ] You chose a real [location code](../iata-codes.md). +- [ ] You read [Observer data, access, and privacy](../data-collection-access.md). + +## What this changes + +You will add two MQTT broker entries and enable packet payloads. Do not enter a Wi-Fi password or static MQTT password. + +## Set up + +Open: + +**Settings** → **Devices & Services** → **MeshCore** → **Configure** → **Manage MQTT Brokers** + +Add the primary entry: | Field | Value | -|-------|-------| +|---|---| | Server | `mqtt1.meshcore.ca` | | Port | `443` | | Transport | `websockets` | -| Use TLS | :material-check: Enabled | -| TLS Verify | :material-check: Enabled | +| Use TLS | Enabled | +| TLS Verify | Enabled | | Username / Password | Leave blank | -| Use MeshCore Auth Token | :material-check: Enabled | +| Use MeshCore Auth Token | Enabled | | Token Audience | `mqtt1.meshcore.ca` | -| Auth Token TTL | Leave default | | Payload Mode | `packet` | | Status Topic | `meshcore/{IATA}/{PUBLIC_KEY}/status` | | Packets Topic | `meshcore/{IATA}/{PUBLIC_KEY}/packets` | -| Client ID Prefix | Optional | -| Owner Public Key | Optional (TLS verified only) | -| Owner Email | Optional (TLS verified only) | -### 3. Configure the Backup Broker +Set the integration's location field to the real three-letter code nearest the observer. -Add a second broker with the same settings, changing only: +Add the backup entry with the same settings, changing only: | Field | Value | -|-------|-------| +|---|---| | Server | `mqtt2.meshcore.ca` | | Token Audience | `mqtt2.meshcore.ca` | -All other fields remain the same as the primary broker. - -!!! warning "Packet mode is required" - If your Home Assistant version shows a checkbox or option named **Packets (Lets Mesh)**, enable it. In newer versions this is the **Payload Mode** setting and it must be `packet`. If this is missed, the broker can appear connected while no packet data shows up. - -### 4. Set Your Region - -Make sure your IATA code is set in the integration. The `{IATA}` placeholder in the topic templates will be replaced with your code (e.g. `YOW`). +Leave optional owner fields blank unless they are needed. Save both entries. -The Home Assistant integration must use the same IATA code as the observer that is publishing packets. If Home Assistant is set to `YOW` but the observer publishes under `YKF`, Home Assistant will subscribe to the wrong topics and look empty. +## What you should see -Current MeshCore-HA versions use a free-text **Broker IATA Code** field, so you can type a real code such as `YTR` even if it is missing from a quick list. If your Home Assistant screen only offers a picker and does not let you enter the code manually, update the MeshCore integration before using a neighboring airport code as a workaround. +Both entries show connected and nearby radio activity changes the packet count. If the brokers connect but no packets appear, packet mode may be off or the radio may hear nothing. -!!! tip - You can find common Canadian IATA codes on the [IATA Region Codes](../iata-codes.md) page. If your nearest real airport code is missing from that quick list, you can still use it. +## Verify in CoreScope -## Common HA Symptoms +1. Find the observer in [CoreScope Observers](https://live.meshcore.ca/#/observers). +2. Wait for normal nearby MeshCore activity. +3. Confirm a recent packet in [CoreScope Packets](https://live.meshcore.ca/#/packets). -| Symptom | Most likely fix | -|---------|-----------------| -| Brokers show connected but no packets appear | Enable **Packets (Lets Mesh)** or set **Payload Mode** to `packet` | -| `YTR` or another code is not selectable | Update MeshCore-HA, then type the code in **Broker IATA Code** | -| One broker works and the backup does not | Check the second broker's **Token Audience** is `mqtt2.meshcore.ca` | -| Packets appear under the wrong city | Set both broker IATA fields to the nearest real airport code | +Finish with [Check your observer](../verify.md). Home Assistant's connected badge is not proof that packets reached CoreScope. -## Quick Reference +## Recovery -| Setting | Primary | Backup | -|---------|---------|--------| -| Server | `mqtt1.meshcore.ca` | `mqtt2.meshcore.ca` | -| Port | `443` | `443` | -| Transport | `websockets` | `websockets` | -| TLS | Enabled + Verified | Enabled + Verified | -| Auth | MeshCore JWT Token | MeshCore JWT Token | -| Audience | `mqtt1.meshcore.ca` | `mqtt2.meshcore.ca` | -| Payload Mode | `packet` | `packet` | +Disable or remove only the two MeshCore Canada broker entries you added. Keep unrelated Home Assistant integrations and the radio connection unchanged. Confirm the original MeshCore integration still operates. -## Verify +## If verification fails -After saving, head to [Check Your Observer](../verify.md) to confirm it's reporting correctly. +Use [Troubleshooting](../troubleshooting.md). Include the Home Assistant version, MeshCore integration version, first failed stage, and a redacted error. Review any diagnostics archive before sharing it. diff --git a/docs/analyzer/builds/mqtt-firmware.fr.md b/docs/analyzer/builds/mqtt-firmware.fr.md new file mode 100644 index 0000000..ba89f87 --- /dev/null +++ b/docs/analyzer/builds/mqtt-firmware.fr.md @@ -0,0 +1,266 @@ +--- +title: Construire un observateur MQTT autonome +description: Transformez une carte LoRa Wi-Fi compatible en observateur consacré à CoreScope. +audience: + - observer-operators +task: configure-standalone-observer +scope: experimental +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 35 minutes +destructive: true +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-command-builder.js?v=20260722-2 +--- + +# Construire un observateur MQTT autonome + +Un micrologiciel d’observation spécialisé permet à une carte LoRa Wi-Fi +compatible de transmettre la télémétrie des paquets à proximité sans ordinateur +hôte distinct. + +## Cette méthode vous convient-elle? + +
+
Bon choixVous pouvez consacrer une carte LoRa Wi-Fi compatible à l’observation.
+
Choisissez une autre méthodeRemoteTerm, Home Assistant, PyMC ou un hôte USB à proximité gère déjà la radio.
+
Gardez en fonctionLa carte, une alimentation stable, le Wi-Fi 2,4 GHz et l’accès Internet.
+
+ +Cette méthode utilise un micrologiciel tiers. Confirmez la carte et la version +affichées par l’outil de programmation avant de poursuivre. + +## Confirmer la source du micrologiciel + +| Élément | Référence | +|---|---| +| Outil de programmation | [observer.gessaman.com](https://observer.gessaman.com/) | +| Branche source consignée | `mqtt-bridge-implementation-flex` | +| Révision source consignée | `c0c845f5` | +| Préréglages du courtier | `meshcore-ca-1` et `meshcore-ca-2` | + +La révision indique la source utilisée lors de l’examen de ce guide; elle ne +confirme pas que l’outil en ligne utilise encore cette version. Arrêtez si +l’outil affiche une carte, une source ou une version que vous n’avez pas +vérifiée. + +## Avant de commencer { #before-you-start } + +!!! danger "Faites une sauvegarde avant d’activer Erase device" + L’effacement peut supprimer la clé d’identité privée, le nom, les paramètres radio, les données régionales et toute autre configuration enregistrée. Sauvegardez la clé privée et notez la carte, le rôle, la version du micrologiciel et les paramètres. Si vous ne pouvez pas sauvegarder l’identité, arrêtez et utilisez une nouvelle carte ou demandez de l’aide. + +- [ ] Confirmez que la carte exacte figure dans l’outil de programmation de l’observateur. +- [ ] Décidez si ce nœud doit relayer le trafic ou seulement l’observer. +- [ ] Sauvegardez l’identité et les paramètres actuels. +- [ ] Gardez à portée de main un câble USB de données fiable et la méthode de récupération de la carte. +- [ ] Choisissez un véritable [code d’emplacement](../iata-codes.md). + +## Ce qui sera modifié + +La programmation remplace le micrologiciel de la carte. La configuration +modifie ensuite son nom, les valeurs radio, le mode de hachage des chemins, le +code d’emplacement, les identifiants Wi-Fi, les préréglages du courtier, le mode +de paquets et le comportement de répétition. + +Le générateur de commandes s’exécute localement dans ce navigateur. Il +n’enregistre pas les champs Wi-Fi, ne les place pas dans l’URL et ne les inclut +pas dans l’aperçu par défaut. + +## Configuration + +### 1. Programmer la carte + +1. Ouvrez [observer.gessaman.com](https://observer.gessaman.com/). +2. Sous **MQTT Observer Firmware**, choisissez exactement la carte compatible. +3. Choisissez **Repeater** ou **Room Server**. +4. Pour une carte neuve ou volontairement réaffectée, activez **Erase device** seulement après avoir franchi l’étape de sauvegarde ci-dessus. +5. Programmez l’image fusionnée. +6. Une fois la programmation terminée, privilégiez **Configure via USB**. Utilisez **Console** uniquement pour les paramètres absents de l’écran de configuration. + +L’outil devrait confirmer la fin de l’opération et la carte devrait se +reconnecter par USB. + +### 2. Entrer les paramètres communs + +Utilisez les paramètres du réseau maillé local. En l’absence d’une configuration +propre à la communauté, la configuration canadienne de départ est : + +| Paramètre | Valeur | +|---|---| +| Préréglage radio | **USA/Canada (Recommended)** | +| Valeurs radio brutes | `910.525 MHz / 62.5 kHz / SF7 / CR5` | +| Empreintes de chemin | 3 octets (`set path.hash.mode 2`) | +| Préréglage principal | `meshcore-ca-1` | +| Préréglage de secours | `meshcore-ca-2` | +| Wi-Fi | Un réseau 2,4 GHz | + +### 3. Générer les commandes + +L’interface en ligne de commande n’a pas de règle générale documentée pour les +guillemets. Le générateur rejette les espaces, les guillemets, les barres +obliques inverses, les caractères de contrôle et les autres valeurs Wi-Fi +ambiguës. Utilisez **Configure via USB** pour un réseau qu’il ne peut pas +représenter de façon sûre. + +
+
+ + + + + +
+ +
+ + +
+
+ +
+

Chargement des suggestions d’emplacements canadiens…

+

Le SSID et le mot de passe restent uniquement dans cette page. Ils sont effacés lorsque vous la quittez et masqués dans l’aperçu jusqu’à ce que vous choisissiez de les afficher.

+ +
+
+ + + + +
+
Remplissez les champs obligatoires pour générer les commandes.
+
+ +Vérifiez le résumé non sensible et l’aperçu caviardé. Affichez et copiez les +commandes uniquement sur un ordinateur de confiance. Le presse-papiers +contiendra les identifiants Wi-Fi; effacez-le après utilisation. + +### Entrer les commandes à la main + +Si vous préférez les entrer manuellement, définissez d’abord les valeurs non +sensibles : + +```text +set name YOW-Repeater-01 +set radio 910.525,62.5,7,5 +set path.hash.mode 2 +set mqtt.iata YOW +set wifi.powersave none +set mqtt1.preset meshcore-ca-1 +set mqtt2.preset meshcore-ca-2 +set mqtt3.preset none +set mqtt4.preset none +set mqtt5.preset none +set mqtt6.preset none +set mqtt.status on +set mqtt.packets on +set mqtt.raw off +set mqtt.rx on +set mqtt.tx advert +set bridge.enabled on +set repeat on +advert +``` + +Remplacez le nom et le code d’emplacement. Utilisez `set repeat off` pour un +nœud qui observe sans relayer. + +Entrez les identifiants directement dans la console de l’appareil : + +```text +set wifi.ssid +set wifi.pwd +reboot +``` + +Ne collez pas les lignes d’identifiants complètes dans une conversation, un +billet, une capture d’écran, un journal ou une note enregistrée. N’ajoutez pas +de guillemets génériques de l’interpréteur de commandes à une valeur destinée +à l’interface de l’appareil. + +## Ce que vous devriez voir + +Après le redémarrage, exécutez : + +```text +get name +get wifi.status +get mqtt.iata +get mqtt1.preset +get mqtt2.preset +get mqtt.status +get mqtt.packets +get bridge.enabled +get path.hash.mode +``` + +La carte est correctement configurée lorsque : + +- le Wi-Fi et MQTT indiquent qu’ils sont connectés; +- le code d’emplacement est exact; +- les préréglages sont `meshcore-ca-1` et `meshcore-ca-2`; +- la publication des paquets et le mode pont sont activés; +- le mode de hachage des chemins est `2`. + +## Vérifier dans CoreScope { #check-in-corescope } + +1. Trouvez l’observateur dans [CoreScope Observers](https://live.meshcore.ca/#/observers). +2. Attendez de l’activité radio normale à proximité. +3. Confirmez la présence d’un paquet récent dans [CoreScope Packets](https://live.meshcore.ca/#/packets). + +Terminez avec [Vérifier votre observateur](../verify.md). Une connexion Wi-Fi et +MQTT ne confirme pas que les paquets se sont rendus à CoreScope. + +## Récupération { #recovery } + +Si la carte ne redémarre pas ou ne se reconnecte pas : + +1. débranchez-la et rebranchez-la avec un câble de données fiable; +2. utilisez la séquence de récupération ou de démarrage propre à la carte publiée par l’outil; +3. reprogrammez exactement la bonne cible sans l’effacer de nouveau, sauf si la récupération l’exige; +4. restaurez l’identité et les paramètres sauvegardés uniquement sur la carte prévue; +5. refaites les vérifications locales dans l’interface en ligne de commande avant de remettre la carte en service. + +Si le micrologiciel fonctionne, mais que l’observation échoue, restaurez les +paramètres précédents consignés ou reprogrammez la dernière version vérifiée et +fonctionnelle. Gardez privée la sauvegarde de l’identité privée. + +## Si la vérification échoue + +Consultez le [dépannage](../troubleshooting.md). Partagez uniquement les +résultats des commandes de lecture après avoir retiré les renseignements privés. +Ne partagez jamais les commandes Wi-Fi ni une clé privée. diff --git a/docs/analyzer/builds/mqtt-firmware.md b/docs/analyzer/builds/mqtt-firmware.md index ccb715e..c0145be 100644 --- a/docs/analyzer/builds/mqtt-firmware.md +++ b/docs/analyzer/builds/mqtt-firmware.md @@ -1,242 +1,164 @@ -# MQTT Firmware (Standalone Observer) +--- +title: Build a standalone MQTT observer +description: Turn a supported Wi-Fi LoRa board into a dedicated CoreScope observer. +audience: + - observer-operators +task: configure-standalone-observer +scope: experimental +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 35 minutes +destructive: true +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-command-builder.js?v=20260722-2 +--- + +# Build a standalone MQTT observer + +Dedicated observer firmware lets a supported Wi-Fi LoRa board send nearby packet telemetry without a separate host computer. + +## Is this method right for you? + +
+
Good fitYou can dedicate a supported Wi-Fi LoRa board to observing.
+
Choose another methodRemoteTerm, Home Assistant, PyMC, or a nearby USB host already manages the radio.
+
What stays onlineThe board, stable power, 2.4 GHz Wi-Fi, and internet access.
+
+ +This uses third-party firmware. Confirm the board and build shown by the flasher before continuing. + +## Confirm the firmware source + +| Item | Reference | +|---|---| +| Flasher | [observer.gessaman.com](https://observer.gessaman.com/) | +| Recorded source branch | `mqtt-bridge-implementation-flex` | +| Recorded source commit | `c0c845f5` | +| Broker presets | `meshcore-ca-1` and `meshcore-ca-2` | + +The commit records the source used when this guide was reviewed; it does not prove the live flasher still uses that build. Stop if the flasher shows a board, source, or build you have not checked. + +## Before you start + +!!! danger "Back up before enabling Erase device" + Erasing can remove the private identity key, name, radio settings, region data, and other saved configuration. Back up the private key and record the board, role, firmware version, and settings. If an identity cannot be backed up, stop and use a new board or ask for help. + +- [ ] Confirm the exact board is listed by the observer flasher. +- [ ] Decide whether this node should repeat traffic or observe only. +- [ ] Back up the existing identity and settings. +- [ ] Keep a known-good data USB cable and the board's recovery method nearby. +- [ ] Choose a real [location code](../iata-codes.md). + +## What this changes -Flash standalone MQTT observer firmware with the [MeshCore observer flasher](https://observer.gessaman.com/). MeshCore Canada no longer hosts separate observer firmware binaries on this page. +Flashing replaces the board firmware. Setup then changes its name, radio values, path-hash mode, location code, Wi-Fi credentials, broker presets, packet mode, and repeat behaviour. -!!! success "MeshCore Canada presets verified" - The observer firmware currently offered by `observer.gessaman.com` is built from Adam Gessaman's `mqtt-bridge-implementation-flex` branch at commit `c0c845f5`. That branch includes the built-in presets `meshcore-ca-1` and `meshcore-ca-2`, pointing to `mqtt1.meshcore.ca` and `mqtt2.meshcore.ca`. +The command builder runs locally in this browser. It does not store Wi-Fi fields, place them in the URL, or include them in the default preview. -## Flash The Observer +## Set up + +### 1. Flash the board 1. Open [observer.gessaman.com](https://observer.gessaman.com/). -2. Pick your board under **MQTT Observer Firmware**. +2. Under **MQTT Observer Firmware**, choose the exact supported board. 3. Choose **Repeater** or **Room Server**. -4. For a new board or a board you are repurposing, enable **Erase device** and flash the merged image. -5. When flashing finishes, use **Configure via USB** for the repeater or room server setup screen, or use **Console** for CLI setup. +4. For a new or intentionally repurposed board, enable **Erase device** only after the backup gate above. +5. Flash the merged image. +6. When flashing finishes, prefer **Configure via USB**. Use **Console** only for settings the setup screen does not expose. -!!! warning "First flash can erase settings" - First flashing observer firmware, especially on boards with a changed partition layout, can wipe stored settings and identity data. Back up an existing device private key before repurposing it. +The flasher should report completion and the board should reconnect over USB. -## Required MeshCore Canada Settings +### 2. Enter the shared settings -Use the repeater or room server setup screen where possible. If a setting is not exposed in the setup screen, use the console on the flasher page and paste the CLI commands below. +Use the local mesh settings. When no community override exists, the Canadian onboarding baseline is: | Setting | Value | -|---------|-------| +|---|---| | Radio preset | **USA/Canada (Recommended)** | | Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| CLI radio command | `set radio 910.525,62.5,7,5` | -| Path hash mode | 3-byte advert path hashes: `set path.hash.mode 2` | -| MQTT slot 1 | `meshcore-ca-1` | -| MQTT slot 2 | `meshcore-ca-2` | -| IATA | A real 3-letter IATA airport code near the observer | -| WiFi | 2.4 GHz network credentials | - -!!! note "IATA codes" - Use a real airport code such as `YOW`, `YYZ`, `YUL`, `YVR`, or `YYC`. Do not use placeholders such as `XXX` or `HOME`. Do not use `CAN` as shorthand for Canada; it is an airport code for Guangzhou. See the [IATA region code list](../iata-codes.md) for Canadian quick choices. +| Path hashes | 3 bytes (`set path.hash.mode 2`) | +| Primary preset | `meshcore-ca-1` | +| Backup preset | `meshcore-ca-2` | +| Wi-Fi | A 2.4 GHz network | -## Command Builder +### 3. Build the commands -Use this builder to create a CLI block for the flasher **Console**. It runs only in your browser. +The CLI has no documented general quoting contract. The builder rejects spaces, quotes, backslashes, control characters, and other ambiguous Wi-Fi values. Use **Configure via USB** for a network it cannot represent safely. -
+
+ - -
+

Loading Canadian location suggestions…

+

SSID and password stay in this page only. They are cleared when you leave and hidden from the preview until you reveal them.

+ +
- + + +
-
+
Complete the required fields to build commands.
- - - - -## Manual CLI Reference - -If you prefer to type commands manually, replace `YOW`, `YourWiFiNetwork`, and `YourWiFiPassword` with your own values: +Check the non-sensitive summary and redacted preview. Reveal and copy commands only on a trusted computer. The clipboard will contain Wi-Fi credentials, so clear it after use. + +### Enter commands by hand + +If you prefer manual entry, set the non-sensitive values first: ```text set name YOW-Repeater-01 set radio 910.525,62.5,7,5 set path.hash.mode 2 set mqtt.iata YOW -set wifi.ssid YourWiFiNetwork -set wifi.pwd YourWiFiPassword set wifi.powersave none set mqtt1.preset meshcore-ca-1 set mqtt2.preset meshcore-ca-2 @@ -252,65 +174,64 @@ set mqtt.tx advert set bridge.enabled on set repeat on advert -reboot ``` -For an observe-only node that should not repeat mesh traffic, use: +Replace the name and location code. Use `set repeat off` for an observe-only node. + +Enter credentials directly in the device console: ```text -set repeat off +set wifi.ssid +set wifi.pwd +reboot ``` -## Verify +Do not paste completed credential lines into chat, issues, screenshots, logs, or saved notes. Do not add generic shell quotes to a device CLI value. + +## What you should see -After the device reboots, reopen the flasher **Console** and run: +After reboot, run: ```text +get name get wifi.status get mqtt.iata get mqtt1.preset get mqtt2.preset get mqtt.status +get mqtt.packets +get bridge.enabled get path.hash.mode ``` -Expected broker presets: +The board is configured correctly when: -```text -get mqtt1.preset -> meshcore-ca-1 -get mqtt2.preset -> meshcore-ca-2 -``` +- Wi-Fi and MQTT report connected; +- the location code is correct; +- presets are `meshcore-ca-1` and `meshcore-ca-2`; +- packet publishing and bridge mode are on; and +- path hash mode is `2`. -Once WiFi and MQTT are connected, use [Check Your Observer](../verify.md) to confirm packets are reaching MeshCore Canada. +## Verify in CoreScope -## Useful Links +1. Find the observer in [CoreScope Observers](https://live.meshcore.ca/#/observers). +2. Wait for normal nearby radio activity. +3. Confirm a recent packet in [CoreScope Packets](https://live.meshcore.ca/#/packets). -
+Finish with [Check your observer](../verify.md). Connected Wi-Fi and MQTT are not proof that packets reached CoreScope. -- :material-flash:{ .lg .middle } **Observer Flasher** +## Recovery - --- +If the board does not reboot or reconnect: - Flash MQTT observer firmware and open the serial console. +1. disconnect and reconnect with a known-good data cable; +2. use the board-specific recovery/boot sequence published by the flasher; +3. reflash the exact correct target without erasing again unless recovery requires it; +4. restore the backed-up identity and settings only to the intended board; and +5. repeat the local CLI checks before returning the board to service. - [:octicons-arrow-right-24: observer.gessaman.com](https://observer.gessaman.com/) +If the firmware runs but observing fails, restore the recorded prior settings or reflash the last known-good reviewed build. Keep the private identity backup private. -- :material-airplane:{ .lg .middle } **IATA Region Codes** +## If verification fails - --- - - Pick the real 3-letter airport code nearest to the observer. - - [:octicons-arrow-right-24: IATA codes](../iata-codes.md) - -- :material-check-circle:{ .lg .middle } **Check Your Observer** - - --- - - Confirm that the observer is online and reporting. - - [:octicons-arrow-right-24: verify status](../verify.md) - -
+Use [Troubleshooting](../troubleshooting.md). Share only read-command output after removing private details. Never share Wi-Fi commands or a private key. diff --git a/docs/analyzer/builds/pymc.fr.md b/docs/analyzer/builds/pymc.fr.md new file mode 100644 index 0000000..4373efa --- /dev/null +++ b/docs/analyzer/builds/pymc.fr.md @@ -0,0 +1,155 @@ +--- +title: Observer avec PyMC +description: Transmettez à CoreScope les paquets d’un service de répéteur PyMC déjà en fonction. +audience: + - observer-operators + - service-operators +task: configure-pymc-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 25 minutes +destructive: true +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Observer avec PyMC + +Ajoutez les points de terminaison principal et de secours de MeshCore Canada à +un service de répéteur PyMC que vous exploitez déjà. + +## Cette méthode vous convient-elle? + +
+
Utilisez PyMC siUn service de répéteur PyMC fonctionnel gère déjà la radio.
+
Choisissez autre chose siVous installeriez Python et PyMC uniquement pour observer le trafic.
+
Gardez en fonctionL’hôte PyMC, le service, la connexion radio et l’accès Internet.
+
+ +## Vérifier votre installation + +Cette procédure vise un service systemd Linux nommé `pymc-repeater` dont la +configuration se trouve dans `/etc/pymc_repeater/config.yaml`. Confirmez la +version, le nom du service, le chemin et le format de configuration avant toute +modification. Si les chemins diffèrent, ou si vous utilisez macOS ou Windows, +suivez la documentation de la version de PyMC installée. + +## Avant de commencer { #before-you-start } + +- [ ] PyMC fonctionne correctement avant le changement. +- [ ] Vous connaissez les véritables chemins du service et de sa configuration. +- [ ] La radio utilise les paramètres du réseau maillé local. +- [ ] Vous avez choisi un véritable [code d’emplacement](../iata-codes.md). +- [ ] Vous pouvez restaurer une copie de sauvegarde appartenant à `root`. + +Notez l’état actuel du service : + +```bash +sudo systemctl status pymc-repeater --no-pager +sudo cp -- /etc/pymc_repeater/config.yaml /etc/pymc_repeater/config.yaml.pre-meshcore-ca +``` + +## Ce qui sera modifié + +Vous modifierez la configuration YAML de PyMC et redémarrerez le service. Le +changement ajoute un code d’emplacement et deux entrées de courtier chiffrées +et authentifiées par jeton; il ne modifie pas le micrologiciel de la radio. + +## Configuration + +Dans `/etc/pymc_repeater/config.yaml`, définissez le code d’emplacement dans +`mqtt` : + +```yaml +mqtt: + iata_code: YOW +``` + +Remplacez `YOW` par le véritable code le plus près de l’observateur. + +Sous `mqtt.brokers`, ajoutez : + +```yaml +- name: MeshCore-CA + enabled: true + host: mqtt1.meshcore.ca + port: 443 + transport: websockets + format: letsmesh + disallowed_packet_types: [] + retain_status: false + tls: + enabled: true + insecure: false + use_jwt_auth: true + audience: mqtt1.meshcore.ca +- name: MeshCore-CA Backup + enabled: true + host: mqtt2.meshcore.ca + port: 443 + transport: websockets + format: letsmesh + disallowed_packet_types: [] + retain_status: false + tls: + enabled: true + insecure: false + use_jwt_auth: true + audience: mqtt2.meshcore.ca +``` + +N’ajoutez pas de mot de passe MQTT. Laissez le courriel facultatif du +responsable vide, sauf s’il est nécessaire à l’exploitation. + +Relisez la section modifiée, puis redémarrez le service : + +```bash +sudo systemctl restart pymc-repeater +sudo systemctl status pymc-repeater --no-pager +``` + +Si le service échoue, consultez un court extrait du journal local : + +```bash +sudo journalctl -u pymc-repeater -n 80 --no-pager +``` + +Examinez les résultats avant de les partager. Suivez la +[liste de caviardage](../troubleshooting.md#what-to-share-when-asking-for-help). + +## Ce que vous devriez voir + +Le service demeure actif sans erreur YAML, TLS ou de jeton, et son nombre de +paquets change lorsque la radio capte du trafic à proximité. + +## Vérifier dans CoreScope { #check-in-corescope } + +1. Trouvez l’observateur dans [CoreScope Observers](https://live.meshcore.ca/#/observers). +2. Attendez de l’activité normale à proximité. +3. Confirmez la présence d’un paquet récent dans [CoreScope Packets](https://live.meshcore.ca/#/packets). + +Terminez avec [Vérifier votre observateur](../verify.md). Un service systemd en +bon état ne confirme pas que les paquets se sont rendus à CoreScope. + +## Récupération { #recovery } + +Restaurez exactement la sauvegarde créée avant la modification : + +```bash +sudo cp -- /etc/pymc_repeater/config.yaml.pre-meshcore-ca /etc/pymc_repeater/config.yaml +sudo systemctl restart pymc-repeater +sudo systemctl status pymc-repeater --no-pager +``` + +Conservez le fichier défectueux en privé s’il peut servir au diagnostic. Ne le +publiez pas sans d’abord retirer les renseignements secrets et personnels. + +## Si la vérification échoue + +Consultez le [dépannage](../troubleshooting.md). Indiquez la version de PyMC, le +nom du service, la première étape qui a échoué et un court extrait caviardé du +journal. diff --git a/docs/analyzer/builds/pymc.md b/docs/analyzer/builds/pymc.md index 2ff309d..de60dee 100644 --- a/docs/analyzer/builds/pymc.md +++ b/docs/analyzer/builds/pymc.md @@ -1,33 +1,69 @@ -# PyMC (Python MeshCore) +--- +title: Observe with PyMC +description: Send packets from an existing PyMC repeater service to CoreScope. +audience: + - observer-operators + - service-operators +task: configure-pymc-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: advanced +estimated_time: 25 minutes +destructive: true +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Observe with PyMC + +Add MeshCore Canada's primary and backup endpoints to a PyMC repeater service you already operate. + +## Is this method right for you? + +
+
Use PyMC ifA working PyMC repeater service already manages the radio.
+
Use something else ifYou would install Python and PyMC only to observe traffic.
+
Keep onlineThe PyMC host, service, radio connection, and internet access.
+
+ +## Check your installation + +This procedure is for a Linux systemd service named `pymc-repeater` with config at `/etc/pymc_repeater/config.yaml`. Confirm the version, service name, path, and config format before editing. For different paths, macOS, or Windows, follow the installed PyMC version's documentation. + +## Before you start + +- [ ] PyMC is healthy before the change. +- [ ] You know the actual service and config paths. +- [ ] The radio uses the local mesh settings. +- [ ] You chose a real [location code](../iata-codes.md). +- [ ] You can restore a root-owned backup. + +Record the current service state: -Add the MeshCore.ca broker pair to an existing pyMC repeater installation. PyMC connects to your MeshCore device and forwards traffic to MQTT brokers using a YAML config file. - -## Prerequisites +```bash +sudo systemctl status pymc-repeater --no-pager +sudo cp -- /etc/pymc_repeater/config.yaml /etc/pymc_repeater/config.yaml.pre-meshcore-ca +``` -| Requirement | Details | -|-------------|---------| -| PyMC | A working pyMC repeater installation | -| IATA Code | Your real 3-letter IATA airport code (e.g. `YOW` for Ottawa) | -| Mesh Settings | Repeater radio uses `USA/Canada (Recommended)` and 3-byte path hashes | +## What this changes -## Configuration +You will edit PyMC's YAML config and restart the service. The change adds a location code and two encrypted, token-authenticated broker entries; it does not change the radio firmware. -### 1. Set Your IATA Code +## Set up -In `/etc/pymc_repeater/config.yaml`, set your region code under the MQTT section: +In `/etc/pymc_repeater/config.yaml`, set the location code inside `mqtt`: ```yaml mqtt: iata_code: YOW ``` -Use the real 3-letter airport code nearest to you. The public broker rejects placeholders and made-up region names such as `XXX` or `HOME`. Do not use `CAN` as shorthand for Canada; it is a real airport code for Guangzhou and will tag your observer to the wrong region. +Replace `YOW` with the real code nearest the observer. -Also confirm the underlying repeater is on the MeshCore Canada network settings: **USA/Canada (Recommended)**, or raw radio values `910.525 MHz / 62.5 kHz / SF7 / CR5`, with 3-byte path hashes. - -### 2. Add the Broker Block - -Paste the following under `mqtt.brokers` in your config file: +Under `mqtt.brokers`, add: ```yaml - name: MeshCore-CA @@ -58,35 +94,47 @@ Paste the following under `mqtt.brokers` in your config file: audience: mqtt2.meshcore.ca ``` -### 3. Optional Fields +Do not add an MQTT password. Leave optional owner email blank unless it is operationally needed. -You can also set these optional fields in the `mqtt` section: +Review the edited section, then restart: -```yaml -mqtt: - owner: "your-public-key" - email: "you@example.com" +```bash +sudo systemctl restart pymc-repeater +sudo systemctl status pymc-repeater --no-pager ``` -### 4. Restart the Service +If the service fails, inspect a short local excerpt: ```bash -sudo systemctl restart pymc-repeater +sudo journalctl -u pymc-repeater -n 80 --no-pager ``` -## Quick Reference +Review any output before sharing it. Follow the [redaction checklist](../troubleshooting.md#what-to-share-when-asking-for-help). + +## What you should see + +The service stays active without YAML, TLS, or token errors, and its packet count changes when the radio hears nearby traffic. + +## Verify in CoreScope + +1. Find the observer in [CoreScope Observers](https://live.meshcore.ca/#/observers). +2. Wait for normal nearby activity. +3. Confirm a recent packet in [CoreScope Packets](https://live.meshcore.ca/#/packets). + +Finish with [Check your observer](../verify.md). A healthy systemd service is not proof that packets reached CoreScope. + +## Recovery + +Restore the exact backup made before editing: + +```bash +sudo cp -- /etc/pymc_repeater/config.yaml.pre-meshcore-ca /etc/pymc_repeater/config.yaml +sudo systemctl restart pymc-repeater +sudo systemctl status pymc-repeater --no-pager +``` -| Setting | Value | -|---------|-------| -| Config file | `/etc/pymc_repeater/config.yaml` | -| Primary broker | `mqtt1.meshcore.ca` | -| Backup broker | `mqtt2.meshcore.ca` | -| Port | `443` | -| Transport | `websockets` | -| TLS | Enabled, verified | -| Auth | JWT token (`use_jwt_auth: true`) | -| Format | `letsmesh` | +Keep the failed file privately if it helps with diagnosis. Do not post it without removing secrets and personal details. -## Verify +## If verification fails -After restarting, head to [Check Your Observer](../verify.md) to confirm it's reporting correctly. +Use [Troubleshooting](../troubleshooting.md). Include the PyMC version, service name, first failed stage, and a short redacted log excerpt. diff --git a/docs/analyzer/data-collection-access.fr.md b/docs/analyzer/data-collection-access.fr.md new file mode 100644 index 0000000..1bdf715 --- /dev/null +++ b/docs/analyzer/data-collection-access.fr.md @@ -0,0 +1,91 @@ +--- +title: Données des observateurs et vie privée +description: Voyez ce qu’un observateur envoie, où ces données apparaissent et comment éviter d’y inclure des renseignements privés. +audience: + - observer-operators + - community-members +task: understand-observer-data +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 6 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Données des observateurs et vie privée + +Considérez comme publiques toutes les données captées par un observateur. Un +observateur ne déchiffre pas les messages privés, mais il peut transmettre les +détails des paquets et des renseignements sur son état. N’inscrivez pas de noms, +d’emplacements, d’identifiants ou d’autres renseignements sensibles dans les +messages MeshCore. + +
+
ObservateurTransmet les données de paquets et l’état provenant des paramètres radio qu’il utilise.
+
CoreScopePeut afficher publiquement les observateurs, les paquets et les données cartographiques.
+
CourtierLes abonnements directs sont restreints, mais les données affichées dans CoreScope demeurent publiques.
+
+ +## Résumé de la politique + +| Question | Réponse | +|---|---| +| Qui gère le service? | Les administrateurs de l’infrastructure de MeshCore Canada | +| Où puis-je poser mes questions? | [Forum de MeshCore Canada](https://forum.meshcore.ca/) | +| Combien de temps les données sont-elles conservées? | Aucune période de conservation publique n’a encore été annoncée | + +Ne présumez pas que les données seront supprimées après une période précise. +Communiquez avec l’équipe d’infrastructure si vous devez connaître l’échéancier +de conservation ou de suppression. + +## Parcours des données + +
    +
  1. Paquet radiotransmis sur le réseau maillé configuré
  2. +
  3. Observateurcapte et transmet la télémétrie
  4. +
  5. Infrastructurela reçoit et peut la conserver
  6. +
  7. CoreScopepeut l’afficher publiquement
  8. +
+ +Changer le préréglage radio modifie ce que l’observateur peut capter. Le choix +d’un canal public ou privé ne rend pas privée la télémétrie qui accompagne les +paquets. + +## Collecte, accès et conservation + +| Donnée | Pourquoi elle est utilisée | Où elle peut apparaître | Qui peut y accéder | Conservation | +|---|---|---|---|---| +| État de l’observateur | Indiquer si un observateur est en ligne | CoreScope | Visiteurs; exploitants de l’infrastructure | Non précisée publiquement | +| Télémétrie des paquets captés | Montrer l’activité du réseau et aider à diagnostiquer la couverture | Vues de paquets et cartes de CoreScope | Visiteurs; abonnés autorisés; exploitants de l’infrastructure | Non précisée publiquement | +| Code d’emplacement | Regrouper un observateur près d’un lieu connu | Listes d’observateurs, sujets et cartes | Visiteurs et utilisateurs du courtier | Suit la conservation de l’observateur ou des paquets | +| Coordonnées facultatives du responsable | Aider à identifier un service | Données d’état selon l’intégration | Peuvent être transmises à l’infrastructure et à CoreScope | Non précisée publiquement | +| Identifiants du courtier | Authentifier l’observateur | Devraient rester dans l’intégration locale | Exploitant local et service d’authentification | Ne jamais les inclure dans des diagnostics publics | + +MeshCore Canada n’offre pas d’abonnement direct général au courtier. L’accès +direct est limité à CoreScope, aux administrateurs des réseaux maillés locaux +et aux personnes autorisées par les administrateurs de l’infrastructure. + +## Où les données apparaissent + +[CoreScope](https://live.meshcore.ca/) affiche des renseignements sur les +observateurs, les paquets et la carte. D’autres services autorisés de MeshCore +Canada peuvent utiliser le même flux. + +Les contrôles d’accès au courtier ne rendent pas les renseignements privés une +fois que CoreScope les affiche. + +## Avant d’exploiter un observateur + +- [ ] Informez les personnes qui utilisent le réseau local qu’un observateur est actif. +- [ ] Utilisez un nom général pour l’observateur, et non une adresse résidentielle ou le nom d’une personne. +- [ ] Laissez les champs facultatifs de courriel du responsable vides, sauf s’ils sont nécessaires à l’exploitation. +- [ ] Ne collez jamais de mots de passe Wi-Fi, de jetons MQTT, de clés privées ou de journaux non caviardés dans une demande d’aide. +- [ ] Lisez les étapes de vérification et de récupération propres à la méthode avant d’apporter des changements. + +Retournez à [Choisir une méthode d’observation](intro.md), ou voyez +[quoi retirer avant de demander de l’aide](troubleshooting.md#what-to-share-when-asking-for-help). diff --git a/docs/analyzer/data-collection-access.md b/docs/analyzer/data-collection-access.md index 518d2e8..fac423e 100644 --- a/docs/analyzer/data-collection-access.md +++ b/docs/analyzer/data-collection-access.md @@ -1,47 +1,77 @@ -# MQTT Data Collection & Access - -!!! warning "Treat MeshCore RF traffic as public data" - MeshCore traffic is intended for shared mesh use, and different networks may use different presets or frequencies (including non-default settings). All channels that use a shared public key (and private keys) should be considered inherently insecure. Any node transmitting MeshCore packets over matching settings can be heard by observers on that mesh, not just one published default profile. Traffic forwarded over MQTT through this path should be treated as potentially public. Do not transmit names, locations, notes, or other personal information unless you are comfortable with that information being stored and viewable publicly. Assume that even with encryption on a private channel / setting can potentially be collected and decrypted by anyone with the means and know-how to do so. - -## What We Collect - -MeshCore Canada MQTT receives packet data from observer nodes that capture MeshCore packets and forward telemetry from matched channels. - -Observers listen for all MeshCore traffic they can hear on the channels and presets they are configured for. If a packet is heard by an observer and that observer has packet publishing enabled, that traffic can be sent to the MeshCore Canada MQTT brokers. - -## Where Data Goes - -| Step | What happens | -|------|--------------| -| Radio traffic | Nodes transmit MeshCore packets on the frequencies and settings configured for their local mesh and presets. | -| Observer capture | MeshCore Canada observers and other authorized observers listen to all traffic they can hear on their configured channels. | -| MQTT publish | Observer paths publish packet data to MeshCore Canada MQTT infrastructure. | -| Storage and display | Data is stored on MeshCore Canada infrastructure and may be displayed by Beacon, CoreScope, and other public websites operated by MeshCore Canada or approved third-party operators. | - -## MQTT Subscription Access - -Direct MQTT subscription access is not handed out to everyone. It is limited to local mesh administrators, approved tools, and people approved by MeshCore Canada administration. - -Even when direct broker subscription access is limited, the data can still be viewable by everyone through Beacon, CoreScope, and other public websites that consume the MQTT feed using approved MQTT read accounts. - -## MQTT Read Access - -| Tool or service | Operator | Purpose | -|-----------------|----------|---------| -| Beacon | MeshCore Canada operators | Public viewer for MeshCore packet data. | -| CoreScope (`live.meshcore.ca`) | MeshCore Canada operators | Public observer, packet, and map tools. | - -## Infrastructure Administrators - -The MeshCore Canada infrastructure administrators control the MQTT brokers and related infrastructure. - -| Administrator | Profile | -|---------------|---------| -| Mr. Alderson | [github.com/MrAlders0n](https://github.com/MrAlders0n) | -| Ded | [github.com/446564](https://github.com/446564) | -| n30nex | [github.com/n30nex](https://github.com/n30nex) | -| Kranic | [forum.meshcore.ca/u/djkranic](https://forum.meshcore.ca/u/djkranic) | - -Questions about privacy, MQTT access, or the MeshCore Canada project should be directed to these administrators. - -General discussion and support is also available on the forum at [https://forum.meshcore.ca/](https://forum.meshcore.ca/). +--- +title: Observer data and privacy +description: See what an observer sends, where it appears, and how to keep private information out of it. +audience: + - observer-operators + - community-members +task: understand-observer-data +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 6 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Observer data and privacy + +Treat anything heard by an observer as public. An observer does not decrypt private messages, but it can forward packet details and status information. Do not put private names, locations, credentials, or other sensitive information in MeshCore messages. + +
+
ObserverSends packet data and status from the radio settings it uses.
+
CoreScopeCan show observer, packet, and map data publicly.
+
BrokerDirect subscriptions are restricted, but data shown in CoreScope is still public.
+
+ +## Policy summary + +| Question | Answer | +|---|---| +| Who manages this? | MeshCore Canada infrastructure administrators | +| Where can I ask questions? | [MeshCore Canada forum](https://forum.meshcore.ca/) | +| How long is data kept? | A public retention period has not yet been published | + +Do not assume data will be deleted after a certain time. Ask the infrastructure team if you need a retention or deletion timeline. + +## Data flow + +
    +
  1. Radio packettransmitted on the configured mesh
  2. +
  3. Observerhears and forwards telemetry
  4. +
  5. Infrastructurereceives and may store it
  6. +
  7. CoreScopemay display it publicly
  8. +
+ +Changing the radio preset changes what the observer can hear. Public and private channel choices do not make the surrounding packet telemetry private. + +## Collection, access, and retention + +| Data | Why it is used | Where it may appear | Who can access it | Retention | +|---|---|---|---|---| +| Observer status | Show whether an observer is online | CoreScope | Public viewers; infrastructure operators | Not publicly specified | +| Heard packet telemetry | Show mesh activity and help diagnose coverage | CoreScope packet views and maps | Public viewers; approved subscribers; infrastructure operators | Not publicly specified | +| Location code | Group an observer near a known place | Observer lists, topics, maps | Public viewers and broker users | Follows observer/packet retention | +| Optional owner details | Help identify a service | Integration-dependent status data | May reach infrastructure and CoreScope | Not publicly specified | +| Broker credentials | Authenticate the observer | Should remain only in the local integration | Local operator and authentication service | Never include in public diagnostics | + +MeshCore Canada does not offer general direct broker subscriptions. Direct access is limited to CoreScope, local mesh administrators, and people approved by the infrastructure administrators. + +## Where it appears + +[CoreScope](https://live.meshcore.ca/) shows observer, packet, and map information. Other approved MeshCore Canada services may use the same feed. + +Broker access controls do not make information private once CoreScope displays it. + +## Before you operate an observer + +- [ ] Tell people using the local mesh that an observer is active. +- [ ] Use a broad observer name, not a home address or person's name. +- [ ] Leave optional owner email fields blank unless they are operationally needed. +- [ ] Never paste Wi-Fi passwords, MQTT tokens, private keys, or unredacted logs into support messages. +- [ ] Read the method's verification and recovery steps before making changes. + +Return to [Choose an observer setup](intro.md), or see [what to remove before asking for help](troubleshooting.md#what-to-share-when-asking-for-help). diff --git a/docs/analyzer/iata-codes.fr.md b/docs/analyzer/iata-codes.fr.md new file mode 100644 index 0000000..e175b11 --- /dev/null +++ b/docs/analyzer/iata-codes.fr.md @@ -0,0 +1,74 @@ +--- +title: Trouver le code d’emplacement d’un observateur +description: Trouvez, dans notre liste canadienne, le code à trois lettres de l’aéroport le plus proche de votre observateur. +audience: + - observer-operators +task: choose-location-code +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 3 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-location-codes.js?v=20260722-2 +--- + +# Trouver le code d’emplacement d’un observateur + +Un observateur indique sa zone générale avec le code à trois lettres d’un +aéroport réel. Ce code ne définit pas les limites d’une région MeshCore. + +Choisissez le code de l’aéroport le plus proche de l’observateur. Utilisez le +même code dans chaque entrée du courtier. + +
+
+ + +
+

Chargement des codes d’emplacement…

+
+ + + + + + + + + +
CodeLieuProvince ou territoire
+
+
+ +## À propos de cette liste + +Les [données de référence des codes d’emplacement](location-codes.json) +alimentent l’outil de recherche et les suggestions du générateur de commandes. +Il s’agit d’une courte liste canadienne organisée pour ce site, et non d’un +registre officiel complet des codes d’aéroport. + +Si le code de l’aéroport le plus proche n’y figure pas : + +1. confirmez-le auprès d’une source aéroportuaire fiable; +2. entrez le code à trois lettres dans une méthode qui accepte du texte libre; +3. demandez à MeshCore Canada d’ajouter ce lieu à la liste. + +N’utilisez pas `CAN` pour représenter le Canada : il s’agit du code d’un +aéroport de Guangzhou. N’utilisez pas non plus de valeurs temporaires comme +`XXX` ou `HOME`. + +Retournez à [Choisir une méthode d’observation](intro.md). diff --git a/docs/analyzer/iata-codes.md b/docs/analyzer/iata-codes.md index 7523741..1c6ce73 100644 --- a/docs/analyzer/iata-codes.md +++ b/docs/analyzer/iata-codes.md @@ -1,147 +1,67 @@ -# IATA Region Codes - -Each observer identifies its region with a real 3-letter IATA airport code. Use the airport code nearest to the observer's real location. - -The firmware and helper scripts are not limited to the list below. If your nearest real IATA code is missing here, you can still use it and the public broker will accept it as long as it is a valid airport code. The live site will add observed regions to the picker automatically, but codes missing from the friendly-name list may appear as the bare code until we add a label. - -Do not use placeholders or made-up region names such as `XXX` or `HOME`. Do not use `CAN` as shorthand for Canada; it is a real airport code for Guangzhou and will tag your observer to the wrong region. - -Host-side helper scripts show this same quick list interactively when you omit `--iata`. - -??? note "Ontario" - - | Code | Region | - |------|--------| - | YYZ | Toronto (Pearson) | - | YTZ | Toronto (Billy Bishop) | - | YOW | Ottawa | - | YHM | Hamilton | - | YKF | Kitchener / Waterloo | - | YXU | London | - | YOO | Oshawa | - | YKZ | Buttonville / Markham | - | YAM | Sault Ste. Marie | - | YQT | Thunder Bay | - | YSB | Sudbury | - | YTS | Timmins | - | YQG | Windsor | - | YYB | North Bay | - | YGK | Kingston | - | YPQ | Peterborough | - | YTR | Trenton / Quinte West | - | YHD | Dryden | - | YPL | Pickle Lake | - -??? note "Quebec" - - | Code | Region | - |------|--------| - | YUL | Montreal (Trudeau) | - | YMX | Montreal (Mirabel) | - | YQB | Quebec City | - | YND | Gatineau (Ottawa area) | - | YBG | Bagotville / Saguenay | - | YVO | Val-d'Or | - | YHU | Montreal (St-Hubert) | - | YRJ | Roberval | - | YGL | La Grande Riviere | - | YSC | Sherbrooke | - | YTQ | Tasiujaq | - | YUY | Rouyn-Noranda | - | YZV | Sept-Iles | - | YGP | Gaspe | - | YRQ | Trois-Rivieres | - | YBC | Baie-Comeau | - -??? note "British Columbia" - - | Code | Region | - |------|--------| - | YVR | Vancouver | - | YYJ | Victoria | - | YXX | Abbotsford / Fraser Valley | - | YLW | Kelowna | - | YXS | Prince George | - | YPR | Prince Rupert | - | YXT | Terrace | - | YQQ | Comox / Courtenay | - | YCD | Nanaimo | - | YYD | Smithers | - | YDQ | Dawson Creek | - | YXJ | Fort St. John | - | YYF | Penticton | - | YCG | Castlegar | - | YKA | Kamloops | - | YXC | Cranbrook | - -??? note "Alberta" - - | Code | Region | - |------|--------| - | YYC | Calgary | - | YEG | Edmonton | - | YMM | Fort McMurray | - | YQU | Grande Prairie | - | YQL | Lethbridge | - | YXH | Medicine Hat | - -??? note "Saskatchewan" - - | Code | Region | - |------|--------| - | YQR | Regina | - | YXE | Saskatoon | - | YPA | Prince Albert | - -??? note "Manitoba" - - | Code | Region | - |------|--------| - | YWG | Winnipeg | - | YBR | Brandon | - | YTH | Thompson | - | YDN | Dauphin | - | YPG | Portage la Prairie | - -??? note "New Brunswick" - - | Code | Region | - |------|--------| - | YFC | Fredericton | - | YSJ | Saint John | - | YQM | Moncton | - | ZBF | Bathurst | - -??? note "Nova Scotia" - - | Code | Region | - |------|--------| - | YHZ | Halifax | - | YQY | Sydney | - | YQI | Yarmouth | - -??? note "Prince Edward Island" - - | Code | Region | - |------|--------| - | YYG | Charlottetown | - -??? note "Newfoundland and Labrador" - - | Code | Region | - |------|--------| - | YYT | St. John's | - | YQX | Gander | - | YDF | Deer Lake | - | YYR | Goose Bay | - | YWK | Wabush | - -??? note "Territories (YT / NT / NU)" - - | Code | Region | - |------|--------| - | YXY | Whitehorse (Yukon) | - | YZF | Yellowknife (NWT) | - | YFB | Iqaluit (Nunavut) | - | YEV | Inuvik (NWT) | - | YHY | Hay River (NWT) | +--- +title: Find an observer location code +description: Search the Canadian quick list for the real three-letter airport code nearest to an observer. +audience: + - observer-operators +task: choose-location-code +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 3 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-location-codes.js?v=20260722-2 +--- + +# Find an observer location code + +An observer uses a real three-letter airport code as a broad location label. It does not define a MeshCore region boundary. + +Use the nearest sensible code for the observer's actual area. Use the same code in every broker entry. + +
+
+ + +
+

Loading location codes…

+
+ + + + + + + + + +
CodePlaceProvince or territory
+
+
+ +## About this list + +The [canonical location-code data](location-codes.json) generates the search tool and the command-builder suggestions. It is a curated Canadian quick list, not a complete official airport-code registry. + +If the nearest real airport code is missing: + +1. verify it against a reliable airport source; +2. type the three-letter code into a method that accepts free text; and +3. ask MeshCore Canada to add the friendly place name. + +Do not use `CAN` for Canada; it is an airport code for Guangzhou. Do not use placeholders such as `XXX` or `HOME`. + +Return to [Choose an observer method](intro.md). diff --git a/docs/analyzer/intro.fr.md b/docs/analyzer/intro.fr.md new file mode 100644 index 0000000..514bd68 --- /dev/null +++ b/docs/analyzer/intro.fr.md @@ -0,0 +1,102 @@ +--- +title: Configurer un observateur de réseau +description: Choisissez la méthode d’observation qui convient à votre installation actuelle, puis vérifiez-la dans CoreScope. +audience: + - observer-operators +task: choose-observer-method +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 5 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-method-chooser.js?v=20260722-2 +--- + +# Configurer un observateur de réseau + +Un observateur transmet à CoreScope les données des paquets MeshCore captés à +proximité. Il ne déchiffre pas les messages privés et n’a pas besoin de relayer +le trafic. + +## Comment les éléments communiquent + +
    +
  1. Radiocapte les paquets MeshCore à proximité
  2. +
  3. Observateurtransmet les données des paquets
  4. +
  5. MeshCore Canadales reçoit par deux points de terminaison communs
  6. +
  7. Outils en directCoreScope affiche les observateurs et les paquets
  8. +
+ +Les points de terminaison communs utilisent MQTT, mais vous n’avez pas besoin +d’apprendre MQTT avant de choisir une méthode. + +!!! warning "Les observateurs partagent ce qu’ils captent" + Les renseignements sur l’observateur et les données des paquets captés peuvent apparaître dans CoreScope. Ne transmettez pas de renseignements sensibles. Lisez [Données des observateurs et vie privée](data-collection-access.md) avant d’en activer un. + +## Choisir votre méthode + +Commencez par l’outil qui gère déjà votre radio. + +
+ + +
+ +Les mêmes choix figurent dans ce tableau : + +| Votre installation actuelle | Méthode recommandée | Hôte qui doit rester en fonction | +|---|---|---| +| RemoteTerm gère déjà la radio | [RemoteTerm](remoteterm.md) | L’hôte RemoteTerm | +| Home Assistant utilise déjà MeshCore | [Home Assistant](builds/meshcore-ha.md) | Home Assistant | +| PyMC gère déjà un répéteur | [PyMC](builds/pymc.md) | L’hôte PyMC | +| Un hôte Linux ou macOS est relié par USB | [MCtoMQTT](builds/mctomqtt.md) | L’hôte USB | +| Une carte LoRa Wi-Fi compatible peut y être consacrée | [Micrologiciel MQTT autonome](builds/mqtt-firmware.md) | Aucun hôte distinct | + +Si aucune méthode ne convient, demandez conseil sur le +[forum de MeshCore Canada](https://forum.meshcore.ca/) avant d’installer un +nouvel outil. + +## Ce qu’il faut pour chaque méthode + +Quelle que soit la méthode choisie, il vous faut : + +- une radio déjà configurée pour le réseau maillé local; +- un véritable [code d’emplacement](iata-codes.md) à trois lettres; +- les points de terminaison principal et de secours de MeshCore Canada; +- des connexions chiffrées avec validation des certificats; +- la publication des paquets, et non seulement de l’état; +- un hôte ou une carte qui reste en fonction. + +La configuration canadienne de départ est **USA/Canada (Recommended)**, +`910.525 MHz / 62.5 kHz / SF7 / CR5`, avec le hachage des chemins sur +3 octets. Une configuration locale publiée a priorité. + +Pour connaître les champs exacts du courtier, consultez la +[référence des points de terminaison](broker-reference.md). + +## Terminer la vérification + +L’installation n’est pas terminée dès qu’un écran indique « connected ». Elle +l’est lorsque : + +1. votre observateur apparaît dans [CoreScope Observers](https://live.meshcore.ca/#/observers); +2. un paquet capté par votre radio apparaît dans [CoreScope Packets](https://live.meshcore.ca/#/packets). + +Terminez avec [Vérifier votre observateur](verify.md). En cas de problème, +consultez le [dépannage](troubleshooting.md). diff --git a/docs/analyzer/intro.md b/docs/analyzer/intro.md index 23e8044..f1edbb7 100644 --- a/docs/analyzer/intro.md +++ b/docs/analyzer/intro.md @@ -1,124 +1,92 @@ -# Analyzer & MQTT Packet Broker - -MeshCore observers capture mesh traffic and publish packet telemetry to MQTT brokers, feeding CoreScope dashboards, maps, and packet inspectors. Pick the observer path that matches your hardware and host setup. - -!!! tip "Observer setup checklist" - Every observer path needs the same basics: a MeshCore radio already on the MeshCore Canada network settings, a real 3-letter IATA airport code, the MeshCore.ca broker pair, JWT token authentication, TLS on port `443`, and packet publishing enabled. If the setup screen has two broker entries, use the same IATA code on both entries. - - MeshCore Canada network settings are **USA/Canada (Recommended)**, or raw radio values `910.525 MHz / 62.5 kHz / SF7 / CR5`, with 3-byte path hashes. Standalone observer firmware from the observer flasher includes selectable MeshCore Canada broker presets; set `meshcore-ca-1` and `meshcore-ca-2`, then set `set path.hash.mode 2`, IATA, WiFi, and packet publishing during onboarding. On retained preferences or generic CLI devices, also run `set radio 910.525,62.5,7,5`. - -## Choose Your Observer Path - -
- -- :material-chip:{ .lg .middle } **MQTT Firmware** - - --- - - Flash observer firmware directly onto a WiFi-capable LoRa board. No host computer required after setup. - - Best for: Heltec V3, Heltec V4 OLED, and other published direct MQTT targets. - - [:octicons-arrow-right-24: MQTT Firmware Guide](builds/mqtt-firmware.md) - -- :material-usb:{ .lg .middle } **MCtoMQTT** - - --- - - Bridge a USB-connected MeshCore node to MQTT via a Linux or macOS host. - - Best for: fixed repeaters and room servers with a nearby host machine. - - [:octicons-arrow-right-24: MCtoMQTT Guide](builds/mctomqtt.md) - -- :material-language-python:{ .lg .middle } **PyMC** - - --- - - Add the MeshCore.ca broker pair to an existing pyMC repeater installation. - - Best for: Python-based repeater setups. - - [:octicons-arrow-right-24: PyMC Guide](builds/pymc.md) - -- :material-home-assistant:{ .lg .middle } **Home Assistant** - - --- - - Add MeshCore.ca brokers to the Home Assistant MeshCore integration. - - Best for: Home Assistant users with a connected MeshCore node. - - [:octicons-arrow-right-24: MeshCore-HA Guide](builds/meshcore-ha.md) - -- :octicons-terminal-24:{ .lg .middle } **RemoteTerm** - - --- - - Use RemoteTerm's Community MQTT fanout to report packets from a managed radio. - - Best for: RemoteTerm users already connected over serial, TCP, or BLE. - - [:octicons-arrow-right-24: RemoteTerm Setup](remoteterm.md) - +--- +title: Set up a network observer +description: Choose the observer setup that matches what you already run, then check it in CoreScope. +audience: + - observer-operators +task: choose-observer-method +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 5 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +page_scripts: + - assets/javascripts/analyzer-method-chooser.js?v=20260722-2 +--- + +# Set up a network observer + +An observer forwards nearby MeshCore packet data to CoreScope. It does not decrypt private messages or need to repeat traffic. + +## How the pieces fit + +
    +
  1. Radiohears nearby MeshCore packets
  2. +
  3. Observerforwards the packet data
  4. +
  5. MeshCore Canadareceives it through two shared endpoints
  6. +
  7. Live toolsCoreScope shows observers and packets
  8. +
+ +The shared endpoints use MQTT, but you do not need to learn MQTT before choosing a setup. + +!!! warning "Observers share what they hear" + Observer details and heard packet data can appear in CoreScope. Do not transmit sensitive information. Read [Observer data and privacy](data-collection-access.md) before turning one on. + +## Choose your setup + +Start with what already manages your radio. + +
+ +
-## Shared References - -
- -- :material-check-circle:{ .lg .middle } **Check Your Observer** - - --- - - Confirm your observer is online and reporting to CoreScope. - - [:octicons-arrow-right-24: Check Your Observer](verify.md) +The same choices are listed here: -- :material-wrench:{ .lg .middle } **Troubleshooting** +| Your current setup | Recommended method | Host that must stay on | +|---|---|---| +| RemoteTerm already manages the radio | [RemoteTerm](remoteterm.md) | The RemoteTerm host | +| Home Assistant already has MeshCore | [Home Assistant](builds/meshcore-ha.md) | Home Assistant | +| PyMC already manages a repeater | [PyMC](builds/pymc.md) | The PyMC host | +| A Linux or macOS host is connected by USB | [MCtoMQTT](builds/mctomqtt.md) | The USB host | +| A supported Wi-Fi LoRa board can be dedicated | [Standalone MQTT firmware](builds/mqtt-firmware.md) | No separate host | - --- +If none fit, ask in the [MeshCore Canada forum](https://forum.meshcore.ca/) before installing anything new. - Path-specific diagnostics for firmware, host bridges, PyMC, Home Assistant, and RemoteTerm. +## What every setup needs - [:octicons-arrow-right-24: Troubleshooting](troubleshooting.md) +Whichever setup you choose, you need: -- :material-server-network:{ .lg .middle } **Broker Reference** +- a radio already set for the local mesh; +- a real three-letter [location code](iata-codes.md); +- the MeshCore Canada primary and backup endpoints; +- encrypted connections with certificate checks; +- packet publishing, not status-only publishing; and +- an always-on host or board. - --- +The Canadian onboarding baseline is **USA/Canada (Recommended)**, `910.525 MHz / 62.5 kHz / SF7 / CR5`, with 3-byte path hashes. A published local setting takes priority. - Broker hosts, ports, TLS, JWT audience, and topic conventions. - - [:octicons-arrow-right-24: Broker Details](broker-reference.md) - -- :material-eye:{ .lg .middle } **Data Collection & Access** - - --- - - Learn what observer data is collected, where it is stored, and who administers MQTT access. - - [:octicons-arrow-right-24: Data Collection & Access](data-collection-access.md) - -- :material-airplane:{ .lg .middle } **IATA Codes** - - --- - - Canadian quick-list codes and guidance for choosing a real region code. - - [:octicons-arrow-right-24: IATA Region Codes](iata-codes.md) - -
+For exact broker fields, use the [observer endpoint reference](broker-reference.md). -## Fast Path +## Finish the check -If you are unsure which path to choose: +Setup is not finished when a screen says “connected.” You are done when: -| Situation | Start here | -|-----------|------------| -| You have a WiFi-capable LoRa board and want a standalone observer | [MQTT Firmware](builds/mqtt-firmware.md) | -| You have a repeater connected to a Linux/macOS host over USB | [MCtoMQTT](builds/mctomqtt.md) | -| You already run pyMC | [PyMC](builds/pymc.md) | -| You already use Home Assistant for MeshCore | [MeshCore-HA](builds/meshcore-ha.md) | -| You already manage the radio with RemoteTerm | [RemoteTerm](remoteterm.md) | +1. your observer appears in [CoreScope Observers](https://live.meshcore.ca/#/observers); and +2. a packet heard by your radio appears in [CoreScope Packets](https://live.meshcore.ca/#/packets). -After setup, use [Check Your Observer](verify.md). If it does not appear within a few minutes, use [Troubleshooting](troubleshooting.md). +Finish with [Check your observer](verify.md). If something fails, go to [Troubleshooting](troubleshooting.md). diff --git a/docs/analyzer/location-codes.json b/docs/analyzer/location-codes.json new file mode 100644 index 0000000..b81d3b5 --- /dev/null +++ b/docs/analyzer/location-codes.json @@ -0,0 +1,101 @@ +{ + "schema_version": 1, + "version": "2026.07", + "last_reviewed": "2026-07-19", + "scope": "Curated Canadian quick list; not a complete official airport-code registry.", + "locations": [ + {"code": "YYZ", "name": "Toronto Pearson", "province": "Ontario", "province_code": "ON"}, + {"code": "YTZ", "name": "Toronto Billy Bishop", "province": "Ontario", "province_code": "ON"}, + {"code": "YOW", "name": "Ottawa", "province": "Ontario", "province_code": "ON"}, + {"code": "YHM", "name": "Hamilton", "province": "Ontario", "province_code": "ON"}, + {"code": "YKF", "name": "Kitchener / Waterloo", "province": "Ontario", "province_code": "ON"}, + {"code": "YXU", "name": "London", "province": "Ontario", "province_code": "ON"}, + {"code": "YOO", "name": "Oshawa", "province": "Ontario", "province_code": "ON"}, + {"code": "YKZ", "name": "Buttonville / Markham", "province": "Ontario", "province_code": "ON"}, + {"code": "YAM", "name": "Sault Ste. Marie", "province": "Ontario", "province_code": "ON"}, + {"code": "YQT", "name": "Thunder Bay", "province": "Ontario", "province_code": "ON"}, + {"code": "YSB", "name": "Sudbury", "province": "Ontario", "province_code": "ON"}, + {"code": "YTS", "name": "Timmins", "province": "Ontario", "province_code": "ON"}, + {"code": "YQG", "name": "Windsor", "province": "Ontario", "province_code": "ON"}, + {"code": "YYB", "name": "North Bay", "province": "Ontario", "province_code": "ON"}, + {"code": "YGK", "name": "Kingston", "province": "Ontario", "province_code": "ON"}, + {"code": "YPQ", "name": "Peterborough", "province": "Ontario", "province_code": "ON"}, + {"code": "YTR", "name": "Trenton / Quinte West", "province": "Ontario", "province_code": "ON"}, + {"code": "YHD", "name": "Dryden", "province": "Ontario", "province_code": "ON"}, + {"code": "YPL", "name": "Pickle Lake", "province": "Ontario", "province_code": "ON"}, + + {"code": "YUL", "name": "Montreal Trudeau", "province": "Quebec", "province_code": "QC"}, + {"code": "YMX", "name": "Montreal Mirabel", "province": "Quebec", "province_code": "QC"}, + {"code": "YQB", "name": "Quebec City", "province": "Quebec", "province_code": "QC"}, + {"code": "YND", "name": "Gatineau / Ottawa area", "province": "Quebec", "province_code": "QC"}, + {"code": "YBG", "name": "Bagotville / Saguenay", "province": "Quebec", "province_code": "QC"}, + {"code": "YVO", "name": "Val-d'Or", "province": "Quebec", "province_code": "QC"}, + {"code": "YHU", "name": "Montreal St-Hubert", "province": "Quebec", "province_code": "QC"}, + {"code": "YRJ", "name": "Roberval", "province": "Quebec", "province_code": "QC"}, + {"code": "YGL", "name": "La Grande Riviere", "province": "Quebec", "province_code": "QC"}, + {"code": "YSC", "name": "Sherbrooke", "province": "Quebec", "province_code": "QC"}, + {"code": "YTQ", "name": "Tasiujaq", "province": "Quebec", "province_code": "QC"}, + {"code": "YUY", "name": "Rouyn-Noranda", "province": "Quebec", "province_code": "QC"}, + {"code": "YZV", "name": "Sept-Iles", "province": "Quebec", "province_code": "QC"}, + {"code": "YGP", "name": "Gaspe", "province": "Quebec", "province_code": "QC"}, + {"code": "YRQ", "name": "Trois-Rivieres", "province": "Quebec", "province_code": "QC"}, + {"code": "YBC", "name": "Baie-Comeau", "province": "Quebec", "province_code": "QC"}, + + {"code": "YVR", "name": "Vancouver", "province": "British Columbia", "province_code": "BC"}, + {"code": "YYJ", "name": "Victoria", "province": "British Columbia", "province_code": "BC"}, + {"code": "YXX", "name": "Abbotsford / Fraser Valley", "province": "British Columbia", "province_code": "BC"}, + {"code": "YLW", "name": "Kelowna", "province": "British Columbia", "province_code": "BC"}, + {"code": "YXS", "name": "Prince George", "province": "British Columbia", "province_code": "BC"}, + {"code": "YPR", "name": "Prince Rupert", "province": "British Columbia", "province_code": "BC"}, + {"code": "YXT", "name": "Terrace", "province": "British Columbia", "province_code": "BC"}, + {"code": "YQQ", "name": "Comox / Courtenay", "province": "British Columbia", "province_code": "BC"}, + {"code": "YCD", "name": "Nanaimo", "province": "British Columbia", "province_code": "BC"}, + {"code": "YYD", "name": "Smithers", "province": "British Columbia", "province_code": "BC"}, + {"code": "YDQ", "name": "Dawson Creek", "province": "British Columbia", "province_code": "BC"}, + {"code": "YXJ", "name": "Fort St. John", "province": "British Columbia", "province_code": "BC"}, + {"code": "YYF", "name": "Penticton", "province": "British Columbia", "province_code": "BC"}, + {"code": "YCG", "name": "Castlegar", "province": "British Columbia", "province_code": "BC"}, + {"code": "YKA", "name": "Kamloops", "province": "British Columbia", "province_code": "BC"}, + {"code": "YXC", "name": "Cranbrook", "province": "British Columbia", "province_code": "BC"}, + + {"code": "YYC", "name": "Calgary", "province": "Alberta", "province_code": "AB"}, + {"code": "YEG", "name": "Edmonton", "province": "Alberta", "province_code": "AB"}, + {"code": "YMM", "name": "Fort McMurray", "province": "Alberta", "province_code": "AB"}, + {"code": "YQU", "name": "Grande Prairie", "province": "Alberta", "province_code": "AB"}, + {"code": "YQL", "name": "Lethbridge", "province": "Alberta", "province_code": "AB"}, + {"code": "YXH", "name": "Medicine Hat", "province": "Alberta", "province_code": "AB"}, + + {"code": "YQR", "name": "Regina", "province": "Saskatchewan", "province_code": "SK"}, + {"code": "YXE", "name": "Saskatoon", "province": "Saskatchewan", "province_code": "SK"}, + {"code": "YPA", "name": "Prince Albert", "province": "Saskatchewan", "province_code": "SK"}, + + {"code": "YWG", "name": "Winnipeg", "province": "Manitoba", "province_code": "MB"}, + {"code": "YBR", "name": "Brandon", "province": "Manitoba", "province_code": "MB"}, + {"code": "YTH", "name": "Thompson", "province": "Manitoba", "province_code": "MB"}, + {"code": "YDN", "name": "Dauphin", "province": "Manitoba", "province_code": "MB"}, + {"code": "YPG", "name": "Portage la Prairie", "province": "Manitoba", "province_code": "MB"}, + + {"code": "YFC", "name": "Fredericton", "province": "New Brunswick", "province_code": "NB"}, + {"code": "YSJ", "name": "Saint John", "province": "New Brunswick", "province_code": "NB"}, + {"code": "YQM", "name": "Moncton", "province": "New Brunswick", "province_code": "NB"}, + {"code": "ZBF", "name": "Bathurst", "province": "New Brunswick", "province_code": "NB"}, + + {"code": "YHZ", "name": "Halifax", "province": "Nova Scotia", "province_code": "NS"}, + {"code": "YQY", "name": "Sydney", "province": "Nova Scotia", "province_code": "NS"}, + {"code": "YQI", "name": "Yarmouth", "province": "Nova Scotia", "province_code": "NS"}, + + {"code": "YYG", "name": "Charlottetown", "province": "Prince Edward Island", "province_code": "PE"}, + + {"code": "YYT", "name": "St. John's", "province": "Newfoundland and Labrador", "province_code": "NL"}, + {"code": "YQX", "name": "Gander", "province": "Newfoundland and Labrador", "province_code": "NL"}, + {"code": "YDF", "name": "Deer Lake", "province": "Newfoundland and Labrador", "province_code": "NL"}, + {"code": "YYR", "name": "Goose Bay", "province": "Newfoundland and Labrador", "province_code": "NL"}, + {"code": "YWK", "name": "Wabush", "province": "Newfoundland and Labrador", "province_code": "NL"}, + + {"code": "YXY", "name": "Whitehorse", "province": "Yukon", "province_code": "YT"}, + {"code": "YZF", "name": "Yellowknife", "province": "Northwest Territories", "province_code": "NT"}, + {"code": "YFB", "name": "Iqaluit", "province": "Nunavut", "province_code": "NU"}, + {"code": "YEV", "name": "Inuvik", "province": "Northwest Territories", "province_code": "NT"}, + {"code": "YHY", "name": "Hay River", "province": "Northwest Territories", "province_code": "NT"} + ] +} diff --git a/docs/analyzer/observer-config.json b/docs/analyzer/observer-config.json new file mode 100644 index 0000000..bb79abf --- /dev/null +++ b/docs/analyzer/observer-config.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "version": "2026.07", + "last_reviewed": "2026-07-19", + "network": { + "preset": "USA/Canada (Recommended)", + "frequency_mhz": 910.525, + "bandwidth_khz": 62.5, + "spreading_factor": 7, + "coding_rate": 5, + "path_hash_mode": 2, + "path_hash_bytes": 3 + }, + "brokers": [ + { + "id": "primary", + "name": "MeshCore Canada primary", + "host": "mqtt1.meshcore.ca", + "port": 443, + "transport": "websockets", + "tls": true, + "verify_tls": true, + "authentication": "MeshCore JWT token", + "token_audience": "mqtt1.meshcore.ca" + }, + { + "id": "backup", + "name": "MeshCore Canada backup", + "host": "mqtt2.meshcore.ca", + "port": 443, + "transport": "websockets", + "tls": true, + "verify_tls": true, + "authentication": "MeshCore JWT token", + "token_audience": "mqtt2.meshcore.ca" + } + ], + "topics": { + "packets": "meshcore/{IATA}/{PUBLIC_KEY}/packets", + "status": "meshcore/{IATA}/{PUBLIC_KEY}/status" + }, + "verification": { + "observers": "https://live.meshcore.ca/#/observers", + "packets": "https://live.meshcore.ca/#/packets", + "map": "https://live.meshcore.ca/#/map" + }, + "location_codes": "location-codes.json" +} diff --git a/docs/analyzer/remoteterm.fr.md b/docs/analyzer/remoteterm.fr.md new file mode 100644 index 0000000..6fa8a0b --- /dev/null +++ b/docs/analyzer/remoteterm.fr.md @@ -0,0 +1,113 @@ +--- +title: Observer avec RemoteTerm +description: Transmettez à CoreScope les paquets d’une radio déjà gérée par RemoteTerm. +audience: + - observer-operators +task: configure-remoteterm-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: intermediate +estimated_time: 15 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Observer avec RemoteTerm + +[RemoteTerm for MeshCore](https://github.com/jkingsman/Remote-Terminal-for-MeshCore) +peut transmettre les données de paquets d’une radio qu’il gère déjà. Il ne +déchiffre pas les messages privés. + +## Cette méthode vous convient-elle? + +
+
Utilisez RemoteTerm siIl se connecte déjà à la radio par liaison série, TCP ou BLE.
+
Choisissez autre chose siVous installeriez RemoteTerm uniquement pour observer.
+
Gardez en fonctionL’hôte RemoteTerm, la connexion radio et l’accès Internet.
+
+ +RemoteTerm évolue rapidement. Si les libellés ne correspondent pas, consultez +les instructions du projet pour la version installée avant d’enregistrer les +changements. + +## Avant de commencer + +- [ ] RemoteTerm est installé à partir de sa source vérifiée. +- [ ] La connexion à la radio est stable. +- [ ] La radio utilise les paramètres du réseau maillé local. +- [ ] Vous avez choisi un véritable [code d’emplacement](iata-codes.md). +- [ ] Vous avez lu [Données des observateurs et vie privée](data-collection-access.md). + +## Ce qui sera modifié + +Vous ajouterez une entrée Community MQTT principale et une entrée de secours. +Elles transmettent les données de paquets par des connexions WebSocket chiffrées +et ne modifient pas le micrologiciel de la radio. + +## Configuration + +Ouvrez **Settings** → **MQTT & Automation**, ajoutez +**Community MQTT / meshcoretomqtt**, puis entrez : + +| Champ | Valeur principale | +|---|---| +| Name | `MeshCore.ca 1` | +| Broker Host | `mqtt1.meshcore.ca` | +| Broker Port | `443` | +| Transport | `WebSockets` | +| Authentication | `Token` | +| WebSocket Path | `/` | +| Token Audience | `mqtt1.meshcore.ca` | +| Use TLS | Enabled | +| Verify TLS certificates | Enabled | +| Region Code | Le véritable code d’emplacement à trois lettres le plus près | +| Packet Topic Template | `meshcore/{IATA}/{PUBLIC_KEY}/packets` | + +Laissez le champ facultatif de courriel du responsable vide, sauf s’il est +nécessaire à l’exploitation. Enregistrez l’entrée en la laissant activée. + +Ajoutez une entrée de secours avec les mêmes valeurs, en ne modifiant que : + +| Champ | Valeur de secours | +|---|---| +| Name | `MeshCore.ca 2` | +| Broker Host | `mqtt2.meshcore.ca` | +| Token Audience | `mqtt2.meshcore.ca` | + +Utilisez le même code d’emplacement dans les deux entrées. + +![Paramètres Community MQTT de RemoteTerm pour MeshCore Canada](../assets/mcterm.png) + +!!! note "Diffusion MQTT sous Windows" + Si les instructions actuelles de RemoteTerm exigent l’option Uvicorn `--loop none` pour la diffusion MQTT sous Windows, utilisez cette option de lancement. Confirmez-la dans les instructions correspondant à la version de RemoteTerm installée. + +## Ce que vous devriez voir + +Les deux entrées demeurent activées sans erreurs TLS ou de jeton répétées, et le +nombre de paquets dans RemoteTerm change lorsqu’il capte de l’activité à +proximité. + +## Vérifier dans CoreScope + +1. Ouvrez [CoreScope Observers](https://live.meshcore.ca/#/observers) et trouvez l’observateur RemoteTerm. +2. Produisez de l’activité normale à proximité, ou attendez qu’il y en ait. +3. Ouvrez [CoreScope Packets](https://live.meshcore.ca/#/packets) et confirmez qu’un paquet récent y apparaît. + +Terminez avec [Vérifier votre observateur](verify.md). Une entrée connectée sans +paquet récent ne confirme pas que tout le parcours fonctionne. + +## Récupération + +Désactivez ou supprimez seulement les deux entrées Community MQTT que vous avez +ajoutées. Ne supprimez aucune autre automatisation de RemoteTerm. Confirmez que +RemoteTerm gère toujours la radio normalement. + +## Si la vérification échoue + +Consultez le [dépannage](troubleshooting.md). Si seule la connexion de secours +échoue, vérifiez que son hôte et l’audience de son jeton sont tous deux +`mqtt2.meshcore.ca`. diff --git a/docs/analyzer/remoteterm.md b/docs/analyzer/remoteterm.md index d5905d0..0195999 100644 --- a/docs/analyzer/remoteterm.md +++ b/docs/analyzer/remoteterm.md @@ -1,47 +1,98 @@ -# RemoteTerm Setup +--- +title: Observe with RemoteTerm +description: Send packets from a radio already managed by RemoteTerm to CoreScope. +audience: + - observer-operators +task: configure-remoteterm-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: intermediate +estimated_time: 15 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- -[RemoteTerm for MeshCore](https://github.com/jkingsman/Remote-Terminal-for-MeshCore) can act as an observer by forwarding raw RF packets through its **Community MQTT / meshcoretomqtt** integration. This does not publish decrypted messages. +# Observe with RemoteTerm -Install and start RemoteTerm using the upstream instructions, connect your MeshCore radio over serial, TCP, or BLE, then open the RemoteTerm web UI. +[RemoteTerm for MeshCore](https://github.com/jkingsman/Remote-Terminal-for-MeshCore) can forward packet data from a radio it already manages. It does not decrypt private messages. -## Configure Community MQTT +## Is this method right for you? -In RemoteTerm, open **Settings** -> **MQTT & Automation**, add **Community MQTT/meshcoretomqtt**, and use these values: +
+
Use RemoteTerm ifIt already connects to the radio over serial, TCP, or BLE.
+
Use something else ifYou would install RemoteTerm only for observing.
+
Keep onlineThe RemoteTerm host, radio connection, and internet access.
+
-| Field | Value | -|-------|-------| -| Name | `MeshCore.ca 1` or any descriptive name | +RemoteTerm changes quickly. If the labels do not match, check the upstream instructions for your installed version before saving. + +## Before you start + +- [ ] RemoteTerm is installed from its reviewed upstream source. +- [ ] The radio connection is stable. +- [ ] The radio is on the local mesh settings. +- [ ] You chose a real [location code](iata-codes.md). +- [ ] You read [Observer data, access, and privacy](data-collection-access.md). + +## What this changes + +You will add a primary and backup Community MQTT entry. They send packet data over encrypted WebSocket connections and do not change the radio firmware. + +## Set up + +Open **Settings** → **MQTT & Automation**, add **Community MQTT / meshcoretomqtt**, and enter: + +| Field | Primary value | +|---|---| +| Name | `MeshCore.ca 1` | | Broker Host | `mqtt1.meshcore.ca` | | Broker Port | `443` | | Transport | `WebSockets` | | Authentication | `Token` | | WebSocket Path | `/` | -| Token Audience | `mqtt1.meshcore.ca` or leave blank to default to the broker host | -| Owner Email | Optional | +| Token Audience | `mqtt1.meshcore.ca` | | Use TLS | Enabled | | Verify TLS certificates | Enabled | -| Region Code (IATA) | Your nearest real 3-letter IATA airport code | +| Region Code | Your nearest real three-letter location code | | Packet Topic Template | `meshcore/{IATA}/{PUBLIC_KEY}/packets` | -Click **Save as Enabled**. - -## Add the Backup Broker +Leave optional owner email blank unless it is operationally needed. Save the entry as enabled. -To publish to the redundant broker as well, add a second Community MQTT integration with the same settings, changing only: +Add a backup entry with the same values, changing only: -| Field | Value | -|-------|-------| +| Field | Backup value | +|---|---| | Name | `MeshCore.ca 2` | | Broker Host | `mqtt2.meshcore.ca` | | Token Audience | `mqtt2.meshcore.ca` | -Use the same IATA code on both entries. +Use the same location code in both entries. -![RemoteTerm Community MQTT settings for MeshCore.ca](../assets/mcterm.png) +![RemoteTerm Community MQTT settings for MeshCore Canada](../assets/mcterm.png) !!! note "Windows MQTT fanout" - If you run RemoteTerm on Windows and enable MQTT fanout, start Uvicorn with `--loop none` as described in the RemoteTerm README so the MQTT client can connect reliably. + If RemoteTerm's current upstream instructions require Uvicorn `--loop none` for Windows MQTT fanout, use that documented launch option. Confirm against the installed RemoteTerm version. + +## What you should see + +Both entries stay enabled without repeated TLS or token errors, and RemoteTerm's packet count changes when it hears nearby activity. + +## Verify in CoreScope + +1. Open [CoreScope Observers](https://live.meshcore.ca/#/observers) and find the RemoteTerm observer. +2. Create or wait for normal nearby activity. +3. Open [CoreScope Packets](https://live.meshcore.ca/#/packets) and confirm a recent packet appears. + +Finish with [Check your observer](verify.md). A connected entry without a recent packet is not proof that the whole path works. + +## Recovery + +Disable or remove only the two Community MQTT entries you added. Do not remove unrelated RemoteTerm automation. Confirm RemoteTerm still manages the radio normally. -## Verify +## If verification fails -After saving, use [Check Your Observer](verify.md). If the observer connects but no packets appear, confirm the radio is on the MeshCore Canada preset and that RemoteTerm is publishing packet topics, not only status. +Use [Troubleshooting](troubleshooting.md). If only the backup fails, check that its host and token audience are both `mqtt2.meshcore.ca`. diff --git a/docs/analyzer/scripts/add-meshcore-ca-broker.sh b/docs/analyzer/scripts/add-meshcore-ca-broker.sh index 87a41e9..619ab14 100644 --- a/docs/analyzer/scripts/add-meshcore-ca-broker.sh +++ b/docs/analyzer/scripts/add-meshcore-ca-broker.sh @@ -107,6 +107,8 @@ upper_iata() { printf '%s' "$1" | tr '[:lower:]' '[:upper:]' } +# Keep this quick-list snapshot aligned with ../location-codes.json. The +# analyzer content test fails when the published JSON and helper drift. IATA_CHOICES="$(cat <<'EOF' Ontario|YYZ|Toronto (Pearson) Ontario|YTZ|Toronto (Billy Bishop) @@ -142,6 +144,7 @@ Quebec|YUY|Rouyn-Noranda Quebec|YZV|Sept-Iles Quebec|YGP|Gaspe Quebec|YRQ|Trois-Rivieres +Quebec|YBC|Baie-Comeau British Columbia|YVR|Vancouver British Columbia|YYJ|Victoria British Columbia|YXX|Abbotsford / Fraser Valley @@ -158,7 +161,6 @@ British Columbia|YYF|Penticton British Columbia|YCG|Castlegar British Columbia|YKA|Kamloops British Columbia|YXC|Cranbrook -British Columbia|YBC|Baie-Comeau Alberta|YYC|Calgary Alberta|YEG|Edmonton Alberta|YMM|Fort McMurray @@ -273,8 +275,12 @@ EOF exit 1 fi IATA="$(upper_iata "$IATA" | tr -d '[:space:]')" - if [ "$IATA" = "XXX" ]; then - echo "XXX is a placeholder. Use the real 3-letter IATA airport code nearest to you." >&2 + if [ "$IATA" = "XXX" ] || [ "$IATA" = "HOME" ]; then + echo "$IATA is a placeholder. Use the real 3-letter IATA airport code nearest to you." >&2 + exit 1 + fi + if [ "$IATA" = "CAN" ]; then + echo "CAN is Guangzhou's airport code, not shorthand for Canada. Use the real code nearest to you." >&2 exit 1 fi if ! printf '%s' "$IATA" | grep -Eq '^[A-Z]{3}$'; then diff --git a/docs/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 b/docs/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 index 20fb8cf..9d747ea 100644 --- a/docs/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 +++ b/docs/analyzer/scripts/add-meshcore-ca-packetcapture-broker.ps1 @@ -31,6 +31,8 @@ function Prompt-YesNo { } } +# Keep this quick-list snapshot aligned with ../location-codes.json. The +# analyzer content test fails when the published JSON and helper drift. $KnownIataCodes = @( "YYZ","YTZ","YOW","YHM","YKF","YXU","YOO","YKZ","YAM","YQT","YSB","YTS","YQG","YYB","YGK","YPQ","YTR","YHD","YPL","YND", "YUL","YMX","YQB","YBG","YVO","YHU","YRJ","YGL","YSC","YTQ","YUY","YZV","YGP","YRQ", @@ -53,8 +55,11 @@ function Resolve-Iata { $candidate = Read-Host "Enter 3-letter IATA airport code" $candidate = $candidate.Trim().ToUpperInvariant() } - if ($candidate -eq "XXX") { - throw "XXX is a placeholder. Use the real 3-letter IATA airport code nearest to you." + if ($candidate -in @("XXX", "HOME")) { + throw "$candidate is a placeholder. Use the real 3-letter IATA airport code nearest to you." + } + if ($candidate -eq "CAN") { + throw "CAN is Guangzhou's airport code, not shorthand for Canada. Use the real code nearest to you." } if ($candidate -notmatch '^[A-Z]{3}$') { throw "IATA code must be exactly 3 letters." diff --git a/docs/analyzer/troubleshooting.fr.md b/docs/analyzer/troubleshooting.fr.md new file mode 100644 index 0000000..fbcfe04 --- /dev/null +++ b/docs/analyzer/troubleshooting.fr.md @@ -0,0 +1,177 @@ +--- +title: Dépanner un observateur +description: Trouvez où votre observateur a cessé de fonctionner et demandez de l’aide sans dévoiler de renseignements secrets. +audience: + - observer-operators +task: troubleshoot-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: intermediate +estimated_time: 15 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Dépanner un observateur + +Commencez par ce que vous pouvez constater. Modifiez un seul élément à la fois +pour savoir ce qui a réglé le problème. + +## L’observateur n’apparaît jamais { #observer-never-appears } + +Vérifiez les éléments suivants dans l’ordre : + +1. **Radio :** confirmez qu’elle est alimentée, connectée à la méthode d’observation et réglée selon le réseau maillé local. +2. **Appareil ou service :** confirmez que le processus de l’observateur fonctionne. +3. **Courtier :** confirmez que le point de terminaison principal indique une connexion avec validation TLS. +4. **Affichage :** attendez quelques minutes, actualisez [CoreScope Observers](https://live.meshcore.ca/#/observers), puis recherchez le nom exact de l’observateur. + +Ces commandes consultent seulement l’état des services : + +=== "MCtoMQTT" + + ```bash + sudo systemctl status mctomqtt --no-pager + sudo journalctl -u mctomqtt -n 80 --no-pager + ``` + +=== "Capture du compagnon" + + ```bash + sudo systemctl status meshcore-capture --no-pager + sudo journalctl -u meshcore-capture -n 80 --no-pager + ``` + +=== "PyMC" + + ```bash + sudo systemctl status pymc-repeater --no-pager + sudo journalctl -u pymc-repeater -n 80 --no-pager + ``` + +Avant de partager les résultats, suivez les consignes de la section +[Quoi transmettre lorsque vous demandez de l’aide](#what-to-share-when-asking-for-help). + +Pour un micrologiciel autonome, exécutez seulement ces commandes de lecture +dans l’interface en ligne de commande de l’appareil : + +```text +get name +get wifi.status +get mqtt.iata +get mqtt.status +get mqtt1.preset +get mqtt2.preset +get path.hash.mode +``` + +Vous devriez voir la connexion Wi-Fi et au moins le courtier principal +connectés, un code d’emplacement à trois lettres ainsi que les préréglages +`meshcore-ca-1` et `meshcore-ca-2`. + +## L’observateur apparaît, mais aucun paquet n’arrive { #observer-appears-but-no-packets-arrive } + +Une connexion au courtier ne confirme pas la transmission des paquets. + +1. Confirmez que la radio capte l’activité normale du réseau maillé à proximité. +2. Confirmez que la publication des paquets est activée pour la méthode choisie. +3. Confirmez que le code d’emplacement et le sujet des paquets concordent. +4. Consultez [CoreScope Packets](https://live.meshcore.ca/#/packets) après de l’activité à proximité. + +| Méthode | Paramètre de paquets | +|---|---| +| Micrologiciel MQTT | `get mqtt.packets` indique `on`, `get bridge.enabled` indique `on` et `get mqtt.rx` indique `on` | +| MCtoMQTT / capture du compagnon | Le sujet des paquets se termine par `/packets`, et non seulement par `/status` | +| PyMC | Le champ `format` du courtier est `letsmesh` | +| Home Assistant | **Payload Mode** est réglé à `packet`, ou l’ancien réglage **Packets (Lets Mesh)** est activé | +| RemoteTerm | Le sujet de paquets Community MQTT est activé | + +Si la radio ne reçoit aucun paquet, vérifiez d’abord le préréglage radio, +l’antenne, la connexion et l’activité locale. Ne modifiez les paramètres du +courtier qu’après ces vérifications. + +## Seule la connexion de secours échoue { #only-the-backup-connection-fails } + +Comparez les deux entrées. L’hôte de secours et l’audience du jeton doivent être +`mqtt2.meshcore.ca`. Un jeton destiné à l’hôte principal ne peut pas +s’authentifier auprès de l’hôte de secours. + +Ne désactivez pas la validation TLS pour réussir la connexion. + +## L’observateur apparaît au mauvais endroit { #observer-appears-in-the-wrong-place } + +Vérifiez chaque champ d’emplacement configuré. Utilisez le même véritable +[code d’emplacement](iata-codes.md) à trois lettres dans les deux entrées du +courtier et dans la méthode d’observation. + +N’utilisez pas : + +- `CAN` comme abréviation du Canada; +- `XXX` ou `HOME`; +- un code voisin simplement parce qu’un ancien sélecteur n’affiche pas le bon code. + +Mettez l’intégration à jour si elle n’accepte pas le bon code. + +## L’observateur se connecte et se déconnecte sans arrêt { #observer-connects-and-disconnects-repeatedly } + +Vérifiez, dans cet ordre : + +1. la stabilité de l’alimentation et de la connexion USB; +2. la mise en veille de l’hôte ou les redémarrages du service; +3. la stabilité d’Internet et du DNS; +4. l’exactitude de l’horloge système; +5. les erreurs répétées de jeton, de TLS ou de WebSocket dans les journaux locaux. + +Notez l’heure et le fuseau horaire d’une déconnexion. Un administrateur pourra +ainsi comparer votre signalement aux journaux de l’infrastructure sans que vous +ayez à dévoiler vos identifiants. + +## L’écran de Home Assistant ne correspond pas au guide { #home-assistant-screen-does-not-match-the-guide } + +Les écrans actuels utilisent **Payload Mode** et un champ d’emplacement en texte +libre. Les anciens écrans peuvent plutôt afficher **Packets (Lets Mesh)** et un +sélecteur. + +Mettez l’intégration à jour avant de choisir un mauvais emplacement. Si l’écran +actuel diffère encore, notez les versions de Home Assistant et de l’intégration +MeshCore, puis demandez de l’aide. + +## Quoi transmettre lorsque vous demandez de l’aide { #what-to-share-when-asking-for-help } + +Copiez ce modèle et remplissez les renseignements que vous connaissez. Conservez +les libellés anglais pour faciliter le triage : + +```text +Observer method: +Device or board: +Operating system / Home Assistant version: +Observer app, integration, or firmware version: +Location code: +Time checked (with time zone): +First thing that failed: radio / observer / broker / viewer +Primary connected: yes / no / unknown +Backup connected: yes / no / not supported +Observer visible: yes / no +Recent packet visible: yes / no +Exact error after redaction: +Steps already tried: +``` + +Avant de le publier, retirez : + +- le nom du réseau Wi-Fi et son mot de passe; +- les mots de passe MQTT, JWT, jetons, témoins et en-têtes d’autorisation; +- les clés privées MeshCore; +- le courriel du responsable et ses coordonnées personnelles; +- les adresses résidentielles ou coordonnées exactes; +- les lignes sans rapport provenant de fichiers de configuration complets. + +Publiez le tout sur le [forum de MeshCore Canada](https://forum.meshcore.ca/) ou +le canal de soutien de votre communauté locale. N’incluez que le court extrait +du journal qui montre le problème. + +Retournez à [Vérifier votre observateur](verify.md) après chaque correction. diff --git a/docs/analyzer/troubleshooting.md b/docs/analyzer/troubleshooting.md index 75496ef..2e3ed6d 100644 --- a/docs/analyzer/troubleshooting.md +++ b/docs/analyzer/troubleshooting.md @@ -1,119 +1,158 @@ -# Troubleshooting - -If your observer doesn't show up on [CoreScope](https://live.meshcore.ca/#/observers), work through these checks based on your setup path. - -## MQTT Firmware - -Run these in the device's admin CLI: - -| Command | What to check | -|---------|---------------| -| `get wifi.status` | Should show connected to your 2.4 GHz network | -| `get mqtt.status` | Should show an active broker connection | -| `get mqtt.iata` | Should return your real 3-letter IATA airport code | -| `get mqtt.packets` | Should be `on` for packet publishing | -| `get bridge.enabled` | Should be `on` for bridge publishing | -| `get mqtt.rx` / `get mqtt.tx` | Should match the firmware guide setup | -| `get mqtt1.preset` | Should show `meshcore-ca-1` | -| `get mqtt2.preset` | Should show `meshcore-ca-2` | -| `get name` | Should return the node name you set | - -??? note "Full verify command block" - - ```text - get name - get mqtt.origin - get mqtt.iata - get wifi.status - get mqtt.packets - get bridge.enabled - get mqtt.rx - get mqtt.tx - get mqtt.status - get mqtt1.preset - get mqtt2.preset - get mqtt3.preset +--- +title: Troubleshoot an observer +description: Find where your observer stopped working and ask for help without exposing secrets. +audience: + - observer-operators +task: troubleshoot-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: intermediate +estimated_time: 15 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Troubleshoot an observer + +Start with what you can see. Change one part at a time so you know what fixed it. + +## Observer never appears + +Check these in order: + +1. **Radio:** confirm it is powered, connected to the observer method, and on the local mesh settings. +2. **Device or service:** confirm the observer process is running. +3. **Broker:** confirm the primary endpoint reports connected with TLS verification. +4. **Viewer:** wait a few minutes, refresh [CoreScope Observers](https://live.meshcore.ca/#/observers), and search the exact observer name. + +These commands only read service status: + +=== "MCtoMQTT" + + ```bash + sudo systemctl status mctomqtt --no-pager + sudo journalctl -u mctomqtt -n 80 --no-pager ``` -## MCtoMQTT / Companion (USB Host) +=== "Companion capture" -Check that the systemd service is running: + ```bash + sudo systemctl status meshcore-capture --no-pager + sudo journalctl -u meshcore-capture -n 80 --no-pager + ``` -```bash -# Serial host -sudo systemctl status mctomqtt +=== "PyMC" -# Companion -sudo systemctl status meshcore-capture -``` + ```bash + sudo systemctl status pymc-repeater --no-pager + sudo journalctl -u pymc-repeater -n 80 --no-pager + ``` -If the service is running but nothing appears, check the config drop-in: +Before sharing any output, follow [What to share when asking for +help](#what-to-share-when-asking-for-help). -```bash -# Serial host -cat /etc/mctomqtt/config.d/20-meshcore-ca.toml +For standalone firmware, run only these read commands in the device CLI: -# Companion -cat ~/.meshcore-packet-capture/.env.local +```text +get name +get wifi.status +get mqtt.iata +get mqtt.status +get mqtt1.preset +get mqtt2.preset +get path.hash.mode ``` -Confirm the broker hosts are `mqtt1.meshcore.ca` and `mqtt2.meshcore.ca`. +You should see Wi-Fi and at least the primary broker connected, a three-letter location code, and the `meshcore-ca-1` and `meshcore-ca-2` presets. -Also confirm the observer path is publishing packet payloads, not only status. For companion capture, `PACKETCAPTURE_MQTT1_TOPIC_PACKETS` and `PACKETCAPTURE_MQTT2_TOPIC_PACKETS` should use `meshcore/{IATA}/{PUBLIC_KEY}/packets`. +## Observer appears but no packets arrive -## IATA Code Problems +A connected broker is not packet proof. -Use a real 3-letter IATA airport code such as `YOW`, `YKF`, or `YYZ`. The firmware may accept any text, but the public broker rejects placeholders and made-up region names such as `XXX` or `HOME`. Do not use `CAN` as shorthand for Canada; it is a real airport code for Guangzhou and will tag your observer to the wrong region. +1. Confirm the radio can hear normal nearby mesh activity. +2. Confirm packet publishing is enabled for the selected method. +3. Confirm the location code and packet topic agree. +4. Check [CoreScope Packets](https://live.meshcore.ca/#/packets) after nearby activity. -If your code is not on the quick list, that does not automatically mean it is unsupported. It can still work if it is a real IATA airport code. Make sure every component uses the same code: +| Method | Packet setting | +|---|---| +| MQTT firmware | `get mqtt.packets` is `on`, `get bridge.enabled` is `on`, and `get mqtt.rx` is `on` | +| MCtoMQTT / companion capture | Packet topic ends in `/packets`, not only `/status` | +| PyMC | Broker `format` is `letsmesh` | +| Home Assistant | **Payload Mode** is `packet`, or older **Packets (Lets Mesh)** is enabled | +| RemoteTerm | Community MQTT packet topic is enabled | -| Component | Where to check | -|-----------|----------------| -| MQTT firmware | `get mqtt.iata` | -| MCtoMQTT | `/etc/mctomqtt/config.d/20-meshcore-ca.toml` | -| Companion capture | `PACKETCAPTURE_IATA` in `~/.meshcore-packet-capture/.env.local` | -| Home Assistant | MeshCore integration region/IATA field | -| RemoteTerm | Community MQTT region/IATA field | +If the radio hears nothing, resolve the radio preset, antenna, connection, or local activity before changing broker values. -## PyMC +## Only the backup connection fails -```bash -sudo systemctl status pymc-repeater -``` +Compare the two entries. The backup host and token audience must both be `mqtt2.meshcore.ca`. A token for the primary host cannot authenticate to the backup. + +Do not disable TLS verification to make the connection succeed. + +## Observer appears in the wrong place + +Check every configured location field. Use the same real three-letter [location code](iata-codes.md) in both broker entries and in the observer method. -Check that your `mqtt.iata_code` is set and the broker block is present in `/etc/pymc_repeater/config.yaml`. +Do not use: -## Home Assistant +- `CAN` as shorthand for Canada; +- `XXX` or `HOME`; or +- a neighbouring code merely because an older picker does not list the correct code. -Go to **Settings** > **Devices & Services** > **MeshCore** > **Configure** > **Manage MQTT Brokers** and confirm both brokers show as connected. Make sure your IATA code is set in the integration. +Update the integration if it cannot accept the correct code. -If the brokers connect but packets never appear, check the packet payload setting. Some Home Assistant MeshCore versions label this as **Packets (Lets Mesh)**; newer versions expose it as **Payload Mode**. It must be enabled/set to `packet` for MeshCore.ca packet visibility. +## Observer connects and disconnects repeatedly -If a code such as `YTR` is missing from a picker, update the MeshCore Home Assistant integration and type the code into **Broker IATA Code**. Using a nearby code such as `YGK` can make data visible, but it tags the observer to the wrong region. +Check in this order: -| HA symptom | Check | -|------------|-------| -| Broker connected, no packets | **Packets (Lets Mesh)** enabled or **Payload Mode** = `packet` | -| Cannot enter a real IATA code | Update MeshCore-HA; current versions use free text | -| Backup broker fails | `Token Audience` must match the broker host (`mqtt2.meshcore.ca`) | -| Observer appears under the wrong city | Both broker entries use the same nearest real IATA code | +1. stable power and USB connection; +2. host sleep or service restarts; +3. internet and DNS stability; +4. system clock accuracy; +5. repeated token, TLS, or WebSocket errors in local logs. -## RemoteTerm +Record the time and time zone of one disconnect. That lets an administrator compare your report with infrastructure logs without exposing credentials. -Open **Settings** -> **MQTT & Automation** and confirm each Community MQTT entry: +## Home Assistant screen does not match the guide + +Current screens use **Payload Mode** and a free-text location field. Older screens may use **Packets (Lets Mesh)** and a picker. + +Update the integration before substituting a wrong location. If the current screen still differs, record the Home Assistant and MeshCore integration versions and ask for help. + +## What to share when asking for help + +Copy this template and fill in what you know: + +```text +Observer method: +Device or board: +Operating system / Home Assistant version: +Observer app, integration, or firmware version: +Location code: +Time checked (with time zone): +First thing that failed: radio / observer / broker / viewer +Primary connected: yes / no / unknown +Backup connected: yes / no / not supported +Observer visible: yes / no +Recent packet visible: yes / no +Exact error after redaction: +Steps already tried: +``` -| Field | Expected value | -|-------|----------------| -| Broker Host | `mqtt1.meshcore.ca` or `mqtt2.meshcore.ca` | -| Broker Port | `443` | -| Transport | `WebSockets` | -| Authentication | `Token` | -| TLS | Enabled and verified | -| Region Code | Your nearest real IATA code | -| Packet Topic Template | `meshcore/{IATA}/{PUBLIC_KEY}/packets` | +Before posting, remove: -If the primary entry works and the backup does not, check the second entry's token audience. It must be `mqtt2.meshcore.ca`. +- Wi-Fi SSID and password; +- MQTT passwords, JWTs, tokens, cookies, and authorization headers; +- MeshCore private keys; +- owner email and personal contact details; +- exact home addresses or coordinates; and +- unrelated lines from full config files. -## Still Not Working? +Post it in the [MeshCore Canada forum](https://forum.meshcore.ca/) or your local community's support channel. Include only the small part of the log that shows the problem. -If everything looks correct but your observer still doesn't appear, double check that your device has internet access and can reach `mqtt1.meshcore.ca` on port 443. Firewalls or network restrictions on outbound WebSocket connections are the most common blocker. +Return to [Check your observer](verify.md) after each fix. diff --git a/docs/analyzer/verify.fr.md b/docs/analyzer/verify.fr.md new file mode 100644 index 0000000..72c8644 --- /dev/null +++ b/docs/analyzer/verify.fr.md @@ -0,0 +1,94 @@ +--- +title: Vérifier votre observateur +description: Vérifiez que la radio capte le trafic, que l’observateur le transmet et que CoreScope le reçoit. +audience: + - observer-operators +task: verify-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 10 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Vérifier votre observateur + +Une connexion au courtier confirme seulement l’accès Internet. Votre +observateur fonctionne lorsqu’un véritable paquet radio se rend jusqu’à +CoreScope. + +## Suivre un paquet en quatre étapes + +
    +
  1. + Radio : confirmez que l’appareil utilise les paramètres du réseau local et qu’il capte l’activité à proximité. +
    Un compteur de paquets, un journal ou une application connectée devrait changer lorsqu’un nœud à proximité transmet. +
  2. +
  3. + Observateur : vérifiez l’état du service, de l’intégration ou de l’appareil utilisé par votre méthode. +
    Il devrait indiquer une connexion chiffrée à au moins un point de terminaison principal, sans erreurs d’authentification répétées. +
  4. +
  5. + Vue des observateurs : ouvrez CoreScope Observers. +
    Le nom de l’observateur et son code d’emplacement à trois lettres devraient apparaître avec une heure récente. +
  6. +
  7. + Vue des paquets : produisez de l’activité MeshCore à proximité, puis ouvrez CoreScope Packets. +
    Un paquet récent attribué à l’observateur devrait apparaître en quelques minutes. +
  8. +
+ +Ne produisez pas de trafic inutile sur un réseau maillé occupé. Une annonce +normale ou l’activité déjà présente à proximité suffit. + +## Vérifier les détails + +| Vérification | Ce que vous devriez voir | +|---|---| +| Nom | Un nom de service clair comme `YOW-Repeater-01`, sans adresse résidentielle | +| Emplacement | Le véritable code d’aéroport à trois lettres le plus près, et non `CAN`, `XXX` ou `HOME` | +| Parcours principal | Connexion à `mqtt1.meshcore.ca` avec validation du certificat TLS | +| Parcours de secours | Connexion à `mqtt2.meshcore.ca` si la méthode accepte deux entrées | +| Mode de paquets | Publication des paquets activée, et non seulement celle de l’état | +| Heure du paquet | Un paquet récent apparaît après que la radio a capté de l’activité à proximité | +| Radio | Les paramètres locaux sont utilisés; les paramètres par défaut du Canada s’appliquent seulement en l’absence d’une configuration locale | + +## Conserver une note d’entretien + +Conservez une courte note d’entretien privée. Gardez les libellés du modèle +ci-dessous pour faciliter le soutien : + +```text +Observer: +Method and version: +Location code: +Checked at (include time zone): +Radio heard a packet: yes / no +Primary connected: yes / no +Backup connected: yes / no / not supported +Observer visible: yes / no +Packet visible: yes / no +``` + +N’y inscrivez pas d’identifiants, de clés privées, de noms de réseaux Wi-Fi ou +de mots de passe Wi-Fi. + +## Commencer par le premier échec + +Consultez le guide qui correspond au premier élément qui a échoué : + +| Premier échec | Point de départ | +|---|---| +| La radio ne capte aucune activité | [L’observateur apparaît, mais aucun paquet n’arrive](troubleshooting.md#observer-appears-but-no-packets-arrive) | +| L’observateur ne peut pas se connecter | [L’observateur n’apparaît jamais](troubleshooting.md#observer-never-appears) | +| L’observateur est visible, mais la vue des paquets reste vide | [L’observateur apparaît, mais aucun paquet n’arrive](troubleshooting.md#observer-appears-but-no-packets-arrive) | +| Seule la connexion de secours échoue | [Seule la connexion de secours échoue](troubleshooting.md#only-the-backup-connection-fails) | +| Le lieu ou le nom est incorrect | [L’observateur apparaît au mauvais endroit](troubleshooting.md#observer-appears-in-the-wrong-place) | + +Pour obtenir des commandes sûres et un modèle de demande d’aide caviardée, +consultez le [dépannage](troubleshooting.md). diff --git a/docs/analyzer/verify.md b/docs/analyzer/verify.md index 7681c9c..fa4ed95 100644 --- a/docs/analyzer/verify.md +++ b/docs/analyzer/verify.md @@ -1,64 +1,89 @@ -# Check Your Observer - -After setting up your observer using any supported path ([MQTT Firmware](builds/mqtt-firmware.md), [MCtoMQTT](builds/mctomqtt.md), [PyMC](builds/pymc.md), [Home Assistant](builds/meshcore-ha.md), or [RemoteTerm](remoteterm.md)), use the links below to confirm it's online and reporting. - -
- -- :material-eye:{ .lg .middle } **CoreScope Observers** - - --- - - See all connected observers and their current status. - - [:octicons-arrow-right-24: View Observers](https://live.meshcore.ca/#/observers) - -- :material-swap-horizontal:{ .lg .middle } **CoreScope Packets** - - --- - - Watch live packet traffic flowing through observers in real time. - - [:octicons-arrow-right-24: View Packets](https://live.meshcore.ca/#/packets) - -- :material-map-marker:{ .lg .middle } **MeshCore Map** - - --- - - See observers and nodes plotted on the map. - - [:octicons-arrow-right-24: View Map](https://live.meshcore.ca/#/map) - -- :material-wrench:{ .lg .middle } **Troubleshooting** - - --- - - Not showing up? Path-specific diagnostics for all observer types. - - [:octicons-arrow-right-24: Troubleshooting](troubleshooting.md) - -
- -Your observer should appear within a few minutes of coming online. - -## Healthy Observer Checklist - -| Check | Expected result | -|-------|-----------------| -| Observer name | Clear node name such as `YOW-Repeater-01` | -| Region | Nearest real IATA code, not `CAN`, `XXX`, or `HOME` | -| Broker coverage | Primary and backup broker configured where the path supports both | -| Packet activity | Recent packet timestamps on CoreScope after nearby mesh activity | -| Radio settings | `USA/Canada (Recommended)` and 3-byte path hashes unless your local page differs | - -## First Checks - -If it does not appear, start with the checks that match the symptom: - -| Symptom | First check | -|---------|-------------| -| No observer entry | MQTT is connected and the IATA code is a real airport code | -| Observer appears, but no packets | Packet publishing is enabled: firmware `mqtt.packets`, HA **Payload Mode** = `packet`, or pyMC `format: letsmesh` | -| Backup broker does not connect | The token audience matches the broker host (`mqtt2.meshcore.ca` for broker 2) | -| Observer appears under the wrong city | Every broker/integration entry uses the nearest real IATA code; do not use `CAN` for Canada | - -For path-specific commands and Home Assistant settings, use [Troubleshooting](troubleshooting.md). +--- +title: Check your observer +description: Check that the radio hears traffic, the observer sends it, and CoreScope receives it. +audience: + - observer-operators +task: verify-observer +scope: canada-baseline +status: draft +owner: meshcore-canada +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 10 minutes +destructive: false +page_styles: + - assets/styles/analyzer.css?v=20260722-2 +--- + +# Check your observer + +A broker connection proves only internet access. Your observer is working when +a real radio packet reaches CoreScope. + +## Follow a packet through four stages + +
    +
  1. + Radio: confirm the device is on the local mesh settings and can hear nearby activity. +
    A packet counter, log, or connected application should change when a nearby node transmits. +
  2. +
  3. + Observer: check the method's service, integration, or device status. +
    It should report an encrypted connection to at least the primary endpoint without repeating authentication errors. +
  4. +
  5. + Observer view: open CoreScope Observers. +
    The observer name and three-letter location code should appear with a recent timestamp. +
  6. +
  7. + Packet view: create nearby MeshCore activity, then open CoreScope Packets. +
    A recent packet attributed to the observer should appear within a few minutes. +
  8. +
+ +Do not generate unnecessary traffic on a busy mesh. A normal advert or existing nearby activity is enough. + +## Check the details + +| Check | What you should see | +|---|---| +| Name | A clear service name such as `YOW-Repeater-01`, without a home address | +| Location | The nearest real three-letter airport code, not `CAN`, `XXX`, or `HOME` | +| Primary path | Connected to `mqtt1.meshcore.ca` with TLS certificate verification | +| Backup path | Connected to `mqtt2.meshcore.ca` where the method supports two entries | +| Packet mode | Packet publishing is enabled, not status-only | +| Packet time | A recent packet appears after the radio hears nearby traffic | +| Radio | Local settings are used; the Canada defaults apply only when no local override exists | + +## Keep a maintenance note + +Keep a short private maintenance note: + +```text +Observer: +Method and version: +Location code: +Checked at (include time zone): +Radio heard a packet: yes / no +Primary connected: yes / no +Backup connected: yes / no / not supported +Observer visible: yes / no +Packet visible: yes / no +``` + +Do not include credentials, private keys, Wi-Fi names, or Wi-Fi passwords. + +## Start with the first thing that failed + +Use the matching guide for the first thing that failed: + +| What failed first | Where to start | +|---|---| +| Radio does not hear activity | [Observer appears but no packets](troubleshooting.md#observer-appears-but-no-packets-arrive) | +| Observer cannot connect | [Observer never appears](troubleshooting.md#observer-never-appears) | +| Observer is visible but packet view stays empty | [Observer appears but no packets](troubleshooting.md#observer-appears-but-no-packets-arrive) | +| Backup alone fails | [Only the backup connection fails](troubleshooting.md#only-the-backup-connection-fails) | +| Place or name is wrong | [Observer appears in the wrong place](troubleshooting.md#observer-appears-in-the-wrong-place) | + +For safe commands and a redacted support note, use [Troubleshooting](troubleshooting.md). diff --git a/docs/assets/javascripts/analyzer-broker-reference.js b/docs/assets/javascripts/analyzer-broker-reference.js new file mode 100644 index 0000000..3be0bda --- /dev/null +++ b/docs/assets/javascripts/analyzer-broker-reference.js @@ -0,0 +1,82 @@ +(function () { + "use strict"; + + var isFrench = /^fr(?:-|$)/i.test( + document.documentElement ? document.documentElement.lang || "" : "" + ); + + function tr(english, french) { + return isFrench ? french : english; + } + + function cell(row, value) { + var item = document.createElement("td"); + item.textContent = String(value); + row.appendChild(item); + } + + function validConfig(config) { + return config && + config.schema_version === 1 && + Array.isArray(config.brokers) && + config.brokers.length === 2 && + config.brokers.every(function (broker) { + return /^mqtt[12]\.meshcore\.ca$/.test(broker.host) && + broker.port === 443 && + broker.transport === "websockets" && + broker.tls === true && + broker.verify_tls === true && + broker.token_audience === broker.host; + }); + } + + async function init() { + var root = document.getElementById("broker-reference"); + if (!root || root.dataset.ready === "true") return; + var body = document.getElementById("broker-reference-body"); + var status = document.getElementById("broker-reference-status"); + if (!body || !status || !root.dataset.source) return; + + root.dataset.ready = "true"; + try { + var response = await fetch(root.dataset.source, {credentials: "same-origin"}); + if (!response.ok) throw new Error("configuration request failed"); + var config = await response.json(); + if (!validConfig(config)) throw new Error("configuration is invalid"); + + var fragment = document.createDocumentFragment(); + config.brokers.forEach(function (broker) { + var row = document.createElement("tr"); + cell(row, broker.id === "primary" + ? tr("Primary", "Principal") + : tr("Backup", "Secours")); + cell(row, broker.host); + cell(row, broker.port); + cell(row, "WebSockets"); + cell(row, tr( + "Required; verify certificates", + "Requis; vérifier les certificats" + )); + cell(row, broker.token_audience); + fragment.appendChild(row); + }); + body.replaceChildren(fragment); + status.textContent = isFrench + ? "Configuration de l’observateur " + config.version + + ", vérifiée le " + config.last_reviewed + "." + : "Observer configuration " + config.version + + ", reviewed " + config.last_reviewed + "."; + } catch (_error) { + status.textContent = tr( + "The generated broker table could not be loaded. Open the canonical JSON below.", + "Impossible de charger le tableau des serveurs MQTT. Ouvrez le fichier JSON de référence ci-dessous." + ); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, {once: true}); + } else { + init(); + } +})(); diff --git a/docs/assets/javascripts/analyzer-command-builder.js b/docs/assets/javascripts/analyzer-command-builder.js new file mode 100644 index 0000000..04ef2ca --- /dev/null +++ b/docs/assets/javascripts/analyzer-command-builder.js @@ -0,0 +1,306 @@ +(function () { + "use strict"; + + var isFrench = /^fr(?:-|$)/i.test( + document.documentElement ? document.documentElement.lang || "" : "" + ); + + function tr(english, french) { + return isFrench ? french : english; + } + + function utf8Length(text) { + if (window.TextEncoder) return new TextEncoder().encode(text).length; + return unescape(encodeURIComponent(text)).length; + } + + function safeCliToken(text) { + // MeshCore's device CLI does not publish a general quoting contract. + // Reject ambiguous values instead of guessing shell-style escaping. + return /^[A-Za-z0-9!#$%&()*+,./:=?@^_~-]+$/.test(text); + } + + function markInvalid(field, invalid) { + field.setAttribute("aria-invalid", invalid ? "true" : "false"); + } + + function redactedCommands(commands) { + return commands.map(function (line) { + if (line.indexOf("set wifi.ssid ") === 0) { + return isFrench ? "set wifi.ssid [masqué]" : "set wifi.ssid [hidden]"; + } + if (line.indexOf("set wifi.pwd ") === 0) { + return isFrench ? "set wifi.pwd [masqué]" : "set wifi.pwd [hidden]"; + } + return line; + }); + } + + async function populateLocations(root, datalist, status) { + var source = root.dataset.locationSource; + if (!source || !datalist) return; + try { + var response = await fetch(source, {credentials: "same-origin"}); + if (!response.ok) throw new Error("location data request failed"); + var payload = await response.json(); + if (!payload || payload.schema_version !== 1 || !Array.isArray(payload.locations)) { + throw new Error("location data is invalid"); + } + var fragment = document.createDocumentFragment(); + payload.locations.forEach(function (location) { + if (!/^[A-Z]{3}$/.test(location.code)) return; + var option = document.createElement("option"); + option.value = location.code; + option.label = location.name + ", " + location.province; + fragment.appendChild(option); + }); + datalist.replaceChildren(fragment); + status.textContent = payload.locations.length + tr( + " Canadian quick-list codes loaded.", + " codes canadiens chargés dans la liste de suggestions." + ); + } catch (_error) { + status.textContent = tr( + "Location suggestions are unavailable. You can still enter a real 3-letter airport code.", + "Les suggestions d’emplacement ne sont pas disponibles. Vous pouvez tout de même entrer un vrai code d’aéroport à 3 lettres." + ); + } + } + + function init() { + var root = document.getElementById("observer-command-builder"); + if (!root || root.dataset.ready === "true") return; + + var fields = { + board: document.getElementById("observer-board"), + iata: document.getElementById("observer-iata"), + role: document.getElementById("observer-role"), + number: document.getElementById("observer-number"), + ssid: document.getElementById("observer-ssid"), + password: document.getElementById("observer-password"), + repeat: document.getElementById("observer-repeat") + }; + var output = document.getElementById("observer-command-output"); + var status = document.getElementById("observer-copy-status"); + var errors = document.getElementById("observer-command-errors"); + var summary = document.getElementById("observer-command-summary"); + var copyButton = document.getElementById("observer-copy-commands"); + var revealCommandsButton = document.getElementById("observer-reveal-commands"); + var togglePasswordButton = document.getElementById("observer-toggle-password"); + var clearButton = document.getElementById("observer-clear-secrets"); + var datalist = document.getElementById("observer-iata-list"); + var locationStatus = document.getElementById("observer-location-status"); + if ( + Object.values(fields).some(function (field) { return !field; }) || + !output || !status || !errors || !summary || !copyButton || + !revealCommandsButton || !togglePasswordButton || !clearButton + ) { + return; + } + + root.dataset.ready = "true"; + var exactCommands = []; + var commandsRevealed = false; + + function hideExactCommands() { + commandsRevealed = false; + revealCommandsButton.textContent = tr( + "Reveal sensitive commands", + "Afficher les commandes sensibles" + ); + revealCommandsButton.setAttribute("aria-pressed", "false"); + copyButton.disabled = true; + } + + function showCurrentCommands() { + output.textContent = (commandsRevealed ? exactCommands : redactedCommands(exactCommands)).join("\n"); + revealCommandsButton.textContent = commandsRevealed + ? tr("Hide sensitive commands", "Masquer les commandes sensibles") + : tr("Reveal sensitive commands", "Afficher les commandes sensibles"); + revealCommandsButton.setAttribute("aria-pressed", commandsRevealed ? "true" : "false"); + copyButton.disabled = !commandsRevealed || !exactCommands.length; + } + + function renderSummary(board, iata, nodeName, repeat) { + var values = [ + [tr("Board", "Carte"), fields.board.options[fields.board.selectedIndex].text], + [tr("Location", "Emplacement"), iata || tr("Not set", "Non défini")], + [tr("Node name", "Nom du nœud"), nodeName || tr("Not set", "Non défini")], + [ + tr("Mesh traffic", "Trafic du réseau maillé"), + repeat === "on" + ? tr("Observe and repeat", "Observer et relayer") + : tr("Observe only", "Observer seulement") + ] + ]; + var fragment = document.createDocumentFragment(); + values.forEach(function (entry) { + var wrapper = document.createElement("div"); + var term = document.createElement("dt"); + var detail = document.createElement("dd"); + term.textContent = entry[0]; + detail.textContent = entry[1]; + wrapper.appendChild(term); + wrapper.appendChild(detail); + fragment.appendChild(wrapper); + }); + summary.replaceChildren(fragment); + summary.dataset.board = board; + } + + function render() { + var messages = []; + var board = fields.board.value; + var iata = (fields.iata.value || "").trim().toUpperCase(); + var role = fields.role.value; + var number = (fields.number.value || "").trim(); + var ssid = fields.ssid.value || ""; + var password = fields.password.value || ""; + var repeat = fields.repeat.value; + var nodeName = iata && number ? iata + "-" + role + "-" + number : ""; + + [fields.iata, fields.number, fields.ssid, fields.password].forEach(function (field) { + markInvalid(field, false); + }); + + if (!/^[A-Z]{3}$/.test(iata) || iata === "XXX" || iata === "CAN") { + messages.push(tr( + "Enter a real 3-letter airport code; do not use XXX or CAN.", + "Entrez un vrai code d’aéroport à 3 lettres; n’utilisez pas XXX ni CAN." + )); + markInvalid(fields.iata, true); + } + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,15}$/.test(number)) { + messages.push(tr( + "Node number must use 1–16 letters, numbers, underscores, or hyphens.", + "Le numéro du nœud doit comporter de 1 à 16 lettres, chiffres, traits de soulignement ou traits d’union." + )); + markInvalid(fields.number, true); + } + if (nodeName && utf8Length(nodeName) > 24) { + messages.push(tr( + "The generated node name is over 24 bytes; shorten the node number.", + "Le nom de nœud généré dépasse 24 octets; raccourcissez le numéro du nœud." + )); + markInvalid(fields.number, true); + } + if (!ssid || utf8Length(ssid) > 32 || !safeCliToken(ssid)) { + messages.push(tr( + "Enter a 1–32 byte SSID without spaces, quotes, backslashes, semicolons, pipes, control characters, or non-ASCII text. Use Configure via USB for other SSIDs.", + "Entrez un SSID de 1 à 32 octets, sans espaces, guillemets, barres obliques inverses, points-virgules, barres verticales, caractères de contrôle ni texte non ASCII. Utilisez Configure via USB pour les autres SSID." + )); + markInvalid(fields.ssid, true); + } + if (!password || utf8Length(password) > 64 || !safeCliToken(password)) { + messages.push(tr( + "Enter a 1–64 byte password using the safe CLI character set, or use Configure via USB for this network.", + "Entrez un mot de passe de 1 à 64 octets avec les caractères permis par l’interface de commande, ou utilisez Configure via USB pour ce réseau." + )); + markInvalid(fields.password, true); + } + + hideExactCommands(); + errors.textContent = messages.join(" "); + renderSummary(board, iata, nodeName, repeat); + + if (messages.length) { + exactCommands = []; + revealCommandsButton.disabled = true; + output.textContent = tr( + "Complete the required fields to build commands.", + "Remplissez les champs obligatoires pour générer les commandes." + ); + return; + } + + exactCommands = [ + "set name " + nodeName, + "set radio 910.525,62.5,7,5", + "set path.hash.mode 2", + "set mqtt.iata " + iata, + "set wifi.ssid " + ssid, + "set wifi.pwd " + password, + "set wifi.powersave none", + "set mqtt1.preset meshcore-ca-1", + "set mqtt2.preset meshcore-ca-2", + "set mqtt3.preset none", + "set mqtt4.preset none", + "set mqtt5.preset none", + "set mqtt6.preset none", + "set mqtt.status on", + "set mqtt.packets on", + "set mqtt.raw off", + "set mqtt.rx on", + "set mqtt.tx advert", + "set bridge.enabled on", + "set repeat " + repeat, + "advert", + "reboot" + ]; + revealCommandsButton.disabled = false; + showCurrentCommands(); + } + + function clearSecrets() { + fields.ssid.value = ""; + fields.password.value = ""; + fields.password.type = "password"; + togglePasswordButton.textContent = tr("Show", "Afficher"); + togglePasswordButton.setAttribute("aria-pressed", "false"); + exactCommands = []; + hideExactCommands(); + output.textContent = tr("Wi-Fi fields cleared.", "Champs Wi-Fi effacés."); + errors.textContent = ""; + fields.ssid.focus(); + } + + root.addEventListener("input", render); + root.addEventListener("change", render); + togglePasswordButton.addEventListener("click", function () { + var showing = fields.password.type === "text"; + fields.password.type = showing ? "password" : "text"; + togglePasswordButton.textContent = showing + ? tr("Show", "Afficher") + : tr("Hide", "Masquer"); + togglePasswordButton.setAttribute("aria-pressed", showing ? "false" : "true"); + fields.password.focus(); + }); + revealCommandsButton.addEventListener("click", function () { + if (!exactCommands.length) return; + commandsRevealed = !commandsRevealed; + showCurrentCommands(); + }); + copyButton.addEventListener("click", function () { + if (!commandsRevealed || !exactCommands.length) return; + if (!navigator.clipboard || !navigator.clipboard.writeText) { + status.textContent = tr( + "Clipboard unavailable. Copy the revealed commands manually.", + "Le presse-papiers n’est pas disponible. Copiez manuellement les commandes affichées." + ); + return; + } + navigator.clipboard.writeText(exactCommands.join("\n")).then(function () { + status.textContent = tr( + "Copied. Your clipboard now contains Wi-Fi credentials.", + "Copié. Votre presse-papiers contient maintenant vos identifiants Wi-Fi." + ); + }, function () { + status.textContent = tr( + "Copy failed. Copy the revealed commands manually.", + "Échec de la copie. Copiez manuellement les commandes affichées." + ); + }); + }); + clearButton.addEventListener("click", clearSecrets); + window.addEventListener("pagehide", clearSecrets); + + populateLocations(root, datalist, locationStatus); + render(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, {once: true}); + } else { + init(); + } +})(); diff --git a/docs/assets/javascripts/analyzer-location-codes.js b/docs/assets/javascripts/analyzer-location-codes.js new file mode 100644 index 0000000..0173956 --- /dev/null +++ b/docs/assets/javascripts/analyzer-location-codes.js @@ -0,0 +1,172 @@ +(function () { + "use strict"; + + var isFrench = /^fr(?:-|$)/i.test( + document.documentElement ? document.documentElement.lang || "" : "" + ); + + function tr(english, french) { + return isFrench ? french : english; + } + + var frenchProvinceNames = { + BC: "Colombie-Britannique", + AB: "Alberta", + SK: "Saskatchewan", + MB: "Manitoba", + ON: "Ontario", + QC: "Québec", + NB: "Nouveau-Brunswick", + NS: "Nouvelle-Écosse", + PE: "Île-du-Prince-Édouard", + NL: "Terre-Neuve-et-Labrador", + YT: "Yukon", + NT: "Territoires du Nord-Ouest", + NU: "Nunavut" + }; + + var frenchLocationNames = { + YYZ: "Toronto-Pearson", + YTZ: "Toronto-Billy Bishop", + YUL: "Montréal-Trudeau", + YMX: "Montréal-Mirabel", + YQB: "Québec", + YND: "Gatineau / région d’Ottawa", + YHU: "Montréal–Saint-Hubert", + YGL: "La Grande Rivière", + YZV: "Sept-Îles", + YGP: "Gaspé", + YRQ: "Trois-Rivières" + }; + + function localizedProvince(location) { + return isFrench && frenchProvinceNames[location.province_code] + ? frenchProvinceNames[location.province_code] + : location.province; + } + + function localizedName(location) { + return isFrench && frenchLocationNames[location.code] + ? frenchLocationNames[location.code] + : location.name; + } + + function textCell(text) { + var cell = document.createElement("td"); + cell.textContent = text; + return cell; + } + + function validPayload(payload) { + if (!payload || payload.schema_version !== 1 || !Array.isArray(payload.locations)) return false; + var seen = new Set(); + return payload.locations.every(function (location) { + if ( + !location || + !/^[A-Z]{3}$/.test(location.code) || + !/^[A-Z]{2}$/.test(location.province_code) || + typeof location.name !== "string" || + typeof location.province !== "string" || + seen.has(location.code) + ) { + return false; + } + seen.add(location.code); + return true; + }); + } + + async function init() { + var root = document.getElementById("location-code-tool"); + if (!root || root.dataset.ready === "true") return; + + var search = document.getElementById("location-code-search"); + var province = document.getElementById("location-code-province"); + var body = document.getElementById("location-code-results"); + var status = document.getElementById("location-code-status"); + var source = root.dataset.source; + if (!search || !province || !body || !status || !source) return; + + root.dataset.ready = "true"; + status.textContent = tr( + "Loading location codes…", + "Chargement des codes d’emplacement…" + ); + + try { + var response = await fetch(source, {credentials: "same-origin"}); + if (!response.ok) throw new Error("location data request failed"); + var payload = await response.json(); + if (!validPayload(payload)) throw new Error("location data is invalid"); + + var locale = isFrench ? "fr-CA" : "en-CA"; + var locations = payload.locations.slice().sort(function (left, right) { + return localizedProvince(left).localeCompare(localizedProvince(right), locale) || + localizedName(left).localeCompare(localizedName(right), locale); + }); + var provinces = Array.from(new Set(locations.map(function (item) { + return item.province; + }))).sort(function (left, right) { + var leftLocation = locations.find(function (item) { return item.province === left; }); + var rightLocation = locations.find(function (item) { return item.province === right; }); + return localizedProvince(leftLocation).localeCompare(localizedProvince(rightLocation), locale); + }); + + provinces.forEach(function (name) { + var option = document.createElement("option"); + option.value = name; + var representative = locations.find(function (item) { return item.province === name; }); + option.textContent = localizedProvince(representative); + province.appendChild(option); + }); + + function render() { + var query = search.value.trim().toLocaleLowerCase(locale); + var selectedProvince = province.value; + var matches = locations.filter(function (location) { + var searchable = [ + location.code, + location.name, + location.province, + localizedName(location), + localizedProvince(location), + location.province_code + ].join(" ").toLocaleLowerCase(locale); + return (!query || searchable.includes(query)) && + (!selectedProvince || location.province === selectedProvince); + }); + + var fragment = document.createDocumentFragment(); + matches.forEach(function (location) { + var row = document.createElement("tr"); + row.appendChild(textCell(location.code)); + row.appendChild(textCell(localizedName(location))); + row.appendChild(textCell(localizedProvince(location))); + fragment.appendChild(row); + }); + body.replaceChildren(fragment); + status.textContent = matches.length === 1 + ? tr("1 location code shown.", "1 code d’emplacement affiché.") + : matches.length + tr( + " location codes shown.", + " codes d’emplacement affichés." + ); + } + + search.addEventListener("input", render); + province.addEventListener("change", render); + render(); + } catch (_error) { + status.textContent = tr( + "The location list could not be loaded. Use the raw data link below or try again.", + "Impossible de charger la liste des emplacements. Utilisez le lien vers les données brutes ci-dessous ou réessayez." + ); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, {once: true}); + } else { + init(); + } +})(); diff --git a/docs/assets/javascripts/analyzer-method-chooser.js b/docs/assets/javascripts/analyzer-method-chooser.js new file mode 100644 index 0000000..c0460a0 --- /dev/null +++ b/docs/assets/javascripts/analyzer-method-chooser.js @@ -0,0 +1,91 @@ +(function () { + "use strict"; + + var isFrench = /^fr(?:-|$)/i.test( + document.documentElement ? document.documentElement.lang || "" : "" + ); + + function tr(english, french) { + return isFrench ? french : english; + } + + var methods = { + "remote-term": { + title: tr("Use RemoteTerm", "Utiliser RemoteTerm"), + description: tr( + "Keep using the radio connection and management screen you already have.", + "Continuez d’utiliser la connexion radio et l’écran de gestion dont vous disposez déjà." + ), + href: "../remoteterm/" + }, + "home-assistant": { + title: tr("Use Home Assistant", "Utiliser Home Assistant"), + description: tr( + "Add the MeshCore Canada broker pair inside your existing MeshCore integration.", + "Ajoutez les deux serveurs MQTT de MeshCore Canada à votre intégration MeshCore existante." + ), + href: "../builds/meshcore-ha/" + }, + "pymc": { + title: tr("Use PyMC", "Utiliser PyMC"), + description: tr( + "Add the broker pair to the PyMC service you already operate.", + "Ajoutez les deux serveurs MQTT au service PyMC que vous exploitez déjà." + ), + href: "../builds/pymc/" + }, + "usb-host": { + title: tr("Use MCtoMQTT", "Utiliser MCtoMQTT"), + description: tr( + "Run a small bridge on the Linux or macOS computer connected to the radio by USB.", + "Exécutez une petite passerelle sur l’ordinateur Linux ou macOS relié à la radio par USB." + ), + href: "../builds/mctomqtt/" + }, + "wifi-board": { + title: tr("Use standalone MQTT firmware", "Utiliser un micrologiciel MQTT autonome"), + description: tr( + "A supported Wi-Fi LoRa board can listen and publish without a separate computer.", + "Une carte LoRa Wi-Fi compatible peut écouter et publier sans ordinateur distinct." + ), + href: "../builds/mqtt-firmware/" + } + }; + + function init() { + var root = document.getElementById("observer-method-chooser"); + if (!root || root.dataset.ready === "true") return; + + var select = root.querySelector("select"); + var result = document.getElementById("observer-method-result"); + if (!select || !result) return; + + root.dataset.ready = "true"; + select.addEventListener("change", function () { + var method = methods[select.value]; + if (!method) { + result.hidden = true; + result.replaceChildren(); + return; + } + + var heading = document.createElement("strong"); + heading.textContent = method.title; + var detail = document.createElement("span"); + detail.textContent = method.description + " "; + var link = document.createElement("a"); + link.href = method.href; + link.textContent = tr("Open this setup guide", "Ouvrir ce guide de configuration"); + detail.appendChild(link); + result.replaceChildren(heading, detail); + result.hidden = false; + result.focus(); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, {once: true}); + } else { + init(); + } +})(); diff --git a/docs/assets/javascripts/bootstrap.js b/docs/assets/javascripts/bootstrap.js new file mode 100644 index 0000000..f1f4be6 --- /dev/null +++ b/docs/assets/javascripts/bootstrap.js @@ -0,0 +1,157 @@ +(function () { + "use strict"; + + var storagePrefix = "meshcore-canada:progress:v1:"; + + function storageKey(input) { + var pageKey = input.closest("[data-mc-progress-page]"); + var scope = pageKey ? pageKey.getAttribute("data-mc-progress-page") : window.location.pathname; + return storagePrefix + scope + ":" + input.id; + } + + function readProgress(input) { + try { + return window.localStorage.getItem(storageKey(input)) === "done"; + } catch (_error) { + return false; + } + } + + function writeProgress(input) { + try { + if (input.checked) { + window.localStorage.setItem(storageKey(input), "done"); + } else { + window.localStorage.removeItem(storageKey(input)); + } + } catch (_error) { + // The journey still works when storage is blocked or unavailable. + } + } + + function initialiseProgress() { + document.querySelectorAll("input[type='checkbox'][data-mc-progress]").forEach(function (input) { + if (!input.id || input.dataset.mcProgressReady === "true") return; + input.dataset.mcProgressReady = "true"; + input.checked = readProgress(input); + input.addEventListener("change", function () { + writeProgress(input); + }); + }); + } + + function labelExternalLinks() { + document.querySelectorAll("a[target='_blank']").forEach(function (link) { + if (link.dataset.mcExternalReady === "true") return; + link.dataset.mcExternalReady = "true"; + var current = (link.getAttribute("aria-label") || "").trim(); + if (!current) { + var text = (link.textContent || "").trim(); + if (!text) text = (link.getAttribute("title") || "External link").trim(); + link.setAttribute("aria-label", text + " (opens in a new tab)"); + } + }); + } + + function makeHeaderToggleAccessible(label, checkbox, name, panel) { + if (!label || !checkbox || label.dataset.mcToggleReady === "true") return; + label.dataset.mcToggleReady = "true"; + label.setAttribute("role", "button"); + label.setAttribute("tabindex", "0"); + if (!label.hasAttribute("aria-label")) label.setAttribute("aria-label", name); + if (panel) { + if (!panel.id) panel.id = "mc-" + checkbox.id.replace(/^__/, "") + "-panel"; + label.setAttribute("aria-controls", panel.id); + } + + function updateState() { + var expanded = checkbox.checked; + label.setAttribute("aria-expanded", expanded ? "true" : "false"); + label.setAttribute("aria-label", expanded ? name.replace(/^Open /, "Close ") : name); + } + + var suppressKeyboardClick = false; + label.addEventListener("click", function (event) { + if (!suppressKeyboardClick) return; + event.preventDefault(); + event.stopPropagation(); + suppressKeyboardClick = false; + }); + label.addEventListener("keydown", function (event) { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + event.stopPropagation(); + suppressKeyboardClick = true; + checkbox.checked = !checkbox.checked; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + window.setTimeout(function () { suppressKeyboardClick = false; }, 0); + }); + checkbox.addEventListener("change", updateState); + updateState(); + } + + function labelThemeControls() { + var search = document.querySelector(".md-search[role='dialog']"); + if (search && !search.hasAttribute("aria-label")) { + search.setAttribute("aria-label", "Site search"); + } + + var drawerToggle = document.getElementById("__drawer"); + var drawer = document.querySelector(".md-sidebar--primary"); + document.querySelectorAll("label.md-header__button[for='__drawer']").forEach(function (label) { + makeHeaderToggleAccessible(label, drawerToggle, "Open navigation", drawer); + }); + + var searchToggle = document.getElementById("__search"); + document.querySelectorAll("label.md-header__button[for='__search'], label.md-search__icon[for='__search']").forEach(function (label) { + makeHeaderToggleAccessible(label, searchToggle, "Open site search", search); + }); + + document.querySelectorAll("a[href]").forEach(function (link) { + if ((link.textContent || "").trim() !== "Start") return; + if (!/\/start\/(?:$|[?#])/.test(link.href)) return; + link.setAttribute("aria-label", "Start using MeshCore"); + }); + } + + function initialiseSearchInputs() { + document.querySelectorAll(".md-search__input").forEach(function (input) { + if (input.dataset.mcSearchReady === "true") return; + input.dataset.mcSearchReady = "true"; + + var pendingKeyup; + + input.addEventListener("input", function () { + window.clearTimeout(pendingKeyup); + pendingKeyup = window.setTimeout(function () { + input.dispatchEvent(new KeyboardEvent("keyup", { + bubbles: true, + key: "Unidentified", + code: "Unidentified" + })); + }, 60); + }); + + input.addEventListener("keyup", function (event) { + if (event.isTrusted) { + window.clearTimeout(pendingKeyup); + } + }); + }); + } + + function initialise() { + initialiseProgress(); + labelExternalLinks(); + labelThemeControls(); + initialiseSearchInputs(); + } + + if (window.document$ && typeof window.document$.subscribe === "function") { + window.document$.subscribe(initialise); + } else if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialise, { once: true }); + } else { + initialise(); + } +})(); diff --git a/docs/assets/javascripts/communities.js b/docs/assets/javascripts/communities.js new file mode 100644 index 0000000..5389fdd --- /dev/null +++ b/docs/assets/javascripts/communities.js @@ -0,0 +1,113 @@ +(function () { + "use strict"; + + function normalize(value) { + return String(value || "") + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLocaleLowerCase() + .replace(/\s+/g, " ") + .trim(); + } + + function initializeDirectory(root) { + if (!root || root.dataset.communityReady === "true") { + return; + } + + var search = root.querySelector("#community-search"); + var status = root.querySelector("#community-status"); + var override = root.querySelector("#community-override"); + var count = root.querySelector("[data-community-count]"); + var page = root.closest(".md-content") || document; + var cards = Array.prototype.slice.call( + page.querySelectorAll("[data-community-card]") + ); + var empty = page.querySelector("[data-community-empty]"); + var clearButtons = page.querySelectorAll("[data-community-clear]"); + + if (!search || !status || !override || !count || !empty || !cards.length) { + return; + } + + root.dataset.communityReady = "true"; + + function updateUrl(query) { + if (!window.history || !window.history.replaceState) { + return; + } + var url = new URL(window.location.href); + if (query) { + url.searchParams.set("community", query); + } else { + url.searchParams.delete("community"); + } + window.history.replaceState(null, "", url); + } + + function applyFilters(options) { + var query = normalize(search.value); + var selectedStatus = status.value; + var requireOverride = override.checked; + var visible = 0; + + cards.forEach(function (card) { + var matchesQuery = + !query || normalize(card.dataset.communitySearch).indexOf(query) !== -1; + var matchesStatus = + !selectedStatus || card.dataset.communityStatus === selectedStatus; + var matchesOverride = + !requireOverride || card.dataset.communityOverride === "true"; + var show = matchesQuery && matchesStatus && matchesOverride; + card.hidden = !show; + if (show) { + visible += 1; + } + }); + + empty.hidden = visible !== 0; + count.textContent = + "Showing " + visible + " " + (visible === 1 ? "community" : "communities"); + if (!options || options.updateUrl !== false) { + updateUrl(search.value.trim()); + } + } + + function clearFilters() { + search.value = ""; + status.value = ""; + override.checked = false; + applyFilters(); + search.focus(); + } + + search.addEventListener("input", applyFilters); + status.addEventListener("change", applyFilters); + override.addEventListener("change", applyFilters); + clearButtons.forEach(function (button) { + button.addEventListener("click", clearFilters); + }); + + var initialQuery = new URL(window.location.href).searchParams.get("community"); + if (initialQuery) { + search.value = initialQuery; + } + applyFilters({ updateUrl: false }); + } + + function initialize() { + document + .querySelectorAll("[data-community-directory]") + .forEach(initializeDirectory); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialize, { once: true }); + } else { + initialize(); + } + + if (typeof window.document$ !== "undefined") { + window.document$.subscribe(initialize); + } +})(); diff --git a/docs/assets/javascripts/i18n-runtime.js b/docs/assets/javascripts/i18n-runtime.js new file mode 100644 index 0000000..cb8cf10 --- /dev/null +++ b/docs/assets/javascripts/i18n-runtime.js @@ -0,0 +1,453 @@ +(function () { + "use strict"; + + var pathIsFrench = /(?:^|\/)fr(?:\/|$)/.test(window.location.pathname); + var declaredLanguage = (document.documentElement.lang || "").toLowerCase(); + var isFrench = pathIsFrench || declaredLanguage === "fr" || declaredLanguage.indexOf("fr-") === 0; + + var dictionary = { + "External link": "Lien externe", + "Site search": "Recherche sur le site", + "Open navigation": "Ouvrir la navigation", + "Close navigation": "Fermer la navigation", + "Open site search": "Ouvrir la recherche sur le site", + "Close site search": "Fermer la recherche sur le site", + "Zoom in": "Zoom avant", + "Zoom out": "Zoom arrière", + "Start using MeshCore": "Commencer avec MeshCore", + "Region boundary editor · MeshCore Canada": "Éditeur des limites régionales · MeshCore Canada", + "Skip the map and choose a municipality": "Passer la carte et choisir une municipalité", + "Region boundary editor": "Éditeur des limites régionales", + "Editor": "Éditeur", + "Home": "Accueil", + "Standard": "Norme", + "Help": "Aide", + "Exit editor": "Quitter l’éditeur", + "Boundary editing controls": "Commandes de modification des limites", + "Region proposal": "Proposition régionale", + "Suggest a region change": "Proposer une modification régionale", + "Submitting creates a public proposal for review; it does not change the map.": "La soumission crée une proposition publique à examiner; elle ne modifie pas la carte.", + "Read the region rules": "Lire les règles régionales", + "1. Choose proposal type": "1. Choisir le type de proposition", + "Proposal type": "Type de proposition", + "Adjust an existing boundary": "Modifier une limite existante", + "Move official census areas to an existing region.": "Déplacer des zones de recensement officielles vers une région existante.", + "Advanced: propose a new region/subregion": "Avancé : proposer une nouvelle région ou sous-région", + "Define a new public name, tag, cells, and anchor for review.": "Définir un nouveau nom public, un identifiant, des cellules et un point d’ancrage à examiner.", + "2. Choose an area": "2. Choisir une zone", + "Province or territory": "Province ou territoire", + "Loading editor…": "Chargement de l’éditeur…", + "Choose a proposal type to begin.": "Choisissez un type de proposition pour commencer.", + "Discard saved draft": "Supprimer le brouillon enregistré", + "3. Build the proposal": "3. Préparer la proposition", + "Move selected areas to": "Déplacer les zones sélectionnées vers", + "Choose a target region": "Choisir une région de destination", + "New region name": "Nom de la nouvelle région", + "Short tag": "Identifiant court", + "Lowercase letters, numbers and hyphens; up to 29 characters.": "Lettres minuscules, chiffres et traits d’union; 29 caractères au maximum.", + "Suggested tag:": "Identifiant proposé :", + "Use suggestion": "Utiliser la suggestion", + "Proposed hierarchy": "Hiérarchie proposée", + "Add cells to derive the parent.": "Ajoutez des cellules pour déterminer la région parente.", + "Canada › Province or territory › Proposed region": "Canada › Province ou territoire › Région proposée", + "The parent comes from the shared ancestry of the selected cells and is verified by the server.": "La région parente vient de la hiérarchie commune des cellules sélectionnées et sera vérifiée par le serveur.", + "Municipality or census subdivision": "Municipalité ou subdivision de recensement", + "Choose a municipality": "Choisir une municipalité", + "Add whole municipality": "Ajouter toute la municipalité", + "Start with a whole municipality. You do not need the map to submit a proposal.": "Commencez par une municipalité entière. La carte n’est pas nécessaire pour soumettre une proposition.", + "Repeater paths": "Chemins des répéteurs", + "This editor changes Canadian map cells only. Set cross-province and U.S. paths in the configurator.": "Cet éditeur modifie seulement les cellules canadiennes. Réglez les chemins interprovinciaux et américains dans le configurateur.", + "Read the rule": "Lire la règle", + "Advanced: split a municipality by census cell": "Avancé : diviser une municipalité par cellule de recensement", + "Use individual cells only when a full municipality does not fit the boundary.": "Utilisez des cellules individuelles seulement si la municipalité entière ne correspond pas à la limite.", + "This creates a finer boundary for review.": "Cela crée une limite plus précise à examiner.", + "Map editing mode": "Mode de modification de la carte", + "Pan": "Déplacer", + "Inspect": "Examiner", + "Paint": "Peindre", + "Map inspection": "Examen de la carte", + "Area": "Zone", + "Select a cell on the map": "Sélectionnez une cellule sur la carte", + "Municipality": "Municipalité", + "Current region": "Région actuelle", + "Region anchor": "Point d’ancrage régional", + "Move entire municipality": "Déplacer toute la municipalité", + "New region anchor": "Point d’ancrage de la nouvelle région", + "Choose a changed cell": "Choisir une cellule modifiée", + "Confirm this anchor": "Confirmer ce point d’ancrage", + "Choose a changed cell near the centre, then confirm it. Existing fixed anchors cannot be used.": "Choisissez une cellule modifiée près du centre, puis confirmez-la. Les points d’ancrage fixes existants ne peuvent pas être utilisés.", + "4. Review": "4. Relire", + "0 changes": "0 modification", + "Undo": "Annuler", + "Redo": "Rétablir", + "Clear": "Effacer", + "Boundary comparison": "Comparaison des limites", + "Before": "Avant", + "After": "Après", + "Proposal summary": "Résumé de la proposition", + "Choose a proposal type and geography to build a text review.": "Choisissez un type de proposition et une zone géographique pour produire le résumé.", + "Proposed ownership changes": "Modifications proposées de l’attribution", + "Before and after by official census area": "Avant et après par zone de recensement officielle", + "Municipality / area": "Municipalité ou zone", + "Action": "Action", + "No proposed changes.": "Aucune modification proposée.", + "Submitted by": "Soumis par", + "optional": "facultatif", + "This field is never stored in the local draft.": "Ce champ n’est jamais enregistré dans le brouillon local.", + "Reason": "Raison", + "Why should this cell move?": "Pourquoi cette cellule devrait-elle être déplacée?", + "Ready to submit?": "Prêt à soumettre?", + "Choose a proposal type": "Choisir un type de proposition", + "Choose a province or territory": "Choisir une province ou un territoire", + "Choose or define the destination region": "Choisir ou définir la région de destination", + "Add at least one official census cell": "Ajouter au moins une cellule de recensement officielle", + "Confirm an anchor for a new region": "Confirmer un point d’ancrage pour la nouvelle région", + "Explain the reason": "Expliquer la raison", + "Complete the anti-spam check": "Effectuer la vérification antipourriel", + "Website": "Site Web", + "Anti-spam check": "Vérification antipourriel", + "Loading check…": "Chargement de la vérification…", + "Retry check": "Réessayer la vérification", + "Submit for review": "Soumettre aux fins d’examen", + "Download proposal": "Télécharger la proposition", + "No account needed. Submission starts public review; it does not change the map.": "Aucun compte n’est nécessaire. La soumission lance l’examen public; elle ne modifie pas la carte.", + "Region map editor": "Éditeur de la carte des régions", + "Inspect cells or use the municipality list; yellow means changed": "Examinez les cellules ou utilisez la liste des municipalités; le jaune indique une modification", + "Optional interactive region boundary map": "Carte interactive facultative des limites régionales", + "The map is optional. Use the municipality list in step three for a full non-map flow. In Inspect mode, select a cell to read its details. Enable the advanced acknowledgement before painting individual cells.": "La carte est facultative. Utilisez la liste des municipalités à l’étape trois pour travailler sans carte. En mode Examiner, sélectionnez une cellule pour voir ses détails. Confirmez l’option avancée avant de peindre des cellules individuelles.", + "Proposed change": "Modification proposée", + "Discard this draft?": "Supprimer ce brouillon?", + "Keep editing": "Continuer la modification", + "Discard draft": "Supprimer le brouillon", + "British Columbia": "Colombie-Britannique", + "Alberta": "Alberta", + "Saskatchewan": "Saskatchewan", + "Manitoba": "Manitoba", + "Ontario": "Ontario", + "Quebec": "Québec", + "New Brunswick": "Nouveau-Brunswick", + "Nova Scotia": "Nouvelle-Écosse", + "Prince Edward Island": "Île-du-Prince-Édouard", + "Newfoundland and Labrador": "Terre-Neuve-et-Labrador", + "Yukon": "Yukon", + "Northwest Territories": "Territoires du Nord-Ouest", + "Nunavut": "Nunavut", + "Local draft storage is unavailable. Download before leaving.": "Le stockage local des brouillons n’est pas disponible. Téléchargez la proposition avant de partir.", + "No saved draft.": "Aucun brouillon enregistré.", + "Saved locally.": "Brouillon enregistré sur cet appareil.", + "Draft could not be saved locally. Download before leaving.": "Le brouillon n’a pas pu être enregistré sur cet appareil. Téléchargez la proposition avant de partir.", + "Unsaved changes…": "Modifications non enregistrées…", + "An earlier draft was submitted": "Une version antérieure du brouillon a été soumise", + "This proposal was already submitted": "Cette proposition a déjà été soumise", + "Public review created": "Examen public créé", + "Open": "Ouvrir", + "Schema": "Schéma", + "Proposal hash": "Hachage de la proposition", + "Submitted version": "Version soumise", + "Next: community review, maintainer approval, then independent repository validation. Submission alone does not change the map.": "Ensuite : examen par la communauté, approbation par l’équipe de maintenance, puis validation indépendante dans le dépôt. La soumission seule ne modifie pas la carte.", + "That public region name is already used or reserved.": "Ce nom de région public est déjà utilisé ou réservé.", + "Use lowercase letters, numbers and single hyphens; do not start with a hyphen.": "Utilisez des lettres minuscules, des chiffres et des traits d’union simples; ne commencez pas par un trait d’union.", + "That canonical tag is already used or reserved.": "Cet identifiant canonique est déjà utilisé ou réservé.", + "Not derived yet": "Pas encore déterminée", + "Proposed region": "Région proposée", + "Add cells before choosing an anchor": "Ajoutez des cellules avant de choisir un point d’ancrage", + "Census area": "Zone de recensement", + "Choose a changed cell near the centre, then confirm it. No anchor is selected automatically.": "Choisissez une cellule modifiée près du centre, puis confirmez-la. Aucun point d’ancrage n’est sélectionné automatiquement.", + "This editor changes Canadian map cells only. Choose cross-province and U.S. paths in the configurator.": "Cet éditeur modifie seulement les cellules canadiennes. Choisissez les chemins interprovinciaux et américains dans le configurateur.", + "Edit each province separately; repeater setup keeps the paths together.": "Modifiez chaque province séparément; la configuration du répéteur conserve les chemins ensemble.", + "Unnamed census area": "Zone de recensement sans nom", + "Remove": "Retirer", + "Proposal type": "Type de proposition", + "New region/subregion (v2)": "Nouvelle région ou sous-région (v2)", + "Existing boundary adjustment (v1)": "Modification d’une limite existante (v1)", + "Jurisdiction and hierarchy": "Champ de compétence et hiérarchie", + "Destination": "Destination", + "Not chosen": "Non choisie", + "Official geography": "Géographie officielle", + "Sources": "Sources", + "None yet": "Aucune pour le moment", + "Anchor": "Point d’ancrage", + "Not confirmed": "Non confirmé", + "Existing fixed anchors remain protected": "Les points d’ancrage fixes existants restent protégés", + "Authority base": "Base faisant autorité", + "Loading": "Chargement", + "Validation": "Validation", + "Client checks run before export. The gateway and repository independently revalidate every authority rule.": "Les vérifications du navigateur s’exécutent avant l’exportation. La passerelle et le dépôt revérifient indépendamment toutes les règles faisant autorité.", + "Lifecycle": "Cycle de traitement", + "Public review → maintainer approval → national validation → publication": "Examen public → approbation de l’équipe de maintenance → validation nationale → publication", + "Client checks are ready.": "Les vérifications du navigateur sont terminées.", + "Complete the readiness checklist.": "Terminez la liste de vérification.", + "Editor cells contain an unsupported geometry type.": "Les cellules de l’éditeur contiennent un type de géométrie non pris en charge.", + "Editor cell topology is invalid.": "La topologie des cellules de l’éditeur est invalide.", + "Check failed. Retrying…": "La vérification a échoué. Nouvelle tentative…", + "Retrying check…": "Nouvelle tentative de vérification…", + "Check expired. Retrying…": "La vérification a expiré. Nouvelle tentative…", + "Check timed out. Retrying…": "La vérification a pris trop de temps. Nouvelle tentative…", + "Complete the check.": "Effectuez la vérification.", + "Anti-spam protection is unavailable.": "La protection antipourriel n’est pas disponible.", + "Complete the check first.": "Effectuez d’abord la vérification.", + "Submitting…": "Soumission…", + "Submitted, but later edits were not included. Submit again to include them.": "La proposition a été soumise, mais les modifications plus récentes n’y figurent pas. Soumettez-la de nouveau pour les inclure.", + "Already submitted. Open the issue below.": "Déjà soumise. Ouvrez le billet ci-dessous.", + "Submitted for review.": "Soumise aux fins d’examen.", + "The proposal could not be submitted.": "La proposition n’a pas pu être soumise.", + "Reload and try again.": "Rechargez la page et réessayez.", + "Your edits are saved here. Complete the check and try again.": "Vos modifications sont conservées ici. Effectuez la vérification et réessayez.", + "Download the proposal to share it.": "Téléchargez la proposition pour la partager.", + "Preparing another check…": "Préparation d’une nouvelle vérification…", + "No schema": "Aucun schéma", + "No province selected": "Aucune province sélectionnée", + "Switch proposal type?": "Changer de type de proposition?", + "The current on-screen draft will be put away. Its local saved copy will remain under the current schema.": "Le brouillon affiché sera mis de côté. Sa copie locale restera enregistrée sous le schéma actuel.", + "Switch type": "Changer de type", + "Switch province or territory?": "Changer de province ou de territoire?", + "The current on-screen draft will be put away. Its local saved copy will remain under this province and schema.": "Le brouillon affiché sera mis de côté. Sa copie locale restera enregistrée sous cette province et ce schéma.", + "Switch area": "Changer de zone", + "Confirm the public-content statement before saving a draft.": "Confirmez que le contenu peut être public avant d’enregistrer un brouillon.", + "Draft saved on this device. Turnstile and anti-spam values are never stored.": "Brouillon enregistré sur cet appareil. Les valeurs Turnstile et antipourriel ne sont jamais conservées.", + "This browser blocked draft storage. Your answers remain in the form.": "Ce navigateur a bloqué l’enregistrement du brouillon. Vos réponses restent dans le formulaire.", + "Saved draft updated on this device.": "Brouillon mis à jour sur cet appareil.", + "Saved draft cleared.": "Brouillon enregistré supprimé.", + "An incompatible saved draft was removed.": "Un brouillon enregistré incompatible a été supprimé.", + "Draft restored. Confirm the public-content statement before updating or submitting it.": "Brouillon restauré. Confirmez que le contenu peut être public avant de le mettre à jour ou de le soumettre.", + "The saved draft could not be read and was removed.": "Le brouillon enregistré était illisible et a été supprimé.", + "You can now save a draft on this device.": "Vous pouvez maintenant enregistrer un brouillon sur cet appareil.", + "Update preview": "Mettre l’aperçu à jour", + "Review changes again": "Relire les changements", + "Review idea": "Relire l’idée", + "Earlier version submitted. New changes are not included.": "Une version antérieure a été soumise. Les nouvelles modifications n’y figurent pas.", + "Already submitted.": "Déjà soumise.", + "Open issue": "Ouvrir le billet", + "Maintainers will review the public issue and respond there.": "L’équipe de maintenance examinera le billet public et y répondra.", + "Answers changed. Review them again before submitting.": "Les réponses ont changé. Relisez-les avant de soumettre.", + "Check failed. Retry.": "La vérification a échoué. Réessayez.", + "Check complete.": "Vérification terminée.", + "Retrying the anti-spam check…": "Nouvelle tentative de vérification antipourriel…", + "Anonymous submission is unavailable. Copy the idea or use GitHub.": "La soumission anonyme n’est pas disponible. Copiez l’idée ou utilisez GitHub.", + "Fix the fields listed above, then review again.": "Corrigez les champs indiqués ci-dessus, puis relisez la proposition.", + "Too long to prefill. Copy the text, then use GitHub.": "Le texte est trop long pour être prérempli. Copiez-le, puis utilisez GitHub.", + "Preview ready. Nothing has been submitted. You can now submit.": "L’aperçu est prêt. Rien n’a été soumis. Vous pouvez maintenant soumettre l’idée.", + "Preview ready. Nothing has been submitted. Complete the verification next.": "L’aperçu est prêt. Rien n’a été soumis. Effectuez maintenant la vérification.", + "Check the form and try again.": "Vérifiez le formulaire et réessayez.", + "Copied.": "Texte copié.", + "Copy blocked. Copy the selected preview.": "La copie a été bloquée. Copiez l’aperçu sélectionné.", + "Review the latest answers.": "Relisez les réponses les plus récentes.", + "Submitted. Saved draft cleared from this device.": "Soumise. Le brouillon enregistré a été supprimé de cet appareil.", + "The reviewed version was submitted. Review and submit again to include your newer changes.": "La version relue a été soumise. Relisez et soumettez de nouveau pour inclure les modifications plus récentes.", + "The idea could not be submitted.": "L’idée n’a pas pu être soumise.", + "Your answers remain in the form. Complete the check and try again.": "Vos réponses restent dans le formulaire. Effectuez la vérification et réessayez.", + "Your answers remain in the form. Copy the text or use GitHub.": "Vos réponses restent dans le formulaire. Copiez le texte ou utilisez GitHub.", + "Submit idea": "Soumettre l’idée", + "Review the idea first.": "Relisez d’abord l’idée.", + "The submission request was not accepted.": "La demande de soumission n’a pas été acceptée.", + "The submission was not accepted.": "La soumission n’a pas été acceptée.", + "The proposal no longer matches the current region data.": "La proposition ne correspond plus aux données régionales actuelles.", + "The region map changed after this edit began. Reload the editor and try again.": "La carte des régions a changé depuis le début de cette modification. Rechargez l’éditeur et réessayez.", + "This submission is too large to send at once. Save it and contact a maintainer.": "Cette soumission est trop volumineuse pour être envoyée en une fois. Enregistrez-la et communiquez avec l’équipe de maintenance.", + "The anti-spam check was not accepted. Let it refresh and try again.": "La vérification antipourriel n’a pas été acceptée. Laissez-la se renouveler, puis réessayez.", + "Too many submissions were sent from this connection. Wait a few minutes and try again.": "Trop de soumissions ont été envoyées depuis cette connexion. Attendez quelques minutes, puis réessayez.", + "The submission service is temporarily unavailable. Try again shortly.": "Le service de soumission est temporairement indisponible. Réessayez dans quelques instants.", + "The submission service address is invalid.": "L’adresse du service de soumission est invalide.", + "The submission service must use HTTPS.": "Le service de soumission doit utiliser HTTPS.", + "A validated submission is required.": "Une soumission validée est requise.", + "This browser cannot verify the submitted data.": "Ce navigateur ne peut pas vérifier les données soumises.", + "The submission service returned invalid public configuration.": "Le service de soumission a renvoyé une configuration publique invalide.", + "This browser cannot contact the submission service.": "Ce navigateur ne peut pas joindre le service de soumission.", + "The submission service took too long to respond. Try again shortly.": "Le service de soumission a pris trop de temps à répondre. Réessayez dans quelques instants.", + "The submission service could not be reached. Try again shortly.": "Le service de soumission est inaccessible. Réessayez dans quelques instants.", + "Spam protection is temporarily unavailable. Try again shortly.": "La protection antipourriel est temporairement indisponible. Réessayez dans quelques instants.", + "The submission service returned an unreadable configuration.": "Le service de soumission a renvoyé une configuration illisible.", + "Complete the anti-spam check before submitting.": "Effectuez la vérification antipourriel avant de soumettre.", + "The submission could not be sent.": "La soumission n’a pas pu être envoyée.", + "Choose a valid contribution type.": "Choisissez un type de contribution valide.", + "Choose a valid MeshCore experience level.": "Choisissez un niveau d’expérience MeshCore valide.", + "Confirm that this submission can be public.": "Confirmez que cette soumission peut être publique.", + "Contribution type": "Type de contribution", + "MeshCore experience": "Expérience avec MeshCore", + "Short title": "Titre court", + "What is difficult now": "Ce qui est difficile en ce moment", + "What would help": "Ce qui aiderait", + "City or broad region": "Ville ou grande région", + "Additional context": "Autres précisions", + "Public contact": "Contact public", + "This field": "Ce champ", + "A draft from older map data remains stored but was not restored.": "Un brouillon fondé sur d’anciennes données cartographiques est toujours enregistré, mais n’a pas été restauré.", + "Discard the saved copy?": "Supprimer la copie enregistrée?", + "This removes the local saved copy for this schema and province. The current on-screen proposal stays open until it changes or you leave.": "Cela supprime la copie enregistrée sur cet appareil pour ce schéma et cette province. La proposition affichée reste ouverte jusqu’à sa modification ou votre départ.", + "Discard saved copy": "Supprimer la copie enregistrée", + "Saved copy discarded. Current on-screen work is unchanged.": "La copie enregistrée a été supprimée. Le travail affiché reste inchangé.", + "Choose at least one census cell.": "Choisissez au moins une cellule de recensement.", + "Choose or define the destination region first.": "Choisissez ou définissez d’abord la région de destination.", + "Proposal downloaded. It is not live until merged.": "La proposition a été téléchargée. Elle ne sera publiée qu’après sa fusion.", + "Spam protection cannot load in this browser.": "La protection antipourriel ne peut pas se charger dans ce navigateur.", + "Spam protection did not start. Reload the page and try again.": "La protection antipourriel n’a pas démarré. Rechargez la page et réessayez.", + "Spam protection could not load. Check your connection and try again.": "La protection antipourriel n’a pas pu se charger. Vérifiez votre connexion et réessayez.", + "Spam protection is unavailable.": "La protection antipourriel est indisponible.", + "The submission service took too long to respond. Your work is still here; try again.": "Le service de soumission a pris trop de temps à répondre. Votre travail est toujours ici; réessayez.", + "The submission service could not be reached. Your work is still here; try again.": "Le service de soumission est inaccessible. Votre travail est toujours ici; réessayez.", + "The submission service returned an unreadable response.": "Le service de soumission a renvoyé une réponse illisible.", + "The proposal format is not supported.": "Le format de la proposition n’est pas pris en charge.", + "The proposal contains unsupported fields.": "La proposition contient des champs non pris en charge.", + "The map changed after this edit began. Reload and try again.": "La carte a changé depuis le début de cette modification. Rechargez la page et réessayez.", + "Choose at least one cell before exporting.": "Choisissez au moins une cellule avant d’exporter.", + "Choose fewer cells before exporting.": "Choisissez moins de cellules avant d’exporter.", + "The proposal contains an invalid change.": "La proposition contient une modification invalide.", + "The proposal contains a duplicate cell.": "La proposition contient une cellule en double.", + "One or more cells no longer exist. Reload and try again.": "Une ou plusieurs cellules n’existent plus. Rechargez la page et réessayez.", + "A proposal may change cells in only one province or territory.": "Une proposition peut modifier les cellules d’une seule province ou d’un seul territoire.", + "Add a valid name, tag, and anchor for the new region.": "Ajoutez un nom, un identifiant et un point d’ancrage valides pour la nouvelle région.", + "The new region has the wrong catalogue parent.": "La région parente indiquée pour la nouvelle région ne correspond pas au catalogue.", + "That region tag is already in use.": "Cet identifiant régional est déjà utilisé.", + "That region already exists. Choose it from the list.": "Cette région existe déjà. Choisissez-la dans la liste.", + "Choose one changed cell without an existing region anchor.": "Choisissez une cellule modifiée qui ne sert pas déjà de point d’ancrage régional.", + "Every changed cell must move to the new region.": "Chaque cellule modifiée doit être déplacée vers la nouvelle région.", + "A target region must belong to the same province or territory.": "La région de destination doit appartenir à la même province ou au même territoire.", + "A region anchor cell cannot be moved away from its region.": "Une cellule servant de point d’ancrage régional ne peut pas être déplacée hors de sa région.", + "The proposal contains a change that has no effect.": "La proposition contient une modification sans effet.", + "The submitted-by value is invalid.": "Le nom de la personne qui soumet la proposition est invalide.", + "Add a short reason for this boundary change.": "Ajoutez une courte raison pour cette modification de limite.", + "No": "Non", + "Target region": "Région de destination", + "Unnamed municipality": "Municipalité sans nom", + "The submission was received, but the review link was invalid.": "La soumission a été reçue, mais le lien d’examen était invalide." + }; + + var patterns = [ + [/^(.*) \(opens in a new tab\)$/, function (match) { return match[1] + " (s’ouvre dans un nouvel onglet)"; }], + [/^Loading (.+)…$/, function (match) { return "Chargement de " + translate(match[1]) + "…"; }], + [/^(\d[\d\s,.]*) census cells loaded\.$/, function (match) { return match[1] + " cellules de recensement chargées."; }], + [/^(\d+) change$/, function (match) { return match[1] + " modification"; }], + [/^(\d+) changes$/, function (match) { return match[1] + " modifications"; }], + [/^(\d+) changed cell$/, function (match) { return match[1] + " cellule modifiée"; }], + [/^(\d+) changed cells$/, function (match) { return match[1] + " cellules modifiées"; }], + [/^Showing 200 of (.+) changes\. The downloaded proposal contains all changes\.$/, function (match) { return "Affichage de 200 modifications sur " + match[1] + ". La proposition téléchargée contient toutes les modifications."; }], + [/^Remove proposed change for (.+)$/, function (match) { return "Retirer la modification proposée pour " + match[1]; }], + [/^Confirmed anchor: (.+)$/, function (match) { return "Point d’ancrage confirmé : " + match[1]; }], + [/^Derived parent: (.+)$/, function (match) { return "Région parente déterminée : " + match[1]; }], + [/^GitHub issue #(\d+)$/, function (match) { return "Billet GitHub no " + match[1]; }], + [/^Open issue #(\d+)$/, function (match) { return "Ouvrir le billet no " + match[1]; }], + [/^(.+) — (\d+) cell$/, function (match) { return match[1] + " — " + match[2] + " cellule"; }], + [/^(.+) — (\d+) cells$/, function (match) { return match[1] + " — " + match[2] + " cellules"; }], + [/^(\d[\d\s,.]*) census cells across (\d[\d\s,.]*) municipalities\/CSDs$/, function (match) { return match[1] + " cellules de recensement dans " + match[2] + " municipalités ou SDR"; }], + [/^Draft restored from (.+)\. Contributor identity was not stored\.$/, function (match) { return "Brouillon restauré depuis le " + match[1] + ". L’identité de la personne n’a pas été enregistrée."; }], + [/^(.+) is required\.$/, function (match) { return "Le champ « " + translate(match[1]) + " » est obligatoire."; }], + [/^(.+) is too long\.$/, function (match) { return "Le champ « " + translate(match[1]) + " » est trop long."; }], + [/^(.+) contains invalid text\.$/, function (match) { return "Le champ « " + translate(match[1]) + " » contient du texte invalide."; }], + [/^Could not load editor data \((.+)\)\.$/, function (match) { return "Impossible de charger les données de l’éditeur (" + match[1] + ")."; }], + [/^That selection contains the fixed anchor for (.+)\.$/, function (match) { return "Cette sélection contient le point d’ancrage fixe de " + match[1] + "."; }], + [/^The fixed anchor for (.+) cannot be moved\.$/, function (match) { return "Le point d’ancrage fixe de " + match[1] + " ne peut pas être déplacé."; }], + [/^(.+)\. Edit each province separately; repeater setup keeps the paths together\.$/, function (match) { return match[1] + ". Modifiez chaque province séparément; la configuration du répéteur conserve les chemins ensemble."; }], + [/^(.+) \(confirmed\)$/, function (match) { return match[1] + " (confirmé)"; }], + [/^\. (.+)$/, function (match) { return ". " + translate(match[1]); }], + [/^(.+\.) (Reload and try again\.|Your edits are saved here\. Complete the check and try again\.|Download the proposal to share it\.|Your answers remain in the form\. Complete the check and try again\.|Your answers remain in the form\. Copy the text or use GitHub\.)$/, function (match) { return translate(match[1]) + " " + translate(match[2]); }], + [/^(.+) \(fixed\)$/, function (match) { return match[1] + " (fixe)"; }] + ]; + + function translate(value) { + if (!isFrench || typeof value !== "string") return value; + var leading = value.match(/^\s*/)[0]; + var trailing = value.match(/\s*$/)[0]; + var normalized = value.trim().replace(/\s+/g, " "); + if (!normalized) return value; + var translated = dictionary[normalized]; + if (!translated) { + for (var index = 0; index < patterns.length; index += 1) { + var match = normalized.match(patterns[index][0]); + if (match) { + translated = patterns[index][1](match); + break; + } + } + } + return translated ? leading + translated + trailing : value; + } + + function shouldSkip(node) { + var element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; + return Boolean(element && element.closest("script, style, code, pre, kbd, samp, textarea, [data-i18n-ignore]")); + } + + function localizeTextNode(node) { + if (!node || node.nodeType !== Node.TEXT_NODE || shouldSkip(node)) return; + var next = translate(node.nodeValue); + if (next !== node.nodeValue) node.nodeValue = next; + } + + function localizeElement(element) { + if (!element || element.nodeType !== Node.ELEMENT_NODE || shouldSkip(element)) return; + ["aria-label", "placeholder", "title"].forEach(function (name) { + if (!element.hasAttribute(name)) return; + var current = element.getAttribute(name); + var next = translate(current); + if (next !== current) element.setAttribute(name, next); + }); + } + + function localize(root) { + if (!isFrench || !root) return; + if (root.nodeType === Node.TEXT_NODE) { + localizeTextNode(root); + return; + } + if (root.nodeType !== Node.ELEMENT_NODE && root.nodeType !== Node.DOCUMENT_NODE) return; + if (root.nodeType === Node.ELEMENT_NODE) localizeElement(root); + var elementWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); + while (elementWalker.nextNode()) localizeElement(elementWalker.currentNode); + var textWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + while (textWalker.nextNode()) localizeTextNode(textWalker.currentNode); + } + + function configureEditorLanguageLinks() { + var links = document.querySelectorAll("[data-editor-language]"); + if (!links.length) return; + var pathname = window.location.pathname; + var englishPath = pathname.replace(/\/fr\/config\/editor(?:\/index\.html|\/)?$/, "/config/editor/"); + if (!/\/config\/editor\/$/.test(englishPath)) { + englishPath = pathname.replace(/\/config\/editor(?:\/index\.html|\/)?$/, "/config/editor/"); + } + var frenchPath = englishPath.replace(/\/config\/editor\/$/, "/fr/config/editor/"); + links.forEach(function (link) { + var locale = link.getAttribute("data-editor-language"); + link.href = locale === "fr" ? frenchPath : englishPath; + link.lang = locale; + link.hreflang = locale; + if ((locale === "fr") === isFrench) link.setAttribute("aria-current", "page"); + else link.removeAttribute("aria-current"); + }); + } + + window.MeshCoreCanadaI18n = Object.freeze({ + locale: isFrench ? "fr" : "en", + isFrench: isFrench, + t: translate, + localize: localize + }); + + function initialise() { + if (isFrench) { + document.documentElement.lang = "fr"; + document.title = translate(document.title); + localize(document.body); + new MutationObserver(function (records) { + records.forEach(function (record) { + if (record.type === "characterData") localizeTextNode(record.target); + if (record.type === "attributes") localizeElement(record.target); + record.addedNodes.forEach(localize); + }); + }).observe(document.body, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["aria-label", "placeholder", "title"] + }); + } + configureEditorLanguageLinks(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initialise, { once: true }); + } else { + initialise(); + } +})(); diff --git a/docs/assets/regions/modules/configurator-support.js b/docs/assets/regions/modules/configurator-support.js new file mode 100644 index 0000000..1d0977c --- /dev/null +++ b/docs/assets/regions/modules/configurator-support.js @@ -0,0 +1,63 @@ +(function (root, factory) { + "use strict"; + root.MeshCoreRegionConfiguratorSupport = factory(); +}(typeof globalThis !== "undefined" ? globalThis : this, function () { + "use strict"; + + function finiteCoordinate(value, minimum, maximum) { + var number = Number(String(value == null ? "" : value).trim()); + return Number.isFinite(number) && number >= minimum && number <= maximum ? number : null; + } + + function parseCoordinates(latitude, longitude) { + var lat = finiteCoordinate(latitude, -90, 90); + var lon = finiteCoordinate(longitude, -180, 180); + return lat === null || lon === null ? null : { lat: lat, lon: lon }; + } + + function normalizeText(value) { + return String(value == null ? "" : value).replace(/\s+/g, " ").trim(); + } + + function commissioningRecord(input) { + var paths = Array.isArray(input.paths) ? input.paths.map(normalizeText).filter(Boolean) : []; + var commands = Array.isArray(input.commands) ? input.commands.map(normalizeText).filter(Boolean) : []; + var lines = [ + "MeshCore Canada repeater commissioning summary", + "Generated: " + normalizeText(input.generatedAt), + "Location label: " + (normalizeText(input.locationLabel) || "Not recorded"), + "Home region: " + normalizeText(input.homeRegion), + "Firmware: " + normalizeText(input.firmware), + "Region budget: " + normalizeText(input.budget), + "", + "Forwarding paths:" + ]; + paths.forEach(function (path) { lines.push("- " + path); }); + lines.push("", "Commands:"); + commands.forEach(function (command) { lines.push(command); }); + lines.push( + "", + "Verification:", + "1. Run region before saving and compare every path above.", + "2. Run region save only after the paths and flood permissions are correct.", + "3. Run region again after saving.", + "", + "This summary omits exact coordinates, passwords, private keys, and device identifiers." + ); + return lines.join("\n") + "\n"; + } + + function safeFileStem(value) { + var stem = normalizeText(value).toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); + return stem || "repeater"; + } + + return { + commissioningRecord: commissioningRecord, + parseCoordinates: parseCoordinates, + safeFileStem: safeFileStem + }; +})); diff --git a/docs/assets/regions/regions.css b/docs/assets/regions/regions.css index 5197615..cb09298 100644 --- a/docs/assets/regions/regions.css +++ b/docs/assets/regions/regions.css @@ -3,15 +3,18 @@ body:has([data-mcc-regions="map"]) [data-mcc-regions="map"] { } body:has([data-mcc-regions]) { - background: var(--md-default-bg-color, hsl(232, 15%, 14%)); + background: var(--md-default-bg-color, #ffffff); } +/* Region tools use one deliberate dark workbench in either site palette. This + keeps dense maps, command output, and status colours readable after a theme + switch while the header, navigation, and surrounding site still follow it. */ body:has([data-mcc-regions]) .md-main, body:has([data-mcc-regions]) .md-main__inner, body:has([data-mcc-regions]) .md-content, body:has([data-mcc-regions]) .md-content__inner { - color: var(--md-typeset-color, rgba(255, 255, 255, 0.82)); - background: var(--md-default-bg-color, hsl(232, 15%, 14%)); + color: #dce9e7; + background: #0c151c; } body:has([data-mcc-regions="map"]) .md-main__inner { @@ -24,10 +27,22 @@ body:has([data-mcc-regions]) .md-typeset h3 { color: #ffffff; } -body:has([data-mcc-regions]) .md-typeset a { +body:has([data-mcc-regions]) .md-main .md-typeset a { color: var(--md-typeset-a-color, #5488e8); } +.mcc-visually-hidden { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + .mcc-region-hero { margin: 1.2rem 0 1.5rem; padding: 1rem 1.1rem; @@ -77,13 +92,13 @@ body:has([data-mcc-regions]) .md-typeset a { --mcc-blue: #303fa1; --mcc-gold: #f4bf5a; --mcc-red: #ff7373; - --mcc-border: var(--md-default-fg-color--lightest, rgba(255, 255, 255, 0.12)); - --mcc-soft: var(--md-accent-fg-color--transparent, rgba(66, 135, 255, 0.12)); - --mcc-panel: var(--md-default-bg-color, hsl(232, 15%, 14%)); - --mcc-surface: color-mix(in srgb, var(--md-default-bg-color, hsl(232, 15%, 14%)), #ffffff 5%); - --mcc-surface-2: color-mix(in srgb, var(--md-default-bg-color, hsl(232, 15%, 14%)), #ffffff 8%); - --mcc-text: var(--md-typeset-color, rgba(255, 255, 255, 0.82)); - --mcc-muted: var(--md-default-fg-color--light, rgba(255, 255, 255, 0.56)); + --mcc-border: rgba(159, 216, 189, 0.22); + --mcc-soft: rgba(66, 135, 255, 0.15); + --mcc-panel: #0c151c; + --mcc-surface: #101d26; + --mcc-surface-2: #172630; + --mcc-text: #dce9e7; + --mcc-muted: #a7b9bb; margin: 1rem 0 2rem; color: var(--mcc-text); } @@ -321,7 +336,8 @@ body:has([data-mcc-regions]) .md-typeset a { } .mcc-choice small { - color: var(--mcc-muted); + color: var(--mcc-text); + opacity: 1; } .mcc-chip-list { @@ -837,6 +853,59 @@ body:has([data-mcc-regions]) .md-typeset a { background: var(--mcc-panel); } +.mcc-map-experience { + display: grid; + gap: 1rem; +} + +.mcc-map-product-header { + display: flex; + flex-wrap: wrap; + align-items: end; + justify-content: space-between; + gap: 1rem; +} + +.mcc-map-product-header h2, +.mcc-map-product-header p { + margin-block: 0; +} + +.mcc-map-product-header h2 + p { + margin-top: 0.5rem; +} + +.mcc-map-mode-switch { + display: inline-flex; + width: fit-content; + gap: 0.25rem; + padding: 0.25rem; + border: 1px solid var(--mcc-border); + border-radius: 0.5rem; + background: var(--mcc-panel); +} + +.mcc-map-mode-switch button { + padding: 0.45rem 0.75rem; + border: 0; + border-radius: 0.32rem; + color: var(--mcc-text); + background: transparent; + font: inherit; + font-weight: 750; + cursor: pointer; +} + +.mcc-map-mode-switch button:hover, +.mcc-map-mode-switch button:focus-visible { + background: var(--mcc-soft); +} + +.mcc-map-mode-switch button[aria-pressed="true"] { + color: #ffffff; + background: var(--mcc-green); +} + .mcc-map-panel { display: grid; align-content: start; @@ -846,6 +915,61 @@ body:has([data-mcc-regions]) .md-typeset a { overflow: auto; } +.mcc-map-stage { + position: relative; + min-width: 0; + min-height: 30rem; +} + +.mcc-skip-map { + position: absolute; + top: 0.5rem; + left: 0.5rem; + z-index: 1001; + padding: 0.45rem 0.65rem; + border-radius: 0.3rem; + color: #ffffff; + background: var(--mcc-green-dark); + transform: translateY(calc(-100% - 1rem)); +} + +.mcc-skip-map:focus { + transform: none; +} + +.mcc-map-loading { + display: grid; + position: absolute; + inset: 0; + z-index: 1000; + height: 100%; + place-items: center; + padding: 2rem; + color: var(--mcc-text); + background: + radial-gradient(circle at center, color-mix(in srgb, var(--mcc-green), transparent 86%), transparent 55%), + var(--mcc-panel); + text-align: center; +} + +.mcc-map-loading > div { + display: grid; + max-width: 28rem; + justify-items: center; + gap: 0.65rem; +} + +.mcc-map-loading svg { + width: 2.5rem; + height: 2.5rem; + color: var(--mcc-green); +} + +.mcc-map-loading h3, +.mcc-map-loading p { + margin: 0; +} + .mcc-region-breadcrumbs { display: flex; flex-wrap: wrap; @@ -910,8 +1034,10 @@ body:has([data-mcc-regions]) .md-typeset a { } .mcc-region-child small { - color: var(--mcc-muted); + color: var(--mcc-text); font-size: 0.72rem; + font-weight: 600; + opacity: 1; } .mcc-panel-header { @@ -954,6 +1080,7 @@ body:has([data-mcc-regions]) .md-typeset a { .mcc-map-area { position: relative; + height: 100%; min-height: 0; background: var(--mcc-panel); } @@ -1590,7 +1717,7 @@ body:has([data-mcc-regions]) .md-typeset a { } .mcc-region-table td { - color: var(--mcc-text); + color: #eaf3f1; background: #101d26; font-size: 0.76rem; } @@ -1622,6 +1749,14 @@ body:has([data-mcc-regions]) .md-typeset a { min-height: 32rem; } + .mcc-map-loading { + height: 32rem; + } + + .mcc-map-stage { + min-height: 32rem; + } + .mcc-sticky-result, .mcc-table-detail { position: static; diff --git a/docs/assets/regions/regions.js b/docs/assets/regions/regions.js index 2b3cd9e..62b57d2 100644 --- a/docs/assets/regions/regions.js +++ b/docs/assets/regions/regions.js @@ -12,11 +12,497 @@ var lucidePromise = null; var activeMaps = []; var LUCIDE_SRC = new URL("vendor/lucide.js", assetBase).href; + var configuratorSupport = window.MeshCoreRegionConfiguratorSupport || {}; + var REQUEST_TIMEOUT_MS = 12000; + var frenchRuntime = /^fr(?:-|$)/i.test( + document.documentElement ? document.documentElement.lang || "" : "" + ); + + var FRENCH_RUNTIME_TEXT = { + "Unable to load MeshCore Canada region data": "Impossible de charger les données régionales de MeshCore Canada", + "Unable to load the Canadian map layer": "Impossible de charger la couche cartographique canadienne", + "Unable to load the Canadian location layer": "Impossible de charger la couche canadienne de localisation", + "The Canadian region catalog contains an invalid or duplicate local region": "Le catalogue des régions canadiennes contient une région locale invalide ou en double", + "The Canadian region catalog contains an invalid shared repeater area": "Le catalogue des régions canadiennes contient une zone partagée de répéteurs invalide", + "A shared repeater area must cross a province or territory": "Une zone partagée de répéteurs doit traverser une province ou un territoire", + "The region catalog contains an invalid neighbouring network path": "Le catalogue des régions contient un chemin de réseau voisin invalide", + "A neighbouring network tag collides with the Canadian hierarchy": "Un identifiant de réseau voisin entre en conflit avec la hiérarchie canadienne", + "A neighbouring network tag has no label": "Un identifiant de réseau voisin n’a pas de libellé", + "A neighbouring network tag has conflicting parents": "Un identifiant de réseau voisin a des parents incompatibles", + "A neighbouring network tag has conflicting labels": "Un identifiant de réseau voisin a des libellés incompatibles", + "Canadian map layer is invalid": "La couche cartographique canadienne est invalide", + "Canadian map layer contains an invalid or duplicate region": "La couche cartographique canadienne contient une région invalide ou en double", + "Canadian map layer does not match the region catalog": "La couche cartographique canadienne ne correspond pas au catalogue des régions", + "Canadian location layer is invalid": "La couche canadienne de localisation est invalide", + "Canadian location layer contains an invalid or duplicate region": "La couche canadienne de localisation contient une région invalide ou en double", + "Canadian location layer does not match the region catalog": "La couche canadienne de localisation ne correspond pas au catalogue des régions", + "Geocoding service error": "Erreur du service de géocodage", + "No matching Canadian postal code found": "Aucun code postal canadien correspondant n’a été trouvé", + "Online place lookup is unavailable. Enter coordinates or browse the region list.": "La recherche de lieux en ligne est indisponible. Entrez des coordonnées ou parcourez la liste des régions.", + "Copy": "Copier", + "Copied": "Copié", + "Copy failed": "Échec de la copie", + "Copied to clipboard.": "Copié dans le presse-papiers.", + "Copy failed. Select and copy the command manually.": "La copie a échoué. Sélectionnez et copiez la commande manuellement.", + "Needs review": "À vérifier", + "Draft": "Brouillon", + "Reviewed": "Vérifié", + "Active": "Actif", + "Deprecated": "Obsolète", + "Unreviewed": "Non vérifié", + "No seed": "Aucun point de départ", + "Country": "Pays", + "Province / Territory": "Province ou territoire", + "Local Region": "Région locale", + "Region": "Région", + "Area Group": "Groupe de zones", + "Shared repeater area": "Zone partagée de répéteurs", + "community path": "chemin communautaire", + "confirm locally": "à confirmer localement", + "Add only the paths this repeater should forward. Different repeaters can carry different paths to spread traffic.": "Ajoutez seulement les chemins que ce répéteur doit relayer. Différents répéteurs peuvent transporter différents chemins pour répartir le trafic.", + "Canadian regions": "Régions canadiennes", + "Provinces and territories may be mixed.": "Vous pouvez combiner des provinces et des territoires.", + "required": "obligatoire", + "Add any Canadian region": "Ajouter une région canadienne", + "Choose a region": "Choisissez une région", + "Neighbouring network paths": "Chemins des réseaux voisins", + "Add one only when this repeater should forward traffic for that area. Nothing outside Canada is added to the boundary map.": "Ajoutez-en un seulement si ce répéteur doit relayer le trafic de cette zone. Rien à l’extérieur du Canada n’est ajouté à la carte des limites.", + "No region yet": "Aucune région pour l’instant", + "Choose a location first.": "Choisissez d’abord un emplacement.", + "Too many regions selected": "Trop de régions sélectionnées", + "Cross-province repeater setup": "Configuration de répéteur interprovinciale", + "Connect to the repeater CLI": "Se connecter à l’interface en ligne de commande du répéteur", + "Use USB at the repeater or remote management over LoRa.": "Utilisez l’USB sur place ou la gestion à distance par LoRa.", + "USB serial": "Connexion série USB", + "At the repeater": "Sur place", + "Connect the repeater to a computer with a data-capable USB cable.": "Branchez le répéteur à un ordinateur avec un câble USB qui transmet les données.", + "In desktop Chrome or Edge, open the": "Dans Chrome ou Edge sur un ordinateur, ouvrez", + ". For Gessaman's MQTT Observer firmware, use the": ". Pour le micrologiciel MQTT Observer de Gessaman, utilisez le", + "For Gessaman's MQTT Observer firmware, use the": "Pour le micrologiciel MQTT Observer de Gessaman, utilisez le", + "instead.": "plutôt.", + "Choose": "Choisissez", + ", then approve the repeater's serial or COM port when the browser asks.": ", puis autorisez le port série ou COM du répéteur lorsque le navigateur le demande.", + "then approve the repeater's serial or COM port when the browser asks.": "puis autorisez le port série ou COM du répéteur lorsque le navigateur le demande.", + "Remote over LoRa": "À distance par LoRa", + "Through a companion radio": "Par une radio compagnon", + "On a phone or computer, connect the": "Sur un téléphone ou un ordinateur, connectez l’", + "official MeshCore app": "application MeshCore officielle", + "to your companion radio.": "à votre radio compagnon.", + "Open": "Ouvrez", + ", select the repeater, then choose": ", sélectionnez le répéteur, puis choisissez", + "select the repeater, then choose": "sélectionnez le répéteur, puis choisissez", + "from its menu.": "dans son menu.", + "Enter the repeater admin password, tap": "Entrez le mot de passe administrateur du répéteur, touchez", + ", then open": ", puis ouvrez", + "then open": "puis ouvrez", + "If the repeater is missing, open Tools → Discover Nearby Nodes. If a wait timer appears, let it finish before logging in.": "Si le répéteur est absent, ouvrez Tools → Discover Nearby Nodes. Si une minuterie apparaît, attendez qu’elle se termine avant de vous connecter.", + "Confirm the command line": "Confirmer l’interface en ligne de commande", + "Run": "Exécutez", + "and check the version.": "et vérifiez la version.", + "Apply the settings": "Appliquer les paramètres", + "Run each line in order. Wait for a reply.": "Exécutez chaque ligne dans l’ordre. Attendez une réponse.", + "Stop on": "Arrêtez-vous en cas de", + ". Existing regions are not cleared.": ". Les régions existantes ne sont pas effacées.", + "Existing regions are not cleared.": "Les régions existantes ne sont pas effacées.", + "Check and save": "Vérifier et enregistrer", + "and confirm each path:": "et confirmez chaque chemin :", + "Save:": "Enregistrez :", + "Restart the device, reconnect, then run these final checks:": "Redémarrez l’appareil, reconnectez-vous, puis effectuez ces dernières vérifications :", + "Run this once more to confirm the saved region:": "Exécutez cette commande une dernière fois pour confirmer la région enregistrée :", + "MeshCore command help": "Aide sur les commandes MeshCore", + "Commands": "Commandes", + "Canada-wide region boundary": "Limite régionale pancanadienne", + "Boundary unavailable": "Limite indisponible", + "Region tags": "Identifiants de région", + "Firmware": "Micrologiciel", + "Region budget": "Budget régional", + "Advanced details": "Détails avancés", + "Repeater regions:": "Régions du répéteur :", + "Your region:": "Votre région :", + "Copy commands": "Copier les commandes", + "Commissioning record": "Fiche de mise en service", + "Download or print a summary without exact coordinates, credentials, or device identifiers.": "Téléchargez ou imprimez un résumé sans coordonnées exactes, identifiants de connexion ni identifiants d’appareil.", + "Download": "Télécharger", + "Print": "Imprimer", + "Commissioning summary downloaded. Exact coordinates and credentials were omitted.": "La fiche de mise en service a été téléchargée. Les coordonnées exactes et les identifiants de connexion ont été omis.", + "The print window was blocked. Download the summary instead.": "La fenêtre d’impression a été bloquée. Téléchargez plutôt le résumé.", + "MeshCore Canada commissioning summary": "Fiche de mise en service de MeshCore Canada", + "MeshCore Canada repeater commissioning summary": "Fiche de mise en service du répéteur MeshCore Canada", + "Generated": "Générée", + "Location label": "Libellé de l’emplacement", + "Not recorded": "Non indiqué", + "Forwarding paths:": "Chemins relayés :", + "Commands:": "Commandes :", + "Verification:": "Vérification :", + "1. Run region before saving and compare every path above.": "1. Exécutez region avant d’enregistrer et comparez chaque chemin ci-dessus.", + "2. Run region save only after the paths and flood permissions are correct.": "2. Exécutez region save seulement lorsque les chemins et les autorisations de diffusion sont corrects.", + "3. Run region again after saving.": "3. Exécutez region de nouveau après l’enregistrement.", + "This summary omits exact coordinates, passwords, private keys, and device identifiers.": "Cette fiche omet les coordonnées exactes, les mots de passe, les clés privées et les identifiants d’appareil.", + "Commissioning summary opened for printing. Exact coordinates and credentials were omitted.": "La fiche de mise en service est ouverte pour l’impression. Les coordonnées exactes et les identifiants de connexion ont été omis.", + "Review": "Vérifier", + "Source": "Source", + "Seed": "Point de départ", + "No seed point": "Aucun point de départ", + "Repeater area": "Zone du répéteur", + "Source note": "Note sur la source", + "BC coastal seed follows the PNW reference data": "Le point de départ côtier de la C.-B. suit les données de référence du PNW", + "Strategy draft v1.1.1 region": "Région de l’ébauche de stratégie v1.1.1", + "Copy tag": "Copier l’identifiant", + "Open source": "Ouvrir la source", + "Setup progress": "Progression de la configuration", + "Step 1: Device": "Étape 1 : appareil", + "Step 2: Location": "Étape 2 : emplacement", + "Step 3: Coverage": "Étape 3 : couverture", + "Step 4: Apply": "Étape 4 : appliquer", + "Device": "Appareil", + "Location": "Emplacement", + "Coverage": "Couverture", + "Apply": "Appliquer", + "Step 1 of 4": "Étape 1 sur 4", + "What are you configuring?": "Quel appareil configurez-vous?", + "We will recommend forwarding paths, then show how to apply them.": "Nous recommanderons les chemins à relayer, puis nous vous montrerons comment les appliquer.", + "Device and experience": "Appareil et niveau d’expérience", + "Repeater": "Répéteur", + "Recommended for most operators": "Recommandé pour la plupart des exploitants", + "Room server with repeating": "Serveur de salon avec relais", + "Uses the same region paths": "Utilise les mêmes chemins régionaux", + "Advanced operator": "Exploitant expérimenté", + "Review wide and cross-border paths": "Examine les chemins étendus et transfrontaliers", + "Next": "Suivant", + "Step 2 of 4": "Étape 2 sur 4", + "Step 4 of 4": "Étape 4 sur 4", + "Where is the node?": "Où se trouve le nœud?", + "Search a place": "Rechercher un lieu", + "City, airport code, postal code, or region name": "Ville, code d’aéroport, code postal ou nom de région", + "Find": "Rechercher", + "Enter coordinates": "Entrer des coordonnées", + "Latitude": "Latitude", + "Longitude": "Longitude", + "Use coordinates": "Utiliser les coordonnées", + "Coordinates are checked against the Canadian region data in this page.": "Les coordonnées sont comparées aux données régionales canadiennes de cette page.", + "Use this device": "Utiliser cet appareil", + "Use my location": "Utiliser ma position", + "Your browser asks first. MeshCore Canada does not receive or store your coordinates.": "Votre navigateur demande d’abord votre permission. MeshCore Canada ne reçoit ni ne conserve vos coordonnées.", + "Browse regions without search or a map": "Parcourir les régions sans recherche ni carte", + "Back": "Retour", + "Explore regions": "Explorer les régions", + "Step 3 of 4": "Étape 3 sur 4", + "What should this node serve?": "Quelles zones ce nœud doit-il desservir?", + "Repeater forwarding coverage": "Couverture de relais du répéteur", + "Recommended local area": "Zone locale recommandée", + "Use the home region and any registered shared area": "Utilise la région d’attache et toute zone partagée enregistrée", + "Add nearby or cross-border paths": "Ajouter des chemins voisins ou transfrontaliers", + "For bridge, wide-coverage, mountain, or water-path repeaters": "Pour les répéteurs qui font un pont, couvrent une grande zone, une montagne ou un parcours au-dessus de l’eau", + "Radio and firmware options": "Options radio et de micrologiciel", + "Recommended radio settings": "Paramètres radio recommandés", + "Include recommended radio defaults": "Inclure les paramètres radio recommandés", + "For a new setup": "Pour une nouvelle installation", + "Keep current radio settings": "Conserver les paramètres radio actuels", + "For an existing coordinated network": "Pour un réseau coordonné existant", + "Firmware version": "Version du micrologiciel", + "Review and apply": "Vérifier et appliquer", + "Choose setup instructions": "Choisir les instructions de configuration", + "Guide me": "Me guider", + "Connect, apply, verify, then save": "Se connecter, appliquer, vérifier, puis enregistrer", + "Technical operator flow": "Parcours pour exploitant technique", + "Selection": "Sélection", + "Node": "Nœud", + "Place": "Lieu", + "Home region": "Région d’attache", + "Budget": "Budget", + "Forwarding paths": "Chemins relayés", + "Neighbouring paths are included only on this repeater. Confirm provisional paths with the neighbouring operators.": "Les chemins voisins sont inclus uniquement sur ce répéteur. Confirmez les chemins provisoires auprès des exploitants voisins.", + "Choose a location or browse to a region first.": "Choisissez d’abord un emplacement ou parcourez les régions.", + "Your home region": "Votre région d’attache", + "Select this region to use it as the home region.": "Sélectionnez cette région comme région d’attache.", + "Region found.": "Région trouvée.", + "Checking the Canadian region data…": "Vérification des données régionales canadiennes…", + "Unable to load the Canadian location data.": "Impossible de charger les données canadiennes de localisation.", + "Enter a city, airport code, postal code, or region name.": "Entrez une ville, un code d’aéroport, un code postal ou un nom de région.", + "Finding": "Recherche en cours", + "Location lookup failed": "La recherche du lieu a échoué", + "Enter a latitude from -90 to 90 and a longitude from -180 to 180.": "Entrez une latitude de -90 à 90 et une longitude de -180 à 180.", + "This browser does not provide location access. Enter coordinates or browse regions.": "Ce navigateur ne donne pas accès à la position. Entrez des coordonnées ou parcourez les régions.", + "Waiting for browser location permission…": "En attente de l’autorisation de localisation du navigateur…", + "Current browser location": "Position actuelle du navigateur", + "Location was not available. Enter coordinates or browse regions.": "La position n’était pas disponible. Entrez des coordonnées ou parcourez les régions.", + "Leaflet failed to load": "Leaflet n’a pas pu se charger", + "MeshCore Canada regions": "Régions de MeshCore Canada", + "Explore or audit the region release": "Explorer ou vérifier la publication des régions", + "Find a home region on the interactive map, or inspect the published release evidence.": "Trouvez une région d’attache sur la carte interactive ou examinez les preuves de la publication.", + "Set up a repeater": "Configurer un répéteur", + "Region map mode": "Mode de la carte des régions", + "Audit the release": "Vérifier la publication", + "Region search and details": "Recherche et détails des régions", + "Find a place": "Trouver un lieu", + "Enter coordinates instead": "Entrer plutôt des coordonnées", + "Browse the hierarchy": "Parcourir la hiérarchie", + "Selected region": "Région sélectionnée", + "Skip the interactive map": "Passer la carte interactive", + "Loading interactive map…": "Chargement de la carte interactive…", + "Loading Canadian boundaries and OpenStreetMap tiles.": "Chargement des limites canadiennes et des tuiles OpenStreetMap.", + "Retry map": "Réessayer la carte", + "Interactive Canadian region map": "Carte interactive des régions canadiennes", + "Map legend": "Légende de la carte", + "Selected boundary": "Limite sélectionnée", + "Browsed group outline": "Contour du groupe parcouru", + "List alternative": "Option sous forme de liste", + "All regions": "Toutes les régions", + "If the map is unavailable, search and select a region from this list.": "Si la carte est indisponible, recherchez et sélectionnez une région dans cette liste.", + "Audit data loads when this view is opened.": "Les données de vérification se chargent à l’ouverture de cette vue.", + "Unable to load release QA": "Impossible de charger les contrôles de qualité de la publication", + "Unable to load the source lock": "Impossible de charger le verrouillage des sources", + "Release": "Publication", + "Status": "État", + "Standard": "Norme", + "Connectivity": "Connectivité", + "Online": "En ligne", + "Offline; showing cached static data when available": "Hors ligne; affichage des données statiques en cache lorsqu’elles sont disponibles", + "Digital census cells": "Cellules numériques du recensement", + "Census subdivisions": "Subdivisions de recensement", + "Positive-area overlaps": "Chevauchements de superficie positive", + "Quality checks": "Contrôles de qualité", + "Release artifacts": "Artéfacts de publication", + "Public partition SHA-256": "SHA-256 de la partition publique", + "Resolver partition SHA-256": "SHA-256 de la partition de résolution", + "Membership SHA-256": "SHA-256 des appartenances", + "Unavailable": "Indisponible", + "Download QA JSON": "Télécharger le JSON de contrôle", + "Download catalog": "Télécharger le catalogue", + "Open standard and change process": "Ouvrir la norme et le processus de modification", + "Try again": "Réessayer", + "Loading release evidence…": "Chargement des preuves de publication…", + "Select this leaf to see its full path.": "Sélectionnez cette région terminale pour voir son chemin complet.", + "Province or territory": "Province ou territoire", + "resolves to": "correspond à", + "Aliases": "Alias", + "None recorded": "Aucun", + "Planned paths": "Chemins prévus", + "Configure this region": "Configurer cette région", + "Copy link": "Copier le lien", + "This location is outside Canada.": "Cet emplacement se trouve à l’extérieur du Canada.", + "No Canadian region contains that point. Browse the region list instead.": "Aucune région canadienne ne contient ce point. Parcourez plutôt la liste des régions.", + "No Canadian region contains that point. Browse the list instead.": "Aucune région canadienne ne contient ce point. Parcourez plutôt la liste.", + "Loading Canadian boundaries and map tools…": "Chargement des limites canadiennes et des outils cartographiques…", + "Loading the interactive region map.": "Chargement de la carte interactive des régions.", + "OpenStreetMap tiles did not load. Search and the region list still work.": "Les tuiles OpenStreetMap ne se sont pas chargées. La recherche et la liste des régions fonctionnent toujours.", + "OpenStreetMap tiles could not load. Search and the region list still work.": "Impossible de charger les tuiles OpenStreetMap. La recherche et la liste des régions fonctionnent toujours.", + "Interactive region map loaded.": "La carte interactive des régions est chargée.", + "The map could not load. Search and the region list still work.": "La carte n’a pas pu se charger. La recherche et la liste des régions fonctionnent toujours.", + "Search regions": "Rechercher des régions", + "Filter by area": "Filtrer par zone", + "All areas": "Toutes les zones", + "Region directory table": "Tableau du répertoire des régions", + "Area": "Zone", + "Boundary": "Limite", + "Basis": "Fondement", + "Canada-wide": "Pancanadienne", + "Established": "Établie", + "Proposed": "Proposée", + "Setup": "Configuration", + "Map": "Carte", + "Regions": "Régions", + "Local regions": "Régions locales", + "Provinces & territories": "Provinces et territoires", + "Region status summary": "Résumé de l’état des régions", + "Loading Canadian regions…": "Chargement des régions canadiennes…", + "Newfoundland and Labrador": "Terre-Neuve-et-Labrador", + "Prince Edward Island": "Île-du-Prince-Édouard", + "Nova Scotia": "Nouvelle-Écosse", + "New Brunswick": "Nouveau-Brunswick", + "Quebec": "Québec", + "British Columbia": "Colombie-Britannique", + "Northwest Territories": "Territoires du Nord-Ouest", + "Atlantic": "Atlantique", + "Prairies": "Prairies", + "Northern Canada": "Nord canadien", + "Zoom in": "Zoom avant", + "Zoom out": "Zoom arrière", + "Marker": "Marqueur", + "contributors": "contributeurs", + "Close popup": "Fermer la fenêtre", + "A JavaScript library for interactive maps": "Une bibliothèque JavaScript pour les cartes interactives" + }; + + var FRENCH_RUNTIME_PATTERNS = [ + [/^(.+) \(request timed out\)$/, function (match, message) { + return translateRuntimeText(message) + " (délai de la requête dépassé)"; + }], + [/^(.+): ([^:]+)$/, function (match, prefix, value) { + var translatedPrefix = FRENCH_RUNTIME_TEXT[prefix]; + return translatedPrefix ? translatedPrefix + " : " + translateRuntimeText(value) : match; + }], + [/^(\d+) \/ 32 tags, (\d+) \/ 172 bytes$/, function (match, tags, bytes) { + return tags + " / 32 identifiants, " + bytes + " / 172 octets"; + }], + [/^That name matches more than one region \((.+)\)\. Add a province or postal code\.$/, function (match, choices) { + return "Ce nom correspond à plusieurs régions (" + choices + "). Ajoutez une province ou un code postal."; + }], + [/^Confirm (.+) tags with neighbouring operators before applying them\.$/, function (match, label) { + return "Confirmez les identifiants de " + label + " auprès des exploitants voisins avant de les appliquer."; + }], + [/^Check locally before using: (.+)\.$/, function (match, tags) { + return "Vérifiez localement avant d’utiliser : " + tags + "."; + }], + [/^Do not use: (.+)\.$/, function (match, tags) { + return "N’utilisez pas : " + tags + "."; + }], + [/^Too many regions: (\d+) tags exceeds the 32-tag limit\.$/, function (match, count) { + return "Trop de régions : " + count + " identifiants dépassent la limite de 32."; + }], + [/^Region names use (\d+) bytes, above the 172-byte response limit\.$/, function (match, bytes) { + return "Les noms de régions utilisent " + bytes + " octets, au-delà de la limite de réponse de 172 octets."; + }], + [/^(.+) keeps (.+) in one repeater configuration\.$/, function (match, area, members) { + return area + " regroupe " + members.replace(/ and /g, " et ") + " dans une seule configuration de répéteur."; + }], + [/^This selection uses (\d+) tags and (\d+) bytes\. Remove regions until it fits the 32-tag and 172-byte limits\.$/, function (match, tags, bytes) { + return "Cette sélection utilise " + tags + " identifiants et " + bytes + " octets. Retirez des régions jusqu’à respecter les limites de 32 identifiants et de 172 octets."; + }], + [/^(.+) combines (.+) in one repeater setup\. All map boundaries remain separate\.$/, function (match, area, members) { + return area + " regroupe " + members.replace(/ and /g, " et ") + " dans une seule configuration de répéteur. Toutes les limites cartographiques demeurent distinctes."; + }], + [/^(.+)\. Each map region keeps its own boundary\.$/, function (match, jurisdictions) { + return jurisdictions + ". Chaque région cartographique conserve sa propre limite."; + }], + [/^(.+) is added to this repeater only\. MeshCore Canada does not own or draw those boundaries\.$/, function (match, paths) { + return paths + " est ajouté uniquement à ce répéteur. MeshCore Canada ne possède ni ne trace ces limites."; + }], + [/^Shared repeater area: (.+)$/, function (match, area) { + return "Zone partagée de répéteurs : " + area; + }], + [/^(\d+) subregions$/, function (match, count) { + return count + " sous-régions"; + }], + [/^([A-Z0-9-]+) · region$/, function (match, tag) { + return tag + " · région"; + }], + [/^(.+) resolves to (.+)$/, function (match, place, region) { + return place + " correspond à " + region; + }], + [/^Text result: (.+)\.$/, function (match, path) { + return "Résultat textuel : " + path + "."; + }], + [/^(\d+) leaf regions$/, function (match, count) { + return count + " régions terminales"; + }], + [/^(\d+) of (\d+) reported invariants pass$/, function (match, passed, total) { + return passed + " invariants sur " + total + " sont respectés"; + }], + [/^Source lock: (.+) census vintage · (\d+) locked sources\.$/, function (match, vintage, count) { + return "Verrouillage des sources : recensement de " + vintage + " · " + count + " sources verrouillées."; + }], + [/^(\d+) of (\d+) regions$/, function (match, shown, total) { + return shown + " régions sur " + total; + }], + [/^(\d+) regions$/, function (match, count) { + return count + " régions"; + }], + [/^(\d+) \/ 32 tags · (\d+) \/ 172 bytes$/, function (match, tags, bytes) { + return tags + " / 32 identifiants · " + bytes + " / 172 octets"; + }], + [/^(.+) region$/, function (match, label) { + return "Région de " + label; + }] + ]; + + function translateRuntimeText(value) { + var source = String(value == null ? "" : value); + if (!frenchRuntime || !source) return source; + var leading = (source.match(/^\s*/) || [""])[0]; + var trailing = (source.match(/\s*$/) || [""])[0]; + var text = source.slice(leading.length, source.length - trailing.length || source.length); + if (!text) return source; + if (Object.prototype.hasOwnProperty.call(FRENCH_RUNTIME_TEXT, text)) { + var direct = FRENCH_RUNTIME_TEXT[text]; + var directTrailing = /[’']$/.test(direct) ? "" : trailing; + return leading + direct + directTrailing; + } + for (var index = 0; index < FRENCH_RUNTIME_PATTERNS.length; index += 1) { + if (FRENCH_RUNTIME_PATTERNS[index][0].test(text)) { + return leading + text.replace(FRENCH_RUNTIME_PATTERNS[index][0], FRENCH_RUNTIME_PATTERNS[index][1]) + trailing; + } + } + return source; + } + + function localizeRuntimeNode(node) { + if (!frenchRuntime || !node) return; + if (node.nodeType === 3) { + var parentName = node.parentElement && node.parentElement.tagName; + var original = node.nodeValue; + var trimmed = String(original || "").trim(); + if (parentName === "SCRIPT" || parentName === "STYLE" || parentName === "PRE") return; + if (parentName === "CODE" && trimmed !== "Unavailable") return; + var translated = parentName === "DT" && trimmed === "Review" + ? original.replace("Review", "Vérification") + : translateRuntimeText(original); + if (translated !== node.nodeValue) node.nodeValue = translated; + return; + } + if (node.nodeType !== 1) return; + ["aria-label", "placeholder", "title"].forEach(function (attribute) { + if (!node.hasAttribute(attribute)) return; + var current = node.getAttribute(attribute); + var translated = translateRuntimeText(current); + if (translated !== current) node.setAttribute(attribute, translated); + }); + Array.prototype.slice.call(node.childNodes || []).forEach(localizeRuntimeNode); + } + + function enableRuntimeLocalization(root) { + if (!frenchRuntime || !root || root.dataset.mccLocaleReady === "1") return; + root.dataset.mccLocaleReady = "1"; + localizeRuntimeNode(root); + if (typeof MutationObserver === "undefined") return; + var observer = new MutationObserver(function (records) { + records.forEach(function (record) { + if (record.type === "characterData") localizeRuntimeNode(record.target); + if (record.type === "attributes") localizeRuntimeNode(record.target); + Array.prototype.slice.call(record.addedNodes || []).forEach(localizeRuntimeNode); + }); + }); + observer.observe(root, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ["aria-label", "placeholder", "title"] + }); + root.__mccLocaleObserver = observer; + } + + function fetchWithTimeout(url, options, timeoutMs) { + var requestOptions = Object.assign({}, options || {}); + var upstreamSignal = requestOptions.signal; + var controller = typeof AbortController !== "undefined" ? new AbortController() : null; + var timer = null; + var abortFromUpstream = function () { + if (controller) controller.abort(); + }; + if (controller) { + requestOptions.signal = controller.signal; + timer = window.setTimeout(function () { controller.abort(); }, timeoutMs || REQUEST_TIMEOUT_MS); + if (upstreamSignal) { + if (upstreamSignal.aborted) controller.abort(); + else upstreamSignal.addEventListener("abort", abortFromUpstream, { once: true }); + } + } + return fetch(url, requestOptions).finally(function () { + if (timer) window.clearTimeout(timer); + if (upstreamSignal) upstreamSignal.removeEventListener("abort", abortFromUpstream); + }); + } - function fetchJsonAsset(filename, errorMessage) { - return fetch(new URL(filename, assetBase)).then(function (response) { + function fetchJsonAsset(filename, errorMessage, retrying) { + return fetchWithTimeout(new URL(filename, assetBase), {}, REQUEST_TIMEOUT_MS).then(function (response) { if (!response.ok) throw new Error(errorMessage); return response.json(); + }).catch(function (error) { + if (!retrying && error && error.name !== "AbortError") { + return fetchJsonAsset(filename, errorMessage, true); + } + throw error && error.name === "AbortError" ? new Error(errorMessage + " (request timed out)") : error; }); } @@ -59,19 +545,17 @@ } function loadData(mode) { - if (mode === "map") { - return Promise.all([loadCatalog(), loadDisplayPartition()]).then(function (loaded) { - return applyGeneratedPartition(loaded[0], loaded[1], null); - }); - } - if (mode === "config") { - return Promise.all([loadCatalog(), loadResolverPartition()]).then(function (loaded) { - return applyGeneratedPartition(loaded[0], null, loaded[1]); - }); - } + if (mode === "map" || mode === "config") return loadCatalog(); return loadCatalog(); } + function ensureResolverData(data) { + if (data.resolverRegions) return Promise.resolve(data); + return loadResolverPartition().then(function (partition) { + return applyGeneratedPartition(data, null, partition); + }); + } + function prepareCatalog(data) { if (data.__mccPrepared) return data; var suppliedAliases = data.aliases || {}; @@ -281,10 +765,18 @@ function copyText(text, button, resetLabel) { var feedback = function (copied) { if (!button) return; + var host = button.closest ? button.closest("[data-mcc-regions]") : null; + var live = host && host.querySelector("[data-mcc-copy-status]"); var originalHtml = button.dataset.originalHtml || button.innerHTML || resetLabel || "Copy"; button.dataset.originalHtml = originalHtml; button.classList.toggle("is-copied", copied); button.innerHTML = copied ? "Copied" : "Copy failed"; + if (live) { + live.textContent = ""; + window.setTimeout(function () { + live.textContent = copied ? "Copied to clipboard." : "Copy failed. Select and copy the command manually."; + }, 10); + } window.setTimeout(function () { button.classList.remove("is-copied"); button.innerHTML = button.dataset.originalHtml || resetLabel || "Copy"; @@ -343,9 +835,10 @@ } function labelFor(data, tag) { - if (data.hierarchy[tag]) return data.hierarchy[tag].label; - if (data.externalTagLabels && data.externalTagLabels[tag]) return data.externalTagLabels[tag]; - return tag; + var label = tag; + if (data.hierarchy[tag]) label = data.hierarchy[tag].label; + else if (data.externalTagLabels && data.externalTagLabels[tag]) label = data.externalTagLabels[tag]; + return translateRuntimeText(label); } function scopeExists(data, tag) { @@ -501,6 +994,10 @@ return regionPageHref("map") + (params.toString() ? "?" + params.toString() : ""); } + function configHrefForState(state) { + var mapHref = new URL(mapHrefForState(state)); + return regionPageHref("config") + mapHref.search; + } function haversineKm(aLat, aLon, bLat, bLon) { var rad = function (d) { return d * Math.PI / 180; }; var dLat = rad(bLat - aLat); @@ -870,8 +1367,12 @@ }; } - function geocoderCaSearch(query) { - return fetch("https://geocoder.ca/?locate=" + encodeURIComponent(query) + "&json=1") + function geocoderCaSearch(query, signal) { + return fetchWithTimeout( + "https://geocoder.ca/?locate=" + encodeURIComponent(query) + "&json=1", + { signal: signal }, + REQUEST_TIMEOUT_MS + ) .then(function (res) { if (!res.ok) throw new Error("Geocoding service error"); return res.json(); @@ -881,13 +1382,16 @@ }); } - function nominatimSearch(params) { + function nominatimSearch(params, signal) { var url = "https://nominatim.openstreetmap.org/search?" + new URLSearchParams(Object.assign({ format: "json", limit: "1", addressdetails: "1" }, params)); - return fetch(url, { headers: { "Accept-Language": "en-CA,en" } }) + return fetchWithTimeout(url, { + headers: { "Accept-Language": frenchRuntime ? "fr-CA,fr,en" : "en-CA,en" }, + signal: signal + }, REQUEST_TIMEOUT_MS) .then(function (res) { if (!res.ok) throw new Error("Geocoding service error"); return res.json(); @@ -897,13 +1401,17 @@ }); } - function geocodeCanadianPostal(postal) { - return nominatimSearch({ postalcode: postal.formatted, country: "ca" }).then(function (hit) { + function geocodeCanadianPostal(postal, signal) { + return nominatimSearch({ postalcode: postal.formatted, country: "ca" }, signal).then(function (hit) { if (hit) return hit; - return nominatimSearch({ q: postal.formatted, countrycodes: "ca" }); + return nominatimSearch({ q: postal.formatted, countrycodes: "ca" }, signal); }).then(function (hit) { if (hit) return hit; - return fetch("https://geocoder.ca/?locate=" + encodeURIComponent(postal.compact) + "&json=1") + return fetchWithTimeout( + "https://geocoder.ca/?locate=" + encodeURIComponent(postal.compact) + "&json=1", + { signal: signal }, + REQUEST_TIMEOUT_MS + ) .then(function (res) { if (!res.ok) throw new Error("Geocoding service error"); return res.json(); @@ -917,7 +1425,7 @@ }); } - function geocode(data, query) { + function geocode(data, query, signal) { var localMatch = localGeocode(data, query); if (localMatch && localMatch.ambiguous) { return Promise.reject(new Error("That name matches more than one region (" + localMatch.choices.join("; ") + "). Add a province or postal code.")); @@ -925,18 +1433,19 @@ if (localMatch && localMatch.exactLocalMatch) return Promise.resolve(localMatch); var postal = parseCanadianPostalCode(query); var primaryLookup = postal - ? geocodeCanadianPostal(postal) - : nominatimSearch({ q: query, countrycodes: "ca" }).then(function (hit) { + ? geocodeCanadianPostal(postal, signal) + : nominatimSearch({ q: query, countrycodes: "ca" }, signal).then(function (hit) { if (hit) return hit; - return nominatimSearch({ q: query }); + return nominatimSearch({ q: query }, signal); }); return primaryLookup.catch(function () { - return geocoderCaSearch(postal ? postal.formatted : query).catch(function () { return null; }); + if (signal && signal.aborted) throw new DOMException("Request cancelled", "AbortError"); + return geocoderCaSearch(postal ? postal.formatted : query, signal).catch(function () { return null; }); }).then(function (hit) { if (hit) return hit; if (localMatch) return localMatch; - throw new Error("Location lookup is temporarily unavailable. Try an airport code or use the map."); + throw new Error("Online place lookup is unavailable. Enter coordinates or browse the region list."); }); } @@ -1160,10 +1669,10 @@ '
' + '
' + icon("usb") + '
USB serial
At the repeater
' + '
  1. Connect the repeater to a computer with a data-capable USB cable.
  2. ' + - '
  3. In desktop Chrome or Edge, open the MeshCore Flasher. For Gessaman\'s MQTT Observer firmware, use the MeshCore Observer Flasher instead.
  4. ' + + '
  5. In desktop Chrome or Edge, open the MeshCore Flasher. For Gessaman\'s MQTT Observer firmware, use the MeshCore Observer Flasher instead.
  6. ' + '
  7. Choose Console, then approve the repeater\'s serial or COM port when the browser asks.
' + '
' + icon("radio-tower") + '
Remote over LoRa
Through a companion radio
' + - '
  1. On a phone or computer, connect the official MeshCore app to your companion radio.
  2. ' + + '
    1. On a phone or computer, connect the official MeshCore app to your companion radio.
    2. ' + '
    3. Open Contacts, select the repeater, then choose Remote Management from its menu.
    4. ' + '
    5. Enter the repeater admin password, tap Log In, then open Command Line.
    ' + '

    If the repeater is missing, open Tools → Discover Nearby Nodes. If a wait timer appears, let it finish before logging in.

' + @@ -1186,7 +1695,7 @@ return ''; }).join("") + '
' + '' + - 'MeshCore command help ' + icon("external-link") + '' + + 'MeshCore command help ' + icon("external-link") + '' + '
' : '
' + '
Commands
' + @@ -1230,6 +1739,11 @@ scopeNotice + technicalDetails + resultBody + + '
' + + '

Commissioning record

Download or print a summary without exact coordinates, credentials, or device identifiers.

' + + '
' + + '
' + + '
' + (statusNotes ? '
' + statusNotes + "
" : "") + "
"; @@ -1244,6 +1758,18 @@ copyText(button.getAttribute("data-cmd") || "", button.querySelector("em"), "Copy"); }); }); + var downloadSummary = target.querySelector("[data-action='download-commissioning']"); + if (downloadSummary) { + downloadSummary.addEventListener("click", function () { + downloadCommissioningSummary(data, state, downloadSummary); + }); + } + var printSummary = target.querySelector("[data-action='print-commissioning']"); + if (printSummary) { + printSummary.addEventListener("click", function () { + printCommissioningSummary(data, state, printSummary); + }); + } refreshIcons(target); } @@ -1274,6 +1800,73 @@ .concat(["region", "region save", "region"]); } + function commissioningSummary(data, state) { + if (!state || !state.canGenerate || !state.resolution || !configuratorSupport.commissioningRecord) return null; + var rec = recommend(data, state.resolution, state.type, state.selectedMetros, state.selectedExternalPaths); + var commands = currentCommands(data, state); + if (!rec || !commands) return null; + var homeTag = state.resolution.primary.seed.tag; + var summary = configuratorSupport.commissioningRecord({ + generatedAt: new Date().toISOString(), + locationLabel: labelFor(data, homeTag), + homeRegion: labelledPath(data, ancestryFor(data, homeTag)), + firmware: state.firmware === "1.16" ? "v1.16+" : "v" + state.firmware + ".x", + budget: rec.budget.tagCount + " / 32 tags, " + rec.budget.responseBytes + " / 172 bytes", + paths: rec.paths.map(function (path) { return labelledPath(data, path); }), + commands: commands + }); + return frenchRuntime + ? summary.split("\n").map(translateRuntimeText).join("\n") + : summary; + } + + function announceAction(button, message) { + var host = button && button.closest ? button.closest("[data-mcc-regions]") : null; + var live = host && host.querySelector("[data-mcc-copy-status]"); + if (!live) return; + live.textContent = ""; + window.setTimeout(function () { live.textContent = message; }, 10); + } + + function downloadCommissioningSummary(data, state, button) { + var summary = commissioningSummary(data, state); + if (!summary) return; + var homeTag = state.resolution.primary.seed.tag; + var stem = configuratorSupport.safeFileStem + ? configuratorSupport.safeFileStem(homeTag) + : "repeater"; + var blob = new Blob([summary], { type: "text/plain;charset=utf-8" }); + var url = URL.createObjectURL(blob); + var link = document.createElement("a"); + link.href = url; + link.download = "meshcore-" + stem + "-commissioning.txt"; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(function () { URL.revokeObjectURL(url); }, 0); + announceAction(button, "Commissioning summary downloaded. Exact coordinates and credentials were omitted."); + } + + function printCommissioningSummary(data, state, button) { + var summary = commissioningSummary(data, state); + if (!summary) return; + var popup = window.open("", "_blank", "noopener,noreferrer"); + if (!popup) { + announceAction(button, "The print window was blocked. Download the summary instead."); + return; + } + popup.opener = null; + popup.document.title = translateRuntimeText("MeshCore Canada commissioning summary"); + var heading = popup.document.createElement("h1"); + heading.textContent = translateRuntimeText("MeshCore Canada commissioning summary"); + var pre = popup.document.createElement("pre"); + pre.textContent = summary; + popup.document.body.appendChild(heading); + popup.document.body.appendChild(pre); + popup.focus(); + popup.print(); + announceAction(button, "Commissioning summary opened for printing. Exact coordinates and credentials were omitted."); + } function renderRegionDetail(data, section, target, state, tag) { if (!section || !target) return; if (!tag) { @@ -1322,7 +1915,7 @@ '
' + '' + (commands ? '' : '') + - (sourceUrl ? '' + icon("external-link") + 'Open source' : '') + + (sourceUrl ? '' + icon("external-link") + 'Open source' : '') + '
'; var copyTag = target.querySelector("[data-action='copy-detail-tag']"); @@ -1377,70 +1970,97 @@ function toolUi() { return '' + '
' + + '
' + '
    ' + - '
  1. ' + - '
  2. ' + - '
  3. ' + - '
  4. ' + + '
  5. ' + + '
  6. ' + + '
  7. ' + + '
  8. ' + '
' + '
' + '

Step 1 of 4

' + - '

Choose a location

' + - '' + + '

What are you configuring?

' + + '

We will recommend forwarding paths, then show how to apply them.

' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '' + - '
+
Canada - Census cells · yellow means changed + Inspect cells or use the municipality list; yellow means changed
-
+
+

The map is optional. Use the municipality list in step three for a full non-map flow. In Inspect mode, select a cell to read its details. Enable the advanced acknowledgement before painting individual cells.

+ diff --git a/docs/config/editor/infrastructure/draft-store.js b/docs/config/editor/infrastructure/draft-store.js new file mode 100644 index 0000000..9a97d75 --- /dev/null +++ b/docs/config/editor/infrastructure/draft-store.js @@ -0,0 +1,137 @@ +import { + PROPOSAL_SCHEMA_V1, + PROPOSAL_SCHEMA_V2 +} from "../domain/proposal-types.js?v=20260722-2"; + +const DRAFT_PREFIX = "mcc-region-editor-draft"; +const DRAFT_VERSION = 1; +const HASH_RE = /^[0-9a-f]{64}$/; +const PROVINCE_RE = /^[0-9]{2}$/; +const DGUID_RE = /^[A-Za-z0-9-]{8,64}$/; + +function cleanText(value, maxLength) { + return typeof value === "string" ? value.slice(0, maxLength) : ""; +} + +function cleanChanges(value) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.flatMap((change) => { + if ( + !change || + typeof change !== "object" || + !DGUID_RE.test(String(change.DGUID || "")) || + typeof change.to !== "string" || + change.to.length > 29 || + seen.has(change.DGUID) + ) { + return []; + } + seen.add(change.DGUID); + return [{ DGUID: String(change.DGUID), to: String(change.to) }]; + }); +} + +export function draftKey(schema, authorityHash, province) { + if ( + ![PROPOSAL_SCHEMA_V1, PROPOSAL_SCHEMA_V2].includes(schema) || + !HASH_RE.test(String(authorityHash || "")) || + !PROVINCE_RE.test(String(province || "")) + ) { + return ""; + } + return [DRAFT_PREFIX, schema.endsWith("/v2") ? "v2" : "v1", authorityHash, province].join(":"); +} + +export function serializableDraft(value) { + const schema = value && value.schema; + const baseMembershipSha256 = String(value && value.baseMembershipSha256 || "").toLowerCase(); + const province = String(value && value.province || ""); + if (!draftKey(schema, baseMembershipSha256, province)) return null; + const draft = { + version: DRAFT_VERSION, + schema, + baseMembershipSha256, + province, + target: cleanText(value.target, 29), + reason: cleanText(value.reason, 1000), + changes: cleanChanges(value.changes), + savedAt: Number.isFinite(value.savedAt) ? value.savedAt : Date.now() + }; + if (schema === PROPOSAL_SCHEMA_V2) { + draft.newRegion = { + label: cleanText(value.newRegion && value.newRegion.label, 80), + tag: cleanText(value.newRegion && value.newRegion.tag, 29), + anchorDguid: DGUID_RE.test(String(value.newRegion && value.newRegion.anchorDguid || "")) + ? String(value.newRegion.anchorDguid) + : "" + }; + } + return draft; +} + +export function saveDraft(storage, value) { + const draft = serializableDraft(value); + if (!storage || !draft) return { ok: false, key: "", draft: null }; + const key = draftKey(draft.schema, draft.baseMembershipSha256, draft.province); + try { + storage.setItem(key, JSON.stringify(draft)); + return { ok: true, key, draft }; + } catch (_error) { + return { ok: false, key, draft }; + } +} + +export function loadDraft(storage, schema, authorityHash, province) { + const key = draftKey(schema, authorityHash, province); + if (!storage || !key) return null; + try { + const raw = storage.getItem(key); + if (!raw) return null; + const draft = serializableDraft(JSON.parse(raw)); + if ( + !draft || + draft.schema !== schema || + draft.baseMembershipSha256 !== authorityHash || + draft.province !== province + ) { + return null; + } + return draft; + } catch (_error) { + return null; + } +} + +export function removeDraft(storage, schema, authorityHash, province) { + const key = draftKey(schema, authorityHash, province); + if (!storage || !key) return false; + try { + storage.removeItem(key); + return true; + } catch (_error) { + return false; + } +} + +export function hasStaleDraft(storage, schema, authorityHash, province) { + if (!storage || !schema || !province) return false; + const schemaPart = schema.endsWith("/v2") ? "v2" : "v1"; + const prefix = [DRAFT_PREFIX, schemaPart, ""].join(":"); + try { + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if ( + typeof key === "string" && + key.startsWith(prefix) && + key.endsWith(`:${province}`) && + key !== draftKey(schema, authorityHash, province) + ) { + return true; + } + } + } catch (_error) { + return false; + } + return false; +} diff --git a/docs/config/index.fr.md b/docs/config/index.fr.md new file mode 100644 index 0000000..e28f5e7 --- /dev/null +++ b/docs/config/index.fr.md @@ -0,0 +1,34 @@ +--- +title: Configurer les régions d’un répéteur +description: Choisissez les chemins régionaux canadiens et générez des commandes MeshCore vérifiées pour votre répéteur. +audience: + - repeater-operator +task: configure-repeater-regions +scope: canada-baseline +status: verified +owner: region-maintainers +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + region_catalog: national-partition-2026-07-19 +difficulty: intermediate +estimated_time: 5-10 minutes +page_styles: + - assets/regions/regions.css?v=20260722-3 +page_scripts: + - assets/regions/modules/configurator-support.js?v=20260722-2 + - assets/regions/regions.js?v=20260722-3 +hide: + - navigation + - toc +--- +# Configurer les régions d’un répéteur + +Choisissez les chemins que ce répéteur doit relayer. Ajoutez des chemins vers +d’autres provinces ou les États-Unis seulement si votre réseau local les utilise. + +
+ + + +[Norme sur les régions](standard.md) · [Sources de données](../assets/regions/NOTICE.txt) · [Partager vos commentaires](https://forum.meshcore.ca/t/thoughts-canadian-regions-strategy/54/46) diff --git a/docs/config/index.md b/docs/config/index.md index a4564ec..d5fd8d1 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -1,12 +1,31 @@ --- title: Set up repeater regions +description: Choose Canadian region paths and produce verified MeshCore repeater commands. +audience: + - repeater-operator +task: configure-repeater-regions +scope: canada-baseline +status: verified +owner: region-maintainers +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + region_catalog: national-partition-2026-07-19 +difficulty: intermediate +estimated_time: 5-10 minutes +page_styles: + - assets/regions/regions.css?v=20260722-3 +page_scripts: + - assets/regions/modules/configurator-support.js?v=20260722-2 + - assets/regions/regions.js?v=20260722-3 hide: - navigation - toc --- # Set up repeater regions -Choose the forwarding paths for this repeater. Add cross-province or U.S. paths when needed. +Choose this repeater’s forwarding paths. Add cross-province or U.S. paths only +if your local network uses them.
diff --git a/docs/config/map.fr.md b/docs/config/map.fr.md new file mode 100644 index 0000000..07484b7 --- /dev/null +++ b/docs/config/map.fr.md @@ -0,0 +1,34 @@ +--- +title: Carte des régions canadiennes +description: Trouvez une région canadienne, examinez sa hiérarchie et planifiez les chemins d’un répéteur sans dépendre de la carte. +audience: + - repeater-operator + - region-maintainer +task: explore-regions +scope: canada-baseline +status: verified +owner: region-maintainers +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + region_catalog: national-partition-2026-07-19 +difficulty: intermediate +page_styles: + - assets/regions/regions.css?v=20260722-3 +page_scripts: + - assets/regions/modules/configurator-support.js?v=20260722-2 + - assets/regions/regions.js?v=20260722-3 +hide: + - toc +--- + +# Carte des régions canadiennes + +Parcourez les limites des régions canadiennes et composez les chemins de votre +répéteur. + +
+ + + +[Norme sur les régions](standard.md) · [Sources de données](../assets/regions/NOTICE.txt) diff --git a/docs/config/map.md b/docs/config/map.md index 02ec549..b7ddafa 100644 --- a/docs/config/map.md +++ b/docs/config/map.md @@ -1,12 +1,30 @@ --- title: Canadian region map +description: Find a Canadian region, inspect its hierarchy, and plan repeater paths without requiring the map. +audience: + - repeater-operator + - region-maintainer +task: explore-regions +scope: canada-baseline +status: verified +owner: region-maintainers +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + region_catalog: national-partition-2026-07-19 +difficulty: intermediate +page_styles: + - assets/regions/regions.css?v=20260722-3 +page_scripts: + - assets/regions/modules/configurator-support.js?v=20260722-2 + - assets/regions/regions.js?v=20260722-3 hide: - toc --- # Canadian region map -Browse Canadian boundaries and build multi-region repeater paths. +Browse Canadian boundaries and build repeater paths.
diff --git a/docs/config/standard.fr.md b/docs/config/standard.fr.md new file mode 100644 index 0000000..f504003 --- /dev/null +++ b/docs/config/standard.fr.md @@ -0,0 +1,797 @@ +--- +title: Définition des régions et autorité +description: Les règles publiques, les sources, l’autorité et le processus de modification des régions MeshCore canadiennes. +audience: + - repeater-operator + - region-maintainer +task: understand-region-standard +scope: canada-baseline +status: verified +owner: region-maintainers +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + region_schema: meshcore-canada-regions-v2 +difficulty: advanced +--- + +# Définition des régions et autorité de MeshCore Canada + +Cette norme définit un seul système de régions pour tout le Canada : comment +les emplacements sont attribués, comment les limites sont générées, comment les +régions sont divisées, qui approuve les changements et comment les conflits +entre sources sont résolus. + +| Norme | Valeur | +| --- | --- | +| Identifiant | MCC-REG-1 | +| Version | 1.1 proposée | +| Référence géographique | Géographie du recensement de 2021 de Statistique Canada | +| Entrée sémantique actuelle | Canada MeshCore Region Strategy v1.1.1 | +| Entrée actuelle pour les limites communautaires | Instantané MeshMapper Canada, 2026-07-12 | +| Preuve opérationnelle actuelle | Instantané canadien de densité radio protégeant la vie privée, 2026-07-15 UTC; instantané agrégé des routes Canada–États-Unis, 2026-07-18 | +| Adoption | Devient normative après approbation et fusion par MeshCore Canada | + +Statistique Canada emploie en français les termes **aire de diffusion (AD)**, +**subdivision de recensement (SDR)**, **division de recensement (DR)** et +**région économique (RE)**. Les fichiers sources et le schéma de ce projet +conservent toutefois leurs clés anglaises `DA`, `CSD`, `CD` et `ER`. Dans cette +page, ces clés techniques désignent les mêmes unités officielles. + +!!! important "Quelle source fait autorité aujourd’hui?" + Cette page et la partition nationale générée sont proposées aux fins d’examen. La partition candidate attribue chaque DA numérique exactement une fois, et la carte publique affiche uniquement les régions terminales fusionnées. Aucun polygone source brut ni cercle approximatif ne constitue une limite. La partition devient l’autorité opérationnelle seulement après l’examen communautaire et la réussite de toutes les vérifications de publication prévues par cette norme. + +## La décision + +MeshCore Canada maintient **une seule partition géographique**. Chaque partie +du Canada appartient à exactement une région terminale du chemin +`can → province ou territoire → région → sous-région facultative`. Les +intérieurs des régions terminales ne se chevauchent jamais et leur union couvre +toute l’étendue nationale des DA. + +Le registre publié de MeshCore Canada est la source de vérité unique. Une +limite n’est pas enregistrée sous forme de polygone tracé à la main. Elle est +enregistrée comme la propriété de cellules géographiques officielles de +Statistique Canada, puis régénérée à partir de ces cellules. Par défaut, les +subdivisions de recensement gardent ensemble une municipalité ou son +équivalent; les aires de diffusion demeurent la géométrie exacte utilisée pour +publier la limite commune. + +Seules les régions terminales possèdent un territoire. Les provinces, les +territoires et les grandes régions sont des nœuds de regroupement formés par +leurs enfants. Les polygones bruts de MeshMapper, les cercles de la stratégie +et les zones d’événements ne sont pas publiés comme régions. Une zone de +répéteurs partagée est une métadonnée de configuration, et non une autre couche +cartographique. + +## Modèle de référence + +### Enregistrements géographiques + +| Niveau | Rôle | Règle | +| --- | --- | --- | +| `can` | Racine nationale | Un seul enregistrement; l’identifiant court demeure `can` | +| Province ou territoire | Champ de compétence et intendance | Les 13 provinces et territoires officiels du Canada | +| Région | Zone d’exploitation stable | Exhaustive dans son champ de compétence | +| Sous-région | Division facultative d’une région | Exhaustive dans sa région parente; ne chevauche jamais une région sœur | + +Une région sans enfant est une région géographique terminale. Lorsqu’elle est +divisée, ses cellules passent aux sous-régions et l’ancienne région terminale +devient un nœud de regroupement. Elle n’a ni remplissage indépendant, ni +propriété dans le résolveur, ni portée de commande supplémentaire. Un +emplacement correspond à une seule région terminale. + +Chaque enregistrement contient des champs distincts pour : + +- un identifiant de registre immuable comme `ca-ab-r0014`; +- un identifiant radio de référence, unique partout; +- les noms anglais et français; +- des noms autochtones et historiques facultatifs approuvés localement; +- l’identifiant de registre de son parent; +- l’historique de ses sources et examens; +- son état de publication : `proposed`, `reviewed`, `active`, `deprecated` ou `retired`. + +Les noms, les identifiants radio et la géométrie peuvent changer après examen. +L’identifiant immuable ne change pas. + +### Zones de répéteurs interprovinciales + +Une frontière provinciale sépare la propriété cartographique, pas le trafic +radio. Chaque emplacement conserve une région terminale d’attache, mais un +répéteur peut transporter plusieurs chemins complets lorsque sa couverture +normale traverse une frontière provinciale ou territoriale. + +Une **zone de répéteurs partagée** consigne une communauté établie qui traverse +plusieurs champs de compétence. Elle possède un nom et des régions terminales +membres, mais n’a aucun polygone, parent, résultat de résolveur ou identifiant +radio qui lui soit propre. Le configurateur produit plutôt le chemin complet de +chaque membre : + +- Région de la capitale nationale : `can → on → on-alg → ott` et `can → qc → gatout`; +- Lloydminster : `can → ab → lloyd-ab` et `can → sk → lloyd-sk`. + +Pour le micrologiciel v1.16+, la Région de la capitale nationale devient : + +```text +region def can on on-alg ott|can qc gatout +``` + +Le nom de recherche `ncr` n’est pas transmis par radio. Les deux côtés +conservent leurs identifiants de référence, et chaque répéteur de la zone +partagée reçoit le même arbre ordonné. + +Cette règle s’applique partout au Canada : + +1. Une zone partagée enregistrée est sélectionnée automatiquement pour chacune de ses régions terminales membres. +2. Un répéteur frontalier ou à grande couverture situé hors d’une zone enregistrée peut sélectionner n’importe quel ensemble vérifié de régions terminales canadiennes, y compris dans plusieurs provinces ou territoires. +3. Les commandes contiennent l’union des chemins sélectionnés. Un ancêtre commun apparaît une seule fois, les parents précèdent leurs enfants et chaque nouvelle branche revient à un ancêtre déjà défini. +4. Le configurateur doit refuser le résultat s’il dépasse les limites du micrologiciel de 32 identifiants ou 172 octets de réponse. Aucune ligne de l’interface en ligne de commande ne peut dépasser 160 caractères; si une ligne `region def` serait trop longue, le configurateur utilise plutôt des lignes `region put` ordonnées. +5. L’ajout d’une zone partagée ne déplace jamais une cellule de recensement, ne fusionne aucun polygone et n’affaiblit pas les contrôles de chevauchement. + +Enregistrez une zone partagée automatique uniquement lorsque les exploitants de +chaque côté confirment l’usage régulier de chemins transfrontaliers ou +l’existence d’une seule communauté continue. Les autres longs chemins sont +choisis séparément pour chaque répéteur. Les choix par défaut demeurent ainsi +utiles sans transformer chaque frontière provinciale en une seule zone radio +surdimensionnée. + +### Chemins de grands réseaux et de réseaux voisins + +Un chemin régional est un choix d’acheminement, pas une prévision de portée RF. +Une grande couverture n’exige pas nécessairement une montagne. L’altitude, les +trajets au-dessus de l’eau, les installations ordinaires sur les toits et les +répéteurs reliés peuvent tous produire de longues routes. + +Chaque répéteur conserve une région canadienne d’attache. Les exploitants +peuvent ensuite ajouter les chemins canadiens ou américains voisins que le +répéteur doit acheminer. La correspondance régionale est exacte; chaque portée +prévue doit donc inclure toute son ascendance. Le configurateur n’ajoute jamais +automatiquement un chemin voisin et ne dessine jamais la géométrie des +États-Unis. Le simple fait de capter le trafic d’une zone ne suffit pas : +ajoutez son chemin seulement si ce répéteur doit acheminer le trafic destiné à +cette zone. + +Utilisez le plus petit ensemble utile et répartissez le travail entre les +répéteurs : + +| Rôle du répéteur | Choix d’acheminement | +| --- | --- | +| Accès local | Chemin d’attache seulement | +| Pont régional | Chemin d’attache et quelques chemins canadiens voisins qu’il relie régulièrement | +| Dorsale longue distance | Ensemble examiné de chemins canadiens et américains étayé par les routes observées | + +Ne placez pas tous les chemins disponibles sur chaque répéteur. Par exemple, le +trafic entre Waterloo, Toronto et l’ouest de l’État de New York peut être +réparti entre des répéteurs-ponts plutôt que d’imposer les trois chemins à +chacun. Le choix revient aux exploitants locaux et doit être coordonné avec les +communautés qui utilisent ces chemins. + +Un chemin américain est admissible lorsqu’il est voisin du Canada ou situé de +l’autre côté d’une étendue d’eau commune et qu’il apparaît dans des preuves de +routes résolues. Un chemin plus éloigné exige des preuves répétées. Ces preuves +rendent seulement le chemin disponible; elles n’en font pas un choix par défaut +et ne garantissent pas son rendement futur. + +L’instantané agrégé des routes du 2026-07-18 étaye les chemins suivants : + +| Zone | Chemin exact | État | Côté canadien | Motifs de route / observations | +| --- | --- | --- | --- | ---: | +| Ouest de l’État de New York | `us → us-ny` | Documenté par les exploitants de WNY | Ontario et Québec | 4,508 / 33,820 | +| Washington | `west → pnw → wa` | Chemin PNW documenté | Colombie-Britannique | 5,384 / 21,596 | +| Oregon | `west → pnw → or` | Chemin PNW documenté; route plus éloignée | Colombie-Britannique | 903 / 1,394 | +| Pennsylvanie | `us → us-pa` | Provisoire; à confirmer localement | Ontario | 37 / 82 | +| Ohio | `us → us-oh` | Provisoire; à confirmer localement | Ontario | 6 / 10 | +| Californie | `west → ca` | Extension PNW provisoire; route plus éloignée | Colombie-Britannique | 7 / 12 | + +L’instantané compte les motifs de route résolus uniques et leurs observations +provenant de `dev.meshcore.ca`. Il ne conserve aucun nom de nœud, identifiant ou +coordonnée exacte. Ces nombres constituent des preuves d’acheminement, pas une +mesure de rendement. D’autres États frontaliers ou éloignés pourront être +ajoutés seulement avec le même type de preuve et l’examen des exploitants +voisins. + +### Sélectionner une région plus grande + +La sélection d’une province, d’un territoire ou d’une grande région affiche ses +régions enfants immédiates. La sélection d’un enfant qui a lui-même des enfants +permet de descendre d’un autre niveau. Les plus petits choix affichés sont les +régions terminales actives utilisées pour la recherche d’emplacement et les +commandes. + +Le contour d’un parent est calculé comme l’union de ses enfants pour la +surbrillance et la navigation cartographique. Il n’est jamais enregistré ni +affiché comme une région remplie concurrente. La hiérarchie demeure ainsi utile +sans attribuer deux fois le même lieu. + +L’arbre parent-enfant initial, les noms et les emplacements des points d’origine +regroupent le document de stratégie soumis, les discussions du forum, les +commentaires Discord, les captures d’écran et les anciennes ébauches de +régions. Ces sources communautaires déterminent **quelles** sous-régions +existent et comment les gens les reconnaissent. MeshMapper demeure la +principale préférence de limite là où une région canadienne y est définie. Le +générateur décide de la limite commune exacte en attribuant des DA entières; il +n’invente pas de noms supplémentaires et n’empile pas les formes dessinées par +les utilisateurs. + +Les commentaires futurs passent par le même processus sous forme de proposition +de changement de parent, de point d’origine, de nom ou d’attribution explicite +de DA. La liste avant-après des DA peut être examinée; une préférence locale +peut donc améliorer une limite sans créer ailleurs de trou ni de +chevauchement. + +## Cadre de couverture nationale + +L’unité topologique est l’**aire de diffusion (AD; clé technique `DA`) de +Statistique Canada de 2021**. Le produit numérique contient les 57 936 DA et +constitue tout le domaine de propriété. Le produit cartographique contient 57 932 DA, car quatre +DA composées uniquement d’eau en sont exclues; il sert seulement à obtenir un +littoral public plus propre. Les deux produits utilisent la même appartenance +lorsqu’une DA cartographique existe. + +La cohorte de propriété par défaut est la **subdivision de recensement (SDR; +clé technique `CSD`) de 2021**, l’unité de Statistique Canada utilisée pour une +municipalité ou son équivalent. Les 5 161 CSD sont gardées entières, sauf si une exception approuvée +attribue chaque DA de cette CSD. Les 293 **divisions de recensement (DR; clé +technique `CD`)** fournissent un regroupement supérieur pour les municipalités régionales, les +comtés et les zones comparables. Une décision examinée concernant une CD peut +garder ensemble une communauté régionale établie, mais ne peut écarter le point +d’origine d’une autre région sans examen. + +Le générateur utilise les 76 **régions économiques (RE; clé technique `ER`) +officielles de 2021** comme garde-fous généraux. Elles empêchent un point d’origine urbain d’absorber +une grande zone rurale sans lien simplement parce qu’il est le plus proche. +Leurs noms ne deviennent pas automatiquement des noms radio. + +La stratégie actuelle fournit 192 points d’origine géographiques candidats. Le +générateur crée 193 régions terminales, car l’ancien point d’origine +transfrontalier de Lloydminster est divisé en une région albertaine et une +région saskatchewanaise. MeshMapper fournit 29 polygones sources communautaires +associés à 27 identifiants candidats de référence. Ce sont des entrées +d’attribution, et non des couches finales concurrentes. + +| Province ou territoire | Garde-fous ER | Régions terminales candidates actuelles | Polygones sources MeshMapper | +| --- | ---: | ---: | ---: | +| Colombie-Britannique | 8 | 29 | 9 | +| Alberta | 8 | 14 | 4 | +| Saskatchewan | 6 | 12 | 1 | +| Manitoba | 8 | 10 | 1 | +| Ontario | 11 | 50 | 10 | +| Québec | 17 | 17 | 4 | +| Nouveau-Brunswick | 5 | 15 | 0 | +| Nouvelle-Écosse | 5 | 18 | 0 | +| Île-du-Prince-Édouard | 1 | 3 | 0 | +| Terre-Neuve-et-Labrador | 4 | 11 | 0 | +| Yukon | 1 | 6 | 0 | +| Territoires du Nord-Ouest | 1 | 5 | 0 | +| Nunavut | 1 | 3 | 0 | +| **Canada** | **76** | **193** | **29** | + +Le catalogue des régions terminales candidates demeure lisible par machine +dans [`canada-regions.json`](../assets/regions/canada-regions.json). Le tableau +ci-dessus ne ratifie pas tous les noms et regroupements candidats. Les zones +d’influence incertaines — particulièrement en Alberta, en Saskatchewan, au +Manitoba, à Terre-Neuve-et-Labrador, au Yukon et dans certaines parties du +Québec — demeurent prioritaires pour l’examen local avant leur activation. + +### Vérification du système actuel + +L’ancien prototype à chevauchements a confirmé pourquoi une seule couche +d’autorité générée est nécessaire : + +- les enregistrements du catalogue doivent encore être examinés par les communautés avant leur activation; +- les 192 zones à rayon autour des points d’origine et les 29 polygones MeshMapper bruts étaient affichés ensemble, mais ne formaient pas une partition; +- 52 des 192 centres de points d’origine correspondent actuellement à un autre identifiant ou à aucun identifiant dans le résolveur du prototype, dont trois résultats qui traversent un champ de compétence; +- les 29 polygones MeshMapper contiennent 29 paires de chevauchements non négligeables; ils doivent être conciliés avant de servir de couche unique; +- le polygone source actuel `YXX` est manifestement hors échelle et doit être actualisé ou expressément approuvé avant de pouvoir servir de point d’ancrage à Abbotsford; +- six alias normalisés ont plus d’un propriétaire; une recherche ambiguë exige donc le contexte du champ de compétence ou un choix explicite. + +Il s’agit de constats de migration, et non de définitions régionales acceptées. +La carte publique utilise maintenant uniquement la partition générée; les +cercles sources et les polygones sources bruts demeurent des éléments de preuve +pour le générateur et le rapport d’assurance qualité. + +### Inventaire complet des garde-fous + +Chaque DA se trouve dans l’une de ces combinaisons de province ou territoire et +de région économique. Les codes proviennent de la Classification géographique +type de 2021. + +| Province ou territoire | Régions économiques | +| --- | --- | +| Terre-Neuve-et-Labrador | `1010` Péninsule d’Avalon; `1020` Côte-sud–Péninsule de Burin; `1030` Côte-ouest–Péninsule du Nord–Labrador; `1040` Notre Dame–Centre de la baie de Bonavista | +| Île-du-Prince-Édouard | `1110` Île-du-Prince-Édouard | +| Nouvelle-Écosse | `1210` Cap-Breton; `1220` Côte-nord; `1230` Vallée de l’Annapolis; `1240` Sud; `1250` Halifax | +| Nouveau-Brunswick | `1310` Campbellton–Miramichi; `1320` Moncton–Richibucto; `1330` Saint John–St. Stephen; `1340` Fredericton–Oromocto; `1350` Edmundston–Woodstock | +| Québec | `2410` Gaspésie–Îles-de-la-Madeleine; `2415` Bas-Saint-Laurent; `2420` Capitale-Nationale; `2425` Chaudière-Appalaches; `2430` Estrie; `2433` Centre-du-Québec; `2435` Montérégie; `2440` Montréal; `2445` Laval; `2450` Lanaudière; `2455` Laurentides; `2460` Outaouais; `2465` Abitibi-Témiscamingue; `2470` Mauricie; `2475` Saguenay–Lac-Saint-Jean; `2480` Côte-Nord; `2490` Nord-du-Québec | +| Ontario | `3510` Ottawa; `3515` Kingston–Pembroke; `3520` Muskoka–Kawarthas; `3530` Toronto; `3540` Kitchener–Waterloo–Barrie; `3550` Hamilton–Péninsule du Niagara; `3560` London; `3570` Windsor–Sarnia; `3580` Stratford–Péninsule de Bruce; `3590` Nord-est; `3595` Nord-ouest | +| Manitoba | `4610` Sud-est; `4620` Centre-sud; `4630` Sud-ouest; `4640` Centre-nord; `4650` Winnipeg; `4660` Interlake; `4670` Parklands; `4680` Nord | +| Saskatchewan | `4710` Regina–Moose Mountain; `4720` Swift Current–Moose Jaw; `4730` Saskatoon–Biggar; `4740` Yorkton–Melville; `4750` Prince Albert; `4760` Nord | +| Alberta | `4810` Lethbridge–Medicine Hat; `4820` Camrose–Drumheller; `4830` Calgary; `4840` Banff–Jasper–Rocky Mountain House; `4850` Red Deer; `4860` Edmonton; `4870` Athabasca–Grande Prairie–Peace River; `4880` Wood Buffalo–Cold Lake | +| Colombie-Britannique | `5910` Île de Vancouver et côte; `5920` Lower Mainland–Sud-ouest; `5930` Thompson–Okanagan; `5940` Kootenay; `5950` Cariboo; `5960` Côte-nord; `5970` Nechako; `5980` Nord-est | +| Yukon | `6010` Yukon | +| Territoires du Nord-Ouest | `6110` Territoires du Nord-Ouest | +| Nunavut | `6210` Nunavut | + +## Autorité et priorité des sources + +La **version publiée du registre** constitue l’autorité opérationnelle finale. +Ses entrées jouent des rôles différents : + +| Priorité | Source | Rôle | +| ---: | --- | --- | +| 1 | Décisions approuvées du registre | Décisions explicites sur les limites, les noms, les divisions, les fusions ou la réattribution de DA | +| 2 | MeshMapper Canada | Principal point d’ancrage de l’identité et des limites communautaires lorsqu’une région canadienne y existe | +| 3 | Canada MeshCore Region Strategy v1.1.1 | Identifiants candidats, liens de parenté et points d’origine hors de la couverture MeshMapper | +| 4 | Relations CD/CSD de Statistique Canada | Garder cohérents les municipalités et les regroupements régionaux établis | +| 5 | Instantané de densité radio protégeant la vie privée | Preuve secondaire pour départager des choix serrés et non examinés de CSD entières | +| 6 | Générateur déterministe | Concilie chaque DA dans une seule région sans inventer une autre couche manuelle | + +Statistique Canada demeure l’autorité topologique à chaque niveau de priorité. +MeshMapper et les formes communautaires approuvées déterminent la couverture +voulue; la limite finale est alignée sur des DA entières pour que les régions +voisines partagent exactement la même frontière. + +Les autres sources jouent des rôles de soutien : + +- Les régions économiques de la SGC empêchent une croissance déraisonnable sur de longues distances. +- Les divisions et subdivisions de recensement définissent les regroupements supérieurs et les cohortes de propriété indivisibles par défaut utilisées par le générateur. +- Les grappes protégeant la vie privée provenant des répertoires en direct et de développement de MeshCore Canada tiennent compte de chaque nœud positionné récent. Les répéteurs, les serveurs de salon et les capteurs fixes fournissent les preuves d’attribution; les compagnons ajoutent un contexte indicatif de densité puisqu’ils peuvent se déplacer. Ces grappes complètent l’examen local sans le remplacer. +- Les jeux de données provinciaux et territoriaux actuels servent à valider les changements municipaux et la terminologie locale. +- La Base de données toponymiques du Canada sert à valider les noms de lieux. +- Les données sur les réserves des Premières Nations, les régions inuites, les établissements métis, les traités et les terres du Canada sont des jeux de référence distincts de la couche régionale. Elles ne deviennent ni des limites opérationnelles ni des noms sans l’examen des communautés concernées. + +Une décision locale approuvée peut préciser un point d’ancrage MeshMapper. Elle +doit indiquer les DA modifiées, expliquer le consensus local, réussir les mêmes +contrôles d’assurance qualité et faire l’objet d’une nouvelle version du +registre. Les polygones bruts de différents fournisseurs ne sont jamais empilés +pour former une couche finale « au mieux ». + +## Génération déterministe des limites + +Le générateur doit produire le même résultat à partir des mêmes entrées +verrouillées. + +### 1. Verrouiller les entrées + +Consignez l’URL de téléchargement, la date de publication, la licence, la taille +du fichier et son empreinte SHA-256 pour : + +- les fichiers numériques et cartographiques des limites des DA de 2021; +- la classification des régions économiques de la SGC de 2021; +- les fichiers numériques des limites des divisions et subdivisions de recensement de 2021; +- l’instantané MeshMapper Canada; +- le registre candidat, les dérogations de recensement approuvées, l’instantané de densité radio protégeant la vie privée et la configuration du générateur. + +Ne mélangez pas un fichier plus récent de subdivisions de recensement à +l’ensemble des DA de 2021. Les nouveaux fichiers municipaux demeurent des avis +de changement jusqu’à l’adoption d’un ensemble complet et compatible de +géographie de recensement. + +### 2. Normaliser la géométrie + +Validez et réparez la géométrie source, calculez les superficies et les +distances dans le système `EPSG:3347` de Statistique Canada, puis publiez le +résultat Web en WGS 84. Chaque DA est désignée par son DGUID, et non par un +numéro de ligne ou un nom. + +Pour MCC-REG-1, la couverture numérique des DA constitue la géométrie de +propriété. Le « point représentatif » est le résultat GEOS `PointOnSurface` +calculé à partir de cette géométrie dans la projection `EPSG:3347` de +Statistique Canada. Toute la couverture officielle des DA est conservée comme +un ensemble d’unités communes; elle n’est ni tamponnée, ni réparée, ni +simplifiée entité par entité. Seuls les polygones sources externes sont réparés +avant comparaison. `sources.lock.json` consigne les fichiers sources exacts, +tandis que `generator.yml` consigne l’algorithme, la chaîne d’outils et les +paramètres de précision. + +Le générateur candidat exige pour chaque polygone source une correspondance dans +le registre, une géométrie réparée non vide, un champ de compétence déclaré et +un contact avec au moins une DA de ce champ. Une source mise en quarantaine est +exclue. L’examen du centre, du rayon et de tout débordement hors du champ de +compétence demeure obligatoire avant qu’un candidat puisse faire autorité. + +### 3. Aligner les enveloppes MeshMapper sur les DA + +Un polygone source considère une DA comme faisant partie de son +**enveloppe générale** lorsque l’une de ces conditions est remplie : + +- le point représentatif de la DA est couvert par le polygone source; +- au moins 50 % de la superficie terrestre de la DA chevauche le polygone source. + +Lorsque plusieurs enveloppes couvrent la même DA, l’enveloppe gagnante est +choisie selon cet ordre : + +1. une dérogation explicite approuvée; +2. le plus grand rapport de chevauchement; +3. la priorité de la source dans le registre; +4. le plus petit identifiant de registre immuable selon l’ordre lexical. + +Chaque conflit est inscrit au rapport d’assurance qualité. Une enveloppe décide +quelle cible MeshMapper peut être candidate pour une DA; elle ne possède pas +directement la DA. Les formes sources brutes ne sont jamais exportées comme +polygones opérationnels. + +### 4. Valider les points d’origine communautaires + +Chaque région locale possède un point d’origine examiné. La DA qui contient ce +point est son ancrage. Si un point se trouve sur une limite, la DA couvrante +dont le DGUID est le plus petit selon l’ordre lexical est utilisée. Un point +d’origine situé hors de son champ de compétence déclaré bloque la publication. + +Deux points d’origine candidats dans la même DA constituent un conflit qui +bloque la publication. Le registre doit corriger le point; le générateur ne le +déplace jamais silencieusement. + +### 5. Définir les garde-fous + +L’attribution automatique ne traverse jamais une frontière provinciale ou +territoriale. Chaque point d’origine possède une région économique d’attache. +Une cible MeshMapper peut aussi être candidate dans chaque région économique +touchée par son enveloppe acceptée. + +### 6. Produire une propriété provisoire des DA + +Choisissez d’abord le point d’origine le plus proche dont la région économique +d’attache correspond à celle de la DA. Dans une enveloppe MeshMapper gagnante, +comparez sa cible associée aux points d’origine communautaires couverts par +cette enveloppe dont la région économique d’attache correspond à celle de la +DA. Si seule la cible associée demeure, incluez également le point d’origine +ordinaire le plus proche de la même ER. Attribuez la DA à la candidate la plus +proche dans `EPSG:3347`; une égalité exacte est tranchée selon l’identifiant de +registre immuable. + +Il s’agit d’un vote provisoire, et non de la propriété finale. Cette méthode +conserve MeshMapper comme principale préférence de limite tout en permettant +aux régions locales soumises d’influencer une grande enveloppe. Le rapport +d’assurance qualité consigne le nombre provisoire de DA par région pour chaque +enveloppe et bloque une enveloppe dominante qui priverait un point d’origine +communautaire contenu. + +Si une région économique n’a aucun point d’origine d’attache, le générateur +consigne une solution de repli qui bloque la publication et utilise le point +d’origine le plus proche dans la même province ou le même territoire uniquement +pour garder la carte candidate complète. Il n’attribue jamais une DA de l’autre +côté d’une frontière. + +Une sortie MultiPolygon est valide pour de véritables îles et composantes +terrestres séparées; les ponts d’eau artificiels ne le sont pas. Un fragment +continental déconnecté est signalé pour examen local. + +### 7. Garder les communautés de recensement cohérentes + +Le générateur convertit les votes provisoires des DA en propriété finale de CSD +entières dans cet ordre : + +1. une décision approuvée concernant la CSD; +2. une décision approuvée concernant la CD qui n’entre pas en conflit avec le point d’origine d’une autre région; +3. la région dont le point d’origine se trouve dans la CSD; +4. le seul point d’origine régional de la CD qui contient la CSD; +5. la pluralité des votes provisoires des DA; +6. uniquement pour un choix serré et non examiné, des preuves admissibles de grappes radio protégeant la vie privée; +7. la distance projetée au point d’origine et l’identifiant de registre immuable pour une dernière égalité exacte. + +Une CSD peut être divisée uniquement par une exception approuvée qui énumère +chaque DGUID de cette CSD et son propriétaire. Le générateur échoue si toute +autre CSD possède plus d’un propriétaire. Une limite fondée sur le point +d’origine le plus proche ne peut donc pas couper une ville constituée simplement +parce qu’un quartier est plus près du point suivant. + +Le cas de Kitchener-Waterloo bloque la publication s’il échoue : les 189 DA de +la CSD de Cambridge `3530010`, y compris Hespeler, correspondent à `wat`; les +766 DA de la CD de Waterloo `3530` correspondent également à `wat`. Ces nombres +sont liés à la géographie verrouillée du Recensement de 2021 et doivent être +réexaminés lorsque le millésime de recensement change. + +### 8. Utiliser l’activité radio uniquement pour départager de façon confidentielle + +L’instantané verrouillé `radio-density.json` réunit les observations récentes et +positionnées de `live.meshcore.ca` et les entrées positionnées actuellement +retournées par `dev.meshcore.ca`, puis déduplique en mémoire les clés publiques +identiques. Le point de terminaison de développement n’indique pas l’heure +d’observation de chaque nœud; les entrées provenant uniquement de cet +environnement fournissent donc une densité indicative, mais ne peuvent servir +de preuve décisionnelle. Les observations récentes des répéteurs, serveurs de +salon et capteurs en direct fournissent les nombres décisionnels; les +emplacements des compagnons demeurent indicatifs. L’instantané est lié à une +empreinte SHA-256 du DGUID et du propriétaire provisoire avant l’analyse radio. +Les anciens noms candidats échouent donc de façon sûre, tandis qu’un critère +radio valide peut encore modifier la propriété finale sans dépendance circulaire +de l’empreinte. + +Les grappes ne s’étendent pas sur plus de 30 kilomètres. Chaque nombre +géographique publié est d’au moins cinq. Les nombres par candidat sont publiés +uniquement dans leur propre CSD, et toute la ventilation des candidats d’une CSD +est masquée si l’une des catégories contient moins de cinq nœuds. Aucun +identifiant de nœud, nom ou coordonnée exacte n’est conservé. + +La preuve radio peut départager uniquement des candidates déjà présentes dans +une CSD lorsque la marge provisoire ne dépasse pas 10 points de pourcentage et +qu’au moins 60 % des preuves radio admissibles appuient une candidate. Elle ne +peut pas créer une région, diviser une CSD, traverser une province ou un +territoire ni remplacer une décision de recensement approuvée. Un instantané +radio constitue une preuve reproductible pour une version, et non une autorité +automatique en direct; tout changement passe par un nouvel instantané verrouillé +et l’examen normal. + +### 9. Générer les deux produits de limites + +- Le résolveur utilise les limites **numériques** des DA pour traiter les eaux côtières de façon uniforme. +- La carte publique utilise les limites **cartographiques** des DA pour produire un littoral propre. + +Les deux produits utilisent la même appartenance aux DA. Les intérieurs des +régions terminales générées sont disjoints deux à deux et leur union couvre +toute l’étendue verrouillée des DA. Les régions voisines partagent une seule +limite sans largeur. Un point exactement sur cette limite correspond à +l’identifiant de registre le plus petit selon l’ordre lexical. + +## Divisions et fusions + +Les grandes régions sont divisées en déplaçant des DA entières, et non en +traçant une nouvelle ligne à main levée. + +Une proposition de division doit comprendre : + +- l’identifiant de la région parente; +- la liste des DGUID qui appartiennent à chaque région enfant proposée; +- les identifiants radio proposés et les noms anglais et français; +- la raison locale de la division; +- la preuve d’un examen par les communautés touchées; +- un rapport actualisé sur les limites des commandes. + +Toutes les cellules du parent doivent appartenir à exactement un enfant. Après +une division, le parent devient uniquement un regroupement non terminal. Il ne +possède ni propriété distincte dans le résolveur, ni remplissage publié, ni +portée d’acheminement supplémentaire. + +Les aires de diffusion agrégées peuvent servir de point de départ à une +division, mais leurs codes ne constituent pas une identité permanente. Elles +peuvent être divisées ou combinées lorsque la géographie locale le justifie. + +Les CSD sont les unités par défaut des sous-régions. Privilégiez d’abord les +municipalités ou leurs équivalents au complet, puis combinez des CSD voisines en +fonction de leur CD, de l’identité locale, du terrain et des preuves +opérationnelles. La division d’une CSD est une exception et exige l’attribution +complète et énumérée des DA décrite plus haut. + +Une fusion conserve chaque identifiant et identifiant radio retiré comme alias +ou marqueur permanent. Un identifiant retiré n’est jamais réutilisé +silencieusement pour un autre lieu. + +## Noms et identifiants radio + +Le registre suit la préférence exprimée sur le forum pour des identifiants +courts, plats et faciles à lire. La hiérarchie est enregistrée dans les champs +de parenté plutôt que répétée dans chaque identifiant. + +Les identifiants radio de référence doivent : + +- être uniques dans tout le réseau maillé connecté; +- contenir uniquement les caractères minuscules `a-z`, `0-9` et `-`; +- compter au plus 29 octets UTF-8; +- demeurer stables une fois activés; +- éviter de nommer automatiquement une vaste région rurale d’après sa plus grande ville; +- être comparés aux identifiants actifs, obsolètes, retirés, aux alias et aux groupes de recherche non géographiques. + +Les codes IATA et postaux peuvent servir d’alias lorsqu’ils sont utiles, mais ni +l’un ni l’autre ne constitue l’autorité nationale de dénomination. Une nouvelle +sous-région utilise un identifiant plat significatif localement lorsqu’il est +sans ambiguïté. Un identifiant préfixé par le parent sert en dernier recours à +éviter une collision; il n’est pas obligatoire. + +Les commandes générées utilisent uniquement les identifiants de référence. La +recherche peut accepter les noms et alias. Le registre conserve séparément les +noms anglais et français; les noms autochtones locaux exigent l’examen de la +communauté concernée et ne sont jamais déduits. + +## Gouvernance + +### Responsabilités + +| Rôle | Responsabilité | +| --- | --- | +| Responsables des régions de MeshCore Canada | Intégrité du registre, vérification des collisions, publications, générateur et assurance qualité | +| Responsables provinciaux ou territoriaux | Coordonner les propositions locales et confirmer les effets dans tout leur champ de compétence | +| Exploitants et communautés locales | Premier examen des noms locaux, des regroupements et de la couverture pratique | +| Responsables des régions voisines | Examen conjoint lorsqu’une proposition déplace leur limite commune de DA | + +Les responsables nationaux font respecter le modèle de données; ils n’inventent +pas l’identité locale. Un changement techniquement valide peut attendre +l’examen local. Un changement populaire localement peut quand même échouer s’il +crée un trou, un chevauchement, une collision ou un dépassement des limites des +commandes. + +### Processus de modification + +1. Soumettez une proposition depuis l’éditeur de limites. Le service ouvre un billet public avec les DGUID, les noms, les identifiants, la raison et l’auteur proposé; aucun compte de contribution n’est requis. +2. Générez une comparaison avant-après et un rapport d’assurance qualité. +3. Obtenez l’examen local et celui du champ de compétence touché. Une proposition transfrontalière exige l’accord de chaque côté. +4. Prévoyez une période d’examen public consignée dans la proposition. +5. Un responsable autorisé approuve la proposition en fermant le billet `boundary-update` avec l’état **Completed**. L’état **Close as not planned** la rejette. +6. L’Action du dépôt vérifie la proposition signée, consigne la décision de recensement examinée, régénère et valide toute la couche nationale, enregistre le résultat dans `main` et met sa publication en file d’attente. +7. Conservez la version précédente pour la restauration et la migration. + +### Propositions de l’éditeur de limites + +L’éditeur travaille avec les mêmes cellules de recensement que le générateur. +Son action normale réattribue une CSD entière, et affiche sa CD comme contexte +d’examen. Il n’enregistre jamais un polygone tracé à main levée comme géométrie +opérationnelle. Une personne chargée de l’examen peut utiliser des modifications +provisoires au niveau des DA pour façonner une division exceptionnelle, mais +l’enregistrement `splitExceptions` approuvé doit énumérer chaque DA de la CSD, +sans DGUID en double ni manquant. + +L’éditeur est une page statique à `/config/editor/` et n’exige aucun compte de +contribution. Il peut déplacer des cellules vers une région existante ou +proposer une nouvelle région avec un nom unique, un identifiant court et une +cellule d’ancrage modifiée. La nouvelle région terminale utilise le parent +commun le plus proche de ses régions sources afin de conserver l’arbre actuel. +Il crée une proposition versionnée avec l’empreinte d’appartenance de base et +le propriétaire avant-après de chaque DGUID modifié. Lors de la soumission, un +service exploité par MeshCore Canada répète les vérifications d’autorité et de +proposition, valide le contrôle antipourriel et utilise une GitHub App limitée +au dépôt pour ouvrir automatiquement le billet public. La page statique et le +service de production sont tous deux exploités par MeshCore Canada; aucun +identifiant GitHub ni secret de signature n’est placé dans le navigateur. +L’App publique possède uniquement un accès en lecture-écriture aux billets et +ne peut pas modifier la carte. La proposition de référence est signée par l’App +et enregistrée dans des marqueurs lisibles par machine, tandis que le billet +présente le résumé destiné aux personnes. Les responsables peuvent reproduire +la vérification avec `scripts/validate-region-proposal.py`, qui ajoute le +contexte CD/CSD et exige une raison avant l’examen; un auteur peut aussi être +consigné. + +Après l’examen local et public, une personne autorisée ferme le billet étiqueté +comme **Completed**. L’Action du dépôt vérifie indépendamment l’auteur, la +personne qui ferme, l’étiquette, la signature de l’App, l’empreinte de la +proposition et le champ de compétence. Elle vérifie aussi le propriétaire +actuel de chaque cellule demandée. Une proposition peut demeurer ouverte +pendant que d’autres limites changent, mais elle échoue de façon sûre si l’une +de ses cellules a changé durant l’examen. Une décision concernant une CSD +entière devient une dérogation de cohorte; une décision partielle devient une +division explicite qui énumère toutes les DA de cette CSD. Pour une nouvelle +région approuvée, l’Action tire le point d’origine de la cellule d’ancrage +officielle, ajoute l’entrée au catalogue et applique la même décision de +propriété. Les points d’origine approuvés par la communauté ne participent pas +à l’étape candidate avant l’analyse radio : la cohorte ou la division examinée +possède les cellules approuvées. Cela protège la base de densité radio +verrouillée et empêche une expansion non examinée. L’Action régénère ensuite +les deux partitions nationales et les cellules de l’éditeur à partir des +sources verrouillées, exécute les contrôles de publication et enregistre la +décision source ainsi que les éléments générés dans `main`. Cette mise à jour déclenche le +déploiement normal du site. Tout échec avant la publication rouvre le billet et +laisse `main` inchangée. Une fermeture comme **Not planned** ne change aucune +autorité. Les brouillons de l’éditeur, l’état local du navigateur et les billets +soumis ne font jamais autorité avant la fin de l’approbation et de la +validation. + +La géométrie des cellules de recensement de l’éditeur +(`docs/assets/regions/cells/`) a été générée pour la dernière fois avec +`scripts/build-region-editor-data.py --retain 10%` plutôt qu’avec la valeur par +défaut de 8 %, car 8 % réduit une aire de diffusion de la +Colombie-Britannique à une forme dégénérée. La valeur retenue est consignée avec +les autres entrées de construction dans +`docs/assets/regions/cells/manifest.json`. + +Règles de versionnement : + +- **Majeure :** millésime de géographie de recensement ou modification incompatible de l’autorité ou du modèle. +- **Mineure :** réattribution de DA, division, fusion, modification de hiérarchie ou d’identifiant, ou modification des membres d’une zone de répéteurs partagée. +- **Correctif :** noms, alias, documentation ou métadonnées de source sans modification de l’appartenance. + +Les limites opérationnelles des régions sont des définitions communautaires +d’acheminement. Elles ne constituent aucune revendication juridique, électorale, +cadastrale, territoriale, de traité, de titre ou de souveraineté. + +## Contrôles de publication + +Une version géographique échoue à moins que toutes ces conditions soient +remplies : + +- les 57 936 DA figurent exactement une fois dans la table d’appartenance des régions terminales; +- les 5 161 CSD et 293 CD proviennent du même ensemble verrouillé du Recensement de 2021; +- chaque CSD possède une seule région terminale, sauf si une exception approuvée énumère chaque DGUID; +- la région terminale de chaque DA se trouve dans la même province ou le même territoire; +- chaque paire d’intérieurs de régions terminales a une superficie de chevauchement nulle, sans égard à sa branche hiérarchique; +- la différence symétrique entre l’union des régions terminales et l’union numérique verrouillée des 57 936 DA est nulle à la précision configurée; +- l’union de chaque sous-région correspond à l’appartenance de son parent; +- seules les régions terminales possèdent une géométrie; aucun `routingOverlays`, `sharedParents` ou champ ajouté par profil n’existe; +- chaque zone de répéteurs partagée contient des régions terminales de référence provenant d’au moins deux provinces ou territoires, et aucune région terminale n’appartient à plus d’une zone automatique; +- les noms des zones partagées n’entrent jamais dans l’arbre radio; les chemins complets des membres respectent toutes les limites du micrologiciel et des lignes série; +- chaque chemin voisin est non géographique, facultatif, étayé par des preuves agrégées de routes et utilise une hiérarchie externe documentée ou expressément provisoire; +- les chemins voisins ne possèdent jamais de cellules canadiennes, ne sont jamais résolus à partir d’un point cartographique et ne figurent jamais comme géométrie de limites américaines; +- chaque point de test du résolveur retourne une seule région terminale; +- chaque région active est contiguë par une limite terrestre commune, ou possède une exception documentée d’île ou de MultiPolygon; +- chaque identifiant actif est unique partout et respecte la limite d’octets du micrologiciel; +- chaque ancien identifiant correspond à un enregistrement actif, obsolète ou à un marqueur permanent; +- chaque enregistrement géographique actif possède des `allowed_er_codes` examinés, et aucun conflit de point d’origine ou de cellule verrouillée ne demeure; +- chaque point d’ancrage MeshMapper possède un rapport d’écart au niveau des DA; +- la CSD de Cambridge `3530010` compte exactement 189 DA appartenant à `wat`, et la CD de Waterloo `3530` compte exactement 766 DA appartenant à `wat`; +- toute entrée de densité radio ne contient aucun identifiant de nœud, nom ou coordonnée exacte, utilise des agrégats d’au moins cinq nœuds et ne peut pas diviser une CSD; +- chaque polygone source réussit les contrôles de superficie, de centre, de compatibilité du champ de compétence et d’état d’examen avant de servir d’ancrage; +- toute la géométrie générée est valide et reproductible à partir des entrées verrouillées; +- toutes les commandes d’essai générées respectent les limites de 32 régions, 172 octets de réponse et 160 caractères par ligne série; +- chaque enregistrement géographique actif possède un nom anglais et un nom français; +- le rapport d’assurance qualité ne contient aucun conflit non examiné ni région de repli. + +## Éléments obligatoires du registre + +Avant que la première version géographique MCC-REG-1 soit marquée active, le +dépôt doit publier : + +| Élément | Rôle | +| --- | --- | +| `sources.lock.json` | Entrées, licences et empreintes exactes | +| `generator.yml` | Version de l’algorithme et toutes ses constantes | +| `canada-regions.json` | Enregistrements géographiques, hiérarchie, alias et correspondances de sources | +| `municipal-overrides.json` | Propriété approuvée des CD/CSD, divisions complètes de CSD et décisions concernant les nouvelles régions | +| `radio-density.json` | Preuve agrégée facultative protégeant la vie privée; jamais de données brutes sur les nœuds | +| `canada-region-membership.csv` | Une ligne pour chaque DA numérique, avec le contexte CD/CSD, le vote provisoire et la région terminale finale | +| `aliases.csv` | Alias actuels, obsolètes, historiques et de recherche | +| `canada-region-partition.geojson` | Couche cartographique générée des régions terminales | +| `canada-region-partition-digital.geojson` | Couche complète générée du résolveur | +| `canada-region-partition.qa.json` | Preuves de publication, empreintes et écarts aux sources | +| `configuration.yml` | Politique du micrologiciel et des paramètres radio, séparée de la géographie | + +Le GeoJSON généré est une sortie de construction. Le catalogue, la table +d’appartenance, la configuration du générateur et le verrouillage des sources +sont les entrées qui font autorité. Les groupes de recherche non géographiques +ne possèdent jamais de cellules, n’apparaissent pas dans la couche +cartographique et ne sont pas résolus à partir d’un point. Le nom d’une zone de +répéteurs partagée n’entre jamais dans une commande; seuls les chemins de +référence de ses membres y entrent. + +## Migration depuis la carte actuelle + +| Phase | Résultat | État des limites | +| --- | --- | --- | +| Version candidate générée | 193 régions terminales exclusives; chaque DA numérique est attribuée une fois | Complète et sans chevauchement; toujours proposée | +| Verrouillage des sources | Entrées de Statistique Canada, MeshMapper, de la stratégie et des dérogations figées | Entrées reproductibles | +| Ébauche générée | Chaque DA est attribuée; l’assurance qualité automatisée est publiée | Complète, mais proposée | +| Examen local | Zones d’influence incertaines en Alberta et ailleurs corrigées; noms examinés dans les deux langues officielles | Examinée | +| Version active | L’appartenance et les éléments réussissent chaque contrôle de publication | Fait autorité | + +MeshMapper demeure la principale source d’attribution communautaire lorsqu’une +région y existe. Les cercles et polygones sources bruts ne deviennent jamais +des régions finales et ne sont jamais affichés comme tels. Ottawa et +l’Outaouais demeurent des régions provinciales terminales voisines, et +Lloydminster demeure divisée en `lloyd-ab` et `lloyd-sk`. Leurs configurations +partagées réunissent les chemins de référence des membres sans fusionner la +géométrie cartographique. + +## Règles de configuration des répéteurs + +Les instructions générées doivent suivre la +[documentation officielle actuelle de l’interface MeshCore](https://docs.meshcore.io/cli_commands/), +et non des exemples copiés d’un ancien document de stratégie. + +- Utilisez `region def` ou `region put` pour définir exactement l’arbre requis par ce répéteur. +- `name|jump` crée `name` sous le curseur actuel, puis déplace le curseur vers `jump`; `jump` n’est pas le parent de `name`. +- `region def` n’efface pas l’arbre actuel et peut laisser des modifications partielles après une erreur. +- Une configuration interprovinciale doit utiliser une seule racine `can` avec une branche complète par région terminale sélectionnée. Elle ne doit pas inventer un deuxième parent ou l’identifiant d’une zone partagée. +- Un chemin américain voisin conserve la hiérarchie utilisée par cette communauté. Il constitue une branche racine distincte et ne fait pas partie de `can`. +- Les zones de répéteurs partagées enregistrées utilisent le même ordre déterministe des membres sur chaque répéteur. Les autres sélections à grande couverture sont ordonnées selon la hiérarchie de référence, et non selon l’ordre des clics. +- Sélectionnez les chemins selon le rôle du répéteur. N’ajoutez pas tous les chemins partout. +- Exécutez simplement `region` pour examiner le résultat. +- Exécutez `region save` seulement après avoir confirmé l’arbre et les permissions d’inondation. + +L’outil de configuration doit générer et tester les commandes à partir des +identifiants de parent du registre. Il ne doit jamais déduire l’ordre des +commandes en divisant une chaîne d’identifiant. La carte peut entourer ensemble +toutes les régions terminales canadiennes sélectionnées, mais chaque +remplissage demeure sa région géographique d’origine sans chevauchement. Les +chemins américains voisins apparaissent uniquement dans les commandes et les +noms. L’éditeur de limites continue d’accepter une seule province ou un seul +territoire par proposition; l’appartenance à une zone de répéteurs partagée et +les métadonnées des chemins voisins sont modifiées dans le catalogue et +examinées par chaque côté touché. + +## Registre des sources + +- [Discussion du forum de MeshCore Canada](https://forum.meshcore.ca/t/thoughts-canadian-regions-strategy/54), y compris la discussion sur la couverture complète et les unités administratives dans les [messages 29 à 36](https://forum.meshcore.ca/t/thoughts-canadian-regions-strategy/54/29), la discussion sur l’autorité locale dans les [messages 43 et 44](https://forum.meshcore.ca/t/thoughts-canadian-regions-strategy/54/43) et la préoccupation sur la facilité de trouver l’information dans le [message 50](https://forum.meshcore.ca/t/thoughts-canadian-regions-strategy/54/50). +- Canada MeshCore Region Strategy v1.1.1, datée du 23 juin 2026. Empreinte SHA-256 du PDF fourni : `9f32d71d2656cfa3abfda4736c3ddb64d1b6e7c5d4e88a7d55b63424f9353a3b`. +- Instantané canadien [MeshMapper](https://meshmapper.net/) `meshmapper-ca-2026-07-12`. +- [Définition des DA de Statistique Canada de 2021](https://www12.statcan.gc.ca/census-recensement/2021/ref/dict/az/definition-eng.cfm?ID=geo021), [Guide des fichiers des limites de 2021](https://www150.statcan.gc.ca/n1/pub/92-160-g/92-160-g2021001-eng.htm), [relations de la géographie de diffusion de 2021](https://www12.statcan.gc.ca/census-recensement/2021/geo/sip-pis/dguid-idugd/index2021-eng.cfm?year=21) et [norme des régions économiques de 2021](https://www.statcan.gc.ca/en/subjects/standard/sgc/2021/er-additionalinfo). +- [Licence ouverte de Statistique Canada](https://www.statcan.gc.ca/en/terms-conditions/open-licence). +- Instantané protégeant la vie privée des nœuds positionnés de MeshCore Canada provenant de [`live.meshcore.ca`](https://live.meshcore.ca/) et [`dev.meshcore.ca`](https://dev.meshcore.ca/), où l’infrastructure fixe sert aux décisions et tous les rôles sont conservés uniquement comme contexte agrégé. +- Preuve agrégée de routes résolues provenant de [`dev.meshcore.ca`](https://dev.meshcore.ca/) le 18 juillet 2026. Seuls les totaux par zone sont conservés. +- [Paramètres radio de WNY MeshCore](https://wnymeshcore.org/guides/radio-settings) pour `us → us-ny`, [stratégie du Pacific Northwest](https://gessaman.com/meshcore/regions/) pour `west → pnw → wa` et `west → pnw → or`, et [convention de chemins d’États de RegionMesh](https://www.regionmesh.com/meshcore-region-configuration/) pour les chemins provisoires des États-Unis. diff --git a/docs/config/standard.md b/docs/config/standard.md index 442c117..1c02135 100644 --- a/docs/config/standard.md +++ b/docs/config/standard.md @@ -1,10 +1,25 @@ --- title: Region definition and authority +description: The public rules, sources, authority, and change process for Canadian MeshCore regions. +audience: + - repeater-operator + - region-maintainer +task: understand-region-standard +scope: canada-baseline +status: verified +owner: region-maintainers +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + region_schema: meshcore-canada-regions-v2 +difficulty: advanced --- # MeshCore Canada region definition and authority -This standard defines one Canada-wide region system: how every location is assigned, how boundaries are generated, how large regions split, who may approve changes, and which source wins when sources disagree. +This standard defines one Canada-wide region system: how locations are assigned, +how boundaries are generated, how regions split, who approves changes, and how +source conflicts are resolved. | Standard | Value | | --- | --- | @@ -25,7 +40,10 @@ MeshCore Canada maintains **one geographic partition**. Every part of Canada bel The published MeshCore Canada registry is the single source of truth. A boundary is not stored as a hand-drawn polygon. It is stored as ownership of official Statistics Canada geographic cells, then regenerated from those cells. Census Subdivisions keep a municipality or municipal equivalent together by default; Dissemination Areas remain the exact geometry used to publish the shared edge. -Only leaves own land. Provinces, territories, and larger region records are grouping nodes derived from their children. Raw MeshMapper polygons, strategy circles, and event areas are never published as regions. A shared repeater area is configuration metadata, not another map layer. +Only leaves own land. Provinces, territories, and larger region records are +grouping nodes built from their children. Raw MeshMapper polygons, strategy +circles, and event areas are not published as regions. A shared repeater area +is configuration metadata, not another map layer. ## Canonical model @@ -38,7 +56,10 @@ Only leaves own land. Provinces, territories, and larger region records are grou | Region | Stable operating area | Exhaustive within its jurisdiction | | Subregion | Optional split of a region | Exhaustive within its parent; never overlaps a sibling | -A region with no children is a geographic leaf. When it is split, all of its cells move to subregions and the former leaf becomes a grouping node. It has no independent fill, resolver ownership, or additional command scope. A location resolves to one and only one leaf. +A region with no children is a geographic leaf. When split, its cells move to +subregions and the former leaf becomes a grouping node. It has no independent +fill, resolver ownership, or extra command scope. A location resolves to one +leaf. Every record has separate fields for: @@ -397,7 +418,7 @@ A geographic release fails unless all of these are true: - shared-area names never enter the on-air tree; the complete member paths fit every firmware and serial-line budget; - every neighbouring path is non-geographic, optional, backed by aggregate route evidence, and uses a documented or explicitly provisional external hierarchy; - neighbouring paths never own Canadian cells, resolve from a map point, or appear as U.S. boundary geometry; -- every resolver test point returns exactly one leaf; +- every resolver test point returns one and only one leaf; - every active region is contiguous by shared land edge, or has a documented island/MultiPolygon exception; - every active tag is globally unique and within the firmware byte limit; - every old tag resolves to an active record, a deprecated record, or a tombstone; diff --git a/docs/contributing.fr.md b/docs/contributing.fr.md new file mode 100644 index 0000000..36e644c --- /dev/null +++ b/docs/contributing.fr.md @@ -0,0 +1,70 @@ +--- +title: Contribuer à MeshCore Canada +description: Choisissez la façon la plus simple de proposer une idée, de mettre à jour une communauté, de corriger une page ou de contribuer au code. +audience: + - community-member + - documentation-contributor +task: choose-contribution +scope: canada-baseline +status: verified +owner: maintainers +author: MeshCore Canada maintainers +last_reviewed: 2026-07-22 +review_by: 2027-01-19 +tested_with: + issue_forms: f608cfe +difficulty: beginner +estimated_time: 2-15 minutes +--- + +# Contribuer à MeshCore Canada + +Choisissez la façon dont vous voulez aider. La plupart des options ne demandent +aucune expérience avec Git ou GitHub. + +
+ +- **Partager une idée** + + Signalez quelque chose qui manque, porte à confusion ou est difficile à + utiliser. Aucun compte GitHub n’est nécessaire. + + [:octicons-arrow-right-24: Partager une idée](submit-idea.md) + +- **Ajouter ou mettre à jour une communauté** + + Indiquez le nom, la région, les coordonnées, la langue et les paramètres + locaux de la communauté. + + [:octicons-arrow-right-24: Ajouter une communauté](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new?template=add_new_community.yml) + · [:octicons-arrow-right-24: Mettre à jour une communauté](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new?template=update_existing_community.yml) + +- **Corriger une page** + + Signalez une erreur factuelle, un lien brisé, une coquille ou une + instruction difficile à comprendre. + + [:octicons-arrow-right-24: Signaler une correction](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new?template=fix_correction.yml) + +- **Proposer une modification de région** + + Proposez une nouvelle région ou une modification de ses limites. + + [:octicons-arrow-right-24: Ouvrir l’éditeur de région](config/editor/index.html) + +- **Contribuer sur GitHub** + + Améliorez un guide ou travaillez sur un billet ouvert. + + [:octicons-mark-github-24: Ouvrir le dépôt](https://github.com/MeshCore-ca/MeshCore-Canada) + +
+ +!!! warning "Ne partagez aucun secret" + Les soumissions sont publiques. Retirez les mots de passe, les jetons + d’accès, les clés privées, les adresses privées, les coordonnées privées + précises et les sorties de commande sensibles. + +## Vous ne savez pas quoi choisir? + +[Partagez une idée](submit-idea.md) et nous vous aiderons à l’orienter. diff --git a/docs/contributing.md b/docs/contributing.md index 27e529c..3a16992 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -1,75 +1,67 @@ -# How to Request Changes to MeshCore.ca - -Anyone can contribute an idea, correction, local observation, build note, or documentation request. You do not need to know how the website is built. - -## Easiest option: use the submission helper - -The [Share an Idea](submit-idea.md) page asks plain-language questions and prepares a structured submission for you. Review the result, complete the anti-spam check, and submit it directly without a GitHub account. You can also: - -- copy it into the MeshCore Canada forum; -- paste it into the community Discord; or -- open the prepared form manually if you already use GitHub. - -The direct submission creates a public GitHub issue for maintainer review. No code, Git commands, radio settings, GitHub account, or perfect terminology are required. - --- - -## GitHub issue templates - -If you are comfortable signing in to GitHub, use one of the repository's guided issue forms. - -You do not need to know Git, GitHub, or how to edit the website. Just follow the steps below. - +title: Contribute to MeshCore Canada +description: Choose the simplest way to share an idea, update a community, correct a page, or contribute code. +audience: + - community-member + - documentation-contributor +task: choose-contribution +scope: canada-baseline +status: verified +owner: maintainers +author: MeshCore Canada maintainers +last_reviewed: 2026-07-22 +review_by: 2027-01-19 +tested_with: + issue_forms: f608cfe +difficulty: beginner +estimated_time: 2-15 minutes --- -### 1. Go to the Issues page +# Contribute to MeshCore Canada -Visit the MeshCore Canada GitHub repository and click **Issues**. +Choose how you want to help. Most options do not require Git or GitHub +experience. -Link: +
---- +- **Share an idea** -### 2. Click "New Issue" + Report something missing, confusing, or difficult. No GitHub account is + needed. -Choose the template that matches what you want to do: + [:octicons-arrow-right-24: Share an idea](submit-idea.md) -- **Add a New Community** -- **Update Existing Community** -- **Fix a Mistake / Small Correction** -- **Share a Community Idea** -- **General Request** +- **Add or update a community** ---- + Share the community's name, area, contacts, language, and local settings. -### 3. Fill out the form + [:octicons-arrow-right-24: Add a community](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new?template=add_new_community.yml) + · [:octicons-arrow-right-24: Update a community](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new?template=update_existing_community.yml) -Provide as much detail as possible. +- **Correct a page** ---- + Report a factual error, broken link, typo, or unclear instruction. -### 4. Submit the issue + [:octicons-arrow-right-24: Report a correction](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new?template=fix_correction.yml) -A maintainer will: +- **Propose a region change** -1. Review the request -2. Make the change -3. Close the issue when complete + Suggest a new region or a boundary change. -If we need more information, we’ll comment on the ticket. + [:octicons-arrow-right-24: Open the region editor](config/editor/index.html) ---- +- **Contribute on GitHub** -## Advanced contributions + Improve a guide or work on an open issue. -If you know GitHub and want to edit the markdown files directly: + [:octicons-mark-github-24: Open the repository](https://github.com/MeshCore-ca/MeshCore-Canada) -1. Fork the repo -2. Edit the relevant file in `docs/provinces/` -3. Open a pull request +
---- +!!! warning "Do not share secrets" + Submissions are public. Remove passwords, access tokens, private keys, + private addresses, exact private coordinates, and sensitive command output. -## Thank you +## Need help choosing? -MeshCore.ca is a community-maintained directory, and contributions from all regions help keep the network growing across Canada. +Not sure where to start? [Share an idea](submit-idea.md). diff --git a/docs/hardware/index.fr.md b/docs/hardware/index.fr.md new file mode 100644 index 0000000..3b6ca10 --- /dev/null +++ b/docs/hardware/index.fr.md @@ -0,0 +1,101 @@ +--- +title: Choisir le matériel MeshCore +description: Choisissez un compagnon, un répéteur, une antenne ou un modèle à construire, puis confirmez sa compatibilité avant l’achat. +audience: + - newcomer + - node-builder +task: choose-hardware +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 5-10 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Choisir le matériel MeshCore + +Choisissez d’abord le rôle de l’appareil. Avant de l’acheter, confirmez ensuite +la carte exacte et la cible du micrologiciel dans le +[programme officiel de mise à jour MeshCore](https://meshcore.io/flasher). + +
+ +**Vérifiez avant d’acheter.** Les révisions de produits et la prise en charge +du micrologiciel changent. Les appareils liés sont des options à comparer, et +non des garanties de compatibilité. + +
+ +## Choisir un type d’appareil + +
+
+

Compagnon personnel

+

Utilisez-le pour envoyer et recevoir des messages. La plupart des compagnons se jumellent à un téléphone; certains ont leur propre écran et leurs propres commandes.

+ Choisir un compagnon +
+
+

Répéteur

+

Utilisez-le pour améliorer la couverture. Planifiez la radio, l’alimentation, l’antenne, le boîtier, le montage et la récupération comme un seul système.

+ Planifier un répéteur +
+
+

Antenne et câble

+

Faites correspondre la bande radio et le connecteur avant de commander. Les connecteurs SMA et RP-SMA peuvent se ressembler, mais ne sont pas interchangeables.

+ Choisir une antenne +
+
+

Serveur de salon

+

Utilisez-le pour héberger un salon persistant. Choisissez une carte prise en charge, une alimentation continue et un plan d’administration sécurisé.

+ Configurer un serveur de salon +
+
+ +## Guides de construction de la communauté + +Ces modèles proposés par la communauté sont des références de construction +détaillées, et non des recommandations de produits par défaut. Lisez l’état de +révision et les consignes de sécurité de chaque guide avant d’acheter des pièces +ou de commencer les travaux. + +
+
+

Répéteur solaire de 300 mW

+

Un modèle RAK à l’état d’ébauche pour les personnes expérimentées, avec une liste de pièces, des étapes d’assemblage, des vérifications sur l’établi et des notes d’entretien.

+ Examiner le modèle de 300 mW +
+
+

Répéteur solaire expérimental de 1 W

+

Un modèle haute puissance non vérifié proposé par MrAlders0n. Utilisez-le seulement après qu’un examen électrique, RF et du site a confirmé un besoin réel du réseau.

+ Examiner le modèle expérimental de 1 W +
+
+ +## Avant d’acheter + +
    +
  • Le modèle exact apparaît pour le rôle voulu dans la version actuelle du programme officiel de mise à jour MeshCore.
  • +
  • La radio est un modèle canadien de 902–928 MHz.
  • +
  • Les connecteurs de l’antenne, de la queue de cochon et de la ligne d’alimentation RF correspondent exactement. Les connecteurs SMA et RP-SMA ne sont pas interchangeables.
  • +
  • Le fabricant prend en charge la pile, le chargeur, le boîtier et la plage de températures pour l’utilisation prévue.
  • +
  • Vous avez un câble USB de données fiable et un moyen pratique de récupérer physiquement l’appareil.
  • +
  • La documentation actuelle du fabricant confirme toutes les caractéristiques importantes pour l’achat.
  • +
+ +## Poursuivre la configuration + +Après avoir choisi un appareil pris en charge, suivez le guide correspondant : + +- [Programmer un compagnon](../meshcore/flash-companion.md) +- [Programmer et tester un répéteur sur l’établi](../meshcore/flash-repeater.md) +- [Programmer un serveur de salon](../meshcore/flash-room-server.md) + +Vous avez trouvé un produit ou un renseignement de compatibilité périmé? +[Signalez la correction](../submit-idea.md) en indiquant la source du fabricant +et la date de votre vérification. diff --git a/docs/hardware/index.md b/docs/hardware/index.md new file mode 100644 index 0000000..79197c5 --- /dev/null +++ b/docs/hardware/index.md @@ -0,0 +1,94 @@ +--- +title: Choose MeshCore hardware +description: Choose a companion, repeater, antenna, or build option and confirm compatibility before buying. +audience: + - newcomer + - node-builder +task: choose-hardware +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 5-10 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Choose MeshCore hardware + +Choose what the device will do, then confirm the exact board and firmware target in the [official MeshCore flasher](https://meshcore.io/flasher) before buying. + +
+ +**Check before buying.** Product revisions and firmware support change. Treat the linked devices as options to compare, not compatibility guarantees. + +
+ +## Choose a device type + +
+
+

Personal companion

+

Use this to send and receive messages. Most companions pair with a phone; some have their own screen and controls.

+ Choose a companion +
+
+

Repeater

+

Use this to improve coverage. Plan the radio, power, antenna, enclosure, mount, and recovery as one system.

+ Plan a repeater +
+
+

Antenna and cable

+

Match the radio band and connector before ordering. SMA and RP-SMA can look similar and are not interchangeable.

+ Choose an antenna +
+
+

Room server

+

Use this to host a persistent room. Choose a supported board, continuous power, and a secure administration plan.

+ Set up a room server +
+
+ +## Community build guides + +These community-contributed designs are detailed build references, not default +product recommendations. Read each guide's review status and safety notes before +buying parts or starting work. + +
+
+

300 mW solar repeater

+

A draft RAK-based build for experienced builders, with a parts list, assembly stages, bench checks, and maintenance notes.

+ Review the 300 mW build +
+
+

Experimental 1 W solar repeater

+

An unverified high-power design contributed by MrAlders0n. Use it only after electrical, RF, and site review confirms a real network need.

+ Review the experimental 1 W build +
+
+ +## Before you buy + +
    +
  • The exact model appears for the intended role in the current official MeshCore flasher.
  • +
  • The radio is a Canadian 902–928 MHz model.
  • +
  • The antenna, pigtail, and feed-line connectors match exactly. SMA and RP-SMA are not interchangeable.
  • +
  • The manufacturer supports the battery, charger, enclosure, and temperature range for the planned use.
  • +
  • You have a known-good data USB cable and a practical way to recover the device physically.
  • +
  • Current manufacturer documentation supports every specification that matters to the purchase.
  • +
+ +## Continue setup + +After choosing a supported device, follow the matching setup guide: + +- [Flash a companion](../meshcore/flash-companion.md) +- [Flash and bench-test a repeater](../meshcore/flash-repeater.md) +- [Flash a room server](../meshcore/flash-room-server.md) + +Found an outdated product or compatibility detail? [Share the correction](../submit-idea.md) and include the manufacturer source and date you checked it. diff --git a/docs/hardware/overview.md b/docs/hardware/overview.md deleted file mode 100644 index 953f203..0000000 --- a/docs/hardware/overview.md +++ /dev/null @@ -1,34 +0,0 @@ -# Hardware Overview - -Welcome! This section helps you pick the right hardware for MeshCore, whether you’re just joining the mesh or you’re ready to help it grow. - -## Local Store Options - -- One of our own members recently created a store with inventory in Ottawa to help the community grow, with local delivery by courier options. Visit them at: **[Space Hedgehog](https://space-hedgehog.com/)** -- New local store selling lithium batteries and related hardware, has shipping and free local Dymon locker (Nepean) pickup options: **[Motion Power & Witt Supply Co.](https://mpandw.ca/)** - - - -## Companions (Connect to the Mesh) -Start here to grab a companion and connect to the mesh: - -[Recommended Companions](recommended-companions.md) - -These companions have been tried and tested by the Ottawa community and other meshes around the world. No need to reinvent the wheel, we have already done the testing. - -!!! warning "Upgrade the companion antenna" - The included antenna performs poorly on all of these models. Plan to replace it, and upgrade to at least the Gizont on companions that support changing the antenna. - See: [Recommended Antennas](recommended-antenna.md) - ---- - -## Repeaters (Expand coverage) -Repeaters are how we extend reach, fill dead zones, and make the network more reliable for everyone. Once you want to get started with helping the mesh expand or provide better coverage in your area, check out the recommended repeater hardware here: - -[Recommended Repeaters](recommended-repeaters.md) - -These repeaters have been tried and tested by the Ottawa community and other meshes around the world. No need to reinvent the wheel, we have already done the testing. - -!!! warning "Upgrade the repeater antenna" - The included antenna performs poorly on all of these models. Plan to replace it, and upgrade to at least the ALFA 5.8 dBi. - See: [Recommended Antennas](recommended-antenna.md) diff --git a/docs/hardware/recommended-antenna.fr.md b/docs/hardware/recommended-antenna.fr.md new file mode 100644 index 0000000..b8fb459 --- /dev/null +++ b/docs/hardware/recommended-antenna.fr.md @@ -0,0 +1,118 @@ +--- +title: Choisir une antenne et une ligne d’alimentation +description: Choisissez une antenne et un câble de 902–928 MHz en vérifiant les connecteurs, la perte, le montage et les besoins du site. +audience: + - companion-owner + - repeater-builder +task: choose-antenna +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 10-15 minutes +destructive: false +requires: + - confirmed-radio-band + - confirmed-device-connector +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Choisir une antenne et une ligne d’alimentation + +Commencez par la radio, le connecteur et l’installation. Comparez ensuite les +antennes qui couvrent la bande canadienne de 902–928 MHz. + +
+ +**Vérifiez avant d’acheter.** Les produits ci-dessous sont des exemples à +comparer, et non des recommandations de rendement vérifiées. Confirmez la fiche +technique actuelle, le connecteur, les dimensions, les besoins de montage et la +compatibilité avec la radio. + +
+ +!!! danger "Coupez l’alimentation avant de changer une antenne" + N’alimentez pas une radio et ne transmettez pas sans que la bonne antenne soit branchée. Débranchez l’alimentation USB et la pile avant de brancher ou de retirer une antenne, puis suivez les instructions du fabricant de la radio. + +## Vérifier d’abord la compatibilité + +
    +
  • L’antenne est conçue pour la bande canadienne de 902–928 MHz.
  • +
  • La famille et la polarité des connecteurs correspondent : les connecteurs SMA et RP-SMA peuvent se ressembler, mais leurs contacts électriques diffèrent.
  • +
  • Le genre du connecteur, la queue de cochon et la ligne d’alimentation forment un parcours complet.
  • +
  • L’appareil et le montage peuvent soutenir la taille et le poids de l’antenne, sa charge au vent et la tension exercée par le câble.
  • +
  • Les connecteurs extérieurs peuvent être protégés contre les intempéries et inspectés sans emprisonner l’eau.
  • +
  • La page du produit et la fiche technique actuelles confirment les renseignements utilisés pour votre choix.
  • +
+ +## Antennes portatives à comparer + +Avant de commander, confirmez si la radio utilise un connecteur SMA, RP-SMA ou +interne. + +
+ +| Produit | Connecteur indiqué | À vérifier | Source | +|---|---|---|---| +| LINX ANT-916-CW-HW-SMA | SMA | Plage de fréquences, connecteur correspondant, dimensions et prise en charge de l’appareil | [DigiKey](https://www.digikey.ca/en/products/detail/te-connectivity-linx/ANT-916-CW-HW-SMA/2694126?s=N4IgTCBcDaIDIEkByANABAQSQFQLQE4BGANlwGEB1XACSoGUBZDEAXQF8g) | +| Taoglas TI.09.A.0111 | SMA | Plage de fréquences, connecteur correspondant, dimensions et prise en charge de l’appareil | [DigiKey](https://www.digikey.ca/en/products/detail/taoglas-limited/TI-09-A-0111/2332695?s=N4IgTCBcDaICoEMD2BzANggzgAjgSQDoAGATgIEFiBGGkAXQF8g) | +| Seeed Studio LoRa Antenna Kit | SMA | Plage de fréquences, contenu exact de la trousse, connecteur correspondant et prise en charge de l’appareil | [Seeed Studio](https://www.seeedstudio.com/LoRa-Antenna-Kit-for-reTerminal-DM-p-5714.html) | + +
+ +## Antennes fixes à comparer + +Une antenne permanente de répéteur représente une décision d’installation +complète, et non seulement un chiffre de gain. Tenez compte de la perte du +câble, du nombre de connecteurs, du diagramme de rayonnement, des conditions RF +locales, de la structure, de l’examen de la protection contre la foudre et de +la mise à la terre, des intempéries et de l’accès sécuritaire. + +
+ +| Produit | Type | Connecteur indiqué | À vérifier | Source | +|---|---|---|---|---| +| Seeed Studio 318020693 | Omnidirectionnelle en fibre de verre | Type N | Plage de fréquences, diagramme, dimensions, charge au vent, montage et parcours du câble | [Mouser](https://www.mouser.ca/ProductDetail/Seeed-Studio/318020693?qs=By6Nw2ByBD0kjpJjgHd0aQ%3D%3D) | +| L-com HG913Y-NF | Directionnelle | Type N | Plage de fréquences, diagramme, orientation, charge au vent, montage et parcours du câble | [DigiKey](https://www.digikey.ca/en/products/detail/l-com/HG913Y-NF/21289980) | + +
+ +## Choisir la ligne d’alimentation + +Utilisez le câble pratique le plus court dont la perte est acceptable. +Confirmez les deux connecteurs, le type et la longueur du câble, sa perte à la +fréquence d’utilisation, son indice pour l’extérieur, son rayon de courbure, +son serre-câble et son étanchéité. [Infinite Cables](https://www.infinitecables.com/) +est une source canadienne de câbles RF assemblés; son +[exemple LMR-240 Ultra Flex de type N](https://www.infinitecables.com/products/lmr-240-ultra-flex-n-type-male-to-n-type-female-cable?variant=42809804980465) +pourrait ne pas avoir les connecteurs dont vous avez besoin. + +## Consigner le choix + +Avant l’installation, consignez : + +- le produit et la révision de l’antenne; +- la bande et le diagramme publiés; +- chaque connecteur et adaptateur, dans l’ordre; +- le type et la longueur du câble; +- la méthode de montage et de protection contre les intempéries; +- les liens vers les sources et la date de vérification; +- l’essai local qui sera effectué après l’installation. + +## Vérifier après l’installation + +Pendant que le boîtier est encore accessible, confirmez que la radio indique +les réglages prévus, que la ligne d’alimentation n’est ni desserrée ni pliée +brusquement, que l’étanchéité ne crée pas un chemin pour l’eau et qu’un essai de +message local réussit. N’attribuez pas un changement de couverture à l’antenne +seule sans résultats comparatifs reproductibles. + +## Terminer l’installation + +Pour une installation fixe, consultez les +[options de montage](repeater-mounting-options.md). Pour un nœud portatif, +revenez aux [choix de compagnons](recommended-companions.md). diff --git a/docs/hardware/recommended-antenna.md b/docs/hardware/recommended-antenna.md index 75fdd3f..b123dfe 100644 --- a/docs/hardware/recommended-antenna.md +++ b/docs/hardware/recommended-antenna.md @@ -1,51 +1,97 @@ -# Antenna Recommendation +--- +title: Choose an antenna and feed line +description: Choose a 902–928 MHz antenna and cable by checking connector fit, loss, mounting, and site needs. +audience: + - companion-owner + - repeater-builder +task: choose-antenna +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 10-15 minutes +destructive: false +requires: + - confirmed-radio-band + - confirmed-device-connector +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- -Most LoRa devices ship with a very basic factory antenna that performs poorly. -The Ottawa mesh community has tested many replacements, and the antennas below are highly recommended as reliable upgrades. +# Choose an antenna and feed line -!!! warning "Swapping Antennas" - Make sure your device is disconnected to power/battery when swapping an antenna. Since these devices can transmit radio signals, turning on a device without an antenna can damage it. [See more information here.](https://electronics.stackexchange.com/questions/335912/can-i-break-a-radio-tranceiving-device-by-operating-it-with-no-antenna-connected) +Start with the radio, connector, and installation. Then compare antennas that cover the Canadian 902–928 MHz band. -## Companion Antennas +
-These are SMA antennas and are more compact, yet they’ve consistently shown excellent performance in Ottawa and other meshes. We recommend any of the options listed here. +**Check before buying.** The products below are examples to compare, not verified performance recommendations. Confirm the current datasheet, connector, dimensions, mounting needs, and radio compatibility. -!!! warning "SMA vs. RP-SMA" - Pay close attention to what connection type a Companion/Repeater has since some come with Reverse Polarity SMA (RP-SMA). You will need an adapter to connect your SMA antenna or you will need to buy a RP-SMA antenna. - [More information on the differences between these connections.](https://blog.linitx.com/what-are-sma-rp-sma-connectors-and-whats-the-difference/) +
-| Product | Connector | Cost (CAD) | Link | -|-----------------------------|-----------|------------|------| -| Gizont 167CM 915MHz SMA M | SMA | $12 | [Space Hedgehog (Local Store)](https://space-hedgehog.com/products/gizont-915mhz-antenna?variant=51602989711416) | -| Gizont 167CM 915MHz SMA M | SMA | $10.53 | [AliExpress](https://www.aliexpress.com/item/1005004607615001.html) | -| Gizont 167CM 915MHz RP-SMA M | RP-SMA | $10.53 | [AliExpress](https://www.aliexpress.com/item/1005004607615001.html) | -| LINX ANT-916-CW-HW-SMA | SMA | $14.65 | [DigiKey](https://www.digikey.ca/en/products/detail/te-connectivity-linx/ANT-916-CW-HW-SMA/2694126?s=N4IgTCBcDaIDIEkByANABAQSQFQLQE4BGANlwGEB1XACSoGUBZDEAXQF8g) | -| Taoglas TI.09.A.0111 | SMA | $17.47 | [DigiKey](https://www.digikey.ca/en/products/detail/taoglas-limited/TI-09-A-0111/2332695?s=N4IgTCBcDaICoEMD2BzANggzgAjgSQDoAGATgIEFiBGGkAXQF8g) | -| Seeed Studio LoRa Antenna Kit | SMA | $6.79 | [Seeed Studio](https://www.seeedstudio.com/LoRa-Antenna-Kit-for-reTerminal-DM-p-5714.html) | +!!! danger "Disconnect power before changing an antenna" + Do not power or transmit from a radio without the correct antenna attached. Disconnect USB and battery power before connecting or removing an antenna, and follow the radio manufacturer's instructions. -## Repeater Omni Antennas +## Check compatibility first -These are N-Type antennas and are best suited for repeaters. At an absolute minimum, all repeaters should use the Alfa antenna — it’s a major reason the Ottawa mesh performs as well as it does. MrAlders0n has made a link between a repeater and a companion at 110KM distance with an Alfa on both ends. +
    +
  • The antenna is specified for the Canadian 902–928 MHz band.
  • +
  • The connector family and polarity match: SMA and RP-SMA can look similar but do not mate electrically in the same way.
  • +
  • The connector gender, pigtail, and feed line form one complete path.
  • +
  • The device and mount can support the antenna's size, weight, wind load, and cable strain.
  • +
  • Outdoor connectors can be weatherproofed and inspected without trapping water.
  • +
  • The current product page and datasheet support the details used in your decision.
  • +
-If you want something larger and higher-performing, we’ve tested the Seeed 1300 mm fiberglass antenna with excellent results. Please note that it is 1.3 metres long. We only recommend this antenna for repeaters installed at significant height (around 30 m AGL or higher) and intended for long-distance links or backbone use. +## Portable antennas to compare -| Product | Connector | Cost (CAD) | Link | -|-----------------------------|-----------|------------|------| -| Alfa AOA-915-5ACM | N-Type | $34.99 | [Amazon](https://a.co/d/ieEIQpy) | -| Seeed Studio RF Explorer 902-928MHz 8dBi; 1300mm | N-Type | $110 | [Mouser](https://www.mouser.ca/ProductDetail/Seeed-Studio/318020693?qs=By6Nw2ByBD0kjpJjgHd0aQ%3D%3D) | +Confirm whether the radio uses SMA, RP-SMA, or an internal connector before ordering. -## Repeater Directional Antennas +
-Directional antennas are intended for fixed repeaters and long-distance point-to-point or point-to-multipoint links. All antennas listed here use N-Type connectors and are suitable for permanent outdoor installations. +| Product | Listed connector | Check | Source | +|---|---|---|---| +| LINX ANT-916-CW-HW-SMA | SMA | Frequency range, mating connector, dimensions, and device support | [DigiKey](https://www.digikey.ca/en/products/detail/te-connectivity-linx/ANT-916-CW-HW-SMA/2694126?s=N4IgTCBcDaIDIEkByANABAQSQFQLQE4BGANlwGEB1XACSoGUBZDEAXQF8g) | +| Taoglas TI.09.A.0111 | SMA | Frequency range, mating connector, dimensions, and device support | [DigiKey](https://www.digikey.ca/en/products/detail/taoglas-limited/TI-09-A-0111/2332695?s=N4IgTCBcDaICoEMD2BzANggzgAjgSQDoAGATgIEFiBGGkAXQF8g) | +| Seeed Studio LoRa Antenna Kit | SMA | Frequency range, exact kit contents, mating connector, and device support | [Seeed Studio](https://www.seeedstudio.com/LoRa-Antenna-Kit-for-reTerminal-DM-p-5714.html) | -| Product | Connector | Cost (CAD) | Link | -|------------------------|-----------|------------|------| -| L-com HG913Y-NF | N-Type | $237.17 | [DigiKey](https://www.digikey.ca/en/products/detail/l-com/HG913Y-NF/21289980) | +
-## Antenna Cables +## Fixed antennas to compare -For short, high-quality LMR-240 cables, [Infinite Cables](https://www.infinitecables.com/) in Toronto is the best source we’ve found. Their cables are on the expensive side, but the build quality is excellent and they offer a wide variety of lengths and connector combinations to suit any installation. +A permanent repeater antenna is a complete installation decision, not just a gain number. Include feed-line loss, connector count, pattern, local RF conditions, structure, lightning/grounding review, weather, and safe access. -| Product | Connector | Link | -|------------------------|-----------|------| -| LMR-240 Ultra Flex N-Type Male to N-Type Female | N-Type M to N-Type F | [Infinite Cables](https://www.infinitecables.com/products/lmr-240-ultra-flex-n-type-male-to-n-type-female-cable?variant=42809804980465) | +
+ +| Product | Type | Listed connector | Check | Source | +|---|---|---|---|---| +| Seeed Studio 318020693 | Fiberglass omnidirectional | N-type | Frequency range, pattern, dimensions, wind load, mount, and cable path | [Mouser](https://www.mouser.ca/ProductDetail/Seeed-Studio/318020693?qs=By6Nw2ByBD0kjpJjgHd0aQ%3D%3D) | +| L-com HG913Y-NF | Directional | N-type | Frequency range, pattern, aiming, wind load, mount, and cable path | [DigiKey](https://www.digikey.ca/en/products/detail/l-com/HG913Y-NF/21289980) | + +
+ +## Choose the feed line + +Use the shortest practical cable with acceptable loss. Confirm both connectors, cable type, length, loss at the operating frequency, outdoor rating, bend radius, strain relief, and weather sealing. [Infinite Cables](https://www.infinitecables.com/) is one Canadian source for assembled RF cables; its [LMR-240 Ultra Flex N-type example](https://www.infinitecables.com/products/lmr-240-ultra-flex-n-type-male-to-n-type-female-cable?variant=42809804980465) may not match your required connectors. + +## Record the decision + +Before installation, record: + +- antenna product and revision; +- published band and pattern; +- every connector and adapter in order; +- cable type and length; +- mounting and weatherproofing method; +- source links and the date checked; and +- the local test you will use after installation. + +## Check after installation + +With the enclosure still accessible, confirm the radio reports the intended settings, the feed line is not loose or sharply bent, the weather seal does not create a water path, and a local message test succeeds. Do not attribute a change in coverage to the antenna alone without repeatable before/after evidence. + +## Finish the installation + +For a fixed installation, continue to [mounting options](repeater-mounting-options.md). For a portable node, return to [companion choices](recommended-companions.md). diff --git a/docs/hardware/recommended-companions.fr.md b/docs/hardware/recommended-companions.fr.md new file mode 100644 index 0000000..69f49d5 --- /dev/null +++ b/docs/hardware/recommended-companions.fr.md @@ -0,0 +1,90 @@ +--- +title: Choisir un appareil compagnon +description: Comparez les compagnons jumelés à un téléphone et les appareils autonomes, puis vérifiez leur compatibilité avant l’achat. +audience: + - newcomer + - companion-owner +task: choose-companion +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 10-15 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Choisir un appareil compagnon + +Un compagnon est votre appareil personnel de messagerie MeshCore. Choisissez +si vous préférez utiliser un téléphone ou des commandes intégrées, puis +confirmez le modèle exact dans le +[programme officiel de mise à jour MeshCore](https://meshcore.io/flasher). + +
+ +**Vérifiez avant d’acheter.** Ces appareils sont proposés à titre de comparaison +et ne forment pas une liste de compatibilité garantie. Confirmez le modèle +exact, la bande radio canadienne, la cible de micrologiciel du compagnon, le +connecteur et les accessoires inclus. + +
+ +## Choisir comment vous voulez l’utiliser + +
+
+

Jumelé à un téléphone

+

Votre téléphone fournit l’interface principale et se connecte à la radio par Bluetooth Low Energy (BLE).

+

À vérifier : la compatibilité du téléphone, la pile, l’antenne et la cible exacte du compagnon.

+
+
+

Autonome

+

Un écran et des commandes intégrées permettent l’utilisation normale de la messagerie sans téléphone.

+

À vérifier : si le micrologiciel actuel prend en charge le modèle et toutes les commandes dont vous avez besoin.

+
+
+

À construire soi-même

+

Vous choisissez la carte, la pile, le boîtier, le câble et l’antenne.

+

À vérifier : la révision de la carte, la polarité et la protection de la pile, l’ajustement du boîtier et chaque connecteur d’antenne.

+
+
+ +## Appareils à comparer + +
+ +| Appareil | Style | À vérifier avant l’achat | Fabricant | +|---|---|---|---| +| ThinkNode M1 | Jumelé à un téléphone, avec écran | Cible exacte du programme de mise à jour, modèle de 902–928 MHz, connecteur d’antenne, pile et accessoires inclus | [Elecrow](https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html) | +| LilyGO T-Echo | Jumelé à un téléphone, avec écran | Révision exacte du matériel, cible du programme de mise à jour, bande radio, connecteur et antenne incluse | [LilyGO](https://lilygo.cc/products/t-echo-lilygo) | +| SenseCAP T1000-E | Jumelé à un téléphone, boîtier en forme de carte | Cible exacte du programme de mise à jour, bande radio, limites de l’antenne interne et prise en charge du téléphone | [Seeed Studio](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | +| LilyGO T-LORA Pager | Commandes autonomes | Révision exacte du matériel et prise en charge actuelle de l’écran, du clavier et de l’utilisation prévue | [LilyGO](https://lilygo.cc/en-ca/products/t-lora-pager) | +| LilyGO T-Deck Plus | Commandes autonomes | Révision exacte du matériel et prise en charge actuelle de l’écran, du clavier, de la boule de commande et de l’utilisation prévue | [LilyGO](https://lilygo.cc/products/t-deck-plus-meshtastic) | + +
+ +!!! note "Les offres changent" + Une page de produit peut sélectionner par défaut une autre bande radio, une autre trousse d’accessoires ou un autre connecteur. Vérifiez l’option choisie avant de passer à la caisse. + +## Avant d’acheter + +
    +
  • La révision exacte du produit apparaît comme compagnon dans la version actuelle du programme officiel de mise à jour MeshCore.
  • +
  • La radio est la variante canadienne de 902–928 MHz.
  • +
  • Les exigences concernant le téléphone et l’application correspondent à l’utilisation prévue.
  • +
  • Le connecteur, la polarité, la protection et les dimensions de la pile ainsi que sa méthode de recharge sont documentés.
  • +
  • L’antenne et chaque adaptateur correspondent au connecteur de l’appareil.
  • +
  • Le boîtier laisse le port de récupération USB accessible.
  • +
  • Vous avez vérifié la disponibilité, l’expédition, les droits de douane et les conditions de retour actuels.
  • +
+ +## Poursuivre la configuration + +Lorsque la carte exacte est confirmée, +[programmez et configurez le compagnon](../meshcore/flash-companion.md). +Après la programmation, redémarrez-le et effectuez un essai de message local. diff --git a/docs/hardware/recommended-companions.md b/docs/hardware/recommended-companions.md index ee09468..32db80b 100644 --- a/docs/hardware/recommended-companions.md +++ b/docs/hardware/recommended-companions.md @@ -1,78 +1,82 @@ -# Companion Nodes - -Companion nodes run dedicated companion firmware and operate as user endpoints on the MeshCore/Meshtastic network. -Most companion nodes pair with your smartphone over BLE to provide access to the mesh. - -There are also standalone companion nodes with built-in screens and input devices. These operate without a smartphone but still function as endpoints. - -!!! warning "Upgrade the companion antenna" - The included antenna performs poorly on all of these models. Plan to replace it, and upgrade to at least the Gizont on companions that support changing the antenna. - See: [Recommended Antennas](recommended-antenna.md) - -## Bluetooth Low Energy (BLE) Companions - -These devices require a smartphone and the MeshCore or Meshtastic app. They connect to your phone over BLE, and you use the app to interact with the mesh. In this setup, the companion acts only as the radio, linking your phone to the mesh network. - -### Pre-Built - -The easiest way to get started is to buy a companion node, flash it with MeshCore/Meshtastic, and join the mesh. - -The MeshCore/Meshtastic app connects to the node over Bluetooth (BLE) and is used to send and receive messages on the mesh. - -!!! warning "Important ThinkNode M1 Note" - Make sure to order an **RP-SMA Antenna** with the device. - **Do not accidentally buy SMA — you specifically need RP-SMA.** - ThinkNode uses RP-SMA for the ThinkNode M1 for some reason - -The following pre-built companion nodes are popular and widely available: +--- +title: Choose a companion device +description: Compare phone-paired and standalone companion devices and check compatibility before buying. +audience: + - newcomer + - companion-owner +task: choose-companion +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 10-15 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- -!!! warning "Aliexpress bundles" - Aliexpress usually shows the cheapest items (e.g., only the GPS module) when opening their links. Make sure you select the right bundle when adding to your cart. - +# Choose a companion device -| Product | Notes | Link | -|--------------------|-------|------| -| **ThinkNode M1** | Compact device powered by the nRF52840 with a 1.54" screen and GPS support. Designed as a ready-to-use companion node for reliable messaging and tracking. **Note:** Has RP-SMA connector - See SMA vs. RP-SMA warning above. | [Elecrow](https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html) | -| **LilyGO T-Echo** | Compact device with onboard display and GPS. A solid ready-to-use option with minimal setup required. **Note:** Buy the non-flashed version; it’s cheaper and easy to flash MeshCore/Meshtastic using the web flasher. | [LilyGO Store](https://lilygo.cc/products/t-echo-lilygo) | -| **SenseCAP T1000-E** | Slim card-style tracker device from SeeedStudio. Portable and IP65-rated. **Note:** Range is more limited due to internal antennas. | [SeeedStudio](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | -| **RAK WisMesh Tag** | Rugged device with GPS, integrated antennas, 1000mAh battery, and IP66 enclosure. Pre-flashed firmware for instant use. **Note:** Range is more limited due to internal antennas. | [AliExpress](https://www.aliexpress.com/item/1005009754254701.html) | +A companion is your personal MeshCore messaging device. Choose whether you want to use a phone or built-in controls, then confirm the exact model in the [official MeshCore flasher](https://meshcore.io/flasher). ---- +
-### Build Your Own +**Check before buying.** These are devices to compare, not a guaranteed compatibility list. Confirm the exact model, Canadian radio band, companion firmware target, connector, and included accessories. -For hobbyists who like to source parts and assemble their own node, here is an Ottawa-friendly example build (antenna not included; see [Recommended Antennas](recommended-antenna.md)). +
-This is a **companion node** role and requires a smartphone. -The MeshCore/Meshtastic app connects to the node over Bluetooth (BLE) and is used to send and receive messages on the mesh. +## Choose how you want to use it -### Example DIY Build +
+
+

Phone-paired

+

Your phone provides the main interface and connects to the radio over Bluetooth Low Energy (BLE).

+

Check: phone compatibility, battery, antenna, and the exact companion target.

+
+
+

Standalone

+

A display and built-in controls allow normal messaging without a phone.

+

Check: whether current firmware supports the model and all controls you need.

+
+
+

Build your own

+

You choose the board, battery, enclosure, cable, and antenna.

+

Check: board revision, battery polarity and protection, enclosure fit, and every antenna connector.

+
+
-| Item | Product Name | Cost (CAD) | Link | -|--------------|-------------------------------|------------|------| -| **LoRa Board** | Heltec T114 (Bundle with screen) | $45.99 | [AliExpress](https://www.aliexpress.com/item/1005007916299029.html)| -| **Right-angle IPEX to SMA Pigtail Cable** | SMA-KW 2PCS 8cm | $4.67 | [AliExpress](https://www.aliexpress.com/item/1005009270132403.html?)| -| **Battery** | Makerfocus 3.7V 3000mAh LiPo - (Pack of 4), Micro JST 1.5 connection with protection board | $34.34 | [MakerFocus](https://www.makerfocus.com/products/makerfocus-3-7v-3000mah-lithium-rechargeable-battery-1s-3c-lipo-battery-pack-of-4?variant=44823607541998) | -| **Antenna** | Gizont 167CM 915MHz SMA M |$10.68 | [AliExpress](https://www.aliexpress.com/item/1005004607615001.html) (Make sure you select the right antenna when opening the link) +## Devices to compare -*Approximate total cost:* **$95.68 CAD** -*Prices will vary and may include shipping costs, so please confirm with links. MakerFocus batteries are shipped from China with no duties.* +
-!!! warning "Case for Example DIY Build" - This DIY build example does not include a case. For 3D-printable cases, check out **[Alley Cat’s models](https://www.printables.com/@AlleyCat/models)** — they are excellent for custom companion node builds. Make sure the case you choose will fit the 3000 mAh battery and the right angle IPEX to SMA connector. +| Device | Style | Check before buying | Manufacturer | +|---|---|---|---| +| ThinkNode M1 | Phone-paired, display | Exact flasher target, 902–928 MHz model, antenna connector, included battery and accessories | [Elecrow](https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html) | +| LilyGO T-Echo | Phone-paired, display | Exact hardware revision, flasher target, radio band, connector, and included antenna | [LilyGO](https://lilygo.cc/products/t-echo-lilygo) | +| SenseCAP T1000-E | Phone-paired, card-style enclosure | Exact flasher target, radio band, internal-antenna limitations, and phone support | [Seeed Studio](https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html) | +| LilyGO T-LORA Pager | Standalone controls | Exact hardware revision and current support for the display, keyboard, and intended workflow | [LilyGO](https://lilygo.cc/en-ca/products/t-lora-pager) | +| LilyGO T-Deck Plus | Standalone controls | Exact hardware revision and current support for the display, keyboard, trackball, and intended workflow | [LilyGO](https://lilygo.cc/products/t-deck-plus-meshtastic) | -If you are in the Ottawa area, you can also purchase this build fully assembled locally from [Space Hedgehog](https://space-hedgehog.com/). +
---- +!!! note "Listings change" + A product page can default to another radio band, accessory bundle, or connector. Check the selected option before checkout. -## Standalone Nodes +## Before buying -There are standalone devices such as the **T-Deck**, but we recommend starting with a companion node instead. -Standalone units tend to be more expensive, the UI is not as smooth as the mobile app, and they still have quirks and firmware limitations that can make them challenging for beginners. +
    +
  • The exact product revision appears in the current official MeshCore flasher as a companion.
  • +
  • The radio is the Canadian 902–928 MHz variant.
  • +
  • The phone/app requirement matches how you intend to use it.
  • +
  • The battery connector, polarity, protection, dimensions, and charging method are documented.
  • +
  • The antenna and every adapter match the device connector.
  • +
  • The enclosure leaves the USB recovery path accessible.
  • +
  • You checked current stock, shipping, duty, and return terms.
  • +
-### Available Standalone Devices +## Continue setup -| Product | Notes | Link | -|----------------------|-------|------| -| **LilyGO T-LORA Pager** | A compact standalone LoRa messaging device styled like a classic pager. Useful for simple off-grid communication without needing a smartphone. | [LilyGO Store](https://lilygo.cc/en-ca/products/t-lora-pager) | -| **LilyGO T-Deck Plus** | Updated version of the T-Deck with improved specs and refinements. Built with Meshtastic/MeshCore/Meshtastic in mind.
**However:** the built-in trackball is a major downside and many users dislike it. | [LilyGO Store](https://lilygo.cc/products/t-deck-plus-meshtastic) | +Once the exact board is confirmed, [flash and configure the companion](../meshcore/flash-companion.md). After flashing, reboot it and complete a local message test. diff --git a/docs/hardware/recommended-repeaters.fr.md b/docs/hardware/recommended-repeaters.fr.md new file mode 100644 index 0000000..436aee0 --- /dev/null +++ b/docs/hardware/recommended-repeaters.fr.md @@ -0,0 +1,118 @@ +--- +title: Planifier un répéteur +description: Choisissez une approche et vérifiez l’ensemble formé par la radio, l’alimentation, l’antenne, le boîtier et la méthode de récupération. +audience: + - repeater-builder + - network-operator +task: choose-repeater +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 10-20 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Planifier un répéteur + +Un répéteur ne se limite pas à une carte radio. Planifiez son alimentation, son +antenne, son boîtier, son montage et un moyen de l’atteindre en cas de problème. + +
+ +**Vérifiez le système complet.** Les révisions de produits et la prise en charge +du micrologiciel changent. Confirmez la radio exacte, le système d’alimentation, +le parcours de l’antenne, le boîtier, le montage et la méthode de récupération +avant d’acheter ou de construire. + +
+ +## Choisir une approche + +
+
+

Système extérieur préassemblé

+

Il réduit la fabrication nécessaire, mais les piles, les câbles d’antenne, le matériel de montage ou la radio peuvent tout de même être vendus séparément.

+

À vérifier : le contenu exact, la bande radio, la cible du micrologiciel, le parcours des connecteurs, l’indice de protection contre les intempéries et l’accès USB.

+ Examiner un exemple +
+
+

Construction personnalisée

+

Vous choisissez chaque pièce, mais vous devenez responsable de la compatibilité électrique, de l’étanchéité, du montage et de l’accès pour l’entretien.

+

À vérifier : les limites du fabricant, la polarité, la protection, le câblage, les conditions thermiques et la récupération sécuritaire.

+ Voir les guides de construction de la communauté +
+
+ +!!! tip "Commencez par le besoin du réseau, et non par la puissance d’émission" + Le site, la hauteur, le système d’antenne, la perte du câble, le bruit local, les régions voisines, le budget énergétique et l’accès pour l’entretien influencent tous un répéteur. Coordonnez-vous avec la communauté locale avant de choisir une puissance élevée ou un parcours à longue portée. + +## Système extérieur à comparer { #outdoor-system-to-compare } + +Le SenseCAP Solar Node P1 est un exemple de boîtier préassemblé à évaluer. Il ne +s’agit pas d’une recommandation complète : confirmez chaque option et +accessoire sélectionné dans la documentation actuelle du fabricant. + +
+ +| Élément du système | Ce qu’il faut vérifier | Source | +|---|---|---| +| SenseCAP Solar Node P1 | Modèle exact pour la bande canadienne, carte et cible de répéteur prises en charge, indice du boîtier, pièces d’alimentation incluses, montage et accès USB | [RobotShop Canada](https://ca.robotshop.com/products/sensecap-solar-node-p1-meshtastic-w-o-gps-battery) | +| Système de pile | Chimie, protection, capacité, plage de températures, compatibilité du chargeur et instructions d’installation | Documentation du fabricant du produit | +| Antenne et câble | Bande de 902–928 MHz, connecteur d’usine, perte du câble, genre et polarité des connecteurs, montage et étanchéité | [Guide des antennes LoRa de Seeed](https://wiki.seeedstudio.com/lora_antenna_selection_guide/) | + +
+ +!!! warning "Les connecteurs SMA et RP-SMA ne sont pas interchangeables" + Confirmez le connecteur d’usine, son genre et sa polarité, la longueur et la perte du câble, le connecteur de l’antenne et l’étanchéité comme un seul parcours complet. Ne commandez jamais en vous fiant seulement à l’apparence du connecteur. + +## Guides de construction de la communauté { #community-build-guides } + +
+
+

Répéteur solaire de 300 mW

+

Un modèle RAK à l’état d’ébauche avec une liste de pièces, des étapes d’assemblage, des vérifications sur l’établi et des notes d’entretien.

+ Examiner le modèle de 300 mW +
+
+

Répéteur solaire expérimental de 1 W

+

Un modèle haute puissance non vérifié destiné à un besoin mesuré du réseau. Un examen électrique, RF et du site est requis avant d’acheter des pièces ou de commencer les travaux.

+ Examiner le modèle expérimental de 1 W +
+
+ +Ces guides sont des références de la communauté. Ils ne constituent ni des +listes de matériel vérifiées ni un remplacement de la documentation actuelle +du fabricant et d’un examen par une personne qualifiée. + +## Avant de construire ou d’acheter + +
    +
  • La carte radio exacte apparaît pour le rôle de répéteur dans la version actuelle du programme officiel de mise à jour MeshCore.
  • +
  • La radio et l’antenne sont destinées à la bande canadienne de 902–928 MHz.
  • +
  • Toute la chaîne d’alimentation respecte les limites du fabricant et utilise les protections documentées.
  • +
  • L’antenne est branchée avant que la radio puisse transmettre.
  • +
  • L’autorisation du propriétaire, l’examen de la structure, les charges dues aux intempéries, le passage des câbles et les risques électriques sont pris en compte.
  • +
  • Le répéteur peut être récupéré par USB après son installation.
  • +
  • La région et les réglages locaux sont confirmés dans le configurateur de répéteur.
  • +
  • Un essai sur l’établi et un plan d’entretien sont prêts avant l’installation.
  • +
+ +## Le tester d’abord sur l’établi + +Un répéteur terminé doit rester sur l’établi jusqu’à ce qu’il résiste à un +redémarrage, conserve ses réglages, envoie une annonce reçue par un compagnon à +proximité, réussisse un essai d’acheminement local et puisse toujours être +récupéré par USB. Utilisez ensuite la +[liste de vérification pour un montage sécuritaire](repeater-mounting-options.md). + +## Poursuivre la configuration + +- [Programmer et tester un répéteur sur l’établi](../meshcore/flash-repeater.md) +- [Choisir une antenne et une ligne d’alimentation](recommended-antenna.md) +- [Planifier un montage sécuritaire](repeater-mounting-options.md) diff --git a/docs/hardware/recommended-repeaters.md b/docs/hardware/recommended-repeaters.md index 8f7b381..9849bd7 100644 --- a/docs/hardware/recommended-repeaters.md +++ b/docs/hardware/recommended-repeaters.md @@ -1,67 +1,108 @@ -# Repeaters - -Repeaters form the stable backbone of the Ottawa MeshCore network. -You can choose between **pre-built repeaters** or **DIY builds**, and both are great options. - -Pre-built units have improved a lot in reliability and price, while DIY builds remain popular for those who like full control over their hardware. - -!!! warning "Upgrade the repeater antenna" - The included antenna performs poorly on all of these models. Plan to replace it, and upgrade to at least the ALFA 5.8 dBi. - See: [Recommended Antennas](recommended-antenna.md) - +--- +title: Plan a repeater +description: Choose a repeater approach and verify the complete radio, power, antenna, enclosure, and recovery system. +audience: + - repeater-builder + - network-operator +task: choose-repeater +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 10-20 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 --- -## Important Note for nRF52-Based Repeaters +# Plan a repeater -If you plan to use a **nRF52** board for a repeater, you must update it to the **OTAFIX bootloader firmware**. -Without this fix, if an OTA update fails over BLE, the repeater will enter an unusable state and require physical access to recover. +A repeater is more than a radio board. Plan its power, antenna, enclosure, +mount, and a way to reach it if something goes wrong. -See more infomation [in the repeater flashing instructions page](../meshcore/flash-repeater.md). +
---- +**Check the complete system.** Product revisions and firmware support change. Confirm the exact radio, power system, antenna path, enclosure, mount, and recovery method before buying or building. -## Pre-Built +
-These options come fully assembled; simply flash MeshCore and mount them in a high location to expand the mesh. +## Choose an approach -### Pre-Built Repeater Options +
+
+

Packaged outdoor system

+

Reduces fabrication, but batteries, antenna cables, mounting hardware, or the radio may still be separate.

+

Check: exact contents, radio band, firmware target, connector path, weather rating, and USB access.

+ Review an example +
+
+

Custom build

+

Lets you choose each part, but you become responsible for electrical compatibility, weather sealing, mounting, and service access.

+

Check: manufacturer limits, polarity, protection, wiring, thermal conditions, and safe recovery.

+ See community build guides +
+
-!!! warning "SenseCAP Solar Node P1 cable choice" - Buy the three base items below, then choose **one complete cable path**. The P1 has a factory RP-SMA antenna connection. SMA and RP-SMA are not interchangeable, so do not mix parts from the two paths. See [Seeed's LoRa antenna guide](https://wiki.seeedstudio.com/lora_antenna_selection_guide/). +!!! tip "Start with the network need, not transmit power" + Site, height, antenna system, feed-line loss, local noise, neighbouring regions, power budget, and maintenance access all affect a repeater. Coordinate with the local community before selecting a high-power or long-reach path. -| Product | Notes | Link | -|---------|-------|------| -| **SenseCAP Solar Node P1 w/o GPS & Battery** | Solar-powered communication node using the XIAO nRF52840 Plus + Wio-SX1262 LoRa module. Includes a 5W solar panel, IPX5 waterproofing. | [RobotShop (Canadian Store)](https://ca.robotshop.com/products/sensecap-solar-node-p1-meshtastic-w-o-gps-battery) | -| **Batteries for SenseCAP Solar Node P1 (Local Store)** | Four button-top 18650 cells, required if you purchase the P1 model without GPS & Battery. | [Motion Power & Witt Supply Co.](https://mpandw.ca/products/button-top-eve-35v-house-made) | -| **LoRa Antenna for SenseCAP Solar Node P1** | External antenna for the node. | [Amazon.ca](https://www.amazon.ca/dp/B08H8J6ZV6?ref=ppx_yo2ov_dt_b_fed_asin_title) | +## Outdoor system to compare -#### Choose one cable path +The SenseCAP Solar Node P1 is one packaged enclosure to evaluate. It is not a complete recommendation: confirm every selected option and accessory against current manufacturer documentation. -| Path | Buy | Notes | -|------|-----|-------| -| **Recommended: keep the factory pigtail** | [RP-SMA to N-Type cable](https://www.aliexpress.com/item/1005004652556159.html) | Select **Type 2 (RP-SMA)** and **30 cm**. This connects the factory RP-SMA port to the N-Type antenna. | -| **Advanced: replace the factory pigtail** | **Both** the [I-PEX MHF1 to SMA bulkhead cable](https://www.digikey.ca/en/products/detail/seeed-technology-co-ltd/321990397/15277462) **and** the [SMA to N-Type cable](https://www.aliexpress.com/item/1005004652556159.html) | Select **Type 1 (SMA)** and **30 cm** for the second cable. Do not also buy the Type 2 cable. This requires opening the enclosure and restoring its weather seal. | -| **Direct replacement** | One verified I-PEX MHF1 to N-Type bulkhead pigtail | This may replace both advanced-path cables. Confirm the connector, gender, length, mounting fit, and weather seal before ordering. | +
---- +| System item | What to verify | Source | +|---|---|---| +| SenseCAP Solar Node P1 | Exact Canadian-band model, supported board and repeater target, enclosure rating, included power parts, mount, and USB access | [RobotShop Canada](https://ca.robotshop.com/products/sensecap-solar-node-p1-meshtastic-w-o-gps-battery) | +| Battery system | Chemistry, protection, capacity, temperature range, charger compatibility, and installation instructions | Product manufacturer documentation | +| Antenna and cable | 902–928 MHz band, factory connector, cable loss, connector gender and polarity, mount, and weather sealing | [Seeed LoRa antenna guide](https://wiki.seeedstudio.com/lora_antenna_selection_guide/) | -## Build Your Own +
-We have two DIY solar repeater builds. Choose based on the role the repeater will fill in the mesh. +!!! warning "SMA and RP-SMA are not interchangeable" + Confirm the factory connector, gender, polarity, cable length and loss, antenna connector, and weather seal as one complete path. Do not order by connector appearance alone. -### 300mW Solar Repeater (default) +## Community build guides -Use this for the vast majority of deployments. It is cheaper, simpler to assemble, and fully sufficient for most rooftop, gutter, and pole-mounted locations across the city. +
+
+

300 mW solar repeater

+

A draft RAK-based build with a parts list, assembly stages, bench checks, and maintenance notes.

+ Review the 300 mW build +
+
+

Experimental 1 W solar repeater

+

An unverified high-power design for a measured network need. It requires electrical, RF, and site review before anyone buys parts or starts work.

+ Review the experimental 1 W build +
+
-[300mW Solar Repeater Build Guide](./repeater-solar-300mw-diy-build.md) +These guides are community references, not verified bills of materials or a +substitute for current manufacturer documentation and qualified review. -### 1W Solar Repeater (backbone / long-reach) +## Before building or buying -Only build a 1W repeater when you have a specific reason to. Good reasons include backbone links between repeaters, and locations that have been proven through testing to need the extra output to reach far enough to connect to the rest of the mesh. Higher transmit power increases noise floor and battery draw. +
    +
  • The exact radio board appears in the current official MeshCore flasher for the repeater role.
  • +
  • The radio and antenna are for the Canadian 902–928 MHz band.
  • +
  • The full power chain follows manufacturer limits and uses documented protection.
  • +
  • The antenna is attached before the radio can transmit.
  • +
  • Property permission, structural review, weather loads, cable routing, and electrical hazards are addressed.
  • +
  • The repeater can be recovered by USB after installation.
  • +
  • The local region and settings are confirmed in the repeater configurator.
  • +
  • A bench test and maintenance plan are ready before installation.
  • +
-[1W Solar Repeater Build Guide](./repeater-solar-1w-diy-build.md) +## Test it on the bench first -!!! tip "Not sure which to build?" - Start with the **300mW** build. Reach out to the community first if you think you need a 1W, since location and antenna choice usually matter more than transmit power. +A completed repeater should remain on the bench until it survives a reboot, retains its settings, sends an advert received by a nearby companion, passes a local routing test, and can still be recovered over USB. Then use the [mounting safety checklist](repeater-mounting-options.md). ---- +## Continue setup + +- [Flash and bench-test a repeater](../meshcore/flash-repeater.md) +- [Choose an antenna and feed line](recommended-antenna.md) +- [Plan a safe mount](repeater-mounting-options.md) diff --git a/docs/hardware/repeater-mounting-options.fr.md b/docs/hardware/repeater-mounting-options.fr.md new file mode 100644 index 0000000..dc0b5fc --- /dev/null +++ b/docs/hardware/repeater-mounting-options.fr.md @@ -0,0 +1,158 @@ +--- +title: Planifier le montage d’un répéteur +description: Comparez des exemples de montage en privilégiant l’autorisation, la sécurité de la structure, les intempéries, les câbles et l’inspection. +audience: + - repeater-builder + - site-owner +task: plan-repeater-mount +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: site dependent +destructive: false +requires: + - property-permission + - site-specific-safety-review +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Planifier le montage d’un répéteur + +Utilisez ces exemples de la communauté pour discuter d’un site. Il ne s’agit +pas de plans d’ingénierie conçus pour un bâtiment, un mât, un climat ou une +propriété en particulier. + +
+ +**Référence d’installation à l’état d’ébauche.** Ces exemples n’ont pas été +examinés en fonction de votre structure, du vent, de la glace, de la neige, de +l’exposition à la foudre, de la mise à la terre, des dégagements électriques, +de l’accès au toit ni des exigences locales. + +
+ +!!! danger "La hauteur de l’antenne ne passe jamais avant la sécurité" + Ne travaillez pas près de lignes électriques aériennes ni sur un toit, une échelle, un mât, une cheminée, une gouttière ou un évent qui n’est pas sécuritaire. Si la fixation, la charge, la mise à la terre, l’exposition aux intempéries ou le plan d’accès est incertain, arrêtez et faites appel à une personne qualifiée de votre région. + +## Avant de choisir un montage + +
    +
  • Le propriétaire et toute autorité concernée approuvent l’emplacement et le passage des câbles.
  • +
  • Une personne qualifiée a examiné la structure, le vent, la glace, la neige, la corrosion, les dégagements électriques, le contexte de foudre et de mise à la terre ainsi que l’accès sécuritaire.
  • +
  • Le répéteur a réussi son essai sur l’établi et reste accessible par USB.
  • +
  • L’antenne, le boîtier, la ligne d’alimentation RF, les connecteurs, le serre-câble et les chemins d’écoulement sont planifiés comme un seul système.
  • +
  • Un plan d’inspection et de retrait est consigné avant l’installation.
  • +
+ +## Ce que cette installation change + +Un montage fixe ajoute une charge et des ouvertures ou des pinces à une +propriété, expose le matériel et les câbles aux intempéries et peut rendre la +récupération physique plus difficile. Consignez le nom du propriétaire du site, +les personnes qui peuvent y accéder et la façon dont l’installation sera +inspectée et retirée. + +## Exemples de la communauté + +### Poteau et pince + +Un poteau ou un mât conçu à cette fin peut offrir un passage de câble et un +point de montage bien définis. Chaque section de poteau, pince, ancrage, +structure et charge doit quand même être examinée pour le site précis. + +Pistes d’achat et de conception proposées par la communauté : + +- [Support de poteau réglable](https://a.co/d/5U5cT4m) +- [Option de pince pour poteau 1](https://www.aliexpress.com/item/1005004943447000.html) +- [Guide de montage d’un boîtier RAKwireless](https://docs.rakwireless.com/product-categories/wisblock/rakbox-uo180x130x60/installation-guide/#mounting-guide) +- [Option de pince pour poteau 2](https://www.aliexpress.com/item/1005004943650198.html) +- [Poteau de tente militaire de 4 pi de Princess Auto](https://www.princessauto.com/en/4-ft-army-tent-pole/product/PA0009280777) +- [Tube d’acier rond de 3/4 × 36 po](https://www.homedepot.ca/product/paulin-3-4-x-36-inch-round-steel-tube/1000126774) + +Stéphane P a partagé un support d’antenne Alfa imprimable : +[télécharger le fichier STL](https://drive.google.com/file/d/1wIU9kLxolzM9vPUB35ETY1sCPLGvtfFu/view?usp=share_link). + +![Support d’antenne Alfa imprimé en 3D et fixé à un poteau](images/PoleMount.jpg){ .mc-build-photo loading=lazy } + +![Exemple de la communauté montrant un poteau de répéteur fixé près d’une cheminée](images/ChimneyMount.jpg){ .mc-build-photo loading=lazy } + +Une installation sur une cheminée exige un examen précis de la cheminée, de la +structure, de la chaleur, des dégagements, des attaches, des intempéries et de +l’accès sécuritaire. La photographie n’est pas une instruction pour reproduire +la fixation. + +### Exemple de montage sur une gouttière + +Aussiemandias a partagé un support de gouttière imprimable : +[télécharger le fichier STL](https://drive.proton.me/urls/A0P57SRHT0#voPRasptRVbW). + +![Boîtier RAK Unify installé avec un support de gouttière conçu par la communauté](images/RAKUnify_GutterMounted.jpeg){ .mc-build-photo loading=lazy } + +Une gouttière n’est pas nécessairement un élément structural. Inspectez la +gouttière, la planche de rive, les attaches, le drainage, les charges de glace +et de neige, le vent, le passage des câbles et l’accès avant d’envisager cette +approche. + +### Exemple de rallonge de tuyau d’évent + +N’utilisez pas une rallonge de tuyau d’évent avant qu’une personne qualifiée +ait confirmé l’évent existant, les matériaux du bâtiment, les exigences du code +et la charge ajoutée. + +![Exemple de la communauté montrant un répéteur monté sur une rallonge de tuyau d’évent en ABS](images/VentPipeExtension.jpg){ .mc-build-photo loading=lazy } + +## Liste de vérification de l’installation + +
    +
  • L’autorisation, l’accès sécuritaire, les dégagements électriques et l’examen propre au site sont documentés.
  • +
  • Le montage, les attaches, la structure et le passage des câbles correspondent au plan examiné.
  • +
  • La quincaillerie extérieure et les métaux différents ont été pris en compte selon les conditions locales.
  • +
  • Les câbles ont un serre-câble, une protection contre les plis, un chemin d’écoulement et des entrées étanches.
  • +
  • Le boîtier peut s’aérer comme prévu et n’accumule pas d’eau.
  • +
  • L’antenne est branchée avant que la radio puisse transmettre.
  • +
  • Le retrait et la récupération physique par USB demeurent pratiques.
  • +
+ +## Vérifier l’installation terminée + +Depuis une position sécuritaire, inspectez toute l’installation pour repérer +les mouvements, les tensions sur les câbles, la quincaillerie desserrée, le +drainage obstrué et les chemins d’eau. Confirmez ensuite que le répéteur se +reconnecte, conserve ses réglages, envoie une annonce reçue localement et +réussit l’essai d’acheminement de messages prévu. + +## Récupération et retrait + +Si un élément bouge, fuit, se desserre, se corrode, change électriquement ou +échoue à la vérification radio, cessez d’utiliser le site. Coupez l’alimentation +lorsque c’est sécuritaire, retirez ou fixez le matériel conformément au plan +d’accès approuvé, puis retournez-le sur l’établi. Ne dépannez pas du matériel +sous tension en hauteur. + +## Dossier d’entretien + +
+ +Aucun intervalle d’inspection pancanadien n’a été approuvé. Avant le +déploiement, consignez un intervalle propre au site et les conditions qui +déclenchent une inspection supplémentaire, comme des conditions +météorologiques extrêmes, des travaux à proximité, une infiltration d’eau ou +un changement du comportement radio. + +Pour chaque inspection, consignez la date, le nom de la personne qui l’a +effectuée, les photographies, l’état de la quincaillerie et des câbles, l’état +du boîtier, la vérification radio, les travaux correctifs et la date de la +prochaine inspection. + +
+ +## Avant l’installation + +Revenez au [choix du répéteur](recommended-repeaters.md) ou +[programmez et testez le répéteur sur l’établi](../meshcore/flash-repeater.md) +avant de l’installer. diff --git a/docs/hardware/repeater-mounting-options.md b/docs/hardware/repeater-mounting-options.md index 859bf6a..63efc41 100644 --- a/docs/hardware/repeater-mounting-options.md +++ b/docs/hardware/repeater-mounting-options.md @@ -1,72 +1,121 @@ +--- +title: Plan a repeater mount +description: Compare community mounting examples while prioritizing permission, structural safety, weather, cable routing, and inspection. +audience: + - repeater-builder + - site-owner +task: plan-repeater-mount +scope: canada-baseline +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: site dependent +destructive: false +requires: + - property-permission + - site-specific-safety-review +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- -# Mounting Repeaters +# Plan a repeater mount -Mounting repeaters securely is critical for good performance and long-term reliability. -The goal is always to place the antenna as high as possible with clear line-of-sight, while ensuring the mount can withstand wind and weather. +Use these community examples to discuss a site. They are not engineered plans for a particular building, mast, climate, or property. -**Remember: Height is might — the higher the node, the better connection you will get to the mesh.** +
---- +**Draft installation reference.** The examples have not been reviewed for your structure, wind, ice, snow, lightning exposure, grounding, electrical clearance, roof access, or local requirements. -## Mounting Options -### Pole Mount -The most common method is using a standard pole and clamp system. +
-* Simple to set up with widely available hardware. -* Can be attached to an existing mast, tower, or sturdy pole. -* Best suited for permanent installations where height and stability are required. +!!! danger "Antenna height never overrides safety" + Do not work near overhead electrical lines or on an unsafe roof, ladder, mast, chimney, gutter, or vent. If the attachment, load, grounding, weather exposure, or access plan is uncertain, stop and use a qualified local installer. -Examples of pole mounts: +## Before you choose a mount -* [Amazon – Adjustable Pole Mount](https://a.co/d/5U5cT4m) -* [AliExpress – Pole Clamp Option 1](https://www.aliexpress.com/item/1005004943447000.html) -** [Mounting Guide](https://docs.rakwireless.com/product-categories/wisblock/rakbox-uo180x130x60/installation-guide/#mounting-guide) -* [AliExpress – Pole Clamp Option 2](https://www.aliexpress.com/item/1005004943650198.html) +
    +
  • The property owner and any required authority approve the location and cable route.
  • +
  • A qualified person has addressed structure, wind, ice, snow, corrosion, electrical clearance, lightning/grounding context, and safe access.
  • +
  • The repeater passed its bench test and can still be reached by USB.
  • +
  • The antenna, enclosure, feed line, connectors, strain relief, and drip paths are planned as one system.
  • +
  • An inspection and removal plan is recorded before installation.
  • +
-Examples of poles: +## What this installation changes -* [Princess Auto - 4ft Army Tent Pole](https://www.princessauto.com/en/4-ft-army-tent-pole/product/PA0009280777) -** Can be linked together to increase pole length (ensure proper anchoring) -* [3/4 x 36-inch steel tube](https://www.homedepot.ca/product/paulin-3-4-x-36-inch-round-steel-tube/1000126774) +A fixed mount adds load and penetrations or clamps to a property, exposes equipment and cables to weather, and can make physical recovery harder. Record who owns the site, who may access it, and how the installation will be inspected and removed. -**Pole mount Alfa 5.8dBi Antenna** +## Community examples -Stéphane P created this 3D print to allow pole mounting of the Alfa 5.8 dBI Antenna -![](images/PoleMount.jpg){ width="300" } +### Pole and clamp -* [Download STL](https://drive.google.com/file/d/1wIU9kLxolzM9vPUB35ETY1sCPLGvtfFu/view?usp=share_link) +A pole or purpose-built mast can provide a defined cable route and mounting point. Every pole section, clamp, anchor, structure, and load still needs site-specific review. -**Pole mount on chimney** +Community purchasing and design leads: -![](images/ChimneyMount.jpg){ width="300" } +- [Adjustable pole mount](https://a.co/d/5U5cT4m) +- [Pole clamp option 1](https://www.aliexpress.com/item/1005004943447000.html) +- [RAKwireless enclosure mounting guide](https://docs.rakwireless.com/product-categories/wisblock/rakbox-uo180x130x60/installation-guide/#mounting-guide) +- [Pole clamp option 2](https://www.aliexpress.com/item/1005004943650198.html) +- [Princess Auto 4 ft army tent pole](https://www.princessauto.com/en/4-ft-army-tent-pole/product/PA0009280777) +- [3/4 × 36-inch steel tube](https://www.homedepot.ca/product/paulin-3-4-x-36-inch-round-steel-tube/1000126774) ---- +Stéphane P shared a printable Alfa antenna mount: [download the STL](https://drive.google.com/file/d/1wIU9kLxolzM9vPUB35ETY1sCPLGvtfFu/view?usp=share_link). -### Gutter Mount +![A 3D-printed Alfa antenna mount attached to a pole](images/PoleMount.jpg){ .mc-build-photo loading=lazy } -Community member **Aussiemandias** designed a specialized 3D printable gutter mount for quick and discreet repeater installation. +![A community example of a repeater pole attached near a chimney](images/ChimneyMount.jpg){ .mc-build-photo loading=lazy } -* Ideal for residential areas where pole mounting isn’t practical. -* Attaches directly to house gutters without the need for drilling into walls. -* Provides a stable base while keeping the antenna elevated. +A chimney installation requires specific review of the chimney, structure, heat, clearances, fasteners, weather, and safe access. The photograph is not an instruction to copy the attachment. -![](images/RAKUnify_GutterMounted.jpeg){ width="300" } +### Gutter-mounted community example -* [Download STL](https://drive.proton.me/urls/A0P57SRHT0#voPRasptRVbW) +Aussiemandias shared a printable gutter mount: [download the STL](https://drive.proton.me/urls/A0P57SRHT0#voPRasptRVbW). ---- +![A RAK Unify enclosure installed with a community-designed gutter mount](images/RAKUnify_GutterMounted.jpeg){ .mc-build-photo loading=lazy } -### Vent Pipe Extension Mount +A gutter is not automatically structural. Inspect the gutter, fascia, fasteners, drainage, ice/snow loads, wind, cable route, and access before considering this approach. -* Standard 3 foot ABS pipe from Home Depot, 3" in diameter. -* ABS cement on the one side attached to the extension and on the side attached to the house two self-tapping screws in both sides as a safety measure, which allows removal in the future. +### Vent-pipe extension community example -![](images/VentPipeExtension.jpg){ width="300" } +Do not use a vent-pipe extension until a qualified person confirms the existing +vent, building materials, code requirements, and added load. ---- +![A community example of a repeater mounted on an ABS vent-pipe extension](images/VentPipeExtension.jpg){ .mc-build-photo loading=lazy } + +## Installation checklist + +
    +
  • Permission, safe access, electrical clearance, and site-specific review are documented.
  • +
  • Mount, fasteners, structure, and cable route match the reviewed plan.
  • +
  • Outdoor hardware and dissimilar metals are addressed for local conditions.
  • +
  • Cables have strain relief, bend protection, a drip path, and sealed entries.
  • +
  • The enclosure can vent as designed and does not collect water.
  • +
  • The antenna is attached before the radio can transmit.
  • +
  • Removal and physical USB recovery remain practical.
  • +
+ +## Check the finished installation + +From a safe position, inspect the complete installation for movement, cable strain, loose hardware, blocked drainage, and water paths. Then confirm the repeater reconnects, retains its settings, sends an advert received locally, and passes the planned message-routing check. + +## Recovery and removal + +If anything moves, leaks, loosens, corrodes, changes electrically, or fails its radio check, stop using the site. Disconnect power when safe, remove or secure the equipment through the approved access plan, and return it to the bench. Do not troubleshoot energized equipment at height. + +## Maintenance record + +
+ +No Canada-wide inspection interval has been approved. Before deployment, record a site-specific interval and the conditions that trigger an extra inspection, such as severe weather, nearby work, water entry, or changed radio behaviour. + +Record each inspection date, inspector, photographs, hardware/cable condition, enclosure condition, radio verification, corrective work, and next due date. + +
-## Notes +## Before installing -* Regardless of mount type, always ensure the mount and fasteners are weather-rated and can handle local wind conditions. -* Periodically inspect mounts for wear, corrosion, or loosening. -* These options are specifically for the recommended solar repeater build, linked here: [Building a Solar Node – Rak Unify Box](repeater-solar-300mw-diy-build.md). +Return to the [repeater choice](recommended-repeaters.md) or [flash and bench-test the repeater](../meshcore/flash-repeater.md) before installing it. diff --git a/docs/hardware/repeater-solar-1w-diy-build.fr.md b/docs/hardware/repeater-solar-1w-diy-build.fr.md new file mode 100644 index 0000000..23e3115 --- /dev/null +++ b/docs/hardware/repeater-solar-1w-diy-build.fr.md @@ -0,0 +1,262 @@ +--- +title: Répéteur solaire expérimental de 1 W +description: Évaluez un modèle de répéteur solaire haute puissance non vérifié avant de décider de le reproduire. +audience: + - advanced-repeater-builder + - hardware-reviewer +task: review-1w-solar-repeater-build +scope: experimental +status: experimental +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: multiple days including fabrication and cure time +destructive: false +requires: + - multimeter + - soldering-experience + - fabrication-experience + - manufacturer-documentation +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Répéteur solaire expérimental de 1 W + +Ce modèle non vérifié combine une Ikoka Stick 0.4.0, un gestionnaire +d’alimentation solaire, un parcours RF filtré et un boîtier fabriqué sur mesure. + +
+ +**Ne construisez et ne déployez pas ce modèle sans examen électrique, RF et du +site.** Sa cible de micrologiciel, sa conception électrique, la télémétrie +facultative, ses mesures et ses limites climatiques n’ont pas été reproduites. + +
+ +
+
Radio
GOME Ikoka Stick 0.4.0
+
Alimentation
Waveshare Solar Power Manager
+
Micrologiciel
Ni épinglé ni reproduit
+
Entretien
À définir pendant l’examen du site
+
+ +## Cette construction convient-elle? + +Il s’agit d’une expérience spécialisée de dorsale ou de longue portée, et non +d’une mise à niveau par défaut. Coordonnez-vous avec les communautés voisines +et démontrez le besoin du site avant de modifier la couverture ou les habitudes +de trafic. + +Utilisez le [modèle de 300 mW à l’état d’ébauche](repeater-solar-300mw-diy-build.md) +ou un système préassemblé examiné, sauf si une personne responsable du réseau +et une personne responsable de l’examen du matériel et des RF conviennent que +cette expérience répond à un besoin mesuré. + +## Avant de commencer + +!!! danger "Cette construction réunit des RF haute puissance, des piles au lithium, de la recharge, du soudage, de la coupe et une installation extérieure surélevée" + Vérifiez chaque composant et chaque limite dans la documentation actuelle du fabricant. Branchez une antenne et un parcours RF examiné avant tout essai de transmission. Ne mettez pas sous tension une pile ou un chargeur lorsque la polarité, la protection, le câblage, la température ou les valeurs attendues sont inconnus. + +
    +
  • Le besoin du réseau local et les effets sur les autres régions sont documentés.
  • +
  • Le matériel Ikoka exact et la cible du micrologiciel du répéteur sont confirmés et récupérables par USB.
  • +
  • Une personne responsable de l’examen du matériel et de l’électricité approuve la pile, le chargeur, le panneau, le câblage, la protection, la télémétrie facultative et le plan thermique du boîtier.
  • +
  • Une personne responsable de l’examen RF approuve la radio, le filtre, la ligne de transmission RF, l’antenne, les connecteurs et le site.
  • +
  • Un examen de la structure et du site couvre la masse finale, la surface du panneau, le montage, le vent, la glace, la neige, le passage des câbles et l’accès.
  • +
  • La construction restera sous surveillance sur l’établi jusqu’à l’approbation de son dossier d’essai.
  • +
+ +## Ce que cette construction change + +La procédure découpe et perce un boîtier, colle un panneau solaire avec de +l’époxy et du scellant, modifie le système de montage, branche un parcours RF +haute puissance et un filtre, assemble un système d’alimentation au lithium et +peut retirer les broches de l’écran pour la télémétrie facultative. Plusieurs +changements sont difficiles ou impossibles à annuler sans remplacer des pièces. + +## Liste du matériel à vérifier + +
+ +| Nº | Pièce | Qté | Détail à vérifier | Source | +|---:|---|---:|---|---| +| 1 | GOME Ikoka Stick 0.4.0 | 1 | Révision du matériel, bande canadienne, sortie RF, cible du micrologiciel, récupération | [Page du projet](https://github.com/ndoo/ikoka-stick-meshtastic-device) | +| 2 | Waveshare Solar Power Manager | 1 | Modèle exact, limites du panneau, de la pile et de la sortie, comportement au démarrage, température | [Waveshare](https://www.waveshare.com/wiki/Solar_Power_Manager) | +| 3 | Boîte de jonction IP65, 220×170×110 mm | 1 | Matériau, indice de protection après modification, température, évent | [AliExpress](https://www.aliexpress.com/item/1005007587120013.html) | +| 4 | Panneau de 10 W / 18 V, 250×340 mm | 1 | Limites électriques, dimensions, masse, résistance aux intempéries et aux charges | [Amazon](https://a.co/d/0eJo5GCr) | +| 5 | Bloc 18650 3P1S ou 4P1S | 1 | Chimie, cellules appariées, protection, construction, chargeur, température | [MP&W Supply](https://mpandw.ca/) | +| 6 | Carte de protection de pile | 1 | Seuils, courant, température, câblage, compatibilité | [Space Hedgehog](https://space-hedgehog.com/products/battery-protection-with-low-voltage-cut-off) | +| 7 | Filtre passe-bande à quatre cavités de 890–960 MHz, 50 W | 1 | Réponse à 902–928 MHz, perte d’insertion, connecteurs, intempéries, température | [Alibaba](https://www.alibaba.com/product-detail/50W-890-960MHz-4-Cavity-Filter_1601399651944.html) | +| 8 | Câble de 10–15 cm, SMA à angle droit vers traversée femelle de type N | 1 | Genre et polarité des connecteurs, perte, courbure, joint | [AliExpress](https://www.aliexpress.com/item/1005008569444661.html) | +| 9 | Câble SMA mâle à angle droit de 7.5 cm | 1 | Correspondance du connecteur, perte, courbure, puissance admissible | [AliExpress](https://www.aliexpress.com/item/1005006702037541.html) | +| 10 | Câble USB-A vers USB-C à angle droit de 0.5 pi | 1 | Besoins en données et en alimentation, courant, ajustement, tension mécanique | [Amazon](https://a.co/d/045htrEG) | +| 11–12 | Entretoises M3×35 et vis M3×5 | selon les besoins | Matériau, résistance, ajustement, corrosion | Source locale | +| 13 | Gorilla Epoxy, 25 ml | 1 | Compatibilité des matériaux, durcissement, limites extérieures et de température | [Home Depot](https://www.homedepot.ca/product/gorilla-epoxy-syringe-25ml/1000778451) | +| 14 | Bouchon d’évent étanche | 1 | Filetage, circulation d’air, protection contre l’infiltration après l’installation | [AliExpress](https://www.aliexpress.com/item/1005006370919409.html) | +| 15 | Scellant de silicone extérieur transparent | 1 | Compatibilité des matériaux, durcissement, limites UV et de température | Quincaillerie locale | +| 16 | Adafruit INA3221, facultatif | 1 | Interface électrique, adresse, prise en charge du micrologiciel, isolation, étalonnage | [DigiKey](https://www.digikey.ca/en/products/detail/adafruit-industries-llc/6062/25660599) | + +
+ +

Ces pièces sont des pistes non vérifiées. Vérifiez les caractéristiques, la disponibilité, l’expédition et le coût total actuels avant d’acheter.

+ +## Outils et téléchargements + +Prévoyez des outils de soudage et de câblage, un multimètre, des forets et des +outils de coupe appropriés, des serre-joints, une protection oculaire et un +espace de travail sécuritaire. + +Fichiers imprimables à vérifier : + +- [Plaque de montage et support de pile (.3mf)](files/repeater-solar-1w-diy-build-plate-and-battery-holder.3mf) +- [Plaque de montage (.stl)](files/repeater-solar-1w-diy-build-plate.stl) +- [Support de pile 18650 3P1S (.stl)](files/repeater-solar-1w-diy-build-3x18650-battery-holder.stl) + +Confirmez les dimensions, le matériau, le rendement thermique, les attaches, le +maintien de la pile et la qualité d’impression avant l’utilisation. + +## Étapes d’assemblage + +
+
+

Étape 1 — Examiner et vérifier l’ajustement à sec

+

Confirmez toutes les révisions, les dimensions, les limites électriques, les parcours des connecteurs, l’orientation du filtre, les dégagements, les pièces imprimées, les courbures de câble, l’emplacement de l’évent, celui du panneau et l’accès pour l’entretien avant de couper ou de coller.

+

Point de contrôle : le schéma, le parcours RF et la disposition mécanique examinés correspondent aux pièces exactes que vous avez en main.

+
+
+

Étape 2 — Préparer le boîtier et le panneau

+
    +
  1. Mesurez la boîte de jonction du panneau et marquez la découpe du boîtier à partir des pièces réelles.
  2. +
  3. Avec le boîtier vide et solidement soutenu, utilisez une ouverture pilote et un outil de coupe appropriés.
  4. +
  5. Ébavurez et nettoyez la découpe, puis vérifiez à sec l’ajustement du panneau et le passage du câble.
  6. +
  7. Collez et serrez seulement au moyen d’une méthode examinée et compatible avec les matériaux. Laissez durcir pendant toute la période indiquée par le fabricant.
  8. +
  9. Appliquez le joint d’étanchéité examiné sans bloquer le drainage ni l’aération.
  10. +
+
Fils du panneau solaire passant dans la découpe du boîtier
Exemple seulement. Mesurez les pièces réelles.
+
+
+

Étape 3 — Installer la plaque de montage, le filtre, la traversée et l’évent

+
    +
  1. Installez les entretoises et la plaque de montage examinées sans exercer de contrainte sur le boîtier.
  2. +
  3. Montez le filtre en repérant les ports d’entrée et de sortie et en conservant un rayon de courbure suffisant pour les câbles.
  4. +
  5. Percez et ébavurez les trous de la traversée et de l’évent en vérifiant l’ajustement du matériel réel.
  6. +
  7. Installez les joints, la traversée et l’évent selon leur documentation.
  8. +
  9. Laissez les raccords RF sans alimentation et accessibles pour l’inspection.
  10. +
+
Plaque de montage avec entretoises et filtre RF
Exemple de disposition de la plaque et du filtre.
+
Boîtier avec filtre, câbles de traversée RF et bouchon d’évent
Vérifiez le sens des connecteurs et chaque joint.
+
+
+

Étape 4 — Examiner la modification du démarrage du gestionnaire d’alimentation

+

Ne pontez pas les pastilles du bouton de démarrage du gestionnaire d’alimentation. Cette modification n’a pas été reproduite ni examinée.

+

Avant d’envisager tout changement, exigez qu’une personne responsable de l’examen du matériel approuve le schéma exact, les modes de défaillance, les répercussions sur la garantie, la méthode de soudage, l’essai de démarrage et le retour en arrière.

+
Pont de fil non vérifié sur les pastilles du bouton de démarrage du gestionnaire solaire
Modification non vérifiée. Ne la reproduisez pas à partir de cette photographie.
+
+
+

Étape 5 — Monter la radio et décider de la télémétrie facultative

+

Vérifiez à sec l’ajustement de l’Ikoka Stick et du gestionnaire d’alimentation. La branche facultative INA3221 retire l’embase de l’écran et se soude aux pastilles I2C; elle doit donc rester séparée de la construction de base.

+

Le brochage de télémétrie, le parcours de mesure, l’étalonnage, la plage de courant, l’adresse I2C et la prise en charge par le micrologiciel ne sont pas vérifiés.

+

Par défaut : omettez la télémétrie facultative. Ne retirez pas les broches de l’écran et n’ajoutez pas d’options de micrologiciel avant d’avoir reproduit le matériel exact et la cible actuelle de MeshCore.

+
Raccord facultatif non vérifié d’un INA3221 près de l’embase d’écran de l’Ikoka
Télémétrie facultative non vérifiée. Ne la reproduisez pas à partir de cette photographie.
+
+
+

Étape 6 — Assembler le parcours de la pile et de la recharge

+
    +
  1. Gardez l’alimentation solaire, la pile, l’USB et la radio débranchés.
  2. +
  3. Confirmez la construction du bloc-pile, l’appariement des cellules, la protection, le maintien, l’isolation et les limites de température.
  4. +
  5. Confirmez chaque borne du gestionnaire d’alimentation et de la carte de protection dans la documentation actuelle des fabricants.
  6. +
  7. Mesurez et consignez la polarité; ne vous fiez pas à la couleur des fils.
  8. +
  9. Branchez une seule branche examinée à la fois et inspectez-la avant de continuer.
  10. +
+

Point de contrôle : il ne reste aucune borne, limite ou valeur attendue inconnue, aucun conducteur exposé ni aucune condition de pile non prise en charge.

+
+
+

Étape 7 — Terminer les parcours RF et USB

+
    +
  1. Vérifiez le sens d’entrée et de sortie du filtre ainsi que chaque connecteur SMA et de type N.
  2. +
  3. Branchez tout le parcours d’antenne examiné avant que la radio puisse transmettre.
  4. +
  5. Acheminez le câble d’alimentation USB sans pli brusque, frottement ni tension.
  6. +
  7. Gardez le boîtier ouvert pendant la première mise sous tension supervisée et les vérifications.
  8. +
+
Intérieur terminé avec télémétrie INA3221 facultative
Disposition avec télémétrie facultative non vérifiée.
+
Intérieur terminé sans télémétrie INA3221 facultative
Exemple de disposition de base.
+
Intérieur complet du boîtier avec radio, filtre, gestionnaire d’alimentation et pile
Exemple d’intérieur. Une photographie ne constitue pas une approbation électrique.
+
+
+ +## Vérifier les mesures + +Aucune plage numérique d’acceptation n’a été examinée par des pairs pour cette +page. Ne remplacez pas les valeurs manquantes par des tensions ou des courants +devinés. + +
+ +| Vérification | Résultat acceptable | Condition d’arrêt | +|---|---|---| +| Continuité débranchée | Aucun court-circuit involontaire dans les parcours d’alimentation ou RF documentés | Continuité inattendue ou configuration incertaine du multimètre | +| Polarité des connecteurs | Les mesures correspondent au schéma examiné et au brochage du fabricant | Toute contradiction ou broche inconnue | +| Valeurs solaires, de pile et de sortie | Chaque valeur mesurée respecte la documentation actuelle de tous les composants branchés | Limite manquante ou valeur hors limites | +| Première mise sous tension | Démarrage stable et attendu, sans chaleur, odeur, enflure, fumée, bruit ni cycle de redémarrage | Tout comportement physique ou électrique anormal | +| Parcours RF | Bande, sens du filtre et raccords exacts, avec l’antenne branchée avant la transmission | Antenne manquante, connecteur desserré ou forcé, réponse inconnue du filtre | +| Télémétrie facultative | Mesures et comportement du micrologiciel reproduits indépendamment | Câblage, adresse, étalonnage ou cible de construction non vérifiés | + +
+ +## Le tester sur l’établi + +
    +
  • Consignez les pièces exactes, les révisions, les documents sources, le schéma, les photographies, la polarité et les valeurs mesurées.
  • +
  • Utilisez des méthodes supervisées de première mise sous tension et de limitation du courant adaptées au matériel examiné.
  • +
  • Confirmez que l’appareil démarre après l’application de chaque source d’alimentation prévue et après une coupure d’alimentation contrôlée.
  • +
  • Confirmez que l’identité, le micrologiciel et les réglages de radio, de région, de parcours, d’annonce et d’accès du répéteur résistent au redémarrage.
  • +
  • Confirmez qu’un compagnon à proximité reçoit une annonce et que l’essai d’acheminement local prévu réussit.
  • +
  • Mesurez le comportement RF et électrique avec la méthode approuvée par la personne responsable de l’examen; ne déduisez pas la puissance de sortie à partir d’un seul réglage du micrologiciel.
  • +
  • Effectuez sous surveillance un essai de recharge et de charge qui couvre les conditions examinées sans dépasser les limites des composants.
  • +
  • Inspectez la tension des câbles et les joints, puis effectuez un essai de résistance à l’eau adapté au boîtier.
  • +
  • Démontrez la récupération USB et documentez le plan de retrait et d’accès.
  • +
+ +Ne déployez pas l’appareil avant qu’une personne responsable de l’examen du +matériel et de l’électricité, une personne responsable de l’examen RF et le +propriétaire du site aient approuvé le dossier d’essai complet. + +## Récupération et retour en arrière + +Si une vérification échoue, cessez de transmettre et débranchez l’alimentation +solaire, USB et de la pile selon la séquence sécuritaire examinée. Déplacez le +matériel vers un espace de travail surveillé approprié. Ne rebranchez pas une +pile au lithium chaude, gonflée, endommagée, qui fuit ou qui paraît autrement +douteuse. Restaurez le micrologiciel par USB seulement après avoir compris le +problème électrique ou RF. + +Les découpes, l’époxy, les broches retirées et les ponts de soudure peuvent être +irréversibles. Remplacez les composants douteux plutôt que d’improviser une +réparation. Pour revenir de façon sécuritaire sur la branche de télémétrie +facultative, omettez-la avant toute modification; ne supposez pas que le +matériel retiré pourra être restauré. + +## Entretien + +Aucun intervalle n’est approuvé. Le dossier du site doit définir les +inspections du montage, du panneau et de son collage, des infiltrations d’eau, +de l’évent, des joints, de la corrosion, des câbles et connecteurs, du filtre, +de l’état de la pile, du comportement de recharge, des valeurs mesurées, du +micrologiciel et de la configuration, de la vérification radio et de la +récupération physique. Reprenez l’inspection après tout événement grave ou +comportement inexpliqué défini par la personne responsable de l’examen du site. + +## Source + +Fondé sur des notes expérimentales fournies par MrAlders0n en 2026. Aucune +affirmation concernant le matériel ou le micrologiciel de cette page n’est +vérifiée. + +Privilégiez une option examinée du +[guide de sélection des répéteurs](recommended-repeaters.md). Si cette +expérience est approuvée et mise en service, terminez le +[plan de montage](repeater-mounting-options.md) et la +[configuration du répéteur](../config/index.md). diff --git a/docs/hardware/repeater-solar-1w-diy-build.md b/docs/hardware/repeater-solar-1w-diy-build.md index 4af5405..e31d1c7 100644 --- a/docs/hardware/repeater-solar-1w-diy-build.md +++ b/docs/hardware/repeater-solar-1w-diy-build.md @@ -1,227 +1,231 @@ -# Building a 1W Solar Repeater – Ikoka Stick - -Authored By: MrAlders0n (Ottawa) -Date: 2026-01-01 - -!!! warning "No warranty" - This is to help the community understand how to make a repeater, and I by no means provide any warranty for anyone following this guide. Please test everything first with a multimeter and other tools before powering anything on. - -**Board:** GOME Ikoka Stick (HW v0.4.0) -**Power:** Waveshare Solar Power Manager (standard) -**Firmware:** MeshCore - -This guide walks through assembling a solar-powered MeshCore repeater using the GOME Ikoka Stick, a junction box enclosure, and an epoxied solar panel. Follow each step carefully for a reliable and weatherproof build. - -!!! info "When to use a 1W repeater" - 1W repeaters should typically be used for backbone links and for locations that are struggling to connect to the mesh. They are not necessary for every deployment. - -!!! danger "Antenna Required" - **Always ensure a LoRa antenna is attached to the Ikoka Stick before powering it on. Transmitting without an antenna can permanently damage the radio module.** - ---- - -## Parts List - -| # | Part | Qty | Price | Source | -|---|------|-----|-------|--------| -| 1 | GOME Ikoka Stick (HW v0.4.0) | 1 | $55 | [GitHub](https://github.com/ndoo/ikoka-stick-meshtastic-device) (group buy) | -| 2 | Waveshare Solar Power Manager (standard) | 1 | $15 | [Waveshare](https://www.waveshare.com/wiki/Solar_Power_Manager) | -| 3 | IP65 Junction Box, 220x170x110mm, Gray | 1 | $30 | [AliExpress](https://www.aliexpress.com/item/1005007587120013.html) | -| 4 | Solar panel, 10W/18V, 250x340mm | 1 | $35 | [Amazon](https://a.co/d/0eJo5GCr) | -| 5 | 3P1S 18650 battery pack (or 4P1S) | 1 | $25-35 | [MP&W Supply](https://mpandw.ca/) | -| 6 | Battery protection board (high/low voltage cutoff) | 1 | $8 | [Space Hedgehog](https://space-hedgehog.com/products/battery-protection-with-low-voltage-cut-off) | -| 7 | 890-960MHz 4-cavity bandpass filter, 50W (recommended) | 1 | $65 | [Alibaba](https://www.alibaba.com/product-detail/50W-890-960MHz-4-Cavity-Filter_1601399651944.html) | -| 8 | 10-15cm SMA right angle to N-Type female O-ring nut cable | 1 | $6 | [AliExpress](https://www.aliexpress.com/item/1005008569444661.html) | -| 9 | 7.5cm SMA Male 90° to SMA Male 90° cable | 1 | $7 | [AliExpress](https://www.aliexpress.com/item/1005006702037541.html) | -| 10 | 0.5ft USB-A to USB-C right angle cable | 1 | $7 | [Amazon](https://a.co/d/045htrEG) | -| 11 | M3x35 spacers | 4 | | | -| 12 | M3x5 screws | 8 | | | -| 13 | Gorilla Epoxy Syringe, 25ml | 1 | $15 | [Home Depot](https://www.homedepot.ca/product/gorilla-epoxy-syringe-25ml/1000778451) | -| 14 | Waterproof vent plug (breather valve) | 1 | $2 | [AliExpress](https://www.aliexpress.com/item/1005006370919409.html) | -| 15 | Outdoor silicone caulk (clear) | 1 | $10 | Any hardware store | -| 16 | Adafruit INA3221 (optional) | 1 | $15-20 | [DigiKey](https://www.digikey.ca/en/products/detail/adafruit-industries-llc/6062/25660599) | - -**Estimated total cost:** ~$280-290 without the INA3221, ~$300-310 with it. This does not include the antenna, 3D-printed parts, or mounting hardware (M3 spacers/screws). - -**Alternative filter option (minimal):** Callboost 915MHz cavity filter, 26M BW, $82. [AliExpress](https://www.aliexpress.com/item/1005004468960058.html) - -### Antenna - -An antenna is not included in this parts list as selection will vary by deployment. It is highly recommended to use a higher dBi antenna such as a 6-8 dBi or higher with 1W builds. We have found that the Ikoka Stick does not pair well with the Alfa 5.8 dBi antenna for some reason, with several folks in Ottawa observing noticeably worse signal quality when using them together. This is not a definitive statement, just a pattern observed across multiple deployments. - -For recommended antenna options, see the [GOME Repeater Omni Antennas](recommended-antenna.md#repeater-omni-antennas) page. - ---- - -## Tools Required - -| Tool | Notes | -|------|-------| -| Soldering iron | Fine tip recommended for I2C wiring | -| Solder + flux | | -| Multimeter | For continuity and voltage checks | -| Step drill bit | For drilling clean holes in the junction box | -| Jigsaw or rotary tool | For cutting the solar panel opening | -| 1/8" drill bit | For opening up mounting holes | -| Pencil | For marking the cutout | -| Clamps + flat piece of wood | For clamping solar panel during epoxy cure | -| Wire strippers | | - ---- - -## Prerequisites - -Before starting assembly, you will need the following 3D-printed parts. Both are available for download below: - -- **Mounting plate** for the junction box (holds the filter, Ikoka, and Waveshare board) -- **3P1S 18650 battery holder** (or sized to match your chosen battery configuration) - -### Downloads - -- [Mounting plate + 3P1S 18650 battery holder (combined .3mf)](./files/repeater-solar-1w-diy-build-plate-and-battery-holder.3mf) -- [Mounting plate (.stl)](./files/repeater-solar-1w-diy-build-plate.stl) -- [3P1S 18650 battery holder (.stl)](./files/repeater-solar-1w-diy-build-3x18650-battery-holder.stl) - --- - -## Assembly Steps - -### Enclosure Preparation - -1. Flip the solar panel face-down. On the back, locate the black junction box where the red and black power wires exit. Measure this junction box with calipers or a ruler. - -2. On the **front face** of the gray junction box, use a pencil to trace a rectangle matching the solar panel junction box dimensions. Center it near the top of the panel. - -3. Using a step drill bit, drill a large starter hole in the center of the traced rectangle. - -4. Using a jigsaw or rotary tool, cut along the traced lines to remove the rectangular section. Clean up any rough edges. - -### Mounting the Solar Panel - -5. Mix a batch of Gorilla Epoxy and apply it generously across the entire front face of the junction box, covering the area where the solar panel will sit. - -6. Route the solar panel's power cable through the rectangular cutout from the outside into the box. - - ![Solar panel wires routed through junction box](./images/repeater-solar-1w-diy-build-1.jpg){ width="300" } - -7. Press the solar panel onto the epoxied face of the junction box. Place a flat piece of wood across the front of the panel and use clamps on all four corners to apply even pressure. Be careful not to over-tighten, as this can crack the panel. Alternatively, lay the assembly flat on the ground with weights on the back of the box. - -8. Allow the epoxy to fully cure according to the manufacturer's instructions before removing the clamps. - -9. Using clear outdoor silicone caulk, apply a bead around all four edges where the solar panel meets the junction box. Smooth the caulk to create a weatherproof seal. - -### Mounting Plate and Filter - -10. Attach the four M3x35 spacers to the corners of the junction box mounting plate. You may need to use a 1/8" drill bit to open the holes slightly to fit M3 screws through the plate. Use the 3D-printed mount plate as a reference for hole placement. - - ![Mounting plate with spacers and filter assembled](./images/repeater-solar-1w-diy-build-2.jpg){ width="300" } - -11. Mount the RF bandpass filter to the mounting plate, positioning it roughly in the center. - -12. Connect the N-Type to SMA cable to the filter's **output** port. This cable will pass through the box wall to the external antenna. - -13. Determine where the N-Type connector should exit the junction box. Using a step drill bit, drill the hole, stepping up one size at a time and test-fitting after each step until the connector fits snugly. - -14. Feed the N-Type connector through the box wall from the inside out and tighten the O-ring nut to secure it. - -15. On the **bottom** of the junction box, drill a hole for the waterproof vent plug using the step drill bit. - -16. Install the vent plug and tighten it. - - ![Filter mounted in box with N-Type, SMA cables, and vent plug installed](./images/repeater-solar-1w-diy-build-3.jpg){ width="300" } - -### Filter and Radio Preparation - -17. Connect the SMA-to-SMA cable to the filter's **input** port. Finger-tighten for now. - -18. **Waveshare Solar Power Manager prep (standard model):** On the back of the board, solder a wire across the boot button pads to short it permanently. Without this modification, the board will not automatically resume power output after a power loss, meaning your repeater would stay offline until you physically press the button. - - ![Boot button shorted on Waveshare Solar Power Manager](./images/repeater-solar-1w-diy-build-4.jpg){ width="300" } - -### Component Mounting - -19. Outside the box (for easier access), mount the Ikoka Stick and the Waveshare Solar Power Manager to the 3D-printed mounting plate. If using the optional INA3221, mount it as well. - -20. **(Optional, Adafruit INA3221)** Using a soldering iron, carefully remove the Ikoka Stick's OLED display header pins. Work slowly and gently to avoid damaging nearby components. - -21. **(Optional, Adafruit INA3221)** Solder the INA3221 to the Ikoka's I2C bus using the display header pads. See the [INA3221 Wiring](#ina3221-wiring-optional) section below for the pin connections. - - ![INA3221 wired to Ikoka Stick I2C with Waveshare Solar Power Manager](./images/repeater-solar-1w-diy-build-5.jpg){ width="300" } - -22. Install the mounting plate assembly into the junction box and secure it with M3x5 screws into the M3x35 spacers. - -### Battery Installation - -23. Attach the 3D-printed battery holder to the inside front of the case, below where the solar panel wires enter. Use double-sided tape or epoxy to secure it. - -24. If your 18650 battery pack does not have a built-in PCM (protection circuit module), wire the battery protection board between the battery and the Waveshare. Follow the polarity markings carefully. - -### Power Wiring - -25. **(Optional, Adafruit INA3221) Solar wiring:** Connect the solar panel positive wire to INA3221 **CH3+**. Connect CH3- to the Waveshare solar input positive. Connect the solar panel negative wire directly to the Waveshare solar input negative. - -26. **(Optional, Adafruit INA3221) Battery wiring:** Connect the battery (or the PCM) positive wire to INA3221 **CH1+**. Connect CH1- to the positive pin on the Waveshare battery JST PH2.0 connector. Connect the battery negative wire directly to the negative pin on the JST PH2.0 connector. Plug the connector into the Waveshare board. - - ```Note: If you are not using the INA3221, wire the solar panel and battery directly to the Waveshare board's solar and battery inputs.``` - -### Final Connections - -27. Connect the SMA cable from the filter's input to the Ikoka Stick's SMA connector. Tighten both ends firmly with a wrench or pliers. A loose RF connection will cause signal loss. - -28. Run the USB-A to USB-C cable from the Waveshare Solar Power Manager's USB output to the Ikoka Stick's USB-C input. - - *If using the Adafruit INA3221, your completed assembly should look similar to this:* - - ![Completed build with INA3221, Waveshare, and Ikoka Stick](./images/repeater-solar-1w-diy-build-6.jpg){ width="300" } - - *Without the INA3221:* - - ![Completed build without INA3221](./images/repeater-solar-1w-diy-build-7.jpg){ width="300" } - - *Full interior view with INA3221, battery pack, and all wiring complete:* - - ![Full interior view of completed build with INA3221](./images/repeater-solar-1w-diy-build-8.jpg){ width="300" } - +title: Experimental 1 W solar repeater +description: Evaluate an unverified high-power solar repeater design before deciding whether to reproduce it. +audience: + - advanced-repeater-builder + - hardware-reviewer +task: review-1w-solar-repeater-build +scope: experimental +status: experimental +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: multiple days including fabrication and cure time +destructive: false +requires: + - multimeter + - soldering-experience + - fabrication-experience + - manufacturer-documentation +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 --- -## INA3221 Wiring (Optional) - -The Adafruit INA3221 connects to the Ikoka Stick via I2C using the **OLED display header** pads. Remove the display header pins and solder wires directly to the pads. - -**I2C connection (Ikoka display header to INA3221):** - -| Ikoka Display Header | INA3221 Pin | Wire | -|----------------------|-------------|------| -| Pin 1 - GND | GND | Ground | -| Pin 2 - VCC (3.3V) | VCC | Power | -| Pin 3 - SCL | SCL | Clock | -| Pin 4 - SDA | SDA | Data | - -**INA3221 channel assignments:** - -| Channel | Connected To | Purpose | -|---------|--------------|---------| -| CH1 | Battery | Monitor battery voltage and current draw | -| CH2 | (unused) | Available for future use | -| CH3 | Solar | Monitor solar input voltage and charging current | - -```Note: VCC on the INA3221 is the board power pin (3.3V from the Ikoka). VIN1/VIN2/VIN3 are the measurement channel inputs, not the power pin.``` - -**Firmware build flags:** MeshCore does not auto-detect the Adafruit INA3221 at its default address. Add these flags to your build configuration: - -```ini -[env:ikoka_stick_nrf_30dbm_repeater] -build_flags = - ${ikoka_stick_nrf_repeater.build_flags} - ${ikoka_stick_nrf_e22_30dbm.build_flags} - -D TELEM_INA3221_ADDRESS=0x40 - -UENV_INCLUDE_INA219 -``` - -The Adafruit INA3221 sits at I2C address `0x40`, but MeshCore expects `0x42`. The `-D TELEM_INA3221_ADDRESS=0x40` flag corrects this. The `-UENV_INCLUDE_INA219` flag disables INA219 support to prevent conflicts. - ---- +# Experimental 1 W Solar Repeater -## Wiring Diagram +This unverified design combines an Ikoka Stick 0.4.0, a solar power manager, +a filtered RF path, and a fabricated enclosure. -![Wiring diagram](./images/repeater-solar-1w-diy-build-9.svg){ width="600" } +
+ +**Do not build or deploy this design without electrical, RF, and site review.** +Its firmware target, electrical design, optional telemetry, measurements, and +climate limits have not been reproduced. + +
+ +
+
Radio
GOME Ikoka Stick 0.4.0
+
Power
Waveshare Solar Power Manager
+
Firmware
Not pinned or reproduced
+
Maintenance
Set during the site review
+
+ +## Is this build appropriate? + +This is a specialized backbone or long-range experiment, not a default +upgrade. Coordinate with nearby communities and show that the site needs it +before changing coverage or traffic patterns. + +Use the [300 mW draft build](repeater-solar-300mw-diy-build.md) or a reviewed pre-built path unless a network operator and hardware/RF reviewer agree that this experiment addresses a measured need. + +## Before you start + +!!! danger "This combines high-power RF, lithium batteries, charging, soldering, cutting, and an outdoor elevated installation" + Verify every component and limit from current manufacturer documentation. Keep an antenna and reviewed RF path attached before any transmit test. Do not energize a battery or charger path when polarity, protection, wiring, temperature, or expected readings are unknown. + +
    +
  • The local network need and cross-region effects are documented.
  • +
  • The exact Ikoka hardware and repeater firmware target are confirmed and recoverable by USB.
  • +
  • A hardware/electrical reviewer approves the battery, charger, panel, wiring, protection, optional telemetry, and enclosure thermal plan.
  • +
  • An RF reviewer approves the radio, filter, feed line, antenna, connectors, and site.
  • +
  • A structural/site review covers the finished mass, panel area, mount, wind, ice, snow, cable route, and access.
  • +
  • The build will remain supervised on the bench until its test record is approved.
  • +
+ +## What this build changes + +The procedure cuts and drills an enclosure, bonds a solar panel with epoxy and sealant, modifies the mounting system, connects a high-power RF path and filter, assembles a lithium power system, and may remove display pins for optional telemetry. Several changes are difficult or impossible to undo without replacing parts. + +## Bill of materials to verify + +
+ +| # | Part | Qty | Detail to verify | Source | +|---:|---|---:|---|---| +| 1 | GOME Ikoka Stick 0.4.0 | 1 | Hardware revision, Canadian band, RF output, firmware target, recovery | [Project page](https://github.com/ndoo/ikoka-stick-meshtastic-device) | +| 2 | Waveshare Solar Power Manager | 1 | Exact model, panel, battery and output limits, startup behaviour, temperature | [Waveshare](https://www.waveshare.com/wiki/Solar_Power_Manager) | +| 3 | IP65 junction box, 220×170×110 mm | 1 | Material, ingress rating after modification, temperature, vent | [AliExpress](https://www.aliexpress.com/item/1005007587120013.html) | +| 4 | 10 W / 18 V panel, 250×340 mm | 1 | Electrical limits, dimensions, mass, weather and load suitability | [Amazon](https://a.co/d/0eJo5GCr) | +| 5 | 3P1S or 4P1S 18650 pack | 1 | Chemistry, matched cells, protection, construction, charger, temperature | [MP&W Supply](https://mpandw.ca/) | +| 6 | Battery protection board | 1 | Thresholds, current, temperature, wiring, compatibility | [Space Hedgehog](https://space-hedgehog.com/products/battery-protection-with-low-voltage-cut-off) | +| 7 | 890–960 MHz four-cavity band-pass filter, 50 W | 1 | Response at 902–928 MHz, insertion loss, connectors, weather, temperature | [Alibaba](https://www.alibaba.com/product-detail/50W-890-960MHz-4-Cavity-Filter_1601399651944.html) | +| 8 | 10–15 cm SMA right-angle to N-type female bulkhead cable | 1 | Connector gender and polarity, loss, bend, seal | [AliExpress](https://www.aliexpress.com/item/1005008569444661.html) | +| 9 | 7.5 cm SMA male right-angle cable | 1 | Connector match, loss, bend, power handling | [AliExpress](https://www.aliexpress.com/item/1005006702037541.html) | +| 10 | 0.5 ft USB-A to USB-C right-angle cable | 1 | Data and power needs, current, fit, strain | [Amazon](https://a.co/d/045htrEG) | +| 11–12 | M3×35 spacers and M3×5 screws | as needed | Material, strength, fit, corrosion | Local source | +| 13 | Gorilla Epoxy, 25 ml | 1 | Material compatibility, cure, outdoor and temperature limits | [Home Depot](https://www.homedepot.ca/product/gorilla-epoxy-syringe-25ml/1000778451) | +| 14 | Waterproof vent plug | 1 | Thread, airflow, ingress performance after install | [AliExpress](https://www.aliexpress.com/item/1005006370919409.html) | +| 15 | Clear outdoor silicone sealant | 1 | Material compatibility, cure, UV and temperature limits | Local hardware source | +| 16 | Adafruit INA3221, optional | 1 | Electrical interface, address, firmware support, isolation, calibration | [DigiKey](https://www.digikey.ca/en/products/detail/adafruit-industries-llc/6062/25660599) | + +
+ +

These parts are unverified leads. Check current specifications, availability, shipping, and total cost before buying.

+ +## Tools and downloads + +Plan for soldering and wire tools, a multimeter, suitable drills and cutting +tools, clamps, eye protection, and a safe work area. + +Printable files to verify: + +- [Mounting plate and battery holder (.3mf)](files/repeater-solar-1w-diy-build-plate-and-battery-holder.3mf) +- [Mounting plate (.stl)](files/repeater-solar-1w-diy-build-plate.stl) +- [3P1S 18650 battery holder (.stl)](files/repeater-solar-1w-diy-build-3x18650-battery-holder.stl) + +Confirm dimensions, material, temperature performance, fasteners, battery restraint, and print quality before use. + +## Assembly stages + +
+
+

Stage 1 — Review and dry-fit

+

Confirm all revisions, dimensions, electrical limits, connector paths, filter orientation, clearances, printed parts, cable bends, vent location, panel location, and service access before cutting or bonding.

+

Checkpoint: the reviewed schematic, RF path, and mechanical layout agree with the exact parts in hand.

+
+
+

Stage 2 — Prepare the enclosure and panel

+
    +
  1. Measure the panel junction box and mark the enclosure cutout from the actual parts.
  2. +
  3. Use an appropriate pilot opening and cutting tool with the enclosure empty and safely supported.
  4. +
  5. Deburr and clean the cutout, then dry-fit the panel and cable route.
  6. +
  7. Bond and clamp only with a reviewed material-compatible process. Let it cure for the full manufacturer time.
  8. +
  9. Apply the reviewed weather seal without blocking drainage or venting.
  10. +
+
Solar-panel wires routed through the enclosure cutout
Example only. Measure the actual parts.
+
+
+

Stage 3 — Install the mounting plate, filter, bulkhead, and vent

+
    +
  1. Fit the reviewed spacers and mounting plate without stressing the enclosure.
  2. +
  3. Mount the filter with identified input and output ports and adequate cable bend radius.
  4. +
  5. Drill and deburr the bulkhead and vent holes by test-fitting the actual hardware.
  6. +
  7. Install seals, bulkhead, and vent according to their documentation.
  8. +
  9. Leave RF connections unpowered and accessible for inspection.
  10. +
+
Mounting plate with spacers and RF filter
Example plate and filter layout.
+
Enclosure with filter, RF bulkhead cables, and vent plug
Check connector direction and every seal.
+
+
+

Stage 4 — Review the power-manager startup modification

+

Do not bridge the power manager's boot-button pads. That modification has not been reproduced or reviewed.

+

Before considering any change, require the exact schematic, failure modes, warranty impact, soldering method, startup test, and rollback to be approved by a hardware reviewer.

+
Unverified wire bridge across solar-manager boot-button pads
Unverified modification. Do not reproduce it from this photograph.
+
+
+

Stage 5 — Mount the radio and decide on optional telemetry

+

Dry-fit the Ikoka Stick and power manager. The optional INA3221 branch removes the display header and solders to I2C pads, so it must remain separate from the base build.

+

The telemetry pinout, measurement path, calibration, current range, I2C address, and firmware support are not verified.

+

Default: omit optional telemetry. Do not remove display pins or add firmware flags until the exact hardware and current MeshCore target are reproduced.

+
Unverified optional INA3221 connection to the Ikoka display-header area
Unverified optional telemetry. Do not reproduce it from this photograph.
+
+
+

Stage 6 — Assemble the battery and charging path

+
    +
  1. Keep solar, battery, USB, and radio disconnected.
  2. +
  3. Confirm the battery pack construction, cell matching, protection, restraint, insulation, and temperature limits.
  4. +
  5. Confirm every power-manager and protection-board terminal from current manufacturer documentation.
  6. +
  7. Measure and record polarity; do not rely on wire colour.
  8. +
  9. Connect one reviewed branch at a time and inspect before proceeding.
  10. +
+

Checkpoint: no unknown terminal, limit, expected value, exposed conductor, or unsupported battery condition remains.

+
+
+

Stage 7 — Complete the RF and USB paths

+
    +
  1. Verify filter input/output direction and every SMA/N-type connector.
  2. +
  3. Attach the complete reviewed antenna path before the radio can transmit.
  4. +
  5. Route the USB power cable without sharp bends, abrasion, or strain.
  6. +
  7. Keep the enclosure open for supervised first power and checks.
  8. +
+
Completed interior with optional INA3221 telemetry
Unverified optional-telemetry layout.
+
Completed interior without optional INA3221 telemetry
Example base layout.
+
Full enclosure interior with radio, filter, power manager, and battery
Example interior. A photograph is not electrical approval.
+
+
+ +## Check the readings + +No numeric acceptance range has been peer-reviewed for this page. Do not substitute guessed voltages or currents. + +
+ +| Check | Acceptable result | Stop condition | +|---|---|---| +| Disconnected continuity | No unintended short on the documented supply or RF paths | Unexpected continuity or uncertain meter setup | +| Connector polarity | Measurements match the reviewed schematic and manufacturer pinout | Any disagreement or unknown pin | +| Solar, battery, and output values | Each measured value is within every connected component's current documentation | Missing limit or out-of-range value | +| First power | Stable expected startup with no heat, odour, swelling, smoke, noise, or cycling | Any abnormal physical or electrical behaviour | +| RF path | Correct band, filter direction, connections, and attached antenna before transmit | Missing antenna, loose/forced connector, unknown filter response | +| Optional telemetry | Independently reproduced measurement and firmware behaviour | Unverified wiring, address, calibration, or build target | + +
+ +## Test it on the bench + +
    +
  • Record exact parts, revisions, source documents, schematic, photographs, polarity, and measured values.
  • +
  • Use supervised and current-limited first-power methods appropriate to the reviewed hardware.
  • +
  • Confirm the unit starts after each intended power source is applied and after a controlled power interruption.
  • +
  • Confirm the repeater's identity, firmware, radio, region, path, advert, and access settings survive reboot.
  • +
  • Confirm a nearby companion receives an advert and the planned local routing test succeeds.
  • +
  • Measure the RF/power behaviour using the reviewer's approved method; do not infer output from a firmware setting alone.
  • +
  • Run a supervised charge/load test that covers the reviewed conditions without exceeding component limits.
  • +
  • Inspect cable strain and seals, then complete an enclosure-appropriate water-resistance test.
  • +
  • Prove USB recovery and document the removal/access plan.
  • +
+ +Do not deploy until a hardware/electrical reviewer, RF reviewer, and site owner approve the completed test record. + +## Recovery and undo + +If a check fails, stop transmitting and disconnect solar, USB, and battery power using the reviewed safe sequence. Move the equipment to a suitable supervised work area. Do not reconnect a warm, swollen, damaged, leaking, or otherwise questionable lithium battery. Restore firmware by USB only after the electrical/RF fault is understood. + +Cutouts, epoxy, removed pins, and solder bridges may be irreversible. Replace uncertain components rather than improvising a repair. The safe rollback from the optional telemetry branch is to omit it before modification, not to assume removed hardware can be restored. + +## Maintenance + +No interval is approved. The site record must define inspections for mounting, panel/bond, water entry, vent, seals, corrosion, cables/connectors, filter, battery condition, charge behaviour, measured values, firmware/configuration, radio verification, and physical recovery. Reinspect after any severe event or unexplained behaviour defined by the site reviewer. + +## Source + +Based on experimental community notes contributed by MrAlders0n in 2026. No +hardware or firmware claim on this page is verified. + +Prefer a reviewed option from the [repeater selection guide](recommended-repeaters.md). +If this experiment is approved and commissioned, complete the +[mounting plan](repeater-mounting-options.md) and +[repeater configuration](../config/index.md). diff --git a/docs/hardware/repeater-solar-300mw-diy-build.fr.md b/docs/hardware/repeater-solar-300mw-diy-build.fr.md new file mode 100644 index 0000000..cded2e9 --- /dev/null +++ b/docs/hardware/repeater-solar-300mw-diy-build.fr.md @@ -0,0 +1,236 @@ +--- +title: Construire un répéteur solaire de 300 mW +description: Examinez et testez sur l’établi ce répéteur solaire fondé sur RAK avant de l’installer. +audience: + - repeater-builder + - hardware-reviewer +task: build-300mw-solar-repeater +scope: experimental +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: one day plus sealant cure time +destructive: false +requires: + - multimeter + - safe-work-area + - manufacturer-documentation +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- + +# Construire un répéteur solaire de 300 mW + +
+ +**Vérifiez chaque pièce avant de construire.** Confirmez les limites +électriques, la polarité des connecteurs, la cible du micrologiciel et +l’étanchéité avant de mettre l’appareil sous tension ou de l’installer. + +
+ +
+
Radio
RAK19003 / RAK4631 Type 6
+
Micrologiciel
Confirmer la cible actuelle du répéteur
+
Résistance aux intempéries
Vérifier après chaque modification du boîtier
+
Entretien
Établir un calendrier d’inspection pour le site
+
+ +## Avant de commencer + +Cette construction exige de découper un boîtier, d’assembler un parcours RF et +de brancher une pile au lithium à un système de recharge solaire. Elle crée +aussi du matériel destiné à une installation exposée et surélevée. + +!!! danger "Arrêtez jusqu’à ce que le système complet ait été examiné" + Vérifiez dans la documentation des fabricants la chimie exacte de la pile, sa protection, les limites du chargeur, la polarité des connecteurs, les caractéristiques des fils, l’entrée solaire, le boîtier, l’évent, le parcours de l’antenne et l’étanchéité. Faites appel à une personne qualifiée pour tout détail électrique ou structural qui dépasse vos compétences. + +
    +
  • Chaque pièce et révision du matériel correspond à une source datée.
  • +
  • La carte radio apparaît pour le rôle de répéteur dans la version actuelle du programme officiel de mise à jour MeshCore.
  • +
  • L’antenne est destinée à la bande canadienne de 902–928 MHz et tout son parcours de connecteurs est confirmé.
  • +
  • Le parcours de la pile et de l’alimentation solaire possède les protections documentées et des limites compatibles.
  • +
  • Vous avez un multimètre, une protection oculaire, des outils de perçage et de coupe appropriés ainsi qu’un espace de travail sécuritaire.
  • +
  • Vous avez un câble de récupération USB et garderez l’appareil sur l’établi jusqu’à ce qu’il réussisse les vérifications.
  • +
+ +## Ce que cette construction change + +La procédure perce des ouvertures dans le boîtier, installe un connecteur +d’antenne externe et un évent, branche la radio et l’antenne, ajoute une +protection de pile dans le parcours d’alimentation et rend le boîtier étanche. +Ces changements peuvent être difficiles à annuler sans remplacer des pièces. + +## Liste du matériel + +
+ +| Partie du système | Exemple | Qté | Ce qu’il faut vérifier | Source | +|---|---|---:|---|---| +| Boîtier | WisMesh Unify Enclosure 910422 | 1 | Révision, limites du panneau et du chargeur, joints, aération, quincaillerie incluse | [AliExpress](https://aliexpress.com/item/1005008369061766.html) | +| Carte LoRa | RAK19003 / RAK4631, Type 6 | 1 | Carte exacte, bande canadienne, cible du micrologiciel, connecteurs | [AliExpress](https://aliexpress.com/item/1005008285698839.html) | +| Antenne | Alfa AOA-915-5ACM | 1 | Bande, connecteur, montage, exposition aux intempéries | [Amazon Canada](https://www.amazon.ca/dp/B08H8J6ZV6) | +| Queue de cochon d’antenne | N femelle vers IPEX | 1 | Génération IPEX, genre et polarité des connecteurs, longueur, perte | [AliExpress](https://aliexpress.com/item/1005001920963497.html) | +| Option de pile | Bloc Li-ion MakerFocus de 3000 mAh | 1 | Chimie, dimensions, connecteur, polarité, protection, limites du chargeur | [MakerFocus](https://www.makerfocus.com/products/makerfocus-3-7v-3000mah-lithium-rechargeable-battery-1s-3c-lipo-battery-pack-of-4) | +| Option de pile | Bloc Li-ion de 3000 mAh | 1 | Les mêmes vérifications, en plus des restrictions d’expédition au Canada | [Amazon US](https://www.amazon.com/3000mAh-Rechargable-Protection-Insulated-Development/dp/B08T6GT7DV) | +| Option de pile | Pile Space Hedgehog de 3000 mAh | 1 | Les mêmes vérifications, en plus de la disponibilité et de la révision actuelles | [Space Hedgehog](https://space-hedgehog.com/products/3000mah-battery) | +| Protection de pile | PCM Li-ion | 1 | Si elle est nécessaire et compatible; limites de courant, de tension, de température et de câblage | [Space Hedgehog](https://space-hedgehog.com/products/battery-protection-with-low-voltage-cut-off?variant=51646910660664) | +| Évent | Bouchon d’évent étanche M12×1.5 | 1 | Filetage, joint, circulation d’air, compatibilité du boîtier | [AliExpress](https://aliexpress.com/item/1005006370919409.html) | + +
+ +

Ces pièces sont à étudier et ne constituent pas des recommandations vérifiées. Vérifiez les caractéristiques, la disponibilité, l’expédition et le coût total actuels avant d’acheter.

+ +## Outils et fournitures + +- un multimètre et la documentation du fabricant de chaque composant électrique; +- une protection oculaire et des outils de perçage et de coupe appropriés; +- un foret étagé dont la taille est déterminée en ajustant réellement la traversée et l’évent; +- des outils pour les fils et une isolation ou une gaine thermorétractable adaptées aux pièces choisies; +- du ruban de silicone autofusionnant ou une autre méthode examinée pour rendre les connecteurs extérieurs étanches; +- un scellant extérieur compatible avec les matériaux du boîtier; +- un câble USB de données fiable pour la programmation et la récupération. + +## Étapes d’assemblage + +
+
+

Étape 1 — Inspecter avant de modifier

+

Confirmez chaque numéro de pièce et chaque révision. Inspectez les joints et la quincaillerie du boîtier. Repérez le haut du boîtier, l’emplacement de l’antenne, celui de l’évent, les dégagements des câbles et l’accès pour l’entretien avant de percer.

+

Point de contrôle : arrêtez si un connecteur, une polarité, une protection, une limite de chargeur ou un détail du boîtier est incertain.

+
+
+

Étape 2 — Préparer le boîtier et le parcours RF

+
    +
  1. Fixez la plaque arrière RAK avec la quincaillerie fournie.
  2. +
  3. Marquez des ouvertures distinctes pour la traversée de type N et l’évent. Percez lentement avec une protection appropriée, ébavurez les trous et vérifiez l’ajustement après chaque étape.
  4. +
  5. Installez la traversée et l’évent avec les joints et l’orientation examinés.
  6. +
  7. Toute alimentation étant débranchée, raccordez la queue de cochon du côté radio au bon connecteur LoRa.
  8. +
  9. Installez l’antenne Bluetooth indiquée dans la documentation de la carte exacte.
  10. +
  11. Branchez l’antenne LoRa externe avant toute mise sous tension ou tout essai de transmission.
  12. +
+

Point de contrôle : le parcours RF est solidement fixé, sans contrainte, correctement raccordé et branché avant la mise sous tension.

+
+
+

Étape 3 — Programmer et vérifier l’ajustement à sec

+
    +
  1. Gardez l’appareil ouvert et accessible.
  2. +
  3. Suivez le guide de programmation sécuritaire d’un répéteur pour la carte exacte.
  4. +
  5. Fixez la radio sur la plaque arrière et confirmez que les câbles ne sont ni pincés ni pliés brusquement.
  6. +
+
+ Carte RAK et câbles d’antenne ajustés à sec sur la plaque arrière du boîtier +
Exemple d’ajustement à sec. Vérifiez en main la carte exacte et le passage des câbles.
+
+
+
+

Étape 4 — Vérifier et brancher le parcours d’alimentation

+
    +
  1. Toute alimentation étant débranchée, repérez les bornes de la pile, du chargeur et de toute carte de protection à partir de leur documentation actuelle.
  2. +
  3. Vérifiez la polarité des deux câbles JST avec un multimètre; ne vous fiez pas seulement à la couleur des fils.
  4. +
  5. Branchez le côté chargeur et le côté pile uniquement selon le schéma examiné de l’appareil de protection exact.
  6. +
  7. Isolez et fixez mécaniquement la carte de protection et la pile sans les écraser, les chauffer, les percer ni les coincer contre des arêtes vives.
  8. +
+
+ Disposition des connecteurs RAK19003 utilisée comme repère visuel +
Orientation seulement. Suivez la documentation de votre carte exacte.
+
+
+ Exemple de disposition des raccordements d’une carte de protection +
Concept seulement. Ne déduisez pas les étiquettes des bornes ni les limites d’une autre carte de protection.
+
+
+ Disposition du boîtier solaire montrant le câblage de la pile et de la protection +
Confirmez la polarité et chaque limite électrique avant de reproduire le parcours.
+
+
+
+

Étape 5 — Rendre étanche seulement après la vérification sur l’établi

+
    +
  1. Posez le joint du boîtier sans le tordre ni le pincer.
  2. +
  3. Branchez le fil solaire du boîtier seulement après avoir confirmé sa polarité et ses limites.
  4. +
  5. Fermez le boîtier uniformément avec la quincaillerie, le couple et la méthode indiqués dans sa documentation.
  6. +
  7. Appliquez la méthode examinée d’étanchéité extérieure sur le raccord d’antenne exposé sans bloquer le drainage ni l’aération.
  8. +
+
+ Intérieur terminé d’un répéteur RAK Unify avant la fermeture du boîtier +
Exemple d’intérieur. Les révisions des pièces et leur disposition peuvent différer.
+
+
+ Ruban autofusionnant autour d’un connecteur d’antenne extérieur +
Un exemple d’étanchéité proposé par la communauté, et non une méthode universelle d’installation.
+
+
+ Gaine thermorétractable autour d’un raccord d’antenne extérieur +
Confirmez que la méthode choisie est compatible et inspectable, et qu’elle n’emprisonne pas l’eau.
+
+
+
+ +## Vérifier les mesures + +Aucune plage numérique d’acceptation électrique n’a été examinée par des pairs +pour cette page. Utilisez les limites actuelles du fabricant pour la pile, le +chargeur, l’appareil de protection, la carte radio et le panneau exacts. + +
+ +| Vérification | Résultat acceptable | Condition d’arrêt | +|---|---|---| +| Continuité, alimentation débranchée | Aucun court-circuit involontaire dans le parcours d’alimentation documenté | Continuité inattendue, mesure instable ou configuration incertaine du multimètre | +| Polarité | Chaque connecteur correspond à la polarité documentée avant d’être raccordé | La couleur du fil contredit la polarité mesurée ou documentée | +| Entrée de pile et solaire | Les valeurs mesurées respectent toutes les limites publiées des composants | Une valeur dépasse une limite ou la limite est inconnue | +| Première mise sous tension | Aucune chaleur, odeur, enflure, fumée, aucun bruit ni redémarrage instable | Tout comportement physique ou électrique anormal | +| Courant et fonctionnement de la radio | Le comportement correspond à la documentation examinée de la carte et du rôle | Le courant consommé ou le comportement ne peut pas être expliqué par des données probantes | + +
+ +## Le tester sur l’établi + +
    +
  • Photographiez et consignez les pièces exactes, les révisions, le câblage, les vérifications de polarité et les valeurs mesurées.
  • +
  • Lorsque le matériel examiné le permet, utilisez sous supervision une alimentation d’établi à courant limité.
  • +
  • Confirmez que le répéteur démarre, se reconnecte et conserve son micrologiciel, son identité ainsi que ses réglages de radio, de région, d’annonce et d’accès après un redémarrage.
  • +
  • Confirmez qu’un compagnon à proximité reçoit une annonce et que l’essai d’acheminement local prévu réussit.
  • +
  • Faites fonctionner le parcours solaire et de recharge prévu sous surveillance, dans les conditions permises par la documentation des composants.
  • +
  • Inspectez les joints et la tension des câbles avant et après un essai contrôlé de résistance à l’eau adapté à la documentation du boîtier.
  • +
  • Gardez la récupération USB pratique et consignez sa procédure.
  • +
+ +Ne montez pas l’appareil avant qu’il ait réussi toutes les vérifications et +qu’une personne responsable de l’examen du matériel ait approuvé le dossier du +site. + +## Récupération et retour en arrière + +Si une vérification échoue, débranchez l’alimentation solaire, USB et de la pile +selon la séquence sécuritaire documentée. Déplacez l’appareil vers un espace de +travail surveillé et incombustible, vérifiez s’il y a de la chaleur ou des +dommages à la pile et ne rebranchez pas une pile au lithium douteuse. Restaurez +la dernière configuration fiable de la carte par USB seulement après avoir +corrigé le problème électrique. + +Les trous mécaniques et le travail d’adhésif ou de scellant peuvent être +irréversibles. Remplacez le boîtier et les pièces d’alimentation endommagés ou +douteux plutôt que d’improviser une réparation. + +## Entretien + +Aucun intervalle pancanadien n’est approuvé. Avant le déploiement, consignez un +calendrier d’inspection propre au site qui couvre les mouvements du montage, +les infiltrations d’eau, les joints, la corrosion, la tension des câbles, +l’état de la pile, le comportement de recharge, les valeurs mesurées, le +micrologiciel et la configuration, la vérification radio ainsi que la +récupération physique. + +## Source + +Fondé sur des notes de construction fournies par MrAlders0n en 2026. Les pièces +et les instructions doivent encore faire l’objet d’un examen du matériel. + +Après l’essai sur l’établi, terminez le +[plan de montage](repeater-mounting-options.md) et réglez la région du répéteur +au moyen du [configurateur](../config/index.md). diff --git a/docs/hardware/repeater-solar-300mw-diy-build.md b/docs/hardware/repeater-solar-300mw-diy-build.md index c0447bc..266931f 100644 --- a/docs/hardware/repeater-solar-300mw-diy-build.md +++ b/docs/hardware/repeater-solar-300mw-diy-build.md @@ -1,102 +1,213 @@ -# Building a Solar Node – Rak Unify Box - -Authored By: MrAlders0n (Ottawa) -Date: 2026-01-01 - -!!! warning "No warranty" - This is to help the community understand how to make a repeater, and I by no means provide any warranty for anyone following this guide. Please test everything first with a multimeter and other tools before powering anything on. - -This guide walks through assembling a solar-powered MeshCore repeater using the **RAK Unify Box** enclosure. -Follow each step carefully for a reliable and weatherproof build. - -## Parts List - -| Item | Product Name | Cost (CAD) | Link | -|----------------------|---------------------------------------|------------|------| -| **Enclosure** | WisMesh Unify Enclosure 910422 | $72.50 | [AliExpress](https://aliexpress.com/item/1005008369061766.html) | -| **LoRa Board (Small)** | RAK WisBlock RAK19003/RAK4631 (Type 6) | $36.38 | [AliExpress](https://aliexpress.com/item/1005008285698839.html) | -| **Antenna** | ALFA AOA-915-5ACM | $34.99 | [Amazon](https://www.amazon.ca/dp/B08H8J6ZV6) | -| **Antenna Coax Cable** | N Female to IPX | $6.79 | [AliExpress](https://aliexpress.com/item/1005001920963497.html) | -| **Battery - Option 1** | 3000mAh Li-ion - Makerfocus (Pack of 4) - Ships from China | $34.10 | [Makerfocus](https://www.makerfocus.com/products/makerfocus-3-7v-3000mah-lithium-rechargeable-battery-1s-3c-lipo-battery-pack-of-4) | -| **Battery - Option 2** | 3000mAh Li-ion - Amazon US store (Pack of 4) | $40.92 | [Amazon](https://www.amazon.com/3000mAh-Rechargable-Protection-Insulated-Development/dp/B08T6GT7DV) | -| **Battery - Option 3** | 3000mAh Li-ion - Sold at Local store (Space Hedgehog) | $11.00 | [Space Hedgehog](https://space-hedgehog.com/products/3000mah-battery) | -| **Battery Protection ^** | Space Hedgehog (Local Store) Li-ion PCM | $6.00 | [Space Hedgehog](https://space-hedgehog.com/products/battery-protection-with-low-voltage-cut-off?variant=51646910660664) | -| **Vent** | Waterproof Vent Plug (M12X1.5-10) | $6.12 | [AliExpress](https://aliexpress.com/item/1005006370919409.html) | - -^ If you're using the Makerfocus flat battery: This already includes a PCM, the extra PCM is an added safety measure. If you're using unprotected batteries (e.g., 18650 button top), then you will need to purchase a PCM. - -**Approximate total cost: $180 CAD** - +--- +title: Build a 300 mW solar repeater +description: Review and bench-test this RAK-based solar repeater before installing it. +audience: + - repeater-builder + - hardware-reviewer +task: build-300mw-solar-repeater +scope: experimental +status: draft +status_notice: false +owner: docs-hardware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: one day plus sealant cure time +destructive: false +requires: + - multimeter + - safe-work-area + - manufacturer-documentation +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 --- -## Assembly Steps - -**WARNING: Always ensure a LoRa antenna and the Bluetooth antenna are attached to the RAK board before powering it on. Powering without antennas can permanently damage the board.** - -1. Unbox all components and place them aside. - ```Tip: Try not to misplace the small screws and fittings — they’re easy to lose.``` - -2. Mount the RAK backplate into the box with the four provided screws. - -3. Drill two holes: one for the N-type antenna mount and one for the drain plug. - * Use a step drill bit for clean holes. - * **Finding the top of the box:** - * Flip the box onto its back. - * Locate the mount hole marked "1" — this is the top. - * Drill slowly, one step at a time. Test-fit the N-type connector after each step until it fits snugly. - * Repeat the same process on the bottom of the box for the drain plug. - -4. Attach both the N-type antenna mount and the drain plug. - -5. Connect the N-type antenna to the LoRa IPEX connector on the RAK19003 board. - -6. Mount the Bluetooth antenna to the side of the box using the included double-sided tape. - -7. Connect the Bluetooth IPEX to the RAK19003 board. - -8. Connect an antenna to the N-type connector, then flash and configure the RAK unit following Configuring a Repeater. - -9. Mount the RAK unit onto the backplate, see picture below for what it should look like at this step. - - ![](images/BuildRepeater1_MountedAll.jpeg){ width="300" } - -10. Connect the JST PHR-2 cable to the RAK19003 battery plug, **ensuring correct polarity** (many JST cables are wired incorrectly). - - ![](images/RAK19003-Layout.png){ width="300" } - -11. Connect the other end of this cable to the **CHG** side of the Li-ion PCM. The following picture of the PCM is from VoltaicEnclosures which is what we used initially, but it is the same principle for our current recommended PCM: - - ![](images/VoltaicEnclosures_Layout.png){ width="300" } - -12. Slide a piece of heat-shrink tubing over the cable large enough to cover the PCM before connecting the battery. - -13. Connect the LiPo JST PHR-2 cable to the **BATT** side of the PCM, again **ensuring polarity is correct**. - - ![](images/RAK19003-LayoutSolar.jpg){ width="300" } - -14. Heat-shrink the Li-ion PCM so the entire board is covered. - -15. (Optional) Secure the PCM to the backplate using double-sided tape. It can also float freely inside the box if preferred. - -16. (Optional) Secure the battery to the backplate with double-sided tape or mounting hardware. It should look like the below image. - - ![](images/BuildRepeater1_Finished.jpeg){ width="300" } - -17. Fit the rubber seal into the groove around the edge of the front plate of the box. - -18. Connect the solar panel wire from the front plate to the RAK19003. - - ```Be careful not to let the seal fall out of place while connecting the solar panel wire.``` - -19. Join the front and back plates together and fasten them with the six screws. - - ```Tighten securely to maintain the weatherproof seal.``` - -20. Wrap the entire N-type connector and the exposed metal part of the Alfa antenna with self-adhering silicone tape, or use two layers of heat-shrink tubing for protection. - -21. Apply a bead of clear outdoor silicone caulk around the base of the N-type connector to prevent water from leaking into the box. - - ![](images/BuildRepeater1_SelfFuseTape.jpeg){ width="300" } - ![](images/BuildRepeater1_Heatshrink.jpg){ width="300" } - -22. (Optional) Add a bead of silicone caulk along the top edge of the box seal (between the two plates) and around the base of the antenna as extra waterproofing protection. +# Build a 300 mW solar repeater + +
+ +**Check every part before building.** Confirm the electrical limits, connector +polarity, firmware target, and weatherproofing before applying power or installing it. + +
+ +
+
Radio
RAK19003 / RAK4631 Type 6
+
Firmware
Confirm the current repeater target
+
Weather rating
Verify after every enclosure modification
+
Maintenance
Set an inspection schedule for the site
+
+ +## Before you start + +This build cuts an enclosure, assembles an RF path, and connects a lithium battery to a solar charging system. It also creates equipment intended for an exposed, elevated installation. + +!!! danger "Stop until the complete system is reviewed" + Verify the exact battery chemistry, protection, charger limits, connector polarity, wire ratings, solar input, enclosure, vent, antenna path, and weatherproofing in manufacturer documentation. Use a qualified person for electrical or structural details outside your competence. + +
    +
  • Every part and hardware revision matches a dated source.
  • +
  • The radio board appears in the current official MeshCore flasher for the repeater role.
  • +
  • The antenna is for the Canadian 902–928 MHz band and its complete connector path is confirmed.
  • +
  • The battery and solar power path have documented protection and compatible limits.
  • +
  • You have a multimeter, eye protection, suitable drilling/cutting tools, and a safe work area.
  • +
  • You have a USB recovery cable and will keep the unit on the bench until its checks pass.
  • +
+ +## What this build changes + +The procedure drills enclosure openings, installs an external antenna connector and vent, connects the radio and antenna, adds battery protection in the power path, and seals the enclosure. These changes can be difficult to undo without replacing parts. + +## Bill of materials + +
+ +| System part | Example | Qty | What to verify | Source | +|---|---|---:|---|---| +| Enclosure | WisMesh Unify Enclosure 910422 | 1 | Revision, panel and charger limits, seals, venting, included hardware | [AliExpress](https://aliexpress.com/item/1005008369061766.html) | +| LoRa board | RAK19003 / RAK4631, Type 6 | 1 | Exact board, Canadian band, firmware target, connectors | [AliExpress](https://aliexpress.com/item/1005008285698839.html) | +| Antenna | Alfa AOA-915-5ACM | 1 | Band, connector, mount, weather exposure | [Amazon Canada](https://www.amazon.ca/dp/B08H8J6ZV6) | +| Antenna pigtail | N female to IPEX | 1 | IPEX generation, connector gender and polarity, length, loss | [AliExpress](https://aliexpress.com/item/1005001920963497.html) | +| Battery option | MakerFocus 3000 mAh Li-ion pack | 1 | Chemistry, dimensions, connector, polarity, protection, charger limits | [MakerFocus](https://www.makerfocus.com/products/makerfocus-3-7v-3000mah-lithium-rechargeable-battery-1s-3c-lipo-battery-pack-of-4) | +| Battery option | 3000 mAh Li-ion pack | 1 | The same checks, plus Canadian shipping restrictions | [Amazon US](https://www.amazon.com/3000mAh-Rechargable-Protection-Insulated-Development/dp/B08T6GT7DV) | +| Battery option | Space Hedgehog 3000 mAh battery | 1 | The same checks, plus current stock and revision | [Space Hedgehog](https://space-hedgehog.com/products/3000mah-battery) | +| Battery protection | Li-ion PCM | 1 | Whether it is required and compatible; current, voltage, temperature, and wiring limits | [Space Hedgehog](https://space-hedgehog.com/products/battery-protection-with-low-voltage-cut-off?variant=51646910660664) | +| Vent | M12×1.5 waterproof vent plug | 1 | Thread, seal, airflow, enclosure compatibility | [AliExpress](https://aliexpress.com/item/1005006370919409.html) | + +
+ +

These are parts to investigate, not verified recommendations. Check current specifications, stock, shipping, and total cost before buying.

+ +## Tools and consumables + +- multimeter and manufacturer documentation for each electrical component; +- eye protection and suitable drilling/cutting tools; +- step drill bit sized by test-fitting the actual bulkhead and vent; +- wire tools and insulation/heat-shrink appropriate to the selected parts; +- self-fusing silicone tape or another reviewed outdoor connector-sealing method; +- outdoor sealant compatible with the enclosure materials; and +- known-good data USB cable for flashing and recovery. + +## Assembly stages + +
+
+

Stage 1 — Inspect before modifying

+

Confirm every part number and revision. Inspect the enclosure seals and hardware. Identify the enclosure top, antenna location, vent location, cable clearances, and service access before drilling.

+

Checkpoint: stop if any connector, polarity, protection, charger limit, or enclosure detail is uncertain.

+
+
+

Stage 2 — Prepare the enclosure and RF path

+
    +
  1. Mount the RAK backplate with the supplied hardware.
  2. +
  3. Mark separate openings for the N-type bulkhead and vent. Drill slowly with suitable protection, deburr the holes, and test-fit after each step.
  4. +
  5. Install the bulkhead and vent using the reviewed seals and orientation.
  6. +
  7. With all power disconnected, attach the radio-side pigtail to the correct LoRa connector.
  8. +
  9. Install the Bluetooth antenna described by the exact board documentation.
  10. +
  11. Attach the external LoRa antenna before any power or transmit test.
  12. +
+

Checkpoint: the RF path is mechanically secure, unforced, correctly mated, and attached before power.

+
+
+

Stage 3 — Flash and dry-fit

+
    +
  1. Keep the unit open and accessible.
  2. +
  3. Follow the safe repeater flashing guide for the exact board.
  4. +
  5. Mount the radio on the backplate and confirm that cables are not pinched or sharply bent.
  6. +
+
+ RAK board and antenna cables dry-fitted on the enclosure backplate +
Example dry fit. Check the exact board and cable routing in hand.
+
+
+
+

Stage 4 — Verify and connect the power path

+
    +
  1. With power disconnected, identify the battery, charger, and any protection-board terminals from their current manufacturer documentation.
  2. +
  3. Verify both JST cable polarities with a multimeter; do not rely on wire colour alone.
  4. +
  5. Connect the charger side and battery side only according to the reviewed diagram for the exact protection device.
  6. +
  7. Insulate and mechanically secure the protection board and battery without crushing, heating, puncturing, or trapping them against sharp edges.
  8. +
+
+ RAK19003 connector layout used as a visual orientation note +
Orientation only. Follow the documentation for your exact board.
+
+
+ Example protection-board connection layout +
Concept only. Do not infer terminal labels or limits for another protection board.
+
+
+ Solar enclosure layout showing battery and protection wiring +
Confirm polarity and every electrical limit before reproducing the path.
+
+
+
+

Stage 5 — Seal only after bench verification

+
    +
  1. Fit the enclosure gasket without twisting or pinching it.
  2. +
  3. Connect the enclosure solar lead only after its polarity and limits are confirmed.
  4. +
  5. Close the enclosure evenly with the specified hardware and torque/process from its documentation.
  6. +
  7. Apply the reviewed outdoor weather-sealing method to the exposed antenna connection without blocking drainage or venting.
  8. +
+
+ Completed RAK Unify repeater interior before the enclosure is closed +
Example interior. Part revisions and routing may differ.
+
+
+ Self-fusing tape around an outdoor antenna connector +
One community weather-sealing example, not a universal installation method.
+
+
+ Heat-shrink around an outdoor antenna connection +
Confirm that the chosen method is compatible, inspectable, and does not trap water.
+
+
+
+ +## Check the readings + +No numeric electrical acceptance range has been peer-reviewed for this page. Use the current manufacturer limits for the exact battery, charger, protection device, radio board, and panel. + +
+ +| Check | Acceptable result | Stop condition | +|---|---|---| +| Power disconnected continuity | No unintended short across the documented supply path | Unexpected continuity, unstable reading, or uncertain meter setup | +| Polarity | Every connector matches its documented polarity before mating | Wire colour and measured/documented polarity disagree | +| Battery and solar input | Measured values remain within every component's published limits | Any value is outside a limit or the limit is unknown | +| First power | No heat, odour, swelling, smoke, noise, or unstable restart | Any abnormal physical or electrical behaviour | +| Radio current/operation | Behaviour is consistent with the reviewed board and role documentation | Current draw or behaviour cannot be explained from evidence | + +
+ +## Test it on the bench + +
    +
  • Photograph and record the exact parts, revisions, wiring, polarity checks, and measured values.
  • +
  • Power from a supervised, current-limited bench source where appropriate for the reviewed hardware.
  • +
  • Confirm the repeater boots, reconnects, and retains firmware, identity, radio, region, advert, and access settings after reboot.
  • +
  • Confirm a nearby companion receives an advert and the planned local routing test succeeds.
  • +
  • Exercise the intended solar/charge path under supervised conditions supported by the component documentation.
  • +
  • Inspect seals and cable strain before and after a controlled water-resistance check appropriate to the enclosure documentation.
  • +
  • Keep USB recovery practical and record the recovery procedure.
  • +
+ +Do not mount the build until all checks pass and a hardware reviewer approves the site record. + +## Recovery and undo + +If a check fails, disconnect solar, USB, and battery power using the documented safe sequence. Move the unit to a non-combustible supervised work area, inspect for heat or battery damage, and do not reconnect a questionable lithium battery. Restore the last known-good board configuration by USB only after the electrical fault is resolved. + +Mechanical holes and adhesive/sealant work may not be reversible; replace damaged or uncertain enclosure and power parts rather than improvising a repair. + +## Maintenance + +No Canada-wide interval is approved. Before deployment, record a site-specific inspection schedule covering mount movement, water entry, seals, corrosion, cable strain, battery condition, charging behaviour, measured values, firmware/configuration, radio verification, and physical recovery. + +## Source + +Based on community build notes contributed by MrAlders0n in 2026. The parts and +instructions still require hardware review. + +After the bench test, complete the [mounting plan](repeater-mounting-options.md) +and set the repeater's region with the [configurator](../config/index.md). diff --git a/docs/hardware/repeater-solar-batteries.md b/docs/hardware/repeater-solar-batteries.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/hardware/wire-connector-types.md b/docs/hardware/wire-connector-types.md deleted file mode 100644 index 87963fe..0000000 --- a/docs/hardware/wire-connector-types.md +++ /dev/null @@ -1,11 +0,0 @@ -# Wire Connector Types - -*Page in progress* - -Here are some wire connectors used across our repeater builds: - -| Connector Name | Size | Devices that use it | -|----------------|------|----------------------| -| Molex PicoBlade 1x02P | 1.25mm | Ikoka Stick | -| JST ZHR-2 | 1.5mm | RAK19007 Solar Connector | -| JST PHR-2 | 2.0mm | RAK19007 Battery Connector | \ No newline at end of file diff --git a/docs/index.fr.md b/docs/index.fr.md new file mode 100644 index 0000000..19db301 --- /dev/null +++ b/docs/index.fr.md @@ -0,0 +1,132 @@ +--- +title: MeshCore Canada +description: Rejoignez, bâtissez, exploitez et coordonnez un réseau MeshCore local au Canada. +audience: + - newcomer + - meshcore-operator +task: choose-a-goal +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: origin-main-cbbe9c0-pr66 +difficulty: beginner +estimated_time: 1-2 minutes +destructive: false +hide: + - toc +--- + +# MeshCore Canada + +Bienvenue! Nous améliorons activement ce site. Vous avez trouvé quelque chose +de difficile à comprendre ou de désuet? +[Signalez-le sur GitHub](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new/choose). + +## Que cherchez-vous? { #start-with-your-goal } + +
+ +- :material-message-text:{ .lg .middle } **Vous découvrez MeshCore?** + + --- + + Configurez votre radio LoRa et rejoignez un réseau MeshCore au Canada. + + [:octicons-arrow-right-24: Commencer la configuration guidée](start/index.md) + +- :material-map-marker-radius:{ .lg .middle } **Trouver des gens près de chez vous** + + --- + + Trouvez des communautés, des contacts et les paramètres radio de votre région. + + [:octicons-arrow-right-24: Trouver une communauté](provinces/index.md) + +
+ +## Quel type d’appareil configurez-vous? { #choose-a-role } + +
+ +- :material-cellphone-link:{ .lg .middle } **Appareil compagnon** + + Envoyez et recevez des messages. + + [:octicons-arrow-right-24: Configurer un compagnon](start/companion.md) + +- :material-radio-tower:{ .lg .middle } **Répéteur** + + Améliorez la couverture locale. + + [:octicons-arrow-right-24: Configurer un répéteur](start/repeater.md) + +- :material-forum:{ .lg .middle } **Serveur de salon** + + Gardez un salon partagé accessible. + + [:octicons-arrow-right-24: Configurer un serveur de salon](start/room-server.md) + +- :material-chart-timeline-variant:{ .lg .middle } **Observateur** + + Transmettez des données du réseau à CoreScope. + + [:octicons-arrow-right-24: Configurer un observateur](start/observer.md){ .mc-observer-link } + +
+ +Vous hésitez? [Comparez les types d’appareils](start/choose-a-goal.md). + +Besoin de parler à quelqu’un? Rejoignez le +[Discord national](https://discord.gg/BESFVMt7yk), posez votre question sur le +[forum communautaire](https://forum.meshcore.ca/) ou consultez le +[réseau canadien en direct](https://live.meshcore.ca/). + +## Paramètres radio par défaut au Canada { #canada-baseline } + +Utilisez ces paramètres, sauf si votre communauté en indique d’autres. + +| Paramètre | Valeur par défaut au Canada | +|---|---| +| Préréglage radio | **USA/Canada (Recommended)** | +| Valeurs radio détaillées | `910.525 MHz / 62.5 kHz / SF7 / CR5` | +| Hachage des chemins | **3 octets** | +| Commande correspondante | `set path.hash.mode 2` | + +## Trouver votre région + +Recherchez une ville, un code d’aéroport ou une région. + + + +Vous pouvez aussi consulter la [carte des régions](config/map.md). + +Besoin d’aide dans votre région? [Parcourez les communautés](provinces/index.md), +posez votre question sur le [forum MeshCore Canada](https://forum.meshcore.ca/) +ou rejoignez [Discord](https://discord.gg/BESFVMt7yk). + +## Utiliser les outils réseau + +Configurez un répéteur, explorez les régions canadiennes, consultez CoreScope +ou mettez en service un observateur. + +[:octicons-arrow-right-24: **Choisir un outil réseau**](tools/index.md){ .md-button } + +## Améliorer MeshCore Canada + +Quelque chose manque ou porte à confusion? +[Partagez une idée](submit-idea.md) ou +[mettez à jour une communauté](contributing.md). + +## À propos du projet + +MeshCore Canada est un projet communautaire indépendant. +[Apprenez-en plus](about.md) ou [contribuez au projet](contributing.md). diff --git a/docs/index.md b/docs/index.md index 767133e..1e9f3be 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,98 +1,129 @@ --- +title: MeshCore Canada +description: Join, build, operate, and coordinate a local MeshCore network in Canada. +audience: + - newcomer + - meshcore-operator +task: choose-a-goal +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: origin-main-cbbe9c0-pr66 +difficulty: beginner +estimated_time: 1-2 minutes +destructive: false hide: - toc --- -# MeshCore Canada -!!! warning "Community Project" - **meshcore.ca** is an independent community site. We're not affiliated with, endorsed by, or officially connected to the MeshCore or MeshOS projects. We're just a group of Canadians helping other Canadians organize their local meshes and build useful tools for the community. +# MeshCore Canada -Canada's community hub for MeshCore, a long-range, low-power mesh protocol built on LoRa radios. +Welcome! We're improving this site. Found something unclear or outdated? +[Open a GitHub issue](https://github.com/MeshCore-ca/MeshCore-Canada/issues/new/choose). -Whether you're brand new to mesh networking or looking to deploy repeaters across your region, you'll find guides, hardware recommendations, and your local community here. +## What are you looking for? { #start-with-your-goal }
-- :fontawesome-brands-discord:{ .lg .middle } **Discord** +- :material-message-text:{ .lg .middle } **New to MeshCore** --- - Join the MeshCore Canada Discord to chat with the community, ask questions, and stay up to date. + Set up your LoRa radio and join a Canadian mesh. - [:octicons-arrow-right-24: Join Discord](https://discord.gg/BESFVMt7yk){ target="_blank" rel="noopener" } + [:octicons-arrow-right-24: Start the guided setup](start/index.md) -- :fontawesome-solid-comments:{ .lg .middle } **Forum** +- :material-map-marker-radius:{ .lg .middle } **Find people near you** --- - Browse discussions, share builds, and find answers on the MeshCore Canada community forum. + Find nearby communities, contacts, and local radio settings. - [:octicons-arrow-right-24: Visit Forum](https://forum.meshcore.ca){ target="_blank" rel="noopener" } + [:octicons-arrow-right-24: Find a community](provinces/index.md)
---- - -## Explore the Site +## What kind of device are you setting up? { #choose-a-role }
-- :material-access-point-network:{ .lg .middle } **MeshCore** +- :material-cellphone-link:{ .lg .middle } **Personal companion** - --- + Send and receive messages. - Learn what MeshCore is, browse the FAQ, and find recommended hardware for companions, repeaters, and antennas. + [:octicons-arrow-right-24: Set up a companion](start/companion.md) - [:octicons-arrow-right-24: Introduction](meshcore/general-overview.md) +- :material-radio-tower:{ .lg .middle } **Repeater** -- :material-chart-timeline-variant:{ .lg .middle } **Analyzer & MQTT** + Improve local coverage. - --- + [:octicons-arrow-right-24: Set up a repeater](start/repeater.md) - Set up packet analysis and MQTT bridging with guides for MCtoMQTT, MeshCore-HA, MQTT Firmware, and PyMC. +- :material-forum:{ .lg .middle } **Room server** - [:octicons-arrow-right-24: Overview](analyzer/intro.md) + Host a persistent room. -- :material-map-search:{ .lg .middle } **Canadian Regions** + [:octicons-arrow-right-24: Set up a room server](start/room-server.md) - --- +- :material-chart-timeline-variant:{ .lg .middle } **Observer** - Find the right region for a repeater, follow the setup guide, or explore the map. + Send network data to CoreScope. - [:octicons-arrow-right-24: Open Config](config/index.md) + [:octicons-arrow-right-24: Set up an observer](start/observer.md){ .mc-observer-link } -- :material-map-marker-radius:{ .lg .middle } **Mesh Directory** +
- --- +Not sure which one fits? [Compare the roles](start/choose-a-goal.md). - Find a MeshCore community near you. Browse by province to discover local networks, frequencies, and contacts. +Need help from a person? Join the [national Discord](https://discord.gg/BESFVMt7yk), +ask on the [community forum](https://forum.meshcore.ca/), or check the +[live Canadian network](https://live.meshcore.ca/). - [:octicons-arrow-right-24: Find Your Province](provinces/index.md) +## Canada Default Radio Settings { #canada-baseline } -- :material-bookshelf:{ .lg .middle } **Resources** +Use these defaults unless your local community lists different settings. - --- +| Setting | Canada default | +|---|---| +| Radio preset | **USA/Canada (Recommended)** | +| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | +| Path setting | **3-byte** | +| Command-line path setting | `set path.hash.mode 2` | - New here? Start with the Getting Started guide, browse useful links, or check the glossary. +## Find your region - [:octicons-arrow-right-24: Getting Started](resources/getting-started.md) +Search by city, airport code, or region. - + ---- +Or browse the [region map](config/map.md). -## Who We Are +Need local help? [Browse communities](provinces/index.md), ask on the +[MeshCore Canada forum](https://forum.meshcore.ca/), or join +[Discord](https://discord.gg/BESFVMt7yk). -We are a group of Canadian meshes across the country from British Columbia, Alberta, Ontario, and Quebec (hopefully more soon) that are working together to bring services and standards across Canada for all MeshCore users. We host MQTT servers and a packet analyzer at this site for all Canadians to use. +## Use network tools -The servers and services are currently managed by [**Mr. Alderson**](https://github.com/MrAlders0n), [**Ded**](https://github.com/446564), [**n30nex**](https://github.com/n30nex), and [**Kranic**](https://forum.meshcore.ca/u/djkranic). +Configure a repeater, explore Canadian regions, view CoreScope, or set up an +observer. -The documentation on this site is open to all to contribute to and is backed by markdown files in GitHub at [MeshCore-ca/MeshCore-Canada](https://github.com/MeshCore-ca/MeshCore-Canada). +[:octicons-arrow-right-24: **Choose a network tool**](tools/index.md){ .md-button } ---- +## Improve MeshCore Canada -## Want to Contribute? +Found something missing or confusing? [Share an idea](submit-idea.md) or +[update a community listing](contributing.md). -This site is community-driven. You do not need to know Git, have a GitHub account, or understand MeshCore terminology to help. Use the [Share an Idea](submit-idea.md) page to describe what you noticed or what would make the project better. After you review it, the page can create a public review issue automatically. Forum, Discord, copy, and manual GitHub options remain available. +## About this project -Experienced contributors can still use the [Contributing](contributing.md) page for issue templates and pull requests. +MeshCore Canada is an independent, community-run project. +[Learn more about MeshCore Canada](about.md) or [contribute](contributing.md). diff --git a/docs/javascripts/community-submission.js b/docs/javascripts/community-submission.js index d2906d9..065d25d 100644 --- a/docs/javascripts/community-submission.js +++ b/docs/javascripts/community-submission.js @@ -4,6 +4,7 @@ export const COMMUNITY_SUBMISSION_ENDPOINT = export const COMMUNITY_ISSUE_ENDPOINT = "https://github.com/MeshCore-ca/MeshCore-Canada/issues/new"; export const COMMUNITY_SOURCE_PAGE = "https://meshcore.ca/submit-idea/"; +export const COMMUNITY_FRENCH_SOURCE_PAGE = "https://meshcore.ca/fr/submit-idea/"; export const MAX_GITHUB_URL_LENGTH = 7000; export const COMMUNITY_CATEGORIES = Object.freeze([ @@ -107,7 +108,49 @@ export function buildSubmissionText(proposal) { ].join("\n\n"); } -export function buildManualGithubLink(proposal) { +const FRENCH_SUBMISSION_VALUES = Object.freeze({ + "Newcomer or accessibility improvement": "Amélioration pour les personnes qui débutent ou en matière d’accessibilité", + "Documentation correction": "Correction de la documentation", + "Hardware or build-guide idea": "Idée de matériel ou de guide de montage", + "Regional community information": "Renseignements sur une communauté régionale", + "Network tool or service idea": "Idée d’outil ou de service réseau", + "Feature or project idea": "Idée de fonctionnalité ou de projet", + "Other community feedback": "Autre commentaire de la communauté", + "Brand new / researching": "Je découvre MeshCore ou je me renseigne", + "Setting up my first node": "Je configure mon premier nœud", + "Active mesh user": "J’utilise activement un réseau MeshCore", + "Repeater, room server, or observer operator": "Je m’occupe d’un répéteur, d’un serveur de salon ou d’un observateur", + "Developer or documentation contributor": "Je contribue au développement ou à la documentation" +}); + +export function buildFrenchSubmissionText(proposal) { + const value = (text) => FRENCH_SUBMISSION_VALUES[text] || text; + const frenchSection = (heading, text, fallback = "Non fourni") => + section(heading, value(text), fallback); + return [ + `# ${proposal.summary}`, + frenchSection("Type de contribution", proposal.category), + frenchSection("Expérience avec MeshCore", proposal.experience), + frenchSection("Ville ou grande région", proposal.region), + frenchSection("Problème", proposal.need), + frenchSection("Changement proposé", proposal.idea), + frenchSection("Autres précisions", proposal.context), + frenchSection( + "Contact public", + proposal.followUp, + "Veuillez répondre dans le fil de soumission." + ), + "---\n\n_Préparé sur meshcore.ca._" + ].join("\n\n"); +} + +export function buildManualGithubLink( + proposal, + sourcePage = COMMUNITY_SOURCE_PAGE, +) { + if (![COMMUNITY_SOURCE_PAGE, COMMUNITY_FRENCH_SOURCE_PAGE].includes(sourcePage)) { + throw new Error("Choose a valid source page."); + } const params = new URLSearchParams({ template: "community_idea.yml", title: `[Community idea] ${proposal.summary}`, @@ -119,7 +162,7 @@ export function buildManualGithubLink(proposal) { idea: proposal.idea, context: proposal.context || "", follow_up: proposal.followUp || "", - source_page: COMMUNITY_SOURCE_PAGE + source_page: sourcePage }); const url = `${COMMUNITY_ISSUE_ENDPOINT}?${params.toString()}`; if (url.length <= MAX_GITHUB_URL_LENGTH) { @@ -128,7 +171,7 @@ export function buildManualGithubLink(proposal) { const fallback = new URLSearchParams({ template: "community_idea.yml", title: `[Community idea] ${proposal.summary}`, - source_page: COMMUNITY_SOURCE_PAGE + source_page: sourcePage }); return Object.freeze({ url: `${COMMUNITY_ISSUE_ENDPOINT}?${fallback.toString()}`, diff --git a/docs/javascripts/submission-form.js b/docs/javascripts/submission-form.js index 9953de6..9b17be6 100644 --- a/docs/javascripts/submission-form.js +++ b/docs/javascripts/submission-form.js @@ -3,6 +3,7 @@ const form = document.getElementById("community-submission-form"); if (!form) return; + form.noValidate = true; const scriptUrl = document.currentScript && document.currentScript.src ? document.currentScript.src @@ -11,21 +12,41 @@ const transportModuleUrl = new URL("../config/editor/issue.js", scriptUrl).href; const elements = { preview: document.getElementById("submission-preview"), + previewNote: document.getElementById("submission-preview-note"), status: document.getElementById("submission-status"), review: document.getElementById("review-submission"), + reviewAgain: document.getElementById("review-submission-again"), copy: document.getElementById("copy-submission"), submit: document.getElementById("submit-community-idea"), github: document.getElementById("open-github-submission"), githubNote: document.getElementById("submission-github-note"), result: document.getElementById("submission-result"), + errorSummary: document.getElementById("submission-error-summary"), verification: document.getElementById("submission-verification"), finalActions: document.getElementById("submission-final-actions"), turnstile: document.getElementById("submission-turnstile"), antiSpamStatus: document.getElementById("submission-anti-spam-status"), antiSpamRetry: document.getElementById("submission-anti-spam-retry"), - website: document.getElementById("submission-website") + website: document.getElementById("submission-website"), + publicConsent: document.getElementById("submission-public"), + saveDraft: document.getElementById("save-submission-draft"), + clearDraft: document.getElementById("clear-submission-draft"), + draftStatus: document.getElementById("submission-draft-status"), + stages: Array.from(document.querySelectorAll("[data-submission-stage]")) }; + const DRAFT_KEY = "meshcore-canada:community-idea-draft:v1"; + const DRAFT_FIELD_IDS = [ + "submission-category", + "submission-experience", + "submission-summary", + "submission-region", + "submission-need", + "submission-idea", + "submission-context", + "submission-follow-up" + ]; + let modules = null; let config = null; let turnstile = null; @@ -38,6 +59,185 @@ let preparedRevision = -1; let preparedProposal = null; let preparedText = ""; + let draftOptedIn = false; + let draftTimer = null; + let receiptVisible = false; + + function setStage(stage, complete = false) { + const order = ["review", "verify", "submit"]; + const currentIndex = order.indexOf(stage); + elements.stages.forEach((item) => { + const itemIndex = order.indexOf(item.dataset.submissionStage); + item.toggleAttribute("aria-current", !complete && itemIndex === currentIndex); + item.dataset.complete = String(complete ? itemIndex <= currentIndex : itemIndex < currentIndex); + }); + } + + function addDescription(field, id) { + const ids = new Set((field.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean)); + ids.add(id); + field.setAttribute("aria-describedby", Array.from(ids).join(" ")); + } + + function removeDescription(field, id) { + const ids = (field.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter((value) => value && value !== id); + if (ids.length) field.setAttribute("aria-describedby", ids.join(" ")); + else field.removeAttribute("aria-describedby"); + } + + function clearFieldError(field) { + if (!field || !field.id) return; + const errorId = `${field.id}-error`; + document.getElementById(errorId)?.remove(); + field.removeAttribute("aria-invalid"); + removeDescription(field, errorId); + } + + function clearValidationErrors() { + form.querySelectorAll(".submission-field-error").forEach((error) => error.remove()); + form.querySelectorAll("[aria-invalid='true']").forEach((field) => { + field.removeAttribute("aria-invalid"); + removeDescription(field, `${field.id}-error`); + }); + elements.errorSummary.hidden = true; + elements.errorSummary.querySelector("ul").replaceChildren(); + } + + function fieldLabel(field) { + const label = form.querySelector(`label[for='${CSS.escape(field.id)}']`); + return (label?.textContent || field.name || "This field").replace(/\s+/g, " ").trim(); + } + + function showValidationErrors() { + clearValidationErrors(); + const invalidFields = Array.from(form.querySelectorAll("[required]")).filter( + (field) => !field.validity.valid + ); + if (!invalidFields.length) return true; + + const list = elements.errorSummary.querySelector("ul"); + invalidFields.forEach((field) => { + const label = fieldLabel(field); + const message = field.validity.valueMissing ? `${label} is required.` : field.validationMessage; + const error = document.createElement("p"); + error.id = `${field.id}-error`; + error.className = "submission-field-error"; + error.textContent = message; + const container = field.closest(".submission-field"); + if (container) container.append(error); + else field.closest(".submission-consent")?.insertAdjacentElement("afterend", error); + field.setAttribute("aria-invalid", "true"); + addDescription(field, error.id); + + const item = document.createElement("li"); + const link = document.createElement("a"); + link.href = `#${field.id}`; + link.textContent = message; + link.addEventListener("click", () => window.setTimeout(() => field.focus(), 0)); + item.append(link); + list.append(item); + }); + elements.errorSummary.hidden = false; + elements.errorSummary.focus(); + return false; + } + + function initialiseCharacterCounters() { + form.querySelectorAll("input[maxlength], textarea[maxlength]").forEach((field) => { + if (!field.id || field === elements.website || field.maxLength < 0) return; + const counter = document.createElement("span"); + counter.id = `${field.id}-count`; + counter.className = "submission-character-count"; + const update = () => { + counter.textContent = `${field.value.length} / ${field.maxLength}`; + counter.dataset.nearLimit = String(field.value.length >= field.maxLength * 0.9); + }; + field.insertAdjacentElement("afterend", counter); + addDescription(field, counter.id); + field.addEventListener("input", update); + update(); + }); + } + + function draftValues() { + return Object.fromEntries(DRAFT_FIELD_IDS.map((id) => [id, document.getElementById(id).value])); + } + + function saveDraft(quiet = false) { + if (!elements.publicConsent.checked) { + elements.draftStatus.textContent = "Confirm the public-content statement before saving a draft."; + return false; + } + try { + window.localStorage.setItem(DRAFT_KEY, JSON.stringify({ + version: 1, + savedAt: new Date().toISOString(), + fields: draftValues() + })); + draftOptedIn = true; + elements.clearDraft.hidden = false; + if (!quiet) elements.draftStatus.textContent = "Draft saved on this device. Turnstile and anti-spam values are never stored."; + return true; + } catch (_error) { + elements.draftStatus.textContent = "This browser blocked draft storage. Your answers remain in the form."; + return false; + } + } + + function scheduleDraftSave() { + if (!draftOptedIn || !elements.publicConsent.checked) return; + if (draftTimer) window.clearTimeout(draftTimer); + draftTimer = window.setTimeout(() => { + saveDraft(true); + elements.draftStatus.textContent = "Saved draft updated on this device."; + }, 300); + } + + function clearSavedDraft(message = "Saved draft cleared.") { + if (draftTimer) window.clearTimeout(draftTimer); + draftTimer = null; + try { window.localStorage.removeItem(DRAFT_KEY); } catch (_error) {} + draftOptedIn = false; + elements.clearDraft.hidden = true; + elements.draftStatus.textContent = message; + } + + function restoreDraft() { + try { + const raw = window.localStorage.getItem(DRAFT_KEY); + if (!raw) return; + const draft = JSON.parse(raw); + if (draft?.version !== 1 || !draft.fields || typeof draft.fields !== "object") { + clearSavedDraft("An incompatible saved draft was removed."); + return; + } + DRAFT_FIELD_IDS.forEach((id) => { + const field = document.getElementById(id); + const saved = draft.fields[id]; + if (typeof saved !== "string") return; + const bounded = field.maxLength > -1 ? saved.slice(0, field.maxLength) : saved; + if (field instanceof HTMLSelectElement) { + if (Array.from(field.options).some((option) => option.value === bounded)) field.value = bounded; + } else field.value = bounded; + }); + draftOptedIn = true; + elements.clearDraft.hidden = false; + elements.publicConsent.checked = false; + elements.draftStatus.textContent = "Draft restored. Confirm the public-content statement before updating or submitting it."; + } catch (_error) { + clearSavedDraft("The saved draft could not be read and was removed."); + } + } + + function updateDraftControls() { + elements.saveDraft.disabled = !elements.publicConsent.checked; + if (!elements.publicConsent.checked && !draftOptedIn) { + elements.draftStatus.textContent = "Confirm the public-content statement before saving a draft."; + } else if (elements.publicConsent.checked && draftOptedIn) scheduleDraftSave(); + else if (elements.publicConsent.checked) elements.draftStatus.textContent = "You can now save a draft on this device."; + } async function loadModules() { if (!modules) { @@ -51,14 +251,22 @@ } function updateActions() { - const current = preparedProposal && preparedRevision === revision; + const current = Boolean(preparedProposal && preparedRevision === revision); + const hasPreview = Boolean(preparedText); + const stale = hasPreview && !current; form.dataset.prepared = current ? "true" : "false"; - elements.review.textContent = current ? "Update preview" : "Review idea"; + form.dataset.previewStale = stale ? "true" : "false"; + elements.review.textContent = current ? "Update preview" : stale ? "Review changes again" : "Review idea"; elements.review.classList.toggle("md-button--primary", !current); + elements.preview.hidden = !hasPreview; + elements.previewNote.hidden = !hasPreview; elements.verification.hidden = !current; - elements.finalActions.hidden = !current; + elements.finalActions.hidden = !hasPreview; + elements.reviewAgain.hidden = !stale; elements.submit.disabled = !current || !config || !token || submitting; elements.copy.disabled = !current || submitting; + if (receiptVisible) setStage("submit", true); + else setStage(current ? (token ? "submit" : "verify") : "review"); } function setAntiSpamStatus(message, state = "") { @@ -67,6 +275,7 @@ } function clearResult() { + receiptVisible = false; elements.result.replaceChildren(); elements.result.dataset.state = ""; } @@ -82,8 +291,14 @@ link.target = "_blank"; link.rel = "noopener"; link.textContent = `Open issue #${value.issueNumber}`; - elements.result.replaceChildren(document.createTextNode(prefix), link); + elements.result.replaceChildren( + document.createTextNode(prefix), + link, + document.createTextNode(". Maintainers will review the public issue and respond there.") + ); elements.result.dataset.state = "success"; + receiptVisible = true; + elements.result.focus(); } function values() { @@ -108,15 +323,13 @@ function markUnprepared() { preparedProposal = null; - preparedText = ""; preparedRevision = -1; elements.github.href = "#"; elements.github.classList.add("is-disabled"); elements.github.setAttribute("aria-disabled", "true"); - elements.preview.hidden = true; clearGithubNote(); clearResult(); - if (!submitting) elements.status.textContent = "Answers changed. Review again."; + if (!submitting) elements.status.textContent = "Answers changed. Review them again before submitting."; updateActions(); } @@ -237,17 +450,27 @@ form.addEventListener("submit", async (event) => { event.preventDefault(); - if (!form.reportValidity()) return; + if (!showValidationErrors()) { + elements.status.textContent = "Fix the fields listed above, then review again."; + return; + } try { const loaded = await loadModules(); preparedProposal = loaded.community.buildCommunityIdea(values()); - preparedText = loaded.community.buildSubmissionText(preparedProposal); + const frenchPage = document.documentElement.lang.toLowerCase().startsWith("fr"); + preparedText = frenchPage + ? loaded.community.buildFrenchSubmissionText(preparedProposal) + : loaded.community.buildSubmissionText(preparedProposal); preparedRevision = revision; elements.preview.textContent = preparedText; - elements.preview.hidden = false; clearResult(); - const manual = loaded.community.buildManualGithubLink(preparedProposal); + const manual = loaded.community.buildManualGithubLink( + preparedProposal, + frenchPage + ? loaded.community.COMMUNITY_FRENCH_SOURCE_PAGE + : loaded.community.COMMUNITY_SOURCE_PAGE, + ); elements.github.href = manual.url; elements.github.classList.remove("is-disabled"); elements.github.setAttribute("aria-disabled", "false"); @@ -260,8 +483,8 @@ updateActions(); if (!config || !token) void initialiseSubmission(); elements.status.textContent = config && token - ? "Ready to submit." - : "Preview ready."; + ? "Preview ready. Nothing has been submitted. You can now submit." + : "Preview ready. Nothing has been submitted. Complete the verification next."; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; elements.preview.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "nearest" }); } catch (error) { @@ -270,11 +493,20 @@ } }); - form.addEventListener("input", () => { + form.addEventListener("input", (event) => { + if (event.target === elements.website) return; + clearFieldError(event.target); + if (!form.querySelector(".submission-field-error")) elements.errorSummary.hidden = true; revision += 1; if (preparedProposal) markUnprepared(); + scheduleDraftSave(); }); + elements.publicConsent.addEventListener("change", updateDraftControls); + elements.saveDraft.addEventListener("click", () => saveDraft(false)); + elements.clearDraft.addEventListener("click", () => clearSavedDraft()); + elements.reviewAgain.addEventListener("click", () => form.requestSubmit(elements.review)); + elements.copy.addEventListener("click", async () => { if (!preparedText || preparedRevision !== revision) return; try { @@ -315,6 +547,7 @@ }); const changed = revision !== submittedRevision; showResult(value, changed); + if (!changed) clearSavedDraft("Submitted. Saved draft cleared from this device."); elements.status.textContent = changed ? "The reviewed version was submitted. Review and submit again to include your newer changes." : value.duplicate @@ -322,8 +555,8 @@ : "Submitted for review."; } catch (error) { const nextStep = error.retryable - ? " Your answers are saved here. Complete the check and try again." - : " Copy the text or use GitHub."; + ? " Your answers remain in the form. Complete the check and try again." + : " Your answers remain in the form. Copy the text or use GitHub."; elements.status.textContent = (error.message || "The idea could not be submitted.") + nextStep; } finally { submitting = false; @@ -342,5 +575,8 @@ }); elements.antiSpamRetry.addEventListener("click", initialiseSubmission); + initialiseCharacterCounters(); + restoreDraft(); + updateDraftControls(); updateActions(); })(); diff --git a/docs/meshcore/firmware-heltec-v3-wifi.md b/docs/meshcore/firmware-heltec-v3-wifi.md deleted file mode 100644 index 65e4fcf..0000000 --- a/docs/meshcore/firmware-heltec-v3-wifi.md +++ /dev/null @@ -1,132 +0,0 @@ -# Compiling MeshCore Firmware with Wi-Fi Enabled - -**Device:** Heltec V3 -**OS:** Red Hat 9.6 - -**Note:** -Wi-Fi support in MeshCore is experimental. -Your SSID and password are embedded at compile time, so do **not** share compiled binaries that contain your real credentials. - ---- - -## Install PlatformIO - -1. Change to your home directory: - - ```bash - cd ~ - ``` - -2. Download the PlatformIO installer: - - ```bash - curl -fsSL -o get-platformio.py https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py - ``` - -3. Run the installer: - - ```bash - python3 get-platformio.py - ``` - -4. Add PlatformIO to your PATH (adjust the path if your username is different): - - ```bash - export PATH=$PATH:/home/linuxuser/.platformio/penv/bin - ``` - -## Clone the MeshCore Repository - -1. Clone the MeshCore repo: - - ```bash - git clone https://github.com/ripplebiz/MeshCore.git - ``` - -2. Change into the project directory: - - ```bash - cd MeshCore/ - ``` - ---- - -## Configure Wi-Fi Credentials - -1. Open the PlatformIO configuration for the Heltec V3 Wi-Fi build: - - ```bash - vi variants/heltec_v3/platformio.ini - ``` - -2. Locate the `env:Heltec_v3_companion_radio_wifi` section and update it with your SSID and password: - -```ini -[env:Heltec_v3_companion_radio_wifi] -extends = Heltec_lora32_v3 -build_flags = - ${Heltec_lora32_v3.build_flags} - -D MAX_CONTACTS=100 - -D MAX_GROUP_CHANNELS=8 - -D DISPLAY_CLASS=SSD1306Display - -D WIFI_DEBUG_LOGGING=1 - -D WIFI_SSID="<>" - -D WIFI_PWD="<>" -``` - -3. Save and exit the editor. - -## Compile and Prepare Firmware - -1. Set the firmware version environment variable: - - ```bash - set FIRMWARE_VERSION=1.7.3 - ``` - - *(Or use `export FIRMWARE_VERSION=1.7.3` if you are using a pure Linux shell and not a mixed environment.)* - -2. Build the Wi-Fi firmware target: - - ```bash - ./build.sh build-firmware Heltec_v3_companion_radio_wifi - ``` - -3. Change into the build output directory: - - ```bash - cd .pio/build/Heltec_v3_companion_radio_wifi/ - ``` - -4. Rename the output binaries: - - ```bash - mv firmware-merged.bin Heltec_v3_companion_radio_wifi_1.7.3-merged.bin - mv firmware.bin Heltec_v3_companion_radio_wifi_1.7.3.bin - ``` - -5. Move the generated firmware files to a convenient location (example): - - ```bash - mv Heltec_v3_companion_radio_wifi* /home/linuxuser/ - ``` - ---- - -## Next Steps - -1. Flash one of the compiled firmware files onto your Heltec V3: - - - `Heltec_v3_companion_radio_wifi_1.7.3.bin` - - or `Heltec_v3_companion_radio_wifi_1.7.3-merged.bin` - -2. Connect to the device over serial and monitor logs to confirm: - - - Wi-Fi is enabled - - The device is attempting to associate with your SSID - -3. Remember that Wi-Fi support in MeshCore is experimental: - - - Expect instability - - Features may be incomplete - - Do not deploy this build as a critical node on the mesh diff --git a/docs/meshcore/firmware-rak-custom-display.md b/docs/meshcore/firmware-rak-custom-display.md deleted file mode 100644 index dd52f1b..0000000 --- a/docs/meshcore/firmware-rak-custom-display.md +++ /dev/null @@ -1,177 +0,0 @@ -# Compiling MeshCore Firmware for RAK4631 with MakerFocus 1.3" OLED (SH1106) - -**Device:** RAK4631 with MakerFocus 1.3" SH1106 OLED -**OS:** Red Hat 9.6 - -**Note:** -MeshCore does not natively support the MakerFocus SH1106 OLED on the RAK4631. -The steps below modify the PlatformIO configuration to add SH1106 display support. - ---- - -## Install PlatformIO - -1. Change to your home directory: - - ```bash - cd ~ - ``` - -2. Download the PlatformIO installer: - - ```bash - curl -fsSL -o get-platformio.py https://raw.githubusercontent.com/platformio/platformio-core-installer/master/get-platformio.py - ``` - -3. Run the installer: - - ```bash - python3 get-platformio.py - ``` - -4. Add PlatformIO to your PATH (adjust the path if your username is different): - - ```bash - export PATH=$PATH:/home/linuxuser/.platformio/penv/bin - ``` - -## Clone the MeshCore Repository - -1. Clone the MeshCore repo: - - ```bash - git clone https://github.com/ripplebiz/MeshCore.git - ``` - -2. Change into the project directory: - - ```bash - cd MeshCore/ - ``` - -## 3. Edit the PlatformIO Configuration - -1. Open the RAK4631 PlatformIO configuration file: - -```bash -vi variants/rak4631/platformio.ini -``` - -1. Replace OLED-related lines as shown below. -Every line marked with “<============= CHANGED” is different from the default configuration. - -```ini -[rak4631] -extends = nrf52_base -platform = https://github.com/maxgerhardt/platform-nordicnrf52.git#rak -board = wiscore_rak4631 -board_check = true -build_flags = ${nrf52_base.build_flags} - ${sensor_base.build_flags} - -I variants/rak4631 - -D RAK_4631 - -D RAK_BOARD - -D PIN_BOARD_SCL=14 - -D PIN_BOARD_SDA=13 - -D PIN_GPS_TX=16 - -D PIN_GPS_RX=15 - -D PIN_GPS_EN=-1 - -D PIN_OLED_RESET=-1 - -D RADIO_CLASS=CustomSX1262 - -D WRAPPER_CLASS=CustomSX1262Wrapper - -D LORA_TX_POWER=22 - -D SX126X_CURRENT_LIMIT=140 - -D SX126X_RX_BOOSTED_GAIN=1 -build_src_filter = ${nrf52_base.build_src_filter} - +<../variants/rak4631> - + -; + <============= CHANGED - + <============= CHANGED - + -lib_deps = - ${nrf52_base.lib_deps} - ${sensor_base.lib_deps} -; adafruit/Adafruit SSD1306 @ ^2.5.13 <============= CHANGED - sparkfun/SparkFun u-blox GNSS Arduino Library@^2.2.27 - adafruit/Adafruit SH110X @ ^2.1.13 <============= CHANGED - adafruit/Adafruit GFX Library @ ^1.12.1 <============= CHANGED - - -[env:RAK_4631_companion_radio_ble] -extends = rak4631 -board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld -board_upload.maximum_size = 712704 -build_flags = - ${rak4631.build_flags} - -I examples/companion_radio/ui-new - -D PIN_USER_BTN=9 - -D PIN_USER_BTN_ANA=31 -; -D DISPLAY_CLASS=SSD1306Display <============= CHANGED - -D DISPLAY_CLASS=SH1106Display <============= CHANGED - -D MAX_CONTACTS=350 - -D MAX_GROUP_CHANNELS=40 - -D BLE_PIN_CODE=123456 - -D BLE_DEBUG_LOGGING=1 - -D OFFLINE_QUEUE_SIZE=256 -; -D MESH_PACKET_LOGGING=1 -; -D MESH_DEBUG=1 -build_src_filter = ${rak4631.build_src_filter} - + - +<../examples/companion_radio/*.cpp> - +<../examples/companion_radio/ui-new/*.cpp> -lib_deps = - ${rak4631.lib_deps} - densaugeo/base64 @ ~1.4.0 -``` - -## Compile and Prepare Firmware - -1. Set the firmware version environment variable: - - ```bash - set FIRMWARE_VERSION=1.7.3 - ``` - - *(Or use `export FIRMWARE_VERSION=1.7.3` if you are using a pure Linux shell and not a mixed environment.)* - -2. Build the Wi-Fi firmware target: - - ```bash - ./build.sh build-firmware RAK_4631_companion_radio_ble - ``` - -3. Change into the build output directory: - - ```bash - cd .pio/build/RAK_4631_companion_radio_ble/ - ``` - -4. Rename the output binaries: - - ```bash - mv firmware-merged.bin RAK_4631_companion_radio_ble_1.9.0-merged.bin - mv firmware.bin RAK_4631_companion_radio_ble_1.9.0.bin - ``` - -5. Move the generated firmware files to a convenient location (example): - - ```bash - mv AK_4631_companion_radio_ble* /home/linuxuser/ - ``` - -## Next Steps - -1. Flash one of the compiled firmware files onto your Heltec V3: - - - `RAK_4631_companion_radio_ble_1.9.0.bin` - - or `...merged.bin` - -2. Connect the MakerFocus SH1106 OLED at **I²C address 0x3C** - - - Ensure the jumper is on the **0x7A** side - -3. Power the display using **3.3 V only** - - - The module is **NOT** 5 V tolerant - -4. On boot, the SH1106 display should now initialize normally diff --git a/docs/meshcore/flash-companion.fr.md b/docs/meshcore/flash-companion.fr.md new file mode 100644 index 0000000..a5b244f --- /dev/null +++ b/docs/meshcore/flash-companion.fr.md @@ -0,0 +1,122 @@ +--- +title: Programmer et configurer un compagnon +description: Sauvegardez, programmez, configurez, vérifiez et récupérez un compagnon MeshCore pris en charge sans perdre ses données d’identité importantes. +audience: + - first-time-user + - companion-owner +task: flash-companion +scope: canada-baseline +status: draft +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 20-30 minutes +destructive: true +requires: + - supported-companion + - data-capable-usb-cable +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Programmer et configurer un compagnon + +Confirmez que le programme officiel de mise à jour prend en charge votre modèle +exact de carte comme compagnon. Utilisez ensuite ce guide pour sauvegarder +l’appareil, le programmer et rejoindre votre réseau maillé local. Consultez le +[répertoire des communautés](../provinces/index.md) pour savoir si votre +communauté utilise des réglages locaux différents. + +## Avant d’effacer l’appareil + +!!! danger "Erase Flash supprime les données enregistrées dans l’appareil" + L’effacement peut supprimer l’identité ou la clé privée du nœud, son nom, ses contacts, ses canaux, ses réglages radio et toute autre configuration enregistrée. Une identité effacée ne peut être récupérée que si vous l’avez sauvegardée. + +S’il ne s’agit pas d’un appareil neuf : + +1. Connectez-vous au moyen de l’application ou de l’outil de configuration qui le gère actuellement. +2. Notez le modèle de la carte, son rôle, la version du micrologiciel, le nom de l’appareil, les réglages radio et tout autre réglage à recréer. +3. Exportez ou notez de façon sécuritaire l’identité ou la clé privée de l’appareil en utilisant une méthode de sauvegarde compatible avec ce micrologiciel. Conservez-la comme secret; ne la publiez jamais dans une capture d’écran, une discussion, un billet ou un journal. +4. Exportez les contacts ou les canaux si votre application offre cette option. + +Si vous ne pouvez pas sauvegarder une identité ou une configuration importante, +**arrêtez-vous avant Erase Flash** et demandez l’aide de votre communauté locale. + +## Avant de commencer + +- [ ] J’ai choisi le modèle exact indiqué sur la carte ou signalé par celle-ci. +- [ ] J’ai sauvegardé tout ce que je dois conserver d’un appareil existant. +- [ ] J’ai un câble USB de données fiable et une alimentation stable. +- [ ] J’utilise un navigateur compatible avec Web Serial, comme une version actuelle de Chrome ou d’Edge. +- [ ] Je sais comment remettre cette carte en mode DFU ou chargeur d’amorçage si la programmation échoue. + +!!! warning "Pilotes série USB" + Certaines cartes exigent un pilote série USB avant que le navigateur puisse s’y connecter. + +## Ce que la programmation change + +La programmation remplace le micrologiciel installé et, lorsque **Erase Flash** +est sélectionné, supprime l’identité et la configuration enregistrées. La +configuration suivante inscrit le nom de l’appareil et les réglages radio locaux. + +## Programmer le micrologiciel du compagnon + +Utilisez le [programme de mise à jour Web MeshCore](https://meshcore.io/flasher) +officiel. Il faut un navigateur compatible avec +[Web Serial](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility). + +1. Branchez l’appareil par USB. +2. Sélectionnez le modèle exact de la carte. +3. Sélectionnez **Companion Radio (Bluetooth)**. +4. Sélectionnez la version du micrologiciel destinée à cette carte. +5. Cliquez sur **Enter DFU Mode**. Le programme devrait indiquer qu’il a trouvé un appareil dans le mode attendu. +6. Vérifiez de nouveau le matériel et le micrologiciel sélectionnés. +7. Cliquez sur **Erase Flash**, puis attendez le message confirmant la réussite de l’effacement. +8. Cliquez sur **Flash** et attendez le message de fin avant de débrancher l’appareil. + +## Récupération en cas d’échec + +N’effacez pas l’appareil à répétition. Laissez-le branché, actualisez le +programme de mise à jour, remettez la carte en mode DFU, sélectionnez de nouveau +la carte exacte et le micrologiciel du compagnon, puis réessayez **Flash**. Si +la carte n’apparaît plus, suivez sa méthode documentée de récupération du +chargeur d’amorçage ou demandez de l’aide avant d’essayer une autre cible. + +## Vérifier la programmation + +Le programme de mise à jour confirme la fin de l’opération, la carte redémarre +comme compagnon et l’application compatible peut la détecter. S’il manque une +de ces étapes, suivez la procédure de récupération avant de modifier un autre +réglage. + +## Configurer le compagnon + +1. Jumelez le nœud à une application MeshCore compatible sur votre téléphone ou votre ordinateur. +2. Donnez-lui un nom descriptif qui ne révèle pas un emplacement privé. +3. Vérifiez si la page de votre communauté locale indique des réglages différents. +4. Si elle n’en indique aucun, utilisez les réglages par défaut du Canada : **USA/Canada (Recommended)** (`910.525 MHz / 62.5 kHz / SF7 / CR5`). +5. Enregistrez les réglages, puis reconnectez-vous après le redémarrage de l’appareil. + +Le réglage facultatif **Message Settings → Auto Reset Path** détermine comment +l’application gère les changements de parcours. Conservez sa valeur par défaut, +sauf si votre méthode d’essai locale exige un autre réglage. + +## Vérifier avant l’utilisation courante + +1. Confirmez que l’application se reconnecte et affiche le nom de l’appareil et les réglages radio attendus. +2. Envoyez un message d’essai dans le canal **Public**. +3. Une réponse comme **Heard X Repeats** indique qu’au moins un répéteur a signalé l’avoir reçu. Un simple résultat **Sent** ne prouve pas que les réglages sont incorrects; rendez-vous dans une zone où la couverture est connue ou demandez à la communauté locale de vous aider à faire l’essai. + +La configuration n’est terminée que si les réglages enregistrés résistent à un +redémarrage et qu’un essai local réussit. + +## Après la programmation + +Lorsque le compagnon a réussi le redémarrage et l’essai de message local, +[trouvez votre communauté](../provinces/index.md) et conservez la méthode de +récupération USB propre à la carte dans le dossier de l’appareil. + +## Sources + +- [Programme de mise à jour Web officiel de MeshCore](https://meshcore.io/flasher) +- [Code source et versions officielles de MeshCore](https://github.com/meshcore-dev/MeshCore) diff --git a/docs/meshcore/flash-companion.md b/docs/meshcore/flash-companion.md index c2da2bb..3e5f725 100644 --- a/docs/meshcore/flash-companion.md +++ b/docs/meshcore/flash-companion.md @@ -1,47 +1,104 @@ -# Flashing and Configuring a Companion Node +--- +title: Flash and configure a companion +description: Back up, flash, configure, verify, and recover a supported MeshCore companion without losing important identity data. +audience: + - first-time-user + - companion-owner +task: flash-companion +scope: canada-baseline +status: draft +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 20-30 minutes +destructive: true +requires: + - supported-companion + - data-capable-usb-cable +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Flash and configure a companion -This guide will help you flash a node and configure it to operate as a companion. +Confirm that the official flasher supports your exact board as a companion, +then use this guide to back it up, flash it, and join your local mesh. Check the +[community directory](../provinces/index.md) for different local settings. ---- +## Before you erase -## Flashing a Companion Node +!!! danger "Erase Flash deletes the device's stored data" + Erasing can remove the node identity/private key, name, contacts, channels, radio settings, and other saved configuration. An erased identity cannot be recovered unless you backed it up. -!!! warning "USB Serial Drivers" - You may need to install drivers on your computer to connect to your device. +If this is not a brand-new device: -The easiest way to flash a MeshCore-supported node is by using the official web flasher tool in **Google Chrome**: +1. Connect with the app or configuration tool that currently manages it. +2. Record the board model, role, firmware version, device name, radio settings, and any settings you need to recreate. +3. Export or securely record the device identity/private key using a supported backup method for that firmware. Store it as a secret; never post it in screenshots, chat, issues, or logs. +4. Export contacts or channels if your app provides that option. -**[MeshCore Web Flasher](https://meshcore.io/flasher)** -Only some Chromium-based browsers (e.g., Google Chrome, Microsoft Edge) support the [serial connection](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility) required for flashing. +If you cannot back up an identity or configuration that matters, **stop before Erase Flash** and ask your local community for help. -### Steps +## Before you start -1. Plug your device into your computer via USB. -2. Open the **MeshCore Web Flasher**. -3. Select your device hardware. -4. Select the firmware type: **Companion Radio (Bluetooth)**. -5. Click **Enter DFU Mode**. -6. Click **Erase Flash**. -7. Click **Flash** to install the MeshCore firmware. +- [ ] I selected the exact hardware model printed on or reported by the board. +- [ ] I backed up everything I need from an existing device. +- [ ] I have a known-good data USB cable and stable power. +- [ ] I am using a browser with Web Serial support, such as current Chrome or Edge. +- [ ] I know how to put this board back into DFU or bootloader mode if flashing fails. -**Note:** -If the flash step fails after erasing, refresh the page, click **Enter DFU Mode** again, then click **Flash** to retry. +!!! warning "USB serial drivers" + Some boards require a USB serial driver before the browser can connect. ---- +## What flashing changes -## Configuring a Companion Node +Flashing replaces the installed firmware and, when **Erase Flash** is selected, deletes stored identity and configuration. The later setup writes the device name and local radio settings. -After flashing, follow these steps to complete setup: +## Flash the companion firmware -1. Pair the node with your phone or computer (usually over Bluetooth). -2. Give the node a descriptive **name** (for example: your callsign, location, or handle). -3. Set the Ottawa frequency defaults: - **910.525 MHz / BW: 62.5 kHz / SF7 / CR5** -4. Test the node by sending a message in the **Public channel**. - - If a repeater hears you, the message will show **Heard X Repeats** instead of just **Sent**. +Use the official [MeshCore Web Flasher](https://meshcore.io/flasher). The flasher requires a browser that supports [Web Serial](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility). -**Tip:** -You may want to disable **Message Settings → Auto Reset Path**. -This isn’t required, but many users find it useful when testing unstable links because it prevents the path from constantly resetting. +1. Connect the device by USB. +2. Select the exact hardware model. +3. Select **Companion Radio (Bluetooth)**. +4. Select the firmware version intended for that board. +5. Click **Enter DFU Mode**. The flasher should report that it found a device in the expected mode. +6. Recheck the hardware and firmware selections. +7. Click **Erase Flash**, then wait for a successful erase message. +8. Click **Flash** and wait for the completion message before disconnecting the device. ---- +## Recovery if flashing fails + +Do not keep erasing. Leave the device connected, refresh the flasher, return the board to DFU mode, reselect the exact board and companion firmware, and retry **Flash**. If the board no longer appears, follow its documented bootloader recovery method or ask for help before trying another target. + +## Check the flash + +The flasher reports completion, the board restarts as a companion, and the supported app can discover it. If any part is missing, use the recovery path before changing another setting. + +## Configure the companion + +1. Pair the node with the supported MeshCore app on your phone or computer. +2. Give it a descriptive name that does not reveal a private location. +3. Check your local community page for different settings. +4. If none are listed, use the Canada defaults: **USA/Canada (Recommended)** (`910.525 MHz / 62.5 kHz / SF7 / CR5`). +5. Save the settings and reconnect after the device restarts. + +The optional **Message Settings → Auto Reset Path** preference affects how the app manages changing paths. Leave it at its default unless your local testing process calls for a different setting. + +## Verify before regular use + +1. Confirm the app reconnects and shows the expected device name and radio settings. +2. Send a test message in the **Public** channel. +3. A response such as **Heard X Repeats** indicates that at least one repeater reported hearing it. A plain **Sent** result is not proof that the settings are wrong; move to a known coverage area or ask the local community to help test. + +Do not consider the setup complete until the saved settings survive a reboot and a local test succeeds. + + +## After flashing + +After the companion passes the reboot and local message check, [find your community](../provinces/index.md) and keep the board-specific USB recovery method with the device record. + +## Sources + +- [Official MeshCore web flasher](https://meshcore.io/flasher) +- [Official MeshCore source and releases](https://github.com/meshcore-dev/MeshCore) diff --git a/docs/meshcore/flash-repeater.fr.md b/docs/meshcore/flash-repeater.fr.md new file mode 100644 index 0000000..76fa430 --- /dev/null +++ b/docs/meshcore/flash-repeater.fr.md @@ -0,0 +1,188 @@ +--- +title: Programmer, configurer et tester un répéteur sur l’établi +description: Sauvegardez, programmez par USB, configurez, vérifiez et récupérez un répéteur MeshCore avant qu’il quitte l’établi. +audience: + - repeater-builder + - network-operator +task: flash-repeater +scope: canada-baseline +status: draft +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 30-60 minutes +destructive: true +requires: + - supported-repeater-board + - physical-usb-access + - data-capable-usb-cable +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Programmer, configurer et tester un répéteur sur l’établi + +Confirmez la carte exacte, le chargeur d’amorçage, la cible du répéteur et le +fichier du micrologiciel dans les sources officielles avant d’apporter des +changements. Gardez le répéteur sur l’établi jusqu’à ce qu’il ait réussi toutes +les vérifications de ce guide. + +## Avant de changer le micrologiciel + +!!! danger "Sauvegardez le répéteur avant tout effacement" + **Erase Flash** peut supprimer l’identité ou la clé privée du répéteur, l’accès administrateur, le nom, l’emplacement, les définitions de régions, les réglages radio et toute autre configuration enregistrée. L’identité d’un appareil déployé ne peut pas être recréée si sa clé privée n’a pas été sauvegardée. + +Pour un répéteur existant, notez ou exportez : + +- le modèle exact de la carte, le rôle et la version actuelle du micrologiciel; +- le nom de l’appareil, les réglages radio, l’emplacement choisi, les réglages d’annonce, le mode de hachage des parcours et la configuration des régions; +- les renseignements d’accès administrateur et invité dans votre gestionnaire de mots de passe; +- la clé privée au moyen d’une méthode de sauvegarde sécurisée et compatible. + +Ne publiez jamais la clé privée ni les mots de passe dans un billet, une capture +d’écran, un journal ou une discussion. Si une sauvegarde requise ne peut pas +être effectuée, arrêtez-vous avant l’effacement. + +## Avant de commencer + +- [ ] La carte exacte et son rôle sont confirmés. +- [ ] L’identité et les réglages existants sont sauvegardés de façon sécuritaire. +- [ ] Un câble USB de données fiable et une alimentation stable sont disponibles. +- [ ] Je peux retrouver un accès USB physique si la configuration ou une mise à jour échoue. +- [ ] J’ai consulté le [répertoire des communautés](../provinces/index.md) et le [configurateur de répéteur](../config/index.md) pour connaître la bonne région et les bons réglages locaux. +- [ ] Je vais tester le répéteur sur l’établi avant de l’installer en hauteur ou dans un boîtier éloigné. + +## Ce que la programmation change + +Un effacement remplace le micrologiciel et peut supprimer l’identité et les +réglages. La configuration inscrit aussi des valeurs de radio, d’annonce, de +hachage des parcours, d’accès, d’emplacement et de région qui influencent le +réseau partagé. + +## Plan de récupération + +Gardez sur l’établi la méthode de récupération USB propre à la carte exacte, un +câble de données fiable, l’identité et les réglages sauvegardés ainsi que le +fichier de micrologiciel vérifié. Si l’effacement ou la programmation échoue, +n’essayez pas la cible d’une autre carte; remettez la carte exacte en mode DFU +ou chargeur d’amorçage, puis réessayez par USB avec le même rôle vérifié. + +## Décision concernant le chargeur d’amorçage nRF52 { #nrf52-bootloader-decision } + +Ignorez cette section pour les cartes qui ne sont pas fondées sur nRF52. + +Pour les cartes nRF52 prises en charge, MeshCore Canada demande actuellement +aux responsables d’utiliser les [versions OTAFIX](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases) +avant de compter sur la récupération OTA. Confirmez que le fichier correspond +à la carte exacte; utiliser le chargeur d’amorçage d’une autre carte peut exiger +une récupération physique. + +Téléchargez uniquement le fichier OTAFIX actuel dont le nom de carte correspond +exactement à votre matériel. N’utilisez pas un nom de fichier copié d’un ancien +guide ou d’une capture d’écran. + +1. Téléchargez le fichier `update-*.uf2` correspondant depuis la page des versions OTAFIX. +2. Branchez la carte par USB. +3. Activez son mode de chargeur d’amorçage UF2. Sur une RAK4631, il faut normalement appuyer deux fois sur le bouton près du port USB; pour les autres cartes, utilisez la méthode de réinitialisation documentée. +4. Confirmez qu’un lecteur USB apparaît et examinez `INFO.TXT` pour vérifier l’identité de la carte. +5. Copiez le fichier UF2 correspondant sur ce lecteur. Le lecteur peut se déconnecter pendant le redémarrage de la carte. +6. Revenez au mode de chargeur d’amorçage et confirmez que `INFO.TXT` indique la version `0.9.2` avant de continuer. + +Si l’identité de la carte ou la version attendue ne correspond pas, arrêtez et +récupérez l’appareil par USB avant de programmer MeshCore. + +## Programmer par USB — méthode recommandée + +Utilisez le [programme de mise à jour Web MeshCore](https://meshcore.io/flasher) +officiel dans un navigateur compatible avec +[Web Serial](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility). + +1. Branchez le répéteur par USB. +2. Sélectionnez le modèle exact du matériel. +3. Sélectionnez **Repeater** comme rôle et choisissez la version destinée à cette carte. +4. Cliquez sur **Enter DFU Mode** et attendez que le programme trouve la carte. +5. Vérifiez de nouveau la carte et le rôle sélectionnés. +6. Cliquez sur **Erase Flash** et attendez le message confirmant la réussite de l’effacement. +7. Cliquez sur **Flash** et attendez la fin de l’opération avant de débrancher l’appareil. + +Si la programmation échoue après l’effacement, n’effacez pas l’appareil à +répétition. Actualisez la page, remettez la carte en mode DFU, vérifiez de +nouveau la cible et réessayez **Flash**. Utilisez la procédure de récupération +USB de la carte si elle n’apparaît plus. + +## Vérifier la programmation + +Le programme de mise à jour confirme la fin de l’opération, la carte redémarre +comme répéteur et la console de configuration peut s’y reconnecter. Si cet état +n’est pas atteint, suivez le plan de récupération avant de configurer l’appareil. + +## Configurer le répéteur + +1. Dans le programme de mise à jour Web MeshCore, ouvrez **Repeater Setup**. +2. Connectez-vous au répéteur et activez **Show Advanced Settings** pour afficher les champs requis. +3. Entrez l’emplacement prévu ou utilisez la carte. Ne publiez pas un emplacement privé exact à moins que ce soit approprié pour le site. +4. Donnez-lui un nom descriptif, comme `Callsign_R1` ou `Downtown_R1`. +5. Créez un mot de passe administrateur unique et conservez-le de façon sécuritaire. +6. Confirmez que la communauté locale n’a pas publié de réglages différents. Sinon, appliquez **USA/Canada (Recommended)** (`910.525 MHz / 62.5 kHz / SF7 / CR5`). +7. Utilisez les valeurs d’annonce actuellement recommandées par MeshCore Canada : + - **Advert Interval:** `60` minutes + - **Flood Advert Interval:** `24` heures + - **Flood Max:** `64` +8. Utilisez le [configurateur de répéteur](../config/index.md) pour obtenir les commandes de région et le mode de hachage des parcours. Le réglage canadien par défaut utilise 3 octets (`set path.hash.mode 2`); utilisez les réglages locaux différents lorsque votre communauté en publie. +9. Ajoutez les renseignements sur le propriétaire seulement s’ils conviennent à des annonces publiques. +10. Enregistrez les réglages et redémarrez l’appareil. + +### Détection des boucles { #loop-detection } + +Le micrologiciel de répéteur **1.14 ou une version plus récente** peut rejeter +les paquets qui repassent plusieurs fois par le même répéteur. Modifiez ce +réglage uniquement en coordination avec les responsables locaux. Notez la +valeur actuelle, puis exécutez : + +```text +get loop.detect +set loop.detect moderate +get loop.detect +``` + +La dernière commande doit indiquer `moderate`. Surveillez toute perte de trafic +légitime et restaurez la valeur notée si la livraison se dégrade. Ce réglage +peut limiter une tempête de paquets; il ne répare pas un répéteur défectueux et +ne prouve pas que le réseau maillé est sain. Consultez la +[référence officielle de la CLI](https://docs.meshcore.io/cli_commands/#view-or-change-this-nodes-loop-detection){ target="_blank" rel="noopener" }. + +## Vérifier et tester sur l’établi + +1. Reconnectez-vous après le redémarrage et synchronisez l’horloge du répéteur. +2. Confirmez que la version du micrologiciel, le nom de l’appareil, les réglages radio, le mode de hachage des parcours, les réglages d’annonce et la configuration des régions correspondent au plan. +3. Envoyez une annonce et confirmez qu’un compagnon à proximité la reçoit. +4. Redémarrez encore une fois, reconnectez-vous et confirmez que la configuration enregistrée est intacte. +5. Vérifiez l’administration à distance depuis le compagnon prévu avant de fermer un boîtier ou d’installer le répéteur dans un endroit éloigné. + +Après chaque redémarrage, synchronisez de nouveau l’horloge du répéteur. +L’acheminement peut continuer même si l’horloge est inexacte, mais une heure +d’annonce périmée peut empêcher les compagnons d’accepter une nouvelle annonce. + +Ne déployez pas le répéteur avant qu’il ait réussi cet essai sur l’établi et que +la récupération USB demeure pratique. + +## Changements d’identité sur 1 octet + +La plupart des responsables ne devraient pas changer l’identité d’un répéteur +après sa configuration. Une région qui coordonne encore des identifiants sur +1 octet peut demander de le faire. Suivez +[Générer un identifiant de répéteur](generate-repeater-id.md) uniquement lorsque +la personne responsable de la région confirme que c’est nécessaire, et gardez +l’ancienne clé privée comme moyen de revenir en arrière. + +## Avant l’installation + +Gardez le répéteur sur l’établi jusqu’à ce qu’il ait réussi toutes les +vérifications. Consultez ensuite le [plan de montage](../hardware/repeater-mounting-options.md) +et conservez un accès USB physique. + +## Sources + +- [Programme de mise à jour Web officiel de MeshCore](https://meshcore.io/flasher) +- [Code source et versions officielles de MeshCore](https://github.com/meshcore-dev/MeshCore) +- [Versions OTAFIX citées par la communauté](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases) diff --git a/docs/meshcore/flash-repeater.md b/docs/meshcore/flash-repeater.md index b00d25c..c034898 100644 --- a/docs/meshcore/flash-repeater.md +++ b/docs/meshcore/flash-repeater.md @@ -1,99 +1,154 @@ -# Flashing and Configuring a Repeater Node +--- +title: Flash, configure, and bench-test a repeater +description: Back up, flash by USB, configure, verify, and recover a MeshCore repeater before it leaves the bench. +audience: + - repeater-builder + - network-operator +task: flash-repeater +scope: canada-baseline +status: draft +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 30-60 minutes +destructive: true +requires: + - supported-repeater-board + - physical-usb-access + - data-capable-usb-cable +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Flash, configure, and bench-test a repeater -This guide will help you flash a node and configure it as a MeshCore repeater. +Confirm the exact board, bootloader, repeater target, and firmware file in the +official sources before making changes. Keep the repeater on the bench until +it passes every check in this guide. ---- +## Before changing firmware -## nRF52 Bootloader Update +!!! danger "Back up the repeater before any erase" + **Erase Flash** can remove the repeater's identity/private key, admin access, name, location, region definitions, radio settings, and other saved configuration. A deployed identity cannot be recreated unless its private key was backed up. -*(Skip this section if you are not using an nRF52-based board)* +For an existing repeater, record or export: -**Important:** -Before configuring a repeater, you must update the bootloader on **nRF52 based** boards (e.g, RAK 4631, Xiao NRF52840, Heltec T114, etc). -Without this fix, a failed OTA update can brick the repeater and require physical recovery. +- exact board model, role, and current firmware version; +- device name, radio settings, location choice, advert settings, path-hash mode, and region configuration; +- admin/guest access information in your password manager; and +- the private key through a supported secure backup method. -### Steps +Never post the private key or passwords in an issue, screenshot, log, or chat. If a required backup cannot be completed, stop before erasing. -1. Download the UF2 file (they have the 'update-' prefix) of the OTA bootloader fix for your device in the **[OTAFIX GitHub Repo](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases)** +## Before you start - Examples: +- [ ] The exact board and role are confirmed. +- [ ] Existing identity and settings are backed up securely. +- [ ] A known-good data USB cable and stable power are available. +- [ ] I can regain physical USB access if setup or an update fails. +- [ ] I checked the [community directory](../provinces/index.md) and the [repeater configurator](../config/index.md) for the correct local region and settings. +- [ ] I will bench-test before installing the repeater at height or in a remote enclosure. - RAK 4631 -> update-wiscore_rak4631_board_bootloader-0.9.2-OTAFIX2.2-BP1.3_nosd.uf2 +## What flashing changes - Heltec T114 -> update-heltec_t114_bootloader-0.9.2-OTAFIX2.2-BP1.3_nosd.uf2 +An erase replaces firmware and can delete identity and settings. The setup also writes radio, advert, path-hash, access, location, and region values that affect the shared network. - Xiao NRF52840 (Used in Ikoka Stick) -> update-xiao_nrf52840_ble_sense_bootloader-0.9.2-OTAFIX2.1-BP1.2_nosd.uf2 +## Recovery plan -2. Connect your repeater to your computer via USB. -3. Double-click the button beside the USB port on the RAK board or the reset button on other boards. - - The green LED should turn on, indicating DFU mode (On the RAK specically this will occour). -4. A new **USB drive** should appear on your computer. -5. Drag the `.uf2` file into the drive. -6. The copy will appear to fail, and the board will reboot — **this is expected**. -7. Open **INFO.TXT** on the drive and confirm it reports bootloader version **0.9.2**. +Keep the exact board's USB recovery method, known-good data cable, backed-up identity/settings, and verified firmware file at the bench. If an erase or flash fails, do not try another board target; return the exact board to DFU/bootloader mode and retry the same verified role by USB. ---- +## nRF52 bootloader decision -## Flashing MeshCore Repeater Firmware - USB (Recommended Route) +Skip this section for boards that are not nRF52-based. -1. Plug the device into your computer via USB. -2. Open the **MeshCore Web Flasher**: -3. Select your device hardware. -4. Select **Repeater** as the firmware type. -5. Click **Enter DFU Mode**. -6. Click **Erase Flash**. -7. Click **Flash** to install the firmware. +For supported nRF52 boards, MeshCore Canada currently directs operators to the [OTAFIX releases](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases) before relying on OTA recovery. Confirm the file matches the exact board; using another board's bootloader can require physical recovery. -**Note:** -If flashing fails after erasing, refresh the page, click **Enter DFU Mode** again, then click **Flash**. +Download only the current OTAFIX file whose board name matches your exact +hardware. Do not use a filename copied from an older guide or screenshot. ---- +1. Download the matching `update-*.uf2` from the OTAFIX release page. +2. Connect the board over USB. +3. Enter its UF2 bootloader mode. On a RAK4631 this is normally done by double-pressing the button beside USB; other boards use their documented reset method. +4. Confirm a USB drive appears and inspect `INFO.TXT` so the board identity is what you expect. +5. Copy the matching UF2 file to that drive. The drive may disconnect as the board reboots. +6. Re-enter bootloader mode and confirm `INFO.TXT` reports bootloader version `0.9.2` before continuing. -## Configuring a MeshCore Repeater +If the board identity or expected version does not match, stop and recover over USB before flashing MeshCore. -1. Using a Browser that supports the [required serial connection](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility) (e.g., Google Chrome or Microsoft Edge), open the **MeshCore Web Flasher**: - +## Flash by USB (recommended) -2. Click the **Repeater Setup** button in the top right. +Use the official [MeshCore Web Flasher](https://meshcore.io/flasher) in a browser with [Web Serial support](https://developer.mozilla.org/en-US/docs/Web/API/Web_Serial_API#browser_compatibility). -3. Connect to your repeater and check the **Show Advanced Settings** checkbox near the end so all fields below are visible. +1. Connect the repeater by USB. +2. Select the exact hardware model. +3. Select **Repeater** as the role and choose the intended version for that board. +4. Click **Enter DFU Mode** and wait for the flasher to find the board. +5. Recheck the selected board and role. +6. Click **Erase Flash** and wait for a successful erase message. +7. Click **Flash** and wait for completion before disconnecting. -4. Set a Location either entering Lattitude/Longitude or using the map icon. +If flashing fails after erase, do not repeatedly erase. Refresh the page, return the board to DFU mode, verify the target again, and retry **Flash**. Use the board's USB recovery process if it no longer appears. -5. Set a descriptive repeater **name** (e.g., `Callsign_R1`, `Downtown_R1`). +## Check the flash -6. Set an **admin password** (required for MeshCore Remote Administration). +The flasher reports completion, the board restarts as a repeater, and the setup console can reconnect. If that state is not reached, follow the recovery plan before configuration. -7. Apply the USA/Canada (Recommended) Preset: - **910.525 MHz / BW 62.5 kHz / SF7 / CR5** +## Configure the repeater -8. Set the advert intervals: - 1. **Advert Interval (minutes):** `60` - 2. **Flood Advert Interval (hours):** `24` - 3. **Flood Max:** `64` +1. In the MeshCore Web Flasher, open **Repeater Setup**. +2. Connect to the repeater and enable **Show Advanced Settings** so the required fields are visible. +3. Enter the intended location or use the map. Do not publish an exact private location unless that is appropriate for the site. +4. Set a descriptive name, such as `Callsign_R1` or `Downtown_R1`. +5. Set a unique admin password and store it securely. +6. Confirm the local community has not documented an override. Otherwise apply **USA/Canada (Recommended)** (`910.525 MHz / 62.5 kHz / SF7 / CR5`). +7. Set the current MeshCore Canada default advert values: + - **Advert Interval:** `60` minutes + - **Flood Advert Interval:** `24` hours + - **Flood Max:** `64` +8. Use the [Repeater Configurator](../config/index.md) to get the region commands and path-hash mode. The Canada default is 3-byte (`set path.hash.mode 2`); use different local settings when your community lists them. +9. Add owner information only if it is suitable for public adverts. +10. Save the settings and reboot. -9. Set **Path Hash Mode** to **3-byte (2)**. +### Loop detection -10. *(Optional)* Set your own info (e.g., owner name or contact). +Repeater firmware **1.14 or newer** can reject packets that repeatedly pass +through the same repeater. Change this only with the local operators. Record +the current value, then run: -11. Click **Save Settings**, then reboot the repeater. +```text +get loop.detect +set loop.detect moderate +get loop.detect +``` -12. Reconnect with the configuration tool and click **Send Advert**. +The last command should report `moderate`. Watch for lost legitimate traffic +and restore the recorded value if delivery gets worse. This setting can limit +a packet storm; it does not repair a faulty repeater or prove the mesh is +healthy. See the [official CLI reference](https://docs.meshcore.io/cli_commands/#view-or-change-this-nodes-loop-detection){ target="_blank" rel="noopener" }. -If everything is working, nearby companion nodes should receive the advert. +## Verify and bench-test ---- +1. Reconnect after reboot and sync the repeater clock. +2. Confirm the firmware version, device name, radio settings, path-hash mode, advert settings, and region configuration match the plan. +3. Send an advert and confirm a nearby companion receives it. +4. Reboot once more, reconnect, and confirm the saved configuration remains intact. +5. Verify remote administration from the intended companion before closing an enclosure or installing the repeater remotely. -**Tip:** -After every reboot, you must **resync the repeater’s clock**. -The repeater will still route messages without a clock, but **its adverts will be ignored** by companions that have already heard an advert from it until the time is set. +After every reboot, resync the repeater clock. Routing can continue without a correct clock, but stale advert timing can prevent companions from accepting a new advert. ---- +Do not deploy until the repeater passes this bench test and USB recovery remains practical. -## Changing Repeater Private Key After Initial Setup (not always needed) +## 1-byte identity changes -If your region is still using 1-byte mode, you may need to change your repeater's private key after you have set it up to avoid ID conflicts. If this happens, you can do it again via USB as per the [Configuring a MeshCore Repeater](#configuring-a-meshcore-repeater) section of this page, following the **[Generating a Repeater ID](generate-repeater-id.md)** instructions to pick a new ID and key. +Most operators should not change a repeater identity after setup. A region that still coordinates 1-byte IDs may direct an operator to do so. Follow [Generating a Repeater ID](generate-repeater-id.md) only when the local region operator confirms it is required, and keep the old private key as the rollback path. -*Optional*: If your repeater is on MeshCore Firmware v1.12.0 and up, you can set the private key remotely by logging in to the repeater console with a companion node that has admin access to your repeater. The steps are the same for USB and remote connections. If configuring remotely, make sure you have good signal to your repeater. ---- +## Before installation + +Keep the repeater on the bench until every verification passes. Then review the [mounting plan](../hardware/repeater-mounting-options.md) and retain physical USB access. + +## Sources + +- [Official MeshCore web flasher](https://meshcore.io/flasher) +- [Official MeshCore source and releases](https://github.com/meshcore-dev/MeshCore) +- [OTAFIX releases referenced by the community](https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX/releases) diff --git a/docs/meshcore/flash-room-server.fr.md b/docs/meshcore/flash-room-server.fr.md new file mode 100644 index 0000000..37f4998 --- /dev/null +++ b/docs/meshcore/flash-room-server.fr.md @@ -0,0 +1,126 @@ +--- +title: Programmer et configurer un serveur de salon +description: Sauvegardez, programmez, sécurisez, vérifiez et récupérez un serveur de salon MeshCore pris en charge avant son déploiement. +audience: + - room-server-operator + - network-operator +task: flash-room-server +scope: canada-baseline +status: draft +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 30-45 minutes +destructive: true +requires: + - supported-room-server-board + - data-capable-usb-cable +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Programmer et configurer un serveur de salon + +Confirmez que le programme officiel de mise à jour prend en charge votre modèle +exact de carte comme serveur de salon. Sauvegardez ensuite l’appareil, +programmez-le, sécurisez les accès et mettez-le à l’essai avec un compagnon. + +## Avant d’effacer l’appareil + +!!! danger "Erase Flash supprime les données enregistrées du serveur de salon" + L’effacement peut supprimer l’identité ou la clé privée de l’appareil, les données du salon, les accès invité et administrateur, le nom, les réglages radio et toute autre configuration enregistrée. Sauvegardez tout ce que vous devez conserver avant de continuer. + +Pour un appareil existant, notez la carte, la version du micrologiciel, le nom, +le rôle, les réglages radio et la configuration des accès. Exportez ou notez de +façon sécuritaire la clé privée avec un outil compatible. Conservez les mots de +passe dans un gestionnaire de mots de passe et ne les placez jamais dans une +capture d’écran, un billet ou une discussion. + +Si l’historique du salon ou l’identité ne peut pas être sauvegardé et doit être +conservé, arrêtez-vous avant l’effacement. + +## Avant de commencer + +- [ ] La carte exacte et le rôle **Room Server** sont confirmés. +- [ ] L’identité, les données du salon et les réglages existants ont été sauvegardés lorsque cette fonction est offerte. +- [ ] Un câble USB de données fiable et une alimentation stable sont disponibles. +- [ ] J’utilise un navigateur actuel compatible avec Web Serial, comme Chrome ou Edge. +- [ ] J’ai vérifié si la page de la communauté locale indique d’autres réglages radio. + +## Ce que la programmation change + +La programmation remplace le micrologiciel, et **Erase Flash** peut supprimer +l’identité, les données du salon, les réglages d’accès et la configuration +radio. La configuration inscrit les accès invité et administrateur, le nom et +les réglages radio locaux. + +## Plan de récupération + +Avant l’effacement, gardez à portée de main la méthode de récupération USB +propre à la carte exacte, l’identité et les réglages sauvegardés, un câble +fiable et l’artéfact **Room Server** vérifié. Si la programmation échoue, +remettez cette même carte en mode DFU et réessayez avec la même cible vérifiée; +n’utilisez pas les fichiers d’une autre carte. + +## Programmer le micrologiciel + +1. Ouvrez le [programme de mise à jour Web MeshCore](https://meshcore.io/flasher) officiel. +2. Sélectionnez le modèle exact de l’appareil. +3. Sélectionnez **Room Server** et la version destinée à cette carte. +4. Cliquez sur **Enter DFU Mode** et attendez que l’appareil attendu apparaisse. +5. Vérifiez de nouveau le matériel et le rôle sélectionnés. +6. Cliquez sur **Erase Flash** et attendez le message confirmant la réussite de l’effacement. +7. Cliquez sur **Flash** et attendez la fin de l’opération avant de débrancher l’appareil. + +Si la programmation échoue après l’effacement, laissez l’appareil branché, +actualisez le programme de mise à jour, retournez en mode DFU, confirmez la +carte et le rôle, puis réessayez **Flash**. Si l’appareil n’est plus détecté, +utilisez la procédure de récupération USB documentée de la carte. + +## Vérifier la programmation + +Le programme de mise à jour confirme la fin de l’opération, l’appareil redémarre +comme **Room Server** et **Configure via USB** peut s’y reconnecter. Sinon, +suivez le plan de récupération avant de configurer les accès. + +## Configurer le serveur de salon + +1. Après la programmation, cliquez sur **Configure via USB**. +2. Sélectionnez l’appareil série du serveur de salon et connectez-vous. +3. Donnez-lui un nom descriptif qui ne révèle pas un emplacement privé. +4. Créez des mots de passe invité et administrateur distincts et uniques, puis conservez-les de façon sécuritaire. + - Le mot de passe invité est remis aux personnes qui doivent accéder au salon. + - Le mot de passe administrateur contrôle la gestion et ne doit pas être utilisé comme mot de passe invité. +5. Vérifiez si la page de la communauté locale indique d’autres réglages. Si elle n’en indique aucun, utilisez les réglages par défaut du Canada : **USA/Canada (Recommended)** (`910.525 MHz / 62.5 kHz / SF7 / CR5`). +6. Enregistrez les réglages et redémarrez l’appareil. + +## S’assurer que tout fonctionne + +1. Reconnectez-vous à la console après le redémarrage et confirmez le nom, le rôle et les réglages radio. +2. Envoyez une annonce. +3. Confirmez qu’un compagnon découvre le serveur de salon. +4. Ouvrez une session depuis le compagnon avec le mot de passe invité. +5. Confirmez que le salon fonctionne comme prévu, puis redémarrez de nouveau l’appareil et vérifiez que les réglages sont conservés. + +Ne déployez pas le serveur à distance avant que la récupération USB, l’accès +administrateur, la découverte et l’accès invité fonctionnent tous sur l’établi. + +## Récupération et retour en arrière + +Si la découverte, l’accès invité, l’accès administrateur ou la conservation des +réglages échoue, gardez le serveur à proximité et accessible par USB. Restaurez +l’identité et les réglages sauvegardés lorsque cette fonction est offerte, ou +reprogrammez par USB la cible **Room Server** exacte, puis reprenez la +vérification. Ne déployez pas un serveur dont l’accès ou la récupération est +incertain. + +## Prochaine étape + +Lorsque le serveur résiste au redémarrage et réussit les vérifications d’accès, +[trouvez la communauté locale](../provinces/index.md) et consignez le nom de la +personne qui entretient le salon ainsi que son dossier de récupération. + +## Sources + +- [Programme de mise à jour Web officiel de MeshCore](https://meshcore.io/flasher) +- [Code source et versions officielles de MeshCore](https://github.com/meshcore-dev/MeshCore) diff --git a/docs/meshcore/flash-room-server.md b/docs/meshcore/flash-room-server.md index 847d9f6..f9463f0 100644 --- a/docs/meshcore/flash-room-server.md +++ b/docs/meshcore/flash-room-server.md @@ -1,48 +1,101 @@ +--- +title: Flash and configure a room server +description: Back up, flash, secure, verify, and recover a supported MeshCore Room Server before deployment. +audience: + - room-server-operator + - network-operator +task: flash-room-server +scope: canada-baseline +status: draft +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: intermediate +estimated_time: 30-45 minutes +destructive: true +requires: + - supported-room-server-board + - data-capable-usb-cable +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Flash and configure a room server -← Home +Confirm that the official flasher supports your exact board as a room server, +then back it up, flash it, secure access, and test it with a companion. -Follow these steps to flash and configure a device as a MeshCore Room Server: +## Before you erase -## Configuring a Room Server -### Flashing the Firmware -1. Open the MeshCore Web Flasher: [Link](https://meshcore.io/flasher) +!!! danger "Erase Flash deletes stored room-server data" + Erasing can remove the device identity/private key, room data, guest and admin access, name, radio settings, and other saved configuration. Back up anything you need before continuing. -2. Select your device from the list. +For an existing device, record the board, firmware version, name, role, radio settings, and access configuration. Export or securely record the private key with a supported tool. Store passwords in a password manager and never place them in screenshots, issues, or chat. -3. Select **Room Server**. +If room history or identity cannot be backed up and must be retained, stop before erasing. -4. Click **Enter DFU Mode**. +## Before you start -5. Click **Erase Flash** and wait for it to complete. +- [ ] The exact board and **Room Server** role are confirmed. +- [ ] Existing identity, room data, and settings are backed up where supported. +- [ ] A known-good data USB cable and stable power are available. +- [ ] I am using a current browser with Web Serial support, such as Chrome or Edge. +- [ ] I checked the local community page for radio-setting overrides. -6. Select your desired version (the most recent is generally recommended). +## What flashing changes -7. Click **Flash**. +Flashing replaces firmware and **Erase Flash** can delete identity, room data, access settings, and radio configuration. Setup writes guest/admin access, name, and local radio settings. -**Note:** Sometimes after erasing, the flash step may fail. +## Recovery plan -If this happens, refresh the page, click **Enter DFU Mode** again, and then click **Flash** to retry. +Keep the exact board's USB recovery method, backed-up identity/settings, a known-good cable, and the verified Room Server artifact available before erasing. If flashing fails, return the same board to DFU mode and retry the same verified target; do not switch board files. -### Configuring the Room Server -8. After the flash is complete, click **Configure via USB**. - -9. Select your console device and click **Connect**. +## Flash the firmware -10. Set a descriptive **name** for your Room Server. +1. Open the official [MeshCore Web Flasher](https://meshcore.io/flasher). +2. Select the exact device model. +3. Select **Room Server** and the intended version for that board. +4. Click **Enter DFU Mode** and wait for the expected device to appear. +5. Recheck the hardware and role selections. +6. Click **Erase Flash** and wait for a successful erase message. +7. Click **Flash** and wait for completion before disconnecting. -11. Set both a **guest password** and an **admin password**. +If flashing fails after erase, leave the device connected, refresh the flasher, re-enter DFU mode, confirm the board and role, and retry **Flash**. Use the board's documented USB recovery process if it is no longer detected. -12. Choose your radio preset: in Ottawa use **USA/Canada (Recommended)**. +## Check the flash -13. Click **Save Settings**. +The flasher reports completion, the device restarts as a Room Server, and **Configure via USB** can reconnect. Otherwise use the recovery plan before setting access details. -14. Click **Reboot**. +## Configure the Room Server -### Final Steps -15. Connect back to the device via the console. +1. After flashing, click **Configure via USB**. +2. Select the room server's serial device and connect. +3. Set a descriptive name that does not expose a private location. +4. Set separate, unique guest and admin passwords and store them securely. + - The guest password is shared with people who should enter the room. + - The admin password controls management access and should not be shared as the guest password. +5. Check the local community page for different settings. If none are listed, use the Canada defaults: **USA/Canada (Recommended)** (`910.525 MHz / 62.5 kHz / SF7 / CR5`). +6. Save settings and reboot. -16. Click **Send Advert**. +## Make sure it works -17. On your companion node, it should now discover your Room Server. +1. Reconnect to the console after reboot and confirm the name, role, and radio settings. +2. Send an advert. +3. Confirm a companion discovers the Room Server. +4. Log in from the companion with the guest password. +5. Confirm the intended room behavior, then reboot once more and verify the settings persist. -18. Log in from your companion node to the Room Server using the **guest password**. +Do not deploy the server remotely until USB recovery, admin access, discovery, and guest access all work on the bench. + + +## Recovery and undo + +If discovery, guest access, admin access, or persistence fails, keep the server local and reachable by USB. Restore the backed-up identity/settings where supported or reflash the exact Room Server target by USB, then repeat verification. Do not deploy a server whose access path or recovery is uncertain. + +## What's next + +After the server survives reboot and access checks, [find the local community](../provinces/index.md) and document who maintains the room and its recovery record. + +## Sources + +- [Official MeshCore web flasher](https://meshcore.io/flasher) +- [Official MeshCore source and releases](https://github.com/meshcore-dev/MeshCore) diff --git a/docs/meshcore/general-faq.fr.md b/docs/meshcore/general-faq.fr.md new file mode 100644 index 0000000..7728fa2 --- /dev/null +++ b/docs/meshcore/general-faq.fr.md @@ -0,0 +1,135 @@ +--- +title: Questions et réponses sur MeshCore +description: Trouvez des réponses brèves sur les réglages canadiens, le matériel, la portée, l’adhésion à un réseau maillé et les problèmes d’observateur. +audience: + - newcomer + - meshcore-user + - mesh-operator +task: answer-meshcore-question +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +evidence: + - Existing MeshCore Canada FAQ + - Current configurator and setup guides +difficulty: beginner +estimated_time: 2-10 minutes +destructive: false +--- + +# Questions et réponses sur MeshCore + +Utilisez la commande **Rechercher** de votre navigateur pour parcourir cette +page, ou choisissez un sujet ci-dessous. Comme les réglages peuvent varier +d’un endroit à l’autre, cette page renvoie à la source actuelle plutôt que de +répéter des valeurs qui pourraient changer. + +- [Réglages et portée](#settings-and-range) +- [Matériel](#hardware) +- [Rejoindre ou démarrer un réseau maillé](#joining-or-starting-a-mesh) +- [Observateurs et dépannage](#observers-and-troubleshooting) + +## Réglages et portée { #settings-and-range } + +### Quels réglages radio dois-je utiliser au Canada? + +Pour un répéteur, utilisez le [configurateur de répéteur](../config/index.md). Il +fournit les réglages actuels pour l’emplacement choisi. + +Quel que soit le rôle de l’appareil, consultez d’abord le +[répertoire des communautés](../provinces/index.md). Lorsqu’une communauté +publie des réglages locaux différents, suivez-les. + +### Qu’est-ce que le mode de hachage des parcours? + +Il détermine la taille des identifiants utilisés dans les parcours d’annonce. +La norme canadienne des régions et le configurateur fournissent le réglage +actuel d’un répéteur. Utilisez leur résultat plutôt que de copier une ancienne +commande provenant d’une discussion ou d’une capture d’écran. + +[Lire la norme des régions](../config/standard.md). + +### Dois-je détenir un certificat d’opérateur radioamateur? + +MeshCore Canada ne peut pas déterminer quelle autorisation s’applique à votre +station. Vous êtes responsable de respecter les règles concernant la fréquence, +la puissance, l’antenne et l’utilisation à votre emplacement. + +Commencez par les [liens canadiens sur la réglementation](../resources/links.md#radio-and-regulatory-references). +En cas de doute, consultez une personne qualifiée de votre région avant de +transmettre. + +### À quelle portée puis-je m’attendre? + +La portée dépend de la qualité et de l’emplacement de l’antenne, de la hauteur, +du terrain, des bâtiments, de l’interférence et de la visibilité directe. +Faites un essai avec un nœud fiable situé à proximité avant de conclure que +l’appareil ou le micrologiciel est défectueux. + +## Matériel { #hardware } + +### Quels appareils fonctionnent avec MeshCore? + +Choisissez un appareil offert dans le programme officiel de mise à jour +MeshCore ou dans un guide de MeshCore Canada pour le rôle voulu. La prise en +charge dépend de la carte et de la cible du micrologiciel. + +[Comparez les rôles des appareils](../start/choose-a-goal.md), puis ouvrez le +guide du matériel depuis le guide de configuration correspondant. + +### Puis-je réutiliser un appareil qui exécute actuellement Meshtastic? + +Certaines cartes prises en charge peuvent être reprogrammées avec le +micrologiciel MeshCore. Un appareil qui exécute encore le micrologiciel +Meshtastic ne peut pas rejoindre un réseau MeshCore. + +Confirmez le modèle exact de la carte dans le programme officiel de mise à jour +avant de le modifier, puis sauvegardez tout ce que vous devez conserver. + +### Quelle carte devrais-je acheter en premier? + +Choisissez d’abord le rôle. Pour un compagnon, privilégiez un appareil pris en +charge que des membres de votre communauté connaissent déjà. Pour un service +fixe, la fiabilité de l’alimentation, l’emplacement de l’antenne et la facilité +d’entretien comptent davantage que les fonctions de l’écran. + +## Rejoindre ou démarrer un réseau maillé { #joining-or-starting-a-mesh } + +### Comment rejoindre un réseau maillé à proximité? + +1. [Trouvez la communauté locale](../provinces/index.md). +2. Suivez ses réglages publiés ou utilisez les réglages canadiens par défaut + lorsqu’aucun remplacement local n’est indiqué. +3. Suivez le guide correspondant au [rôle de votre appareil](../start/choose-a-goal.md). +4. Envoyez une annonce et demandez à une personne à proximité de confirmer sa réception. + +### Comment démarrer un réseau maillé lorsqu’aucun n’est répertorié? + +Commencez avec des compagnons pour permettre aux gens de faire des essais +locaux. Ajoutez une infrastructure fixe seulement après avoir vérifié +l’emplacement, l’alimentation, la coordination locale et la norme des régions. + +Lorsque le groupe est prêt, [ajoutez la communauté](../contributing.md) afin que +d’autres personnes puissent trouver son état, ses coordonnées et tout réglage +local révisé. + +## Observateurs et dépannage { #observers-and-troubleshooting } + +### Pourquoi mon observateur n’affiche-t-il aucun paquet? + +Une connexion au service de données ne prouve pas que la radio reçoit le trafic +à proximité. Vérifiez les réglages radio choisis, la publication des paquets, +l’activité de l’appareil et le résultat final de la vérification. + +Utilisez [Vérifier votre observateur](../analyzer/verify.md), puis suivez le +[guide de dépannage de l’observateur](../analyzer/troubleshooting.md) selon le +symptôme constaté. + +### Où puis-je poser une autre question? + +La page [Obtenir de l’aide](../start/get-help.md) indique la façon la plus +rapide d’obtenir du soutien. Retirez les mots de passe, les clés privées, les +emplacements privés précis et toute autre donnée sensible avant de partager +des captures d’écran ou des journaux. diff --git a/docs/meshcore/general-faq.md b/docs/meshcore/general-faq.md index f2f4dbd..1c9c919 100644 --- a/docs/meshcore/general-faq.md +++ b/docs/meshcore/general-faq.md @@ -1,75 +1,125 @@ -# MeshCore FAQ +--- +title: MeshCore questions and answers +description: Find short answers about Canadian settings, hardware, range, joining a mesh, and observer problems. +audience: + - newcomer + - meshcore-user + - mesh-operator +task: answer-meshcore-question +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +evidence: + - Existing MeshCore Canada FAQ + - Current configurator and setup guides +difficulty: beginner +estimated_time: 2-10 minutes +destructive: false +--- -## General +# MeshCore questions and answers -??? question "What frequencies does MeshCore use in Canada?" - MeshCore Canada communities should start with the **USA/Canada (Recommended)** preset. +Search this page with your browser's **Find** command, or choose a topic below. +Settings can differ by place, so this page links to the current source instead +of repeating values that may change. - If your app or config tool shows raw radio values instead of a named preset, use: +- [Settings and range](#settings-and-range) +- [Hardware](#hardware) +- [Joining or starting a mesh](#joining-or-starting-a-mesh) +- [Observers and troubleshooting](#observers-and-troubleshooting) - | Field | Value | - |-------|-------| - | Frequency | `910.525 MHz` | - | Bandwidth | `62.5 kHz` | - | Spreading Factor | `SF7` | - | Coding Rate | `5` | +## Settings and range - Always check your local province or community page in case a nearby mesh publishes a different setting. +### Which radio settings should I use in Canada? -??? question "What is 3-byte path hash mode?" - MeshCore adverts include compact path identifiers. MeshCore Canada recommends **3-byte** path hashes because larger repeater-backed networks have more room for unique identifiers than with the legacy 1-byte setting. +Use the [repeater configurator](../config/index.md) for a repeater. It returns +the current settings for the location you select. - In the MeshCore CLI, use: +For any role, first check the [community directory](../provinces/index.md). +Follow a published local override when one exists. - ```text - set path.hash.mode 2 - ``` +### What is path hash mode? -??? question "Do I need a ham radio license?" - MeshCore Canada cannot give legal advice. Most Canadian MeshCore community docs assume licence-exempt LoRa operation in the appropriate ISM band, but you are responsible for using legal frequencies, power levels, antennas, and duty cycle in your location. +It controls the size of the identifiers used in advert paths. The Canadian +region standard and configurator provide the current setting for a repeater. +Use their output instead of copying an old command from a discussion or +screenshot. - If you are operating as an amateur radio station or using non-standard equipment, check the current ISED rules and local amateur radio guidance before transmitting. +[Read the region standard](../config/standard.md). -??? question "What range should I expect?" - Range depends heavily on antenna quality, height, terrain, obstructions, noise floor, and line of sight. A handheld device indoors may only cover a neighborhood. A well-placed outdoor repeater with a clear antenna view can cover much more. +### Do I need an amateur radio licence? - For troubleshooting, compare against a nearby known-good node before assuming the firmware or MQTT path is broken. +MeshCore Canada cannot decide which authorization applies to your station. +You are responsible for legal frequency, power, antenna, and operating choices +in your location. + +Start with the [Canadian regulatory links](../resources/links.md#radio-and-regulatory-references). +If you are unsure, ask a qualified local operator before transmitting. + +### What range should I expect? + +Range depends on antenna quality and placement, height, terrain, buildings, +interference, and line of sight. Test against a nearby known-good node before +assuming the device or firmware is faulty. ## Hardware -??? question "What devices are compatible with MeshCore?" - Use devices listed by the MeshCore Flasher or by a MeshCore Canada build guide for the role you need. Compatibility varies by radio chip, flash size, board wiring, display, battery hardware, and WiFi support. +### Which devices work with MeshCore? + +Use a device listed by the official MeshCore Flasher or by a MeshCore Canada +guide for the role you need. Support depends on the board and firmware target. + +[Compare device roles](../start/choose-a-goal.md), then open the hardware guide +from the matching setup guide. + +### Can I reuse a device that currently runs Meshtastic? + +Some supported boards can be reflashed with MeshCore firmware. A device still +running Meshtastic firmware will not join a MeshCore network. + +Confirm the exact board in the official flasher before changing it, and back up +anything you need to keep. + +### Which board should I buy first? + +Choose the role first. For a companion, prefer a supported device with nearby +community experience. For a fixed service, reliable power, antenna placement, +and maintainability matter more than screen features. + +## Joining or starting a mesh - Standalone MQTT observer firmware targets WiFi-capable LoRa boards published by the [observer flasher setup guide](../analyzer/builds/mqtt-firmware.md). +### How do I join a nearby mesh? -??? question "Can I use my Meshtastic device with MeshCore?" - Sometimes, but it must be flashed with MeshCore firmware and supported by the MeshCore build you choose. A device running Meshtastic firmware will not join a MeshCore mesh. +1. [Find the local community](../provinces/index.md). +2. Follow its published settings or use the Canadian baseline when no override + is listed. +3. Follow the setup guide for your [device role](../start/choose-a-goal.md). +4. Send an advert and ask a nearby user to confirm it. - Back up any identity or configuration you care about before reflashing. Treat a first MeshCore flash as a new setup. +### How do I start a mesh where none is listed? -??? question "Which board should I buy first?" - For a first companion, choose a board or ready-made device that is listed in the official MeshCore tools and has community support near you. For a fixed repeater or observer, prioritize stable power, a good antenna path, and remote access over display features. +Begin with companions so people can test locally. Add fixed infrastructure only +after checking placement, power, local coordination, and the region standard. -## Network +When the group is ready, [add the community](../contributing.md) so others can +find its status, contact information, and any reviewed local settings. -??? question "How do I join an existing mesh network?" - 1. Find your local page in the [Mesh Directory](../provinces/index.md). - 2. Set the radio preset to **USA/Canada (Recommended)** unless the local page says otherwise. - 3. Set path hash mode to **3-byte**. - 4. Reboot the device after changing radio settings. - 5. Send an advert and check whether nearby users can see you. +## Observers and troubleshooting -??? question "How do I set up a new mesh in my area?" - Use the MeshCore Canada baseline unless you have a local reason to publish a different setting: +### Why does my observer show no packets? - ```text - set radio 910.525,62.5,7,5 - set path.hash.mode 2 - ``` +A connection to the data service does not prove the radio is hearing nearby +traffic. Check the selected radio settings, packet publishing, device activity, +and the final verification result. - Then open an update request through [Contributing](../contributing.md) so the directory can list the community, region, status, contacts, and any setting differences. +Use [Check your observer](../analyzer/verify.md), then follow +[observer troubleshooting](../analyzer/troubleshooting.md) for the symptom you +see. -??? question "Why does my observer show no packets?" - A broker connection only proves the observer reached MQTT. It may still hear no mesh traffic if the radio preset is wrong, path hash mode is wrong, packet publishing is disabled, or no nearby nodes are active. +### Where can I ask another question? - Use [Check Your Observer](../analyzer/verify.md) and [Troubleshooting](../analyzer/troubleshooting.md) to narrow the symptom. +Use [Get help](../start/get-help.md) for the shortest support route. Remove +passwords, private keys, precise private locations, and other sensitive values +before sharing screenshots or logs. diff --git a/docs/meshcore/general-howto.fr.md b/docs/meshcore/general-howto.fr.md new file mode 100644 index 0000000..d453d82 --- /dev/null +++ b/docs/meshcore/general-howto.fr.md @@ -0,0 +1,189 @@ +--- +title: Effectuer les tâches courantes dans l’application MeshCore +description: Partagez ou importez un contact, tracez un parcours et examinez les répétitions reçues dans l’application MeshCore. +audience: + - companion-user + - meshcore-user +task: use-meshcore-app-tools +scope: upstream-meshcore +status: draft +owner: docs-app +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 5-15 minutes +destructive: false +--- + +# Effectuer les tâches courantes dans l’application MeshCore + +Choisissez la tâche dont vous avez besoin. Ces captures d’écran montrent une +version de l’interface de l’application mobile MeshCore; les noms et les +emplacements peuvent changer dans une autre version. + +- [Partager votre lien de contact](#share-your-contact-link) +- [Importer un lien de contact](#import-a-contact-link) +- [Tracer un parcours](#trace-a-path) +- [Vérifier les répétitions reçues](#check-heard-repeats) + +
+ +**Avant de commencer** + +- Connectez l’application à votre compagnon. +- Confirmez que le compagnon peut envoyer et recevoir des messages localement. +- Ne publiez aucune clé privée, aucun mot de passe ni aucun emplacement privé + précis dans une capture d’écran partagée. + +
+ +## Partager votre lien de contact { #share-your-contact-link } + +Utilisez cette procédure lorsqu’une autre personne veut vous ajouter comme +contact. + +1. Ouvrez l’application MeshCore et connectez-la à votre compagnon. + +2. Ouvrez l’écran **Signal**. + + ![Commande Signal dans l’application MeshCore](images/MeshCore_GetContactID1.png){ loading=lazy width="300" } + +3. Sélectionnez **Advert**, puis **To Clipboard**. + + ![Menu Advert avec l’action To Clipboard](images/MeshCore_GetContactID2.png){ loading=lazy width="300" } + +4. Collez le lien de contact dans la conversation où vous souhaitez le partager. + +
+ +Le presse-papiers contient maintenant un lien de contact qu’une autre personne +utilisant MeshCore peut importer. + +
+ +## Importer un lien de contact { #import-a-contact-link } + +Utilisez cette procédure après qu’une autre personne vous a envoyé son lien de +contact MeshCore. + +1. Copiez uniquement le lien de contact, puis connectez l’application à votre compagnon. + +2. Ouvrez le menu **à trois points**. + + ![Menu à trois points de l’application MeshCore](images/MeshCore_AddContactMan1.png){ loading=lazy width="300" } + +3. Sélectionnez **Add Contact**. + + ![Action « Add Contact » de l’application MeshCore](images/MeshCore_AddContactMan2.png){ loading=lazy width="300" } + +4. Sélectionnez **Import from Clipboard Link**. + + ![Action « Import from Clipboard Link » de l’application MeshCore](images/MeshCore_AddContactMan3.png){ loading=lazy width="300" } + +5. Attendez le message de réussite et l’avis de nouveau contact. + + ![Message confirmant l’importation du contact](images/MeshCore_AddContactMan5.png){ loading=lazy width="300" } + +
+ +Le contact nommé apparaît maintenant dans votre liste de contacts. + +
+ +Si l’importation échoue, copiez de nouveau le lien d’origine sans ajouter de +mots ni de signes de ponctuation. Si le problème persiste, demandez un nouveau +lien à la personne qui vous l’a envoyé. + +## Tracer un parcours { #trace-a-path } + +Un traçage manuel vérifie la suite de répéteurs que vous choisissez. Il ne +garantit pas que les messages suivants emprunteront le même parcours. + +### Ouvrir l’outil de traçage + +1. Connectez l’application à votre compagnon. +2. Ouvrez le menu **à trois points**. + + ![Menu à trois points de l’application MeshCore](images/MeshCore_TraceRoute1.png){ loading=lazy width="300" } + +3. Sélectionnez **Tools**. + + ![Action « Tools » de l’application MeshCore](images/MeshCore_TraceRoute2.png){ loading=lazy width="300" } + +4. Sélectionnez **Trace Path - Manual**. + + ![Action de traçage manuel du parcours](images/MeshCore_TraceRoute3.png){ loading=lazy width="300" } + +5. Sélectionnez le bouton **plus** pour ajouter un répéteur. + + ![Bouton d’ajout d’un répéteur dans l’outil de traçage manuel](images/MeshCore_TraceRoute4.png){ loading=lazy width="300" } + +### Tracer un parcours par un seul répéteur + +1. Ajoutez un répéteur et assurez-vous que son identifiant est exact. + + ![Un répéteur sélectionné pour le traçage](images/MeshCore_TraceRoute1Hop1.png){ loading=lazy width="300" } + + ![Parcours à un saut prêt à être tracé](images/MeshCore_TraceRoute1Hop2.png){ loading=lazy width="300" } + +2. Sélectionnez **Trace Path**. + +### Tracer un parcours par plusieurs répéteurs + +1. Ajoutez les répéteurs dans l’ordre aller requis par le parcours. +2. Ajoutez la suite de retour indiquée dans le plan de parcours local. + + ![Plusieurs répéteurs sélectionnés pour le traçage](images/MeshCore_TraceRoute2Hop1.png){ loading=lazy width="300" } + +3. Vérifiez la suite complète, puis sélectionnez **Trace**. + + ![Parcours à plusieurs sauts prêt à être tracé](images/MeshCore_TraceRoute2Hop2.png){ loading=lazy width="300" } + +4. Lisez le résultat. + + ![Résultat d’un traçage à plusieurs sauts](images/MeshCore_TraceRoute2Hop3.png){ loading=lazy width="300" } + +Si vous ne connaissez pas les suites aller et retour, demandez à la communauté +locale plutôt que de deviner les identifiants des répéteurs. + +## Vérifier les répétitions reçues { #check-heard-repeats } + +Une répétition reçue signifie que votre compagnon a reçu une copie répétée d’un +paquet qu’il a envoyé. L’absence d’une répétition n’indique pas à elle seule où +le paquet s’est arrêté : le répéteur peut ne pas avoir reçu le paquet, ou votre +compagnon peut ne pas avoir reçu la copie répétée. + +1. Envoyez un message dans le canal voulu. +2. Lorsque **Heard _n_ repeats** apparaît sous le message, maintenez le doigt + sur celui-ci. + + ![Message affichant le nombre de répétitions reçues](images/MeshCore_HeardRepeats_Step1.png){ loading=lazy width="300" } + +3. Sélectionnez **Heard Repeats**. + + ![Action « Heard Repeats » de l’application MeshCore](images/MeshCore_HeardRepeats_Step2.png){ loading=lazy width="300" } + +4. Examinez les répéteurs que votre compagnon a reçus pour ce paquet. + + ![Liste des répéteurs reçus par le compagnon](images/MeshCore_HeardRepeats_Step3.png){ loading=lazy width="300" } + +5. Sélectionnez un répéteur pour examiner le parcours indiqué. + +### Exemple : répétition directe + +![Schéma d’une répétition directe reçue](images/MeshCore_HeardRepeats_Direct.png){ loading=lazy width="300" } + +![Affichage d’une répétition directe reçue dans l’application](images/MeshCore_HeardRepeats_Step4_1Repeat.png){ loading=lazy width="300" } + +### Exemple : répétition à plusieurs sauts + +![Schéma d’une répétition reçue à plusieurs sauts](images/MeshCore_HeardRepeats_MultiHop.png){ loading=lazy width="300" } + +![Affichage d’une répétition reçue à plusieurs sauts dans l’application](images/MeshCore_HeardRepeats_Step4_2Repeat.png){ loading=lazy width="300" } + +## Besoin d’aide? + +Si un résultat manque ou n’est pas clair, comparez-le avec un nœud fiable situé +à proximité, puis consultez [Obtenir de l’aide](../start/get-help.md). Indiquez +la tâche et l’étape où vous vous êtes arrêté, mais retirez les données sensibles +avant de partager une capture d’écran. diff --git a/docs/meshcore/general-howto.md b/docs/meshcore/general-howto.md index f453a5e..edc620b 100644 --- a/docs/meshcore/general-howto.md +++ b/docs/meshcore/general-howto.md @@ -1,177 +1,184 @@ -# MeshCore How To - -The MeshCore How-To section provides simple, step-by-step guides for the most common tasks you’ll perform on the Ottawa MeshCore network. -Whether you’re sharing your contact URL, importing someone else’s, or tracing multi-hop routes across the mesh, each walkthrough is designed to be clear, visual, and easy to follow. - -These instructions use the MeshCore mobile app and apply to both new and experienced users. More guides will be added as the platform grows and as the community discovers useful workflows. - -## How to Share Your Contact URL - -1. Open the MeshCore app and connect to your node. - -2. Tap the **Signal** icon. - - ![](images/MeshCore_GetContactID1.png){ width="300" } - -3. Tap **Advert → To Clipboard**. - - ![](images/MeshCore_GetContactID2.png){ width="300" } - -4. Paste your contact URL anywhere you want to share it. - +--- +title: Do common tasks in the MeshCore app +description: Share a contact, import a contact, trace a path, and inspect heard repeats in the MeshCore app. +audience: + - companion-user + - meshcore-user +task: use-meshcore-app-tools +scope: upstream-meshcore +status: draft +owner: docs-app +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +difficulty: beginner +estimated_time: 5-15 minutes +destructive: false --- -## How to Import a Contact URL +# Do common tasks in the MeshCore app -1. Open the app and connect to your node. +Choose the task you need. These screenshots show one MeshCore mobile app +layout; names and positions may move in another app version. -2. Tap the **three dots**. +- [Share your contact link](#share-your-contact-link) +- [Import a contact link](#import-a-contact-link) +- [Trace a path](#trace-a-path) +- [Check heard repeats](#check-heard-repeats) - ![](images/MeshCore_AddContactMan1.png){ width="300" } +
-3. Tap **Add Contact**. +**Before you start** - ![](images/MeshCore_AddContactMan2.png){ width="300" } +- Connect the app to your companion. +- Confirm the companion can send and receive locally. +- Do not post private keys, passwords, or precise private locations in a shared + screenshot. -4. Tap **Import from Clipboard Link**. +
- ![](images/MeshCore_AddContactMan3.png){ width="300" } +## Share your contact link -5. After a few seconds you will see: +Use this when another person needs to add you as a contact. - **"Success - contact has been imported"** +1. Open the MeshCore app and connect to your companion. - ![](images/MeshCore_AddContactMan5.png){ width="300" } +2. Open the **Signal** screen. -6. A second popup appears: + ![Signal control in the MeshCore app](images/MeshCore_GetContactID1.png){ loading=lazy width="300" } - **"New Contact Discovered \"** +3. Select **Advert**, then **To Clipboard**. -7. The contact is now added. + ![Advert menu with the To Clipboard action](images/MeshCore_GetContactID2.png){ loading=lazy width="300" } ---- +4. Paste the contact link into the conversation where you intend to share it. -## How to Trace Route to a Node (1 Hop) +
-1. Open the app and connect. +The clipboard now contains a contact link that another MeshCore user can +import. -2. Tap the **three dots**. +
- ![](images/MeshCore_TraceRoute1.png){ width="300" } +## Import a contact link -3. Tap **Tools**. +Use this after another person sends you their MeshCore contact link. - ![](images/MeshCore_TraceRoute2.png){ width="300" } +1. Copy only the contact link, then connect the app to your companion. -4. Tap **Trace Path - Manual**. +2. Open the **three-dot** menu. - ![](images/MeshCore_TraceRoute3.png){ width="300" } + ![Three-dot menu in the MeshCore app](images/MeshCore_AddContactMan1.png){ loading=lazy width="300" } -5. Tap the **plus button**. +3. Select **Add Contact**. - ![](images/MeshCore_TraceRoute4.png){ width="300" } + ![Add Contact action](images/MeshCore_AddContactMan2.png){ loading=lazy width="300" } -6. Select a repeater and confirm. +4. Select **Import from Clipboard Link**. - ![](images/MeshCore_TraceRoute1Hop1.png){ width="300" } + ![Import from Clipboard Link action](images/MeshCore_AddContactMan3.png){ loading=lazy width="300" } -7. You will see one repeater ID; this indicates a **1-hop trace**. +5. Wait for the success message and the new-contact notice. - ![](images/MeshCore_TraceRoute1Hop2.png){ width="300" } + ![Successful contact import message](images/MeshCore_AddContactMan5.png){ loading=lazy width="300" } -8. Tap **Trace Path**. +
---- +The named contact now appears in your contact list. -## How to Trace Route to a Node (2+ Hops) +
-1. Open the app and connect. +If the import fails, copy the original link again without extra words or +punctuation. Ask the sender for a new link if it still fails. -2. Tap the **three dots**. +## Trace a path - ![](./images/MeshCore_TraceRoute1.png){ width="300" } +A manual trace tests the repeater sequence you select. It does not guarantee +that later messages will use the same path. -3. Tap **Tools**. +### Open the trace tool - ![](./images/MeshCore_TraceRoute2.png){ width="300" } +1. Connect the app to your companion. +2. Open the **three-dot** menu. -4. Tap **Trace Path - Manual**. + ![Three-dot menu in the MeshCore app](images/MeshCore_TraceRoute1.png){ loading=lazy width="300" } - ![](./images/MeshCore_TraceRoute3.png){ width="300" } +3. Select **Tools**. -5. Tap the **plus button**. + ![Tools action in the MeshCore app](images/MeshCore_TraceRoute2.png){ loading=lazy width="300" } - ![](./images/MeshCore_TraceRoute4.png){ width="300" } +4. Select **Trace Path - Manual**. -6. Select repeaters **in order**: - - Choose the **forward path** - - Confirm - - Re-open the add menu and choose the **return path** - - Or manually enter IDs: - - Example: `d3, f3, d3` + ![Manual trace-path action](images/MeshCore_TraceRoute3.png){ loading=lazy width="300" } - ![](./images/MeshCore_TraceRoute2Hop1.png){ width="300" } +5. Select the **plus** button to add a repeater. -7. Confirm both forward and return paths, then tap **Trace**. + ![Add-repeater button in the manual trace tool](images/MeshCore_TraceRoute4.png){ loading=lazy width="300" } - ![](./images/MeshCore_TraceRoute2Hop2.png){ width="300" } +### Trace one repeater -8. View the results. +1. Add one repeater and make sure its identifier is correct. - ![](./images/MeshCore_TraceRoute2Hop3.png){ width="300" } + ![One repeater selected for a trace](images/MeshCore_TraceRoute1Hop1.png){ loading=lazy width="300" } ---- + ![One-hop path ready to trace](images/MeshCore_TraceRoute1Hop2.png){ loading=lazy width="300" } -## Heard Repeats +2. Select **Trace Path**. -In the MeshCore app you can generally see if a repeater heard your message you sent in a channel. +### Trace through several repeaters -When you send a message out, the packet travels through the airwaves, hits a nearby repeater and it then repeats your packet out. If your companion is within receiving range and receives the repeated packet, it will be counted as a "heard repeat". There are cases where your companion could miss the heard repeat. Examples of this are: +1. Add the repeaters in the forward order required by the path. +2. Add the return sequence shown by your local path plan. -- Your packet makes it to the repeater, but the repeated packet dosn't make it back to your companion. + ![Multiple repeaters selected for a trace](images/MeshCore_TraceRoute2Hop1.png){ loading=lazy width="300" } -- If you're using a 1w companion, and the repeater that hears you is a 0.3w. You can send further than it - so while your packet may make it to the repeater, when it repeats your packet it's possible you're too far away to receive it. +3. Review the complete sequence, then select **Trace**. -## How to Check Heard Repeats + ![Multi-hop path ready to trace](images/MeshCore_TraceRoute2Hop2.png){ loading=lazy width="300" } -1. Send a message in the public channel. +4. Read the result. -2. When the app shows **Heard X repeats** under your message, press and hold the message. + ![Multi-hop trace result](images/MeshCore_TraceRoute2Hop3.png){ loading=lazy width="300" } - ![](images/MeshCore_HeardRepeats_Step1.png){ width="300" } +If you do not know the forward and return sequence, ask the local community +instead of guessing repeater identifiers. -3. Tap **Heard Repeats**. +## Check heard repeats - ![](images/MeshCore_HeardRepeats_Step2.png){ width="300" } +A heard repeat means your companion received a repeated copy of a packet it +sent. A missing repeat does not by itself show where the packet failed: the +repeater may not have heard the packet, or your companion may not have heard +the repeated copy. -4. You’ll see a list of every repeater your companion heard repeating that packet. +1. Send a message in the intended channel. +2. When **Heard _n_ repeats** appears below the message, press and hold the + message. - ![](images/MeshCore_HeardRepeats_Step3.png){ width="300" } + ![Message showing a heard-repeat count](images/MeshCore_HeardRepeats_Step1.png){ loading=lazy width="300" } -5. Tap a repeater in the list to view the path the repeated packet took to get back to you. See Below for the types of repeats you will see. +3. Select **Heard Repeats**. -**Notes** + ![Heard Repeats action](images/MeshCore_HeardRepeats_Step2.png){ loading=lazy width="300" } -- A repeater may show up as a **direct hop** if it is close to you. -- You may also see a **distant repeater** listed. This happens when that repeater hears your packet shortly after another one and your companion hears both repeats. -- Your companion must hear the repeater *directly* for it to appear in this list. +4. Review the repeaters your companion heard for that packet. ---- + ![List of repeaters heard by the companion](images/MeshCore_HeardRepeats_Step3.png){ loading=lazy width="300" } -### Direct Heard Packet +5. Select a repeater to inspect the reported path. -Below is an example of what a direct heard packet looks like: +### Example: direct repeat - ![Diagram Explanation](images/MeshCore_HeardRepeats_Direct.png){ width="300" } +![Diagram of a direct heard repeat](images/MeshCore_HeardRepeats_Direct.png){ loading=lazy width="300" } - ![Companion View](images/MeshCore_HeardRepeats_Step4_1Repeat.png){ width="300" } +![App view of a direct heard repeat](images/MeshCore_HeardRepeats_Step4_1Repeat.png){ loading=lazy width="300" } ---- +### Example: multi-hop repeat -### Multi Hop Heard Packet +![Diagram of a multi-hop heard repeat](images/MeshCore_HeardRepeats_MultiHop.png){ loading=lazy width="300" } -Below is an example of what it looks like when one repeater hears your message, repeats it, and then your companion hears a second repeater’s repeat of that same packet: +![App view of a multi-hop heard repeat](images/MeshCore_HeardRepeats_Step4_2Repeat.png){ loading=lazy width="300" } - ![Diagram Explanation](images/MeshCore_HeardRepeats_MultiHop.png){ width="300" } +## Need help? - ![Companion View](images/MeshCore_HeardRepeats_Step4_2Repeat.png){ width="300" } +If a result is missing or unclear, compare one known-good nearby node and then +use [Get help](../start/get-help.md). Include the task and step where you +stopped, but remove sensitive values before sharing a screenshot. diff --git a/docs/meshcore/general-meshcore-roles.md b/docs/meshcore/general-meshcore-roles.md deleted file mode 100644 index b053b89..0000000 --- a/docs/meshcore/general-meshcore-roles.md +++ /dev/null @@ -1,45 +0,0 @@ -# MeshCore Device Roles - -MeshCore defines several distinct roles within the network, and each role requires its own specific firmware. -A single piece of hardware can serve as a companion node, a repeater, or a room server depending on what firmware is flashed. -This section explains what each role does and how they work together within the MeshCore network. - -## Companion Nodes - -A **companion node** is a small personal device (handheld or portable) that lets a user connect to the mesh. - -- Runs on battery or USB power -- Usually pairs with a smartphone over Bluetooth for messaging -- Standalone options like the **T-Deck** include a screen and keyboard, but we don’t recommend them for beginners since the firmware is still rough -- Companion nodes do **not** route packets -- They can communicate directly with each other -- **Only repeaters** perform routing across the MeshCore network - -**→ See [Recommended Companions](../hardware/recommended-companions.md)** - ---- - -## Repeaters - -A **repeater** is a fixed installation, typically mounted at elevation (rooftop, tower, mast), that extends range and links mesh segments. - -- Runs continuously on mains or solar power -- Most Ottawa repeaters operate on solar -- In MeshCore, repeaters form the stable **backbone** of the network -- They are the **only devices** that perform packet routing - -**→ See [Recommended Repeaters](../hardware/recommended-repeaters.md)** - ---- - -## Room Servers - -A **room server** is a device flashed with specialized firmware that functions like a persistent chat room or mini-BBS. - -- Stores the last **32 messages** sent to it -- When a companion node connects, it retrieves stored messages (similar to checking an inbox) -- While they technically can repeat, this is strongly discouraged - - Ottawa disables repeat on room servers - - Developers have discussed removing the option entirely -- Room servers are **not full repeaters** and lack many repeater features -- Best used as static message boards or shared chat nodes, **not** as repeaters \ No newline at end of file diff --git a/docs/meshcore/general-overview.fr.md b/docs/meshcore/general-overview.fr.md new file mode 100644 index 0000000..1c8a28f --- /dev/null +++ b/docs/meshcore/general-overview.fr.md @@ -0,0 +1,48 @@ +--- +title: Qu’est-ce que MeshCore? +description: Découvrez le rôle des appareils MeshCore et par où commencer au Canada. +audience: + - newcomer + - meshcore-operator +task: understand-meshcore-roles +scope: canada-baseline +status: draft +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 5-10 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Qu’est-ce que MeshCore? + +MeshCore est un réseau maillé LoRa. Le micrologiciel d’un appareil lui attribue un rôle précis. + +## Rôles des appareils + +- Un **compagnon** envoie et reçoit des messages. +- Un **répéteur** relaie le trafic et étend la couverture. +- Un **serveur de salon** maintient un salon partagé accessible. +- Un **observateur** transmet à CoreScope les données réseau qu’il reçoit. + +[Comparer les rôles des appareils](../start/choose-a-goal.md). + +## Réglages au Canada + +Consultez le [répertoire des communautés](../provinces/index.md) avant de configurer un +appareil. Utilisez les réglages locaux qui y sont indiqués; sinon, utilisez les +[réglages par défaut du Canada](../index.md#canada-baseline). + +## Ressources officielles de MeshCore + +MeshCore Canada présente les communautés, les réglages et les outils propres au +Canada. Pour obtenir les renseignements officiels sur le micrologiciel, les +applications et le protocole, consultez : + +- la [documentation de MeshCore](https://docs.meshcore.io/){ target="_blank" rel="noopener" } +- le [programme de mise à jour MeshCore](https://flasher.meshcore.io/){ target="_blank" rel="noopener" } +- le [code source de MeshCore](https://github.com/meshcore-dev/MeshCore){ target="_blank" rel="noopener" } + +[Choisir un rôle et commencer la configuration](../start/index.md){ .md-button .md-button--primary } diff --git a/docs/meshcore/general-overview.md b/docs/meshcore/general-overview.md index 7dae50d..fd700a0 100644 --- a/docs/meshcore/general-overview.md +++ b/docs/meshcore/general-overview.md @@ -1,58 +1,47 @@ -# MeshCore Overview - -!!! important "MeshCore project split, use the official links" - Due to recent events in the MeshCore development team, the project has split. To stay on the official track, please only use: - - - **Flashing tool and blog:** [meshcore.io](https://meshcore.io/) - - **Source code:** [github.com/meshcore-dev/MeshCore](https://github.com/meshcore-dev/MeshCore) - - **Discord (named "MeshCore.io"):** [discord.com/invite/fUfWevRXAg](https://discord.com/invite/fUfWevRXAg) - - Read more about the split: [The Split, blog.meshcore.io](https://blog.meshcore.io/2026/04/23/the-split). - -MeshCore is a **repeater-driven long-range mesh network** built on top of LoRa radios. This section of the wiki explains the basics of how MeshCore works in the Ottawa region, how the different device roles fit together, and walks through the most common tasks you will perform as a new user. - -If you are brand new to MeshCore, start here and work your way through the pages below in order. - --- +title: What is MeshCore? +description: Learn what MeshCore devices do and where to start in Canada. +audience: + - newcomer + - meshcore-operator +task: understand-meshcore-roles +scope: canada-baseline +status: draft +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: beginner +estimated_time: 5-10 minutes +destructive: false +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# What is MeshCore? -## What's in This Section - -### [MeshCore Roles](general-meshcore-roles.md) -Explains the three device roles (**Companion Nodes**, **Repeaters**, and **Room Servers**), what each one does, and how they interact on the mesh. A single piece of hardware can play any of these roles depending on the firmware you flash, so understanding this section helps you pick the right firmware for your device. - -### [MeshCore FAQ](general-faq.md) -Covers the most common questions about how MeshCore behaves on the Ottawa network, including: - -- How adverts work and the Ottawa advert schedule -- Routing behaviour and repeater neighbours -- The public channel and how "Heard Repeats" is displayed in the app +MeshCore is a LoRa mesh network. A device's firmware gives it a specific job. -### [MeshCore How-To](general-howto.md) -Step-by-step walkthroughs for day-to-day tasks in the MeshCore mobile app, such as sharing your contact URL, importing contacts, and tracing routes. Each guide is visual and suitable for both new and experienced users. +## Device roles -### [Repeater Configurator](../config/index.md) -An interactive tool that finds the right Canadian region for a repeater and produces the exact commands to configure it — radio settings, region path, and verification steps. Also includes the [region map](../config/map.md) and the [region standard](../config/standard.md). +- A **companion** sends and receives messages. +- A **repeater** forwards traffic and extends coverage. +- A **room server** keeps a shared room available. +- An **observer** sends heard network data to CoreScope. -### Firmware Guides -Everything you need to get firmware onto a device: +[Compare device roles](../start/choose-a-goal.md). -- [Flashing a Companion](flash-companion.md) -- [Flashing a Repeater](flash-repeater.md) -- [Updating a Repeater (OTA)](update-repeater-ota.md) -- [Generating a Repeater ID](generate-repeater-id.md) -- [Flashing a Room Server](flash-room-server.md) -- [RAK4631 Custom Display Firmware](firmware-rak-custom-display.md) -- [Heltec V3 Wi-Fi Firmware](firmware-heltec-v3-wifi.md) +## Settings in Canada ---- +Check the [community directory](../provinces/index.md) before configuring a +device. Use its local settings when listed; otherwise use the +[Canada defaults](../index.md#canada-baseline). -## New to MeshCore? +## Official MeshCore resources -If you are just getting started, we recommend this path: +MeshCore Canada covers Canadian communities, settings, and tools. For official +firmware, apps, and protocol information, use: -1. Read **[MeshCore Roles](general-meshcore-roles.md)** to understand what a companion node, repeater, and room server each do. -2. Pick a **[Recommended Companion](../hardware/recommended-companions.md)** and flash it using the **[Flashing a Companion](flash-companion.md)** guide. -3. Skim the **[MeshCore FAQ](general-faq.md)** so you know what to expect from the network. -4. Use the **[MeshCore How-To](general-howto.md)** walkthroughs to share contacts and start messaging. +- [MeshCore documentation](https://docs.meshcore.io/){ target="_blank" rel="noopener" } +- [MeshCore Flasher](https://flasher.meshcore.io/){ target="_blank" rel="noopener" } +- [MeshCore source code](https://github.com/meshcore-dev/MeshCore){ target="_blank" rel="noopener" } -When you are ready to contribute to the network backbone, move on to **[Flashing a Repeater](flash-repeater.md)** and the **[Hardware](../hardware/recommended-repeaters.md)** section. +[Choose a role and start setup](../start/index.md){ .md-button .md-button--primary } diff --git a/docs/meshcore/generate-repeater-id.fr.md b/docs/meshcore/generate-repeater-id.fr.md new file mode 100644 index 0000000..74d48b2 --- /dev/null +++ b/docs/meshcore/generate-repeater-id.fr.md @@ -0,0 +1,72 @@ +--- +title: Changer l’identifiant d’un répéteur dans une ancienne région à identifiant sur 1 octet +description: Coordonnez, sauvegardez, modifiez, vérifiez et restaurez l’identité d’un répéteur uniquement lorsqu’une ancienne région l’exige. +audience: + - legacy-region-operator + - repeater-maintainer +task: change-legacy-repeater-id +scope: legacy +status: legacy +status_notice: false +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: 15-30 minutes +destructive: true +requires: + - local-region-approval + - trusted-serial-connection + - secure-key-storage +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Changer l’identifiant d’un répéteur dans une ancienne région à identifiant sur 1 octet + +!!! warning "Réservé aux anciennes régions à identifiant sur 1 octet" + Au Canada, les hachages de parcours utilisent 3 octets par défaut. Continuez uniquement si la personne responsable de votre région a confirmé que ce répéteur doit rester en mode 1 octet et a approuvé le nouvel identifiant. + +## Ce qui sera modifié + +Changer la clé privée change l’identité du nœud. Cette opération peut rompre les relations existantes d’administration, de contact et de suivi. L’ancienne clé est le seul moyen de rétablir l’identité précédente. + +## Préalables et sauvegarde + +1. Confirmez auprès de la personne responsable de la région que le répéteur doit rester en mode 1 octet et qu’un nouvel identifiant est nécessaire. +2. Connectez-vous au répéteur et notez sa clé publique actuelle avec `get public.key`. +3. À l’aide d’une connexion série fiable, sauvegardez la clé privée actuelle avec `get prv.key`, puis conservez-la comme secret dans un endroit sécurisé. +4. Confirmez que l’identifiant proposé de 2 à 6 caractères n’est pas déjà utilisé dans le registre de coordination de la région. + +Ne publiez et ne transmettez jamais une clé privée dans un billet, une capture d’écran, une discussion publique ou un journal. La clé privée précédente que vous avez sauvegardée est votre seul moyen de revenir en arrière. + +## Générer et appliquer la clé + +1. Ouvrez le [générateur de clés MeshCore](https://gessaman.com/mc-keygen/). +2. Entrez l’identifiant inutilisé de 2 à 6 caractères approuvé localement, puis sélectionnez **Generate Key**. +3. Confirmez que la clé privée produite est une valeur hexadécimale de 64 caractères. +4. Copiez-la directement dans la console fiable du répéteur, sans l’enregistrer dans une note ordinaire ni dans une discussion. +5. Exécutez la commande suivante en remplaçant le texte indicatif par la valeur produite : + + ```text + set prv.key + ``` + +6. Redémarrez le répéteur. + +## Vérifier et restaurer + +Après le redémarrage, exécutez `get public.key` et confirmez que son préfixe correspond à l’identifiant approuvé localement. Vérifiez de nouveau l’accès administrateur, les réglages radio, la configuration de la région, les annonces et l’acheminement local avant de remettre le répéteur en service. + +Si la vérification échoue, restaurez l’ancienne clé privée sauvegardée avec `set prv.key `, redémarrez l’appareil, puis confirmez que la clé publique et les accès d’origine sont rétablis. + +## Récupération + +Si l’accès administrateur, le préfixe de la clé publique, les annonces, la configuration de la région ou l’acheminement sont incorrects, gardez le répéteur sur l’établi. Restaurez l’ancienne clé privée sauvegardée au moyen de la connexion série fiable, redémarrez l’appareil, puis confirmez que tout son état précédent est rétabli avant de le remettre en service. + +## Consigner le changement + +Consignez l’identifiant approuvé localement, l’approbation de la personne responsable, les anciens et nouveaux préfixes de clé publique, le résultat de la vérification et l’emplacement de la sauvegarde, sans inscrire aucune des clés privées dans le dossier d’entretien public. + +## Limites de la vérification + +Cette procédure n’a pas été testée avec le micrologiciel actuel. Faites-la examiner par la personne responsable de la région avant de l’utiliser. diff --git a/docs/meshcore/generate-repeater-id.md b/docs/meshcore/generate-repeater-id.md index 39e4235..88a2bd6 100644 --- a/docs/meshcore/generate-repeater-id.md +++ b/docs/meshcore/generate-repeater-id.md @@ -1,15 +1,73 @@ -## Generating a Repeater ID +--- +title: Change a repeater ID for a legacy 1-byte region +description: Coordinate, back up, change, verify, and restore a repeater identity only when a legacy region requires it. +audience: + - legacy-region-operator + - repeater-maintainer +task: change-legacy-repeater-id +scope: legacy +status: legacy +status_notice: false +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: 15-30 minutes +destructive: true +requires: + - local-region-approval + - trusted-serial-connection + - secure-key-storage +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Change a Repeater ID for a Legacy 1-Byte Region -Regions that are using 1-byte path sizes usually generate Repeater IDs manually to avoid accidentally reusing an ID that’s already in service. Follow the steps below to pick another ID and program the matching private key onto your repeater. +!!! warning "Only for legacy 1-byte regions" + Canada uses 3-byte path hashes by default. Continue only if your local region operator has confirmed this repeater must stay in 1-byte mode and approved the new ID. -!!! note "Where this applies" - Regions that have transitioned to multi-byte path sizes, will not need to worry about overlapping repeater id's.These instructions are left here for those cases where users want to generate a new repeater ID. +## What this will change -1. Open the **[MeshCore Key Generator](https://gessaman.com/mc-keygen/)**. -2. Type the unused ID(2-6 charectors) into the input field and click **Generate Key**. -3. Copy the **Private Key** value. -4. On the repeater console, run (replace `` with the value you copied): - `set prv.key ` -5. Reboot the repeater. +Changing the private key changes the node identity. It can break existing administration, contact, and tracking relationships. The old key is the only identity rollback path. -After reboot, the repeater will use that private key, and its public key will correspond to the ID you selected. +## Prerequisites and backup + +1. Confirm with the local region operator that the repeater must remain in 1-byte mode and that a new ID is required. +2. Connect to the repeater and record its current public key with `get public.key`. +3. Over a trusted serial connection, back up the current private key with `get prv.key` and store it as a secret in a secure location. +4. Confirm the proposed 2–6 character ID is unused in the local region's coordination record. + +Never post or transmit either private key through an issue, screenshot, public chat, or log. The saved old private key is the rollback path. + +## Generate and apply the key + +1. Open the [MeshCore Key Generator](https://gessaman.com/mc-keygen/). +2. Enter the locally approved, unused 2–6 character ID and select **Generate Key**. +3. Confirm the generated private key is a 64-character hexadecimal value. +4. Copy it directly to the trusted repeater console without saving it in an ordinary note or chat. +5. Run, replacing the placeholder with the generated value: + + ```text + set prv.key + ``` + +6. Reboot the repeater. + +## Verify and restore + +After reboot, run `get public.key` and confirm its prefix matches the locally approved ID. Recheck admin access, radio settings, region configuration, adverts, and local routing before returning the repeater to service. + +If verification fails, restore the backed-up old private key with `set prv.key `, reboot, and confirm the original public key and access are restored. + + +## Recovery + +If admin access, the public-key prefix, adverts, region configuration, or routing is wrong, keep the repeater on the bench. Restore the backed-up old private key over the trusted serial connection, reboot, and confirm the complete former state before returning it to service. + +## Record the change + +Record the locally approved ID, operator approval, old/new public-key prefixes, verification, and rollback location without recording either private key in the public maintenance record. + +## Verification limits + +This procedure has not been tested against current firmware. Have the local region operator review it before use. diff --git a/docs/meshcore/update-repeater-ota.fr.md b/docs/meshcore/update-repeater-ota.fr.md new file mode 100644 index 0000000..bcdde25 --- /dev/null +++ b/docs/meshcore/update-repeater-ota.fr.md @@ -0,0 +1,155 @@ +--- +title: Mettre à jour un répéteur ou un serveur de salon à distance +description: Déterminez si une mise à jour OTA est sécuritaire, préparez la récupération physique, mettez à jour un appareil nRF52 et vérifiez tous les réglages conservés. +audience: + - advanced-repeater-operator + - room-server-operator +task: update-repeater-ota +scope: experimental +status: experimental +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: 30-60 minutes +destructive: true +requires: + - supported-nrf52-device + - physical-usb-recovery + - verified-firmware-zip +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Mettre à jour un répéteur ou un serveur de salon à distance + +Utilisez l’USB lorsque c’est possible. Une mise à jour à distance est une +solution de rechange plus risquée réservée aux appareils nRF52 pris en charge, +et une mise à jour ratée peut tout de même exiger une récupération par USB. +Confirmez la carte exacte, le chargeur d’amorçage, la version installée et le +fichier du micrologiciel avant de continuer. + +!!! danger "Chargeur d’amorçage requis" + Continuez seulement après avoir confirmé que la carte exacte possède le chargeur d’amorçage OTAFIX décrit dans [Programmer un répéteur](flash-repeater.md#nrf52-bootloader-decision). Une mise à jour OTA ratée peut exiger une récupération physique par USB. + +!!! warning "Android est la méthode testée par la communauté" + La communauté a observé des échecs de mise à jour OTA depuis iOS et recommande actuellement Android pour cette procédure. Il s’agit d’une recommandation locale pour réduire les risques, et non d’une affirmation selon laquelle la prise en charge d’iOS n’existe pas en amont. + +## Préalables : décider s’il faut arrêter + +Utilisez plutôt l’USB si vous répondez **non** à l’une des affirmations +suivantes : + +- [ ] L’appareil est une carte nRF52 compatible avec OTA, comme une RAK4631, une Heltec T114 ou une XIAO nRF52840. +- [ ] L’identité de la carte, le chargeur d’amorçage installé, le micrologiciel actuel et le fichier du micrologiciel prévu sont connus. +- [ ] La clé privée, les réglages, la configuration des régions et les renseignements d’accès sont sauvegardés de façon sécuritaire. +- [ ] Une alimentation stable et un moyen fiable de gérer la radio sont disponibles pendant toute la mise à jour. +- [ ] Une personne peut atteindre l’appareil avec un câble USB si la mise à jour OTA échoue. +- [ ] Le nom du fichier ZIP téléchargé correspond exactement à la carte et au rôle. + +!!! danger "Sans récupération physique, pas de mise à jour OTA" + Ne commencez pas une mise à jour OTA sur un toit, une tour, un site hivernal ou toute autre installation inaccessible si un échec ne peut pas être corrigé rapidement par USB. + +## Ce que la mise à jour change + +La mise à jour OTA place l’appareil en mode de mise à jour du micrologiciel, +puis remplace celui-ci par Bluetooth. Un transfert raté peut rendre nécessaire +une récupération physique par USB, tandis qu’un mauvais artéfact peut viser la +mauvaise carte ou le mauvais rôle. + +## Plan de récupération avant de commencer + +Gardez le bon micrologiciel USB et un câble de données à portée de main. Notez +la version et les réglages actuels. Si l’appareil reste en mode DFU après un +échec, recherchez de nouveau un nom DFU générique et réessayez avec le même +fichier ZIP vérifié. Si la récupération à distance échoue, arrêtez et +reprogrammez par USB la carte et le rôle exacts à l’aide de la sauvegarde. + +## 1. Télécharger le fichier ZIP exact du micrologiciel + +1. Ouvrez le [programme de mise à jour Web MeshCore](https://meshcore.io/flasher). +2. Sélectionnez l’appareil exact et le rôle **Repeater** ou **Room Server**. +3. Choisissez la version précise approuvée pour cette mise à jour; ne vous fiez pas à une étiquette générique « latest ». +4. Utilisez la commande de téléchargement pour enregistrer l’artéfact `.zip`. + +Vous pouvez aussi utiliser les +[versions officielles de MeshCore](https://github.com/meshcore-dev/MeshCore/releases) +et vérifier que l’artéfact correspond exactement à la carte et au rôle. + +## 2. Placer l’appareil en mode OTA + +1. Dans l’application mobile MeshCore, ouvrez une session avec le mot de passe administrateur. +2. Ouvrez **Command Line** et exécutez : + + ```text + start ota + ``` + +3. Continuez seulement après qu’une réponse semblable à `OK - mac: FF:AA:BB ...` a confirmé le mode OTA. En l’absence de confirmation, arrêtez et laissez l’appareil sur son micrologiciel actuel. + +Vous pouvez également exécuter `start ota` depuis un appareil de gestion +autonome qui prend en charge la ligne de commande du répéteur. + +## 3. Transférer la mise à jour + +Installez l’application **nRF Device Firmware Update** de Nordic depuis la +boutique d’applications officielle du téléphone. + +Pour la méthode Android testée par la communauté, utilisez : + +- **Packet receipts notification:** on +- **Number of packets:** `8` +- **Request high MTU:** off +- **Disable resume:** on +- **Prepare object delay:** `0 ms` +- **Force scanning:** on + +Ensuite : + +1. Sélectionnez le fichier `.zip` vérifié. +2. Sélectionnez l’appareil attendu dans la liste de détection. +3. Lancez la mise à jour et gardez le téléphone, l’appareil et l’alimentation stables jusqu’à ce que l’application confirme la fin de l’opération. + +Si l’application signale un échec, mais qu’un nom générique comme `AdaDFU` ou +`RAK4631_DFU` apparaît, sélectionnez cet appareil et réessayez une fois avec le +même fichier ZIP vérifié. N’utilisez pas le fichier d’une autre carte. + +## Vérifier le transfert + +L’application DFU confirme la fin de l’opération, l’appareil redémarre, +l’administration à distance se reconnecte et l’appareil indique la version du +micrologiciel prévue. Tout autre résultat représente une mise à jour ratée ou +incomplète. + +## 4. S’assurer que la mise à jour a fonctionné + +1. Fermez la session et reconnectez-vous après le redémarrage de l’appareil. +2. Exécutez `ver` et confirmez la version exacte du micrologiciel prévue. +3. Exécutez `clock`; au besoin, exécutez `clock sync` depuis un appareil de gestion à distance compatible. +4. Confirmez que les réglages de radio, de hachage des parcours, de région, d’annonce, d’accès et de rôle correspondent toujours à la configuration sauvegardée. +5. Envoyez une annonce et effectuez un essai local d’acheminement de message. + +La mise à jour n’est terminée que lorsque l’appareil se reconnecte, indique la +version prévue, conserve sa configuration et réussit l’essai local. + +Pour obtenir le contexte en amont, consultez les +[instructions officielles de mise à jour OTA de MeshCore](https://blog.meshcore.io/2026/04/02/nrf-ota-update). + +## Récupération après l’échec d’une mise à jour + +Si le même fichier ZIP vérifié ne peut pas être installé sur l’appareil DFU +attendu, cessez les tentatives à distance et récupérez par USB la carte et le +rôle exacts. Restaurez l’identité et les réglages sauvegardés lorsque cette +fonction est offerte, puis reprenez toute la vérification. N’essayez jamais +l’artéfact d’une autre carte au hasard pour effectuer une récupération. + +## Après la mise à jour + +Consignez l’ancienne et la nouvelle version, le nom et la source de l’artéfact, +l’identité de l’appareil, le résultat, la vérification des réglages, l’essai +radio et l’état de la récupération dans le dossier d’entretien du répéteur. + +## Ce qui a été testé + +La recommandation d’utiliser Android et les réglages DFU proviennent d’essais +menés par la communauté. Ils ne constituent pas une matrice d’essai complète +pour chaque carte, version du micrologiciel, téléphone ou application. diff --git a/docs/meshcore/update-repeater-ota.md b/docs/meshcore/update-repeater-ota.md index 130c659..0249bbb 100644 --- a/docs/meshcore/update-repeater-ota.md +++ b/docs/meshcore/update-repeater-ota.md @@ -1,93 +1,129 @@ -!!! danger "Bootloader Prerequisite" - Please only proceed with this if you have updated your bootloader firmware using the **OTA fix firmware** mentioned in the [Flashing a Repeater](flash-repeater.md#nrf52-bootloader-update) instructions. +--- +title: Update a repeater or room server over the air +description: Decide whether OTA is safe, prepare physical recovery, update an nRF52 device, and verify every retained setting. +audience: + - advanced-repeater-operator + - room-server-operator +task: update-repeater-ota +scope: experimental +status: experimental +owner: docs-firmware +last_reviewed: 2026-07-22 +review_by: 2026-10-17 +difficulty: advanced +estimated_time: 30-60 minutes +destructive: true +requires: + - supported-nrf52-device + - physical-usb-recovery + - verified-firmware-zip +page_styles: + - assets/styles/devices-builds.css?v=20260722-2 +--- +# Update a repeater or room server over the air -!!! danger "Android Only - iOS Not Recommended" - These instructions are intended for **Android only**. iOS is technically supported, however we have experienced several failed OTA upgrades when using iOS. Until that track record changes, we do not recommend using an iOS device for OTA updates. +Use USB when you can. An over-the-air update is a higher-risk fallback for +supported nRF52 devices, and a failed update may still need USB recovery. +Confirm the exact board, bootloader, installed version, and firmware file +before continuing. ---- +!!! danger "Bootloader prerequisite" + Proceed only after confirming that the exact board has the OTAFIX bootloader described in [Flashing a Repeater](flash-repeater.md#nrf52-bootloader-decision). A failed OTA update can require physical USB recovery. -## Flashing MeshCore Repeater Firmware - Over-the-air (OTA) - Non Recommended Route +!!! warning "Android is the method tested by the community" + The community has observed failed OTA attempts from iOS and currently recommends Android for this workflow. This is a local risk-control recommendation, not a claim that upstream iOS support does not exist. -**Note:** This section only applies to NRF-based boards (e.g., RAK4630, Heltec T114, XIAO NRF52). Please read the warning below since we highly recommend you flash firmware using USB instead. +## Prerequisites: decide whether to stop -!!! warning "OTA Risks" - Although it is possible to flash a repeater's firmware OTA, there is a high risk of a flash failing (even if the app says there are no issues) which will require a USB re-flash. We have experienced an OTA failure during Winter that requires to wait until the Spring to get physical access to the repeater. Proceed at your own risk! +Use USB instead if any answer below is **no**: -If you are OK with these risks, you can follow the [official instructions on MeshCore's Blog](https://blog.meshcore.io/2026/04/02/nrf-ota-update). The steps are summarized below for convenience. +- [ ] The device is an OTA-supported nRF52 board such as a RAK4631, Heltec T114, or XIAO nRF52840. +- [ ] The board identity, installed bootloader, current firmware, and intended firmware file are known. +- [ ] The private key, settings, region configuration, and access details are backed up securely. +- [ ] Stable power and a reliable radio-management path are available for the full update. +- [ ] Someone can reach the device with a USB cable if OTA fails. +- [ ] The downloaded ZIP names the exact board and role. ---- +!!! danger "No physical recovery means no OTA" + Do not start an OTA update on a roof, tower, winter site, or other inaccessible installation when a failed update cannot be recovered promptly by USB. -### 1. Download the firmware `.zip` +## What the update changes -1. Open the **[MeshCore Web Flasher](https://meshcore.io/flasher)** and find your device. -2. Select the **Repeater** or **Room Server** role, then choose the latest version. -3. In the bottom-right, click the **Download** button and select the `.zip` file. +OTA places the device into firmware-update mode and replaces its firmware over Bluetooth. A failed transfer can leave the device needing physical USB recovery, while a wrong artifact can target the wrong board or role. -Alternatively, go to the **[MeshCore GitHub Releases page](https://github.com/meshcore-dev/MeshCore/releases)** and download the `.zip` artifact for your board (for example, the T114 Repeater). +## Recovery plan before starting ---- +Keep the correct USB firmware and a data cable ready. Record the current version and settings. If the device remains in DFU mode after a failed attempt, scan again for a generic DFU name and retry the same verified ZIP. If it does not recover over the air, stop and reflash the exact board/role by USB using the backup. -### 2. Log in to the repeater or room server +## 1. Download the Exact Firmware ZIP -1. Using the MeshCore mobile app, log in to your device with the admin password. -2. Switch to the **Command Line** tab and enter: +1. Open the [MeshCore Web Flasher](https://meshcore.io/flasher). +2. Select the exact device and **Repeater** or **Room Server** role. +3. Choose the specific version approved for this update; do not rely on a generic “latest” label. +4. Use the download control to save the `.zip` artifact. - ``` +Alternatively, use the [official MeshCore releases](https://github.com/meshcore-dev/MeshCore/releases) and verify the artifact is for the exact board and role. + +## 2. Put the Device in OTA Mode + +1. In the MeshCore mobile app, log in with the admin password. +2. Open **Command Line** and run: + + ```text start ota ``` -3. You should see a reply similar to: +3. Continue only after a reply similar to `OK - mac: FF:AA:BB ...` confirms OTA mode. If there is no confirmation, stop and keep the device on its current firmware. - ``` - OK - mac: FF:AA:BB ... - ``` +You may also issue `start ota` from a standalone management device that supports the repeater command line. -**Tip:** You can also use a standalone device such as a T-Deck running the Ripple firmware to issue the `start ota` command. +## 3. Transfer the Update ---- +Install Nordic's **nRF Device Firmware Update** app from the official app store for the phone. -### 3. Connect with your phone +For the Android method tested by the community, use: -If it isn't already installed, install the Nordic **nRF Device Firmware Update** app from **Google Play** or the **App Store**. +- **Packet receipts notification:** on +- **Number of packets:** `8` +- **Request high MTU:** off +- **Disable resume:** on +- **Prepare object delay:** `0 ms` +- **Force scanning:** on -#### 3.1 DFU App Settings +Then: -In the app, tap the **Settings** icon and apply the following recommended settings: +1. Select the verified `.zip` file. +2. Select the expected device from the scan list. +3. Start the update and keep the phone, device, and power stable until the app reports completion. -- **Packet receipts notification:** ON -- **Number of packets:** 8 -- **Request high MTU** (Android only): OFF -- **Disable resume:** ON -- **Prepare object delay:** 0 ms -- **Force scanning:** ON +If the app reports failure but a generic name such as `AdaDFU` or `RAK4631_DFU` appears, select that device and retry the same verified ZIP once. Do not switch to another board's file. -#### 3.2 Start the Update +## Check the transfer -1. In the app, select the `.zip` file you downloaded. -2. Select your device from the scan list. -3. Press **Start** and wait for the update to complete. +The DFU app reports completion, the device restarts, remote administration reconnects, and the device reports the intended firmware version. Anything less is a failed or incomplete update. ---- +## 4. Make sure the update worked -### 4. Finishing Up +1. Log out and reconnect after the device restarts. +2. Run `ver` and confirm the exact intended firmware version. +3. Run `clock`; if needed, run `clock sync` from a supported remote management device. +4. Confirm the radio, path-hash, region, advert, access, and role settings still match the saved configuration. +5. Send an advert and complete a local message-routing test. -1. Once the update completes, log out and log back in (either with the app or a standalone device). -2. Verify the clock is correct with the `clock` command. If it is incorrect, issue: +The update is not complete until the device reconnects, reports the intended version, retains its configuration, and passes the local test. - ``` - clock sync - ``` +For upstream context, see the [official MeshCore OTA instructions](https://blog.meshcore.io/2026/04/02/nrf-ota-update). -3. Run the `ver` command to confirm the firmware version has been updated. ---- +## Recovery after a failed update + +If the same verified ZIP cannot complete against the expected DFU device, stop remote attempts and recover the exact board/role by USB. Restore the backed-up identity and settings where supported, then repeat the complete verification. Never try a different board artifact as a recovery guess. -### Troubleshooting +## After the update -If the update stalls or fails, issue the following command from your phone or a standalone device, then try the process again: +Record the old and new versions, artifact filename/source, device identity, result, settings check, radio test, and recovery status in the repeater maintenance record. -``` -reboot -``` +## What has been tested -**Note:** For RAK 4631 boards with the Bootloader update, when following the OTA instructions and when you upload the update, you will likely get an error that the flash has failed. When scanning again for devices in the app, it will appear with a generic name (e.g., AdaDFU, RAK4631_DFU, etc.). Select this device and re-upload the update. +The Android recommendation and DFU settings come from community testing. They +are not a complete test matrix for every board, firmware version, phone, or app. diff --git a/docs/privacy.fr.md b/docs/privacy.fr.md new file mode 100644 index 0000000..4edb41c --- /dev/null +++ b/docs/privacy.fr.md @@ -0,0 +1,65 @@ +--- +title: Confidentialité chez MeshCore Canada +description: Découvrez ce que le site, les outils et les formulaires publics de MeshCore Canada transmettent ou enregistrent. +audience: + - site-visitor + - contributor +task: understand-site-privacy +scope: canada-baseline +status: draft +owner: site-maintainers +last_reviewed: 2026-07-22 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 3 minutes +destructive: false +--- + +# Confidentialité chez MeshCore Canada + +Vous pouvez consulter ce site sans compte MeshCore Canada ni compte GitHub. + +## Quand des données quittent votre navigateur + +| Action | Données transmises | Destination | +|---|---|---| +| Rechercher dans cette documentation | Vos mots de recherche restent dans le navigateur | Nulle part | +| Rechercher un lieu dans les outils de région | Le lieu, le code d’aéroport ou le code postal saisi | Vérification locale d’abord, puis OpenStreetMap ou geocoder.ca au besoin | +| Ouvrir la carte interactive des régions | Votre adresse IP et la partie visible de la carte | OpenStreetMap | +| Ouvrir un lien externe | Les renseignements habituels d’une requête Web | Le service externe indiqué | +| Soumettre une idée ou une proposition de région | Le texte et les détails affichés lors de la révision, ainsi que la vérification antipourriel | Le service de soumission de MeshCore Canada, Cloudflare Turnstile et un billet GitHub public | + +## Soumissions publiques + +Les idées et les propositions de limites deviennent des billets GitHub publics. +Relisez l’aperçu avant de soumettre. N’incluez aucun mot de passe, aucune clé +privée, aucune adresse résidentielle, aucune coordonnée privée ni aucun autre +renseignement personnel. + +Le service de soumission peut conserver des journaux de sécurité et de +limitation du débit. Aucune période de conservation n’a encore été publiée. + +## Données enregistrées sur votre appareil + +Les listes de configuration et les brouillons d’idées sont enregistrés +uniquement dans votre navigateur lorsque vous utilisez ces fonctions. Les mots +de passe, les clés privées, les jetons antipourriel et les recherches +d’emplacement n’y sont pas enregistrés. + +Vous pouvez effacer les données enregistrées par ce site dans les paramètres de +votre navigateur. Le formulaire d’idée offre aussi l’action +**Effacer le brouillon enregistré**. + +## Services externes + +CoreScope, les services MQTT, les outils de reprogrammation et les sites +communautaires ont leurs propres responsables et politiques de confidentialité. + +## Mesure d’audience + +MeshCore Canada n’utilise aucun outil d’analyse d’audience. + +## Questions ou corrections + +[Signalez une correction liée à la confidentialité](submit-idea.md) sans compte +GitHub ou [ouvrez un billet sur GitHub](https://github.com/MeshCore-ca/MeshCore-Canada/issues). diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..aa20fc2 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,62 @@ +--- +title: Privacy at MeshCore Canada +description: Understand what the MeshCore Canada website, tools, and public contribution forms send or store. +audience: + - site-visitor + - contributor +task: understand-site-privacy +scope: canada-baseline +status: draft +owner: site-maintainers +last_reviewed: 2026-07-22 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 3 minutes +destructive: false +--- + +# Privacy at MeshCore Canada + +You can read this site without a MeshCore Canada or GitHub account. + +## When data leaves your browser + +| Action | What is sent | Where it goes | +|---|---|---| +| Search this documentation | Your search words stay in the browser | Nowhere | +| Search for a place in the region tools | The place, airport code, or postal code you enter | Checked locally first, then sent to OpenStreetMap or geocoder.ca if needed | +| Open the interactive region map | Your IP address and the visible map area | OpenStreetMap | +| Open an external link | Normal web request information | The named external service | +| Submit an idea or region proposal | The text and proposal details shown at review, plus anti-spam verification | MeshCore Canada’s submission service, Cloudflare Turnstile, and a public GitHub issue | + +## Public submissions + +Ideas and boundary proposals become public GitHub issues. Review the preview +before submitting. Do not include passwords, private keys, home addresses, +private coordinates, or other personal information. + +The submission service may keep security and rate-limit logs. A retention +period has not yet been published. + +## Saved on your device + +Setup checklists and idea drafts are saved in your browser only when you use +those features. Passwords, private keys, anti-spam tokens, and location +searches are not saved there. + +You can clear saved site data through your browser settings. The idea form also +provides a **Clear draft** action. + +## External services + +CoreScope, MQTT services, flashers, and community sites have their own +operators and privacy policies. + +## Analytics + +MeshCore Canada does not use site analytics. + +## Questions or corrections + +[Share a privacy correction](submit-idea.md) without a GitHub account, or +[open an issue on GitHub](https://github.com/MeshCore-ca/MeshCore-Canada/issues). diff --git a/docs/provinces/alberta.fr.md b/docs/provinces/alberta.fr.md new file mode 100644 index 0000000..69fede1 --- /dev/null +++ b/docs/provinces/alberta.fr.md @@ -0,0 +1,272 @@ +--- +title: Communautés MeshCore en Alberta +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore en Alberta. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore en Alberta + +Il y a **9 fiches de communauté actives** en Alberta. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+
+
+

Alberta MeshCore Networks

+Active +
+

Alberta

+

Développement du réseau maillé LoRa hors réseau de l’Alberta, exploité par la communauté

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Airdrie MeshCore Network

+Active +
+

Airdrie et Calgary

+

Utilise l’identifiant régional YYC partagé avec le réseau régional de Calgary

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Calgary and Area MeshCore

+Active +
+

Calgary et environs

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Calgary MeshCore Network

+Active +
+

Calgary

+

Première région lancée sur AlbertaMesh.ca

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Edmonton MeshCore Network

+Active +
+

Edmonton

+

Communauté régionale d’Edmonton au sein d’Alberta MeshCore

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

yegmesh.ca

+Active +
+

Edmonton

+

Communauté du réseau maillé d’Edmonton axée sur MeshCore

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

YQLMesh

+Active +
+

Lethbridge

+

Relier Lethbridge, un nœud à la fois

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Southern Alberta

+Active +
+

Sud de l’Alberta, y compris Cardston, Magrath, Raymond et les environs

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

YYC MeshCore Discord Group

+Active +
+

Calgary

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Coordonnées pour toute la province ou le territoire + +
+

Telegram : Sujet sur l’Alberta dans MeshCore Canada (externe)

+

Vérification du contact : Pas encore vérifié

+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/alberta.md b/docs/provinces/alberta.md index 565d88a..6263de8 100644 --- a/docs/provinces/alberta.md +++ b/docs/provinces/alberta.md @@ -1,62 +1,272 @@ -# MeshCore Communities in Alberta - -This page lists MeshCore communities in Alberta. - -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). - -## Communities - -### Calgary and Area MeshCore - -| Field | Value | -|-------|-------| -| Region | Calgary and area | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Telegram | Calgary topic in [Mesh Alberta](https://t.me/meshtAlta) | -| Discord | Canada - Calgary, Alberta & Area regional channel in the MeshCore Discord | - -### YQLMesh - -| Field | Value | -|-------|-------| -| Region | Lethbridge | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Website | | -| Facebook | [YQLMesh Community](https://www.facebook.com/groups/839542635605360) | -| Discord | | - -### Southern Alberta - -| Field | Value | -|-------|-------| -| Region | Southern Alberta, including Cardston, Magrath, Raymond, and nearby areas | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | Canada - Southern Alberta regional channel in the MeshCore Discord | -| Telegram | Cardston topic in [Mesh Alberta](https://t.me/meshtAlta) | - -### YYC MeshCore Discord Group - -| Field | Value | -|-------|-------| -| Region | Calgary | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | | -| Website | | - -## Province-Wide Contacts - -| Contact | Link | -|---------|------| -| Telegram | Alberta topic in [MeshCore Canada](https://t.me/MeshCoreCAN) | +--- +title: MeshCore communities in Alberta +description: Find MeshCore community contacts, service areas, and local settings for Alberta. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# MeshCore communities in Alberta + +Alberta has **9 active community listings**. + +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. + +## Community listings + +
+
+
+

Alberta MeshCore Networks

+Active +
+

Alberta

+

Building Alberta's community-operated off-grid LoRa mesh network

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+
+

Airdrie MeshCore Network

+Active +
+

Airdrie and Calgary

+

Uses the YYC regional identifier shared with the Calgary regional network

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+
+

Calgary and Area MeshCore

+Active +
+

Calgary and area

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+
+

Calgary MeshCore Network

+Active +
+

Calgary

+

Initial AlbertaMesh.ca launch region

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+ + +
+
+

YQLMesh

+Active +
+

Lethbridge

+

Connecting Lethbridge, one node at a time

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+
+

Southern Alberta

+Active +
+

Southern Alberta, including Cardston, Magrath, Raymond, and nearby areas

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+
+

YYC MeshCore Discord Group

+Active +
+

Calgary

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+ +## Province-wide contacts + +
+

Telegram: Alberta topic in MeshCore Canada (external)

+

Contact check: Not yet verified

+
+ +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/british-columbia.fr.md b/docs/provinces/british-columbia.fr.md new file mode 100644 index 0000000..1100ce9 --- /dev/null +++ b/docs/provinces/british-columbia.fr.md @@ -0,0 +1,59 @@ +--- +title: Communautés MeshCore en Colombie-Britannique +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore en Colombie-Britannique. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore en Colombie-Britannique + +Il y a **1 fiche de communauté active** en Colombie-Britannique. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+
+
+

Salish Mesh

+Active +
+

Mer des Salish et environs

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/british-columbia.md b/docs/provinces/british-columbia.md index c1966ae..3c48d3f 100644 --- a/docs/provinces/british-columbia.md +++ b/docs/provinces/british-columbia.md @@ -1,18 +1,59 @@ -# MeshCore Communities in British Columbia +--- +title: MeshCore communities in British Columbia +description: Find MeshCore community contacts, service areas, and local settings for British Columbia. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page lists MeshCore communities in British Columbia. + -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). +# MeshCore communities in British Columbia -## Communities +British Columbia has **1 active community listing**. -### Salish Mesh +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. -| Field | Value | -|-------|-------| -| Region | Salish Sea and surrounding area | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Website | | +## Community listings + +
+
+
+

Salish Mesh

+Active +
+

Salish Sea and surrounding area

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+ +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/index.fr.md b/docs/provinces/index.fr.md new file mode 100644 index 0000000..b4e3559 --- /dev/null +++ b/docs/provinces/index.fr.md @@ -0,0 +1,479 @@ +--- +title: Trouver une communauté MeshCore au Canada +description: Recherchez des communautés MeshCore canadiennes par lieu, province, nom de communauté ou alias courant. +audience: + - community-seeker + - community-maintainer +task: find-community +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +page_scripts: + - assets/javascripts/communities.js?v=20260722-2 +--- + + + +# Trouver une communauté MeshCore au Canada + +Recherchez par lieu, province, nom de communauté ou alias courant. La liste +complète fonctionne sans carte, autorisation de localisation ni compte GitHub. + +!!! note "Les renseignements sur les communautés peuvent changer" + 21 fiches sur 21 n’ont pas fait l’objet d’une vérification récente des coordonnées. + Confirmez les réglages et les coordonnées importants avant de vous y fier. + +
+21 fiches +20 fiches actives +1 fiche en formation +0 fiches avec des réglages locaux différents +
+ +
+ +
+ + +
+ + + + Affichage de 21 communautés + +
+ + + +## Communautés + +
+ +
+ +

Alberta

+

Développement du réseau maillé LoRa hors réseau de l’Alberta, exploité par la communauté

+

Province ou territoire : Alberta

+

Réglages : Utilise les réglages par défaut du Canada

+

Dernière vérification : Pas encore vérifiée

+ +

Voir les détails de la fiche

+
+
+ +

Airdrie et Calgary

+

Utilise l’identifiant régional YYC partagé avec le réseau régional de Calgary

+

Province ou territoire : Alberta

+

Réglages : Utilise les réglages par défaut du Canada

+

Dernière vérification : Pas encore vérifiée

+ +

Voir les détails de la fiche

+
+ +
+ +

Calgary

+

Première région lancée sur AlbertaMesh.ca

+

Province ou territoire : Alberta

+

Réglages : Utilise les réglages par défaut du Canada

+

Dernière vérification : Pas encore vérifiée

+ +

Voir les détails de la fiche

+
+
+ +

Edmonton

+

Communauté régionale d’Edmonton au sein d’Alberta MeshCore

+

Province ou territoire : Alberta

+

Réglages : Utilise les réglages par défaut du Canada

+

Dernière vérification : Pas encore vérifiée

+ +

Voir les détails de la fiche

+
+ +
+
+

YQLMesh

+Active +
+

Lethbridge

+

Relier Lethbridge, un nœud à la fois

+

Province ou territoire : Alberta

+

Réglages : Utilise les réglages par défaut du Canada

+

Dernière vérification : Pas encore vérifiée

+ +

Voir les détails de la fiche

+
+ + + + + + + + + +
+ +

Saguenay–Lac-Saint-Jean (YTF)

+

Province ou territoire : Québec

+

Réglages : Utilise les réglages par défaut du Canada

+

Dernière vérification : Pas encore vérifiée

+ +

Voir les détails de la fiche

+
+ + + +
+ +## Réglages par défaut du Canada { #canada-baseline } + +Utilisez ces réglages sauf si votre communauté locale en indique d’autres. + +| Réglage | Valeur par défaut au Canada | +|---|---| +| Préréglage radio | `USA/Canada (Recommended)` | +| Valeurs radio brutes | `910.525 MHz / 62.5 kHz / SF7 / CR5` | +| Mode de hachage des parcours | `3-byte` | +| Réglage du parcours en ligne de commande | `set path.hash.mode 2` | + +!!! warning "Vérifiez d’abord les réglages locaux" + Les appareils à proximité doivent utiliser les mêmes réglages. Une fiche marquée + **Réglages locaux différents** l’emporte sur les réglages par défaut du Canada + après confirmation auprès de la communauté indiquée. + +## Parcourir par province ou territoire + +
+ + + + + + + + + + + +
+ +## Ajouter ou mettre à jour une fiche + +Vous avez trouvé des renseignements manquants ou périmés? +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. diff --git a/docs/provinces/index.md b/docs/provinces/index.md index e0d80d9..c91e6b0 100644 --- a/docs/provinces/index.md +++ b/docs/provinces/index.md @@ -1,50 +1,479 @@ -# Mesh Directory +--- +title: Find a MeshCore community in Canada +description: Search Canadian MeshCore communities by place, province, community name, or common alias. +audience: + - community-seeker + - community-maintainer +task: find-community +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +page_scripts: + - assets/javascripts/communities.js?v=20260722-2 +--- -Find the nearest MeshCore Canada community page for your province or territory. Local pages can document region-specific rooms, repeaters, observers, contact points, and any settings that differ from the national onboarding baseline. + -!!! tip "Start local" - MeshCore Canada generally uses the USA/Canada radio preset and 3-byte path hashes, but local pages are the source of truth when a community documents a different preset or operating practice. +# Find a MeshCore community -## National Baseline +Search by place, province, community name, or a common alias. The full list +works without a map, location permission, or a GitHub account. -Use these defaults unless a local community page publishes a different setting: +!!! note "Community information can change" + 21 of 21 listings do not have a recent contact check. + Confirm important settings and contacts before relying on them. -| Setting | Value | -|---------|-------| +
+21 listings +20 listed active +1 listed forming +0 with different local settings +
+ +
+ +
+ + +
+ + + + Showing 21 communities + +
+ + + +## Communities + +
+ +
+ +

Alberta

+

Building Alberta's community-operated off-grid LoRa mesh network

+

Province: Alberta

+

Settings: Uses the Canada defaults

+

Last verified: Not yet verified

+ +

View listing details

+
+ + + + + +
+
+

YQLMesh

+Active +
+

Lethbridge

+

Connecting Lethbridge, one node at a time

+

Province: Alberta

+

Settings: Uses the Canada defaults

+

Last verified: Not yet verified

+ +

View listing details

+
+ + + + + + + + + + + + + +
+ +## Canada defaults { #canada-baseline } + +Use these settings unless your local community lists different ones. + +| Setting | Canada default | +|---|---| | Radio preset | `USA/Canada (Recommended)` | | Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | | Path hash mode | `3-byte` | -| CLI path setting | `set path.hash.mode 2` | - -## Provinces and Territories - -| Region | Directory status | -|--------|------------------| -| [British Columbia](british-columbia.md) | Active listing | -| [Alberta](alberta.md) | Active listings | -| [Saskatchewan](saskatchewan.md) | Needs community listing | -| [Manitoba](manitoba.md) | Needs community listing | -| [Ontario](ontario.md) | Active listings | -| [Quebec](quebec.md) | Active listings | -| [New Brunswick](new-brunswick.md) | Forming listing | -| [Nova Scotia](nova-scotia.md) | Active listing | -| [Prince Edward Island](prince-edward-island.md) | Needs community listing | -| [Newfoundland and Labrador](newfoundland-and-labrador.md) | Needs community listing | -| [Territories (YT / NT / NU)](territories.md) | Needs community listing | - -## Community Entry Format - -Community pages should use the same basic fields so newcomers can scan quickly: - -| Field | What to include | -|-------|-----------------| -| Name | Community or mesh name | -| Region | City, area, province, or coverage region | -| Status | Active, forming, testing, or needs update | -| Radio settings | Preset plus raw values when known | -| Path hash mode | Usually 3-byte for MeshCore Canada | -| Contact | Discord, Telegram, website, issue link, or maintainer | - -## Missing or outdated local information? - -Open an update request through [Contributing](../contributing.md) so the directory can stay current as Canadian MeshCore communities grow. +| Command-line path setting | `set path.hash.mode 2` | + +!!! warning "Check local settings first" + Nearby devices need matching settings. A card marked **Different local settings** + takes precedence over the Canada defaults after you confirm it with the + listed community. + +## Browse by province or territory + +
+ + + + + + + + + + + +
+ +## Add or update a listing + +Found missing or outdated information? +[Send a community update](../submit-idea.md). No GitHub account needed. diff --git a/docs/provinces/manitoba.fr.md b/docs/provinces/manitoba.fr.md new file mode 100644 index 0000000..7ac2999 --- /dev/null +++ b/docs/provinces/manitoba.fr.md @@ -0,0 +1,41 @@ +--- +title: Communautés MeshCore au Manitoba +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore au Manitoba. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore au Manitoba + +Aucune communauté n’est encore répertoriée au Manitoba. + +
+

Aidez-nous à ajouter la première fiche

+

Indiquez le nom de la communauté, la zone desservie, son état et un lien de contact public.

+

Ajouter une communauté

+

Parcourir toutes les communautés canadiennes

+
+ +Tant qu’aucune fiche locale examinée n’indique d’autres réglages, commencez avec +les [réglages par défaut du Canada](index.md#canada-baseline) et confirmez-les +auprès des personnes à proximité avant de transmettre. + +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/manitoba.md b/docs/provinces/manitoba.md index f7cb5b9..81f6585 100644 --- a/docs/provinces/manitoba.md +++ b/docs/provinces/manitoba.md @@ -1,17 +1,41 @@ -# MeshCore Communities in Manitoba +--- +title: MeshCore communities in Manitoba +description: Find MeshCore community contacts, service areas, and local settings for Manitoba. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page is ready for Manitoba MeshCore community listings. + -## Current Status +# MeshCore communities in Manitoba -No Manitoba community listing has been submitted yet. +We don't have a community listed here yet. -If you operate or know of a Manitoba MeshCore group, open an update request through [Contributing](../contributing.md) with the community name, region, status, contact link, and radio settings. +
+

Help add the first listing

+

Share the community name, service area, status, and a public contact link.

+

Add a community

+

Browse all Canadian communities

+
-## Default Settings +Until a reviewed local listing gives different settings, start with the +[Canada defaults](index.md#canada-baseline) and confirm settings with nearby +operators before transmitting. -| Setting | Value | -|---------|-------| -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/new-brunswick.fr.md b/docs/provinces/new-brunswick.fr.md new file mode 100644 index 0000000..8edebf8 --- /dev/null +++ b/docs/provinces/new-brunswick.fr.md @@ -0,0 +1,62 @@ +--- +title: Communautés MeshCore au Nouveau-Brunswick +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore au Nouveau-Brunswick. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore au Nouveau-Brunswick + +Il y a **1 fiche de communauté en formation** au Nouveau-Brunswick. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+
+
+

Southern New Brunswick

+En formation +
+

Sud du Nouveau-Brunswick, y compris Fredericton, Saint John, Moncton et les environs

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

+Ce groupe est en formation. Communiquez avec lui pour savoir ce qui fonctionne et où votre aide serait utile. +

+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/new-brunswick.md b/docs/provinces/new-brunswick.md index bb0b4bd..4e9837a 100644 --- a/docs/provinces/new-brunswick.md +++ b/docs/provinces/new-brunswick.md @@ -1,18 +1,62 @@ -# MeshCore Communities in New Brunswick +--- +title: MeshCore communities in New Brunswick +description: Find MeshCore community contacts, service areas, and local settings for New Brunswick. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page lists MeshCore communities in New Brunswick. + -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). +# MeshCore communities in New Brunswick -## Communities +New Brunswick has **1 forming community listing**. -### Southern New Brunswick +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. -| Field | Value | -|-------|-------| -| Region | Fredericton, Saint John, Moncton, and nearby areas | -| Status | Forming | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Facebook | [MESHCORE Saint John, N.B.](https://www.facebook.com/groups/613466831163684) | +## Community listings + +
+
+
+

Southern New Brunswick

+Forming +
+

Fredericton, Saint John, Moncton, and nearby areas

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

+This group is forming. Contact it to learn what is working and where help is needed. +

+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+ +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/newfoundland-and-labrador.fr.md b/docs/provinces/newfoundland-and-labrador.fr.md new file mode 100644 index 0000000..e95e782 --- /dev/null +++ b/docs/provinces/newfoundland-and-labrador.fr.md @@ -0,0 +1,41 @@ +--- +title: Communautés MeshCore à Terre-Neuve-et-Labrador +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore à Terre-Neuve-et-Labrador. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore à Terre-Neuve-et-Labrador + +Aucune communauté n’est encore répertoriée à Terre-Neuve-et-Labrador. + +
+

Aidez-nous à ajouter la première fiche

+

Indiquez le nom de la communauté, la zone desservie, son état et un lien de contact public.

+

Ajouter une communauté

+

Parcourir toutes les communautés canadiennes

+
+ +Tant qu’aucune fiche locale examinée n’indique d’autres réglages, commencez avec +les [réglages par défaut du Canada](index.md#canada-baseline) et confirmez-les +auprès des personnes à proximité avant de transmettre. + +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/newfoundland-and-labrador.md b/docs/provinces/newfoundland-and-labrador.md index 8694a1f..1841227 100644 --- a/docs/provinces/newfoundland-and-labrador.md +++ b/docs/provinces/newfoundland-and-labrador.md @@ -1,17 +1,41 @@ -# MeshCore Communities in Newfoundland and Labrador +--- +title: MeshCore communities in Newfoundland and Labrador +description: Find MeshCore community contacts, service areas, and local settings for Newfoundland and Labrador. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page is ready for Newfoundland and Labrador MeshCore community listings. + -## Current Status +# MeshCore communities in Newfoundland and Labrador -No Newfoundland and Labrador community listing has been submitted yet. +We don't have a community listed here yet. -If you operate or know of a Newfoundland and Labrador MeshCore group, open an update request through [Contributing](../contributing.md) with the community name, region, status, contact link, and radio settings. +
+

Help add the first listing

+

Share the community name, service area, status, and a public contact link.

+

Add a community

+

Browse all Canadian communities

+
-## Default Settings +Until a reviewed local listing gives different settings, start with the +[Canada defaults](index.md#canada-baseline) and confirm settings with nearby +operators before transmitting. -| Setting | Value | -|---------|-------| -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/nova-scotia.fr.md b/docs/provinces/nova-scotia.fr.md new file mode 100644 index 0000000..d1d2415 --- /dev/null +++ b/docs/provinces/nova-scotia.fr.md @@ -0,0 +1,59 @@ +--- +title: Communautés MeshCore en Nouvelle-Écosse +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore en Nouvelle-Écosse. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore en Nouvelle-Écosse + +Il y a **1 fiche de communauté active** en Nouvelle-Écosse. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+
+
+

Lunenburg County Mesh

+Active +
+

Comté de Lunenburg

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/nova-scotia.md b/docs/provinces/nova-scotia.md index 0a4ed7a..002a5df 100644 --- a/docs/provinces/nova-scotia.md +++ b/docs/provinces/nova-scotia.md @@ -1,18 +1,59 @@ -# MeshCore Communities in Nova Scotia +--- +title: MeshCore communities in Nova Scotia +description: Find MeshCore community contacts, service areas, and local settings for Nova Scotia. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page lists MeshCore communities in Nova Scotia. + -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). +# MeshCore communities in Nova Scotia -## Communities +Nova Scotia has **1 active community listing**. -### Lunenburg County Mesh +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. -| Field | Value | -|-------|-------| -| Region | Lunenburg County | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Website | | +## Community listings + +
+
+
+

Lunenburg County Mesh

+Active +
+

Lunenburg County

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+ +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/ontario.fr.md b/docs/provinces/ontario.fr.md new file mode 100644 index 0000000..6a2acd6 --- /dev/null +++ b/docs/provinces/ontario.fr.md @@ -0,0 +1,103 @@ +--- +title: Communautés MeshCore en Ontario +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore en Ontario. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore en Ontario + +Il y a **3 fiches de communauté actives** en Ontario. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+ +
+
+

GTA+-Lora-Meshes

+Active +
+

Sud de l’Ontario

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Quinte Mesh Network

+Active +
+

Région de Quinte, y compris Belleville, Trenton, le comté de Prince Edward et les environs

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/ontario.md b/docs/provinces/ontario.md index 69233e5..317b12e 100644 --- a/docs/provinces/ontario.md +++ b/docs/provinces/ontario.md @@ -1,42 +1,103 @@ -# MeshCore Communities in Ontario - -This page lists MeshCore communities in Ontario. - -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). - -## Communities - -### Greater Ottawa Mesh Enthusiasts - -| Field | Value | -|-------|-------| -| Region | Eastern Ontario and Western Quebec | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | | -| Website | | - -### GTA+-Lora-Meshes - -| Field | Value | -|-------|-------| -| Region | Southern Ontario | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | | - -### Quinte Mesh Network - -| Field | Value | -|-------|-------| -| Region | Quinte Region, including Belleville, Trenton, Prince Edward County, and nearby areas | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | | -| Website | | +--- +title: MeshCore communities in Ontario +description: Find MeshCore community contacts, service areas, and local settings for Ontario. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# MeshCore communities in Ontario + +Ontario has **3 active community listings**. + +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. + +## Community listings + +
+ +
+
+

GTA+-Lora-Meshes

+Active +
+

Southern Ontario

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+ +
+ +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/prince-edward-island.fr.md b/docs/provinces/prince-edward-island.fr.md new file mode 100644 index 0000000..8b87759 --- /dev/null +++ b/docs/provinces/prince-edward-island.fr.md @@ -0,0 +1,41 @@ +--- +title: Communautés MeshCore à l’Île-du-Prince-Édouard +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore à l’Île-du-Prince-Édouard. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore à l’Île-du-Prince-Édouard + +Aucune communauté n’est encore répertoriée à l’Île-du-Prince-Édouard. + +
+

Aidez-nous à ajouter la première fiche

+

Indiquez le nom de la communauté, la zone desservie, son état et un lien de contact public.

+

Ajouter une communauté

+

Parcourir toutes les communautés canadiennes

+
+ +Tant qu’aucune fiche locale examinée n’indique d’autres réglages, commencez avec +les [réglages par défaut du Canada](index.md#canada-baseline) et confirmez-les +auprès des personnes à proximité avant de transmettre. + +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/prince-edward-island.md b/docs/provinces/prince-edward-island.md index 8d9e090..d0101f8 100644 --- a/docs/provinces/prince-edward-island.md +++ b/docs/provinces/prince-edward-island.md @@ -1,17 +1,41 @@ -# MeshCore Communities in Prince Edward Island +--- +title: MeshCore communities in Prince Edward Island +description: Find MeshCore community contacts, service areas, and local settings for Prince Edward Island. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page is ready for Prince Edward Island MeshCore community listings. + -## Current Status +# MeshCore communities in Prince Edward Island -No Prince Edward Island community listing has been submitted yet. +We don't have a community listed here yet. -If you operate or know of a Prince Edward Island MeshCore group, open an update request through [Contributing](../contributing.md) with the community name, region, status, contact link, and radio settings. +
+

Help add the first listing

+

Share the community name, service area, status, and a public contact link.

+

Add a community

+

Browse all Canadian communities

+
-## Default Settings +Until a reviewed local listing gives different settings, start with the +[Canada defaults](index.md#canada-baseline) and confirm settings with nearby +operators before transmitting. -| Setting | Value | -|---------|-------| -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/quebec.fr.md b/docs/provinces/quebec.fr.md new file mode 100644 index 0000000..caf2014 --- /dev/null +++ b/docs/provinces/quebec.fr.md @@ -0,0 +1,145 @@ +--- +title: Communautés MeshCore au Québec +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore au Québec. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore au Québec + +Il y a **5 fiches de communauté actives** au Québec. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+
+
+

Mesh Quebec

+Active +
+

Québec

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Montreal Mesh

+Active +
+

Grand Montréal

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+ +
+
+

Réseau Mesh du Saguenay Lac st-Jean YTF

+Active +
+

Saguenay–Lac-Saint-Jean (YTF)

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+
+

Réseau Libre

+Active +
+

Montréal

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/quebec.md b/docs/provinces/quebec.md index 2b0cfc2..ac47746 100644 --- a/docs/provinces/quebec.md +++ b/docs/provinces/quebec.md @@ -1,64 +1,145 @@ -# MeshCore Communities in Quebec +--- +title: MeshCore communities in Quebec +description: Find MeshCore community contacts, service areas, and local settings for Quebec. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page lists MeshCore communities in Quebec. + -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). +# MeshCore communities in Quebec -## Communities +Quebec has **5 active community listings**. -### Mesh Quebec +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. -| Field | Value | -|-------|-------| -| Region | Quebec | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Website | | +## Community listings -### Montreal Mesh +
+
+
+

Mesh Quebec

+Active +
+

Quebec

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+
+

Montreal Mesh

+Active +
+

Greater Montreal

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+ + +
+
+

Réseau Libre

+Active +
+

Montreal

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
-| Field | Value | -|-------|-------| -| Region | Greater Montreal | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Website | | +## Add or update a listing -### Réseau Mesh de la Capitale YQB +[Send a community update](../submit-idea.md). No GitHub account needed. -| Field | Value | -|-------|-------| -| Region | Quebec City | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | | - -### Réseau Mesh du Saguenay Lac st-Jean YTF - -| Field | Value | -|-------|-------| -| Region | Saguenay–Lac-Saint-Jean (YTF) | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Discord | | -| Facebook | | -| MeshMapper | | - -### Réseau Libre - -| Field | Value | -|-------|-------| -| Region | Montreal | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | -| Website | | +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/saskatchewan.fr.md b/docs/provinces/saskatchewan.fr.md new file mode 100644 index 0000000..3eeb8a3 --- /dev/null +++ b/docs/provinces/saskatchewan.fr.md @@ -0,0 +1,59 @@ +--- +title: Communautés MeshCore en Saskatchewan +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore en Saskatchewan. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore en Saskatchewan + +Il y a **1 fiche de communauté active** en Saskatchewan. + +Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline), +sauf si une fiche indique des réglages locaux différents. + +## Fiches des communautés + +
+
+
+

StoonMesh

+Active +
+

Centre de la Saskatchewan

+
+
Réglages
+
Utilise les réglages par défaut du Canada
+
Dernière vérification
+
Pas encore vérifiée
+
+

Coordonnées

+ +

+Vérification du contact : Pas encore vérifié +

+

Mettre cette fiche à jour

+
+
+ +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/saskatchewan.md b/docs/provinces/saskatchewan.md index c216744..a457ad7 100644 --- a/docs/provinces/saskatchewan.md +++ b/docs/provinces/saskatchewan.md @@ -1,18 +1,59 @@ -# MeshCore Communities in Saskatchewan +--- +title: MeshCore communities in Saskatchewan +description: Find MeshCore community contacts, service areas, and local settings for Saskatchewan. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page lists MeshCore communities in Saskatchewan. + -If you know of a community that is missing or needs an update, open an update request through [Contributing](../contributing.md). +# MeshCore communities in Saskatchewan -## Communities +Saskatchewan has **1 active community listing**. -### StoonMesh +All listings use the [Canada defaults](index.md#canada-baseline) unless a +card lists different local settings. -| Field | Value | -|-------|-------| -| Region | Central Saskatchewan | -| Status | Active | -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `1-byte` | -| Discord | | +## Community listings + +
+
+
+

StoonMesh

+Active +
+

Central Saskatchewan

+
+
Settings
+
Uses the Canada defaults
+
Last verified
+
Not yet verified
+
+

Contacts

+ +

+Contact check: Not yet verified +

+

Update this listing

+
+
+ +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/provinces/territories.fr.md b/docs/provinces/territories.fr.md new file mode 100644 index 0000000..f0a2ee2 --- /dev/null +++ b/docs/provinces/territories.fr.md @@ -0,0 +1,41 @@ +--- +title: Communautés MeshCore dans les territoires +description: Trouvez les coordonnées, les zones desservies et les réglages locaux des communautés MeshCore dans les territoires. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- + + + +# Communautés MeshCore dans les territoires + +Aucune communauté n’est encore répertoriée dans les territoires. + +
+

Aidez-nous à ajouter la première fiche

+

Indiquez le nom de la communauté, la zone desservie, son état et un lien de contact public.

+

Ajouter une communauté

+

Parcourir toutes les communautés canadiennes

+
+ +Tant qu’aucune fiche locale examinée n’indique d’autres réglages, commencez avec +les [réglages par défaut du Canada](index.md#canada-baseline) et confirmez-les +auprès des personnes à proximité avant de transmettre. + +## Ajouter ou mettre à jour une fiche + +[Envoyer une mise à jour de la communauté](../submit-idea.md). Aucun compte GitHub requis. + +Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées. diff --git a/docs/provinces/territories.md b/docs/provinces/territories.md index 4b14455..30ea1b3 100644 --- a/docs/provinces/territories.md +++ b/docs/provinces/territories.md @@ -1,17 +1,41 @@ -# MeshCore Communities in the Territories +--- +title: MeshCore communities in the territories +description: Find MeshCore community contacts, service areas, and local settings for the territories. +audience: + - community-seeker + - community-maintainer +task: browse-community-directory +scope: canada-baseline +status: draft +owner: directory-stewards +last_reviewed: 2026-07-19 +review_by: 2027-01-15 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +page_styles: + - assets/styles/communities.css?v=20260722-2 +--- -This page is ready for Yukon, Northwest Territories, and Nunavut MeshCore community listings. + -## Current Status +# MeshCore communities in the territories -No territories community listing has been submitted yet. +We don't have a community listed here yet. -If you operate or know of a MeshCore group in Yukon, Northwest Territories, or Nunavut, open an update request through [Contributing](../contributing.md) with the community name, region, status, contact link, and radio settings. +
+

Help add the first listing

+

Share the community name, service area, status, and a public contact link.

+

Add a community

+

Browse all Canadian communities

+
-## Default Settings +Until a reviewed local listing gives different settings, start with the +[Canada defaults](index.md#canada-baseline) and confirm settings with nearby +operators before transmitting. -| Setting | Value | -|---------|-------| -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash mode | `3-byte` | +## Add or update a listing + +[Send a community update](../submit-idea.md). No GitHub account needed. + +Listings are community-submitted and may be incomplete or out of date. diff --git a/docs/resources/getting-started.md b/docs/resources/getting-started.md deleted file mode 100644 index de5286f..0000000 --- a/docs/resources/getting-started.md +++ /dev/null @@ -1,97 +0,0 @@ -# Getting Started with MeshCore in Canada - -!!! info "This page is a work in progress" - More hardware-specific walkthroughs are still being added. These network settings are the current MeshCore Canada baseline. - -## Welcome to MeshCore! - -MeshCore Canada communities generally use the same radio preset and path settings so companions, repeaters, room servers, and observers can hear each other reliably. - -## Step 1: Understand the Basics - -MeshCore devices must match the local mesh's radio settings before they can join the network. For MeshCore Canada, start with these defaults unless your local community page lists a different setting. - -| Setting | MeshCore Canada value | -|---------|------------------------| -| Radio preset | `USA/Canada (Recommended)` | -| Raw radio values | `910.525 MHz / 62.5 kHz / SF7 / CR5` | -| Path hash / advert path mode | `3-byte` | -| CLI path setting | `set path.hash.mode 2` | - -!!! warning "Preset must match" - A device on another regional preset may appear to be configured correctly but will not hear the MeshCore Canada network. Pick **USA/Canada (Recommended)** in apps or config tools. If the tool only exposes raw radio fields, use `910.525 MHz`, `62.5 kHz`, `SF7`, and `CR5`. - -!!! note "Why 3-byte path hashes?" - MeshCore Canada recommends 3-byte path hashes for better behavior on larger repeater-backed networks. Companion devices often need this changed manually. Repeaters, room servers, and standalone observers should have path hash mode set during onboarding. - -## Step 2: Get Your Hardware - -Choose a role: - -- **Companion**: the handheld or mobile device you use to send and receive messages. -- **Repeater**: a fixed node that extends mesh coverage. -- **Room server**: a fixed node that hosts rooms and can also observe mesh traffic. -- **Observer**: a device or host service that forwards packets to the MeshCore.ca MQTT analyzer. - -Start with [Recommended Companions](../hardware/recommended-companions.md) if you want a personal device, or [Recommended Repeaters](../hardware/recommended-repeaters.md) if you are building a fixed node. - -## Step 3: Flash the Firmware - -Flash firmware for the role you need, then apply the MeshCore Canada network settings before judging whether the device works. - -For repeaters, room servers, and standalone observers, include this first-run CLI setting: - -```text -set path.hash.mode 2 -``` - -If you are reusing a device with retained preferences or configuring firmware through the CLI, also run `set radio 910.525,62.5,7,5`. - -For companion devices, set the radio preset to **USA/Canada (Recommended)** and set path hash mode to **3-byte** in the companion app or config tool. If you are configuring a companion through a CLI that supports MeshCore settings, use: - -```text -set path.hash.mode 2 -``` - -After changing radio parameters, reboot the device. - -### Optional repeater loop protection - -Loop detection is not part of the Canada-wide basic setup. MeshCore leaves it off by default. If local operators are troubleshooting a packet storm caused by a looping repeater, firmware 1.14 and newer can use: - -```text -set loop.detect moderate -``` - -This is a troubleshooting safeguard for repeaters, not a setting to apply to every new device. Coordinate the change with the local mesh, and leave it off when there is no loop problem. See the [upstream MeshCore CLI reference](https://github.com/meshcore-dev/MeshCore/blob/main/docs/cli_commands.md#view-or-change-this-nodes-loop-detection) for the available modes. - -## Step 4: Configure Your Role - -| Role | Next setup step | -|------|-----------------| -| Companion | Pair it with your app, send an advert, and ask a nearby user to confirm they can see you | -| Repeater | Pick a clear node name, test from the ground, then install it where it has good antenna visibility | -| Room server | Configure rooms, verify it still uses the local mesh settings, and document who maintains it | -| Observer | Follow [Analyzer & MQTT](../analyzer/intro.md) and verify it appears in CoreScope | - -## Step 5: Find or Start a Community - -Browse the [Mesh Directory](../provinces/index.md) and check your province or nearby region. If a local community documents a different preset, follow the local community setting for that mesh. - -If your region is missing, use [Contributing](../contributing.md) to request a new listing with the community name, region, status, contacts, and radio settings. - -## Step 6: Get on the Air - -Send an advert after configuration so nearby nodes learn your identity and route. If you do not see other users, re-check the radio preset first, then confirm the path hash mode is set to 3-byte. - -## First Success Checklist - -- Your device shows the **USA/Canada (Recommended)** preset, or the raw values match `910.525 MHz / 62.5 kHz / SF7 / CR5`. -- Path hash mode is **3-byte**. -- The device was rebooted after radio changes. -- A nearby community member or second known-good device can see your advert. -- If this is an observer, [CoreScope](https://live.meshcore.ca/#/observers) shows the expected node name and IATA region. - -## Need Help? - -Open an issue or update request through [Contributing](../contributing.md), or ask in your local MeshCore Canada community channel. diff --git a/docs/resources/glossary.fr.md b/docs/resources/glossary.fr.md new file mode 100644 index 0000000..a41936c --- /dev/null +++ b/docs/resources/glossary.fr.md @@ -0,0 +1,133 @@ +--- +title: Glossaire MeshCore +description: Trouvez des définitions simples des appareils MeshCore, des paramètres radio, des chemins et des données du réseau. +audience: + - newcomer + - meshcore-user +task: define-meshcore-term +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-01-19 +evidence: Existing MeshCore Canada documentation terminology +difficulty: beginner +estimated_time: 2-10 minutes +destructive: false +--- + +# Glossaire MeshCore + +Utilisez la commande **Rechercher** de votre navigateur pour aller directement +à un terme. Les définitions sont courtes; les guides liés expliquent comment +utiliser chaque paramètre ou appareil. + +## A-C + +**Annonce (Advert)** +: Un paquet d’annonce MeshCore qui permet aux nœuds à proximité de découvrir + un appareil et son chemin. Les applications peuvent afficher le terme + anglais **Advert**. + +**Appareil compagnon (Companion)** +: Un appareil MeshCore personnel qui sert à envoyer et à recevoir des + messages. Il se connecte souvent à une application et ne relaie pas le + trafic du réseau. + +**Bande industrielle, scientifique et médicale (ISM)** +: Une partie du spectre radio utilisée par de nombreux appareils de faible + puissance. Son utilisation légale dépend tout de même du lieu, du matériel + et des conditions d’exploitation. + +**Code de localisation** +: Un code court qui regroupe les données des observateurs par secteur. + Certains guides parlent d’un code IATA parce qu’ils utilisent des codes + d’aéroport de trois lettres. + +**CoreScope** +: Les outils publics de MeshCore Canada qui permettent de consulter les + observateurs, les paquets, les nœuds et les données cartographiques. + +## F-M + +**Facteur d’étalement (SF)** +: Un paramètre LoRa qui influence le temps d’occupation des ondes, le débit + de données et la réception. Utilisez la valeur indiquée dans vos paramètres + locaux ou dans le configurateur. + +**JSON Web Token (JWT)** +: Un identifiant à durée limitée utilisé par certains services de données de + MeshCore Canada. Traitez-le comme un renseignement sensible. + +**Largeur de bande** +: Un paramètre radio LoRa qui détermine la partie du spectre utilisée par une + transmission. Utilisez la valeur indiquée dans vos paramètres locaux ou + dans le configurateur. + +**LoRa** +: La modulation radio longue portée et faible puissance utilisée par les + appareils MeshCore. + +**Maillage** +: Un réseau dans lequel les appareils pris en charge peuvent relayer le + trafic au lieu de dépendre d’une seule radio centrale. + +**Message Queuing Telemetry Transport (MQTT)** +: Un protocole de publication et d’abonnement utilisé par les observateurs + pour transmettre des données du réseau aux outils publics. + +**Micrologiciel** +: Le logiciel installé sur un appareil. Un micrologiciel MeshCore est conçu + pour une carte et une fonction précises. + +**Mode de hachage des chemins** +: Le paramètre MeshCore qui détermine la taille des identifiants dans les + chemins d’annonce. Utilisez la valeur de la configuration locale ou + canadienne actuelle. + +## N-S + +**Nœud** +: Tout appareil MeshCore présent sur le réseau. + +**Observateur** +: Une radio ou un service sur un système hôte qui écoute le trafic MeshCore + et transmet des données du réseau aux outils publics. Il ne relaie pas le + trafic du réseau. + +**Préréglage** +: Un ensemble nommé de paramètres radio. + +**Rapport signal-bruit (SNR)** +: Une mesure qui compare un signal reçu au bruit de fond. + +**Répéteur** +: Un appareil MeshCore normalement fixe qui relaie les paquets afin + d’améliorer la couverture du réseau. + +**Serveur de salon (Room Server)** +: Un appareil MeshCore qui garde un salon partagé accessible aux appareils + compagnons. Sa fonction principale est d’héberger le salon, et non + d’améliorer la couverture. + +**Serveur MQTT (broker)** +: Un service qui reçoit et distribue des messages au moyen du protocole MQTT. + +## T-W + +**Taux de codage (CR)** +: Un paramètre LoRa de correction des erreurs. Utilisez la valeur indiquée + dans vos paramètres locaux ou dans le configurateur. + + + +**WebSocket** +: Une connexion Web de longue durée. Certains clients MQTT utilisent + WebSocket pour joindre un serveur MQTT par les voies réseau Web habituelles. + +## Pages connexes + +- [Comparer les types d’appareils MeshCore](../start/choose-a-goal.md) +- [Lire les questions fréquentes](../meshcore/general-faq.md) +- [Consulter la norme sur les régions](../config/standard.md) +- [Choisir une méthode d’observation](../analyzer/intro.md) diff --git a/docs/resources/glossary.md b/docs/resources/glossary.md index 610ea61..ec02c5f 100644 --- a/docs/resources/glossary.md +++ b/docs/resources/glossary.md @@ -1,66 +1,121 @@ -# Glossary - -A quick reference for common terms used across the MeshCore community. +--- +title: MeshCore glossary +description: Find plain-language definitions for MeshCore devices, radio settings, paths, and network data terms. +audience: + - newcomer + - meshcore-user +task: define-meshcore-term +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-01-19 +evidence: Existing MeshCore Canada documentation terminology +difficulty: beginner +estimated_time: 2-10 minutes +destructive: false +--- + +# MeshCore glossary + +Use your browser's **Find** command to jump to a term. Definitions are short; +the linked guides explain how to use each setting or device. + +## A-D **Advert** -: A MeshCore announcement packet that helps nearby nodes learn about a device and route. +: A MeshCore announcement packet that helps nearby nodes learn about a device + and its path. **Bandwidth** -: A LoRa radio setting. MeshCore Canada baseline raw settings use `62.5 kHz`. +: A LoRa radio setting that affects how much radio spectrum a transmission + uses. Use the value from your local settings or the configurator. **Broker** -: An MQTT server. MeshCore.ca uses `mqtt1.meshcore.ca` and `mqtt2.meshcore.ca` for redundant observer publishing. +: A service that receives and distributes messages for Message Queuing + Telemetry Transport (MQTT). -**Coding Rate (CR)** -: A LoRa error-correction setting. MeshCore Canada baseline raw settings use `CR5`. +**Coding rate (CR)** +: A LoRa error-correction setting. Use the value from your local settings or + the configurator. **Companion** -: A portable MeshCore device used with a phone, tablet, desktop app, web app, or serial client to send and receive messages. +: A personal MeshCore device used to send and receive messages. It often + connects to an app and does not relay mesh traffic. **CoreScope** -: The MeshCore.ca live tools for checking observers, packet flow, and map data. +: MeshCore Canada's public tools for viewing observers, packets, nodes, and + map data. + +## F-M -**IATA Code** -: A real 3-letter airport code used by MeshCore.ca observers to tag their region, such as `YOW`, `YYZ`, or `YVR`. +**Firmware** +: Software installed on a device. MeshCore firmware is built for a specific + board and role. -**ISM Band** -: Industrial, Scientific, and Medical radio spectrum used by many low-power devices. You are responsible for legal operation in your location. +**Industrial, scientific, and medical (ISM) band** +: Radio spectrum used by many low-power devices. Legal use still depends on + location, equipment, and operating conditions. -**JWT** -: JSON Web Token. MeshCore.ca MQTT paths use token authentication instead of a static username/password. +**JSON Web Token (JWT)** +: A time-limited credential used by supported MeshCore Canada data services. + Treat it as sensitive. + +**Location code** +: A short code that groups observer data by area. Some observer guides call + this an IATA code because they use three-letter airport codes. **LoRa** -: Long Range radio modulation used by MeshCore devices. +: The long-range, low-power radio modulation used by MeshCore devices. + +**Mesh network** +: A network in which supported devices can relay traffic instead of relying + on one central radio. -**Mesh Network** -: A network where devices can relay traffic for each other instead of depending on one central access point. +**Message Queuing Telemetry Transport (MQTT)** +: A publish-and-subscribe protocol used by observers to send network data to + public tools. -**MQTT** -: A lightweight publish/subscribe messaging protocol used by MeshCore.ca observers to publish packet telemetry. +## N-R **Node** : Any MeshCore device on the network. **Observer** -: A MeshCore radio or host service that publishes mesh packet telemetry to MQTT for live tools. +: A radio or host service that listens for MeshCore traffic and sends network + data to public tools. It does not relay mesh traffic. -**Path Hash Mode** -: The MeshCore setting that controls advert path identifier size. MeshCore Canada recommends **3-byte**, which is `set path.hash.mode 2` in the CLI. +**Path hash mode** +: The MeshCore setting that controls the size of identifiers in advert paths. + Use the value from the current local or Canadian configuration. **Preset** -: A named group of radio settings. MeshCore Canada generally uses `USA/Canada (Recommended)`. +: A named group of radio settings. **Repeater** -: A fixed device that relays packets to extend mesh coverage. +: A normally fixed MeshCore device that relays packets to extend network + coverage. + +**Room server** +: A MeshCore device that keeps a shared room available to companions. Its + main job is hosting the room, not extending coverage. + +## S-W + +**Signal-to-noise ratio (SNR)** +: A measurement comparing a received signal with background noise. -**Room Server** -: A MeshCore node that hosts group chat rooms. It may also repeat or observe traffic depending on its configuration. +**Spreading factor (SF)** +: A LoRa setting that affects airtime, data rate, and reception. Use the value + from your local settings or the configurator. -**SNR** -: Signal-to-noise ratio, a measure of received signal quality. +**WebSocket** +: A long-lived web connection. Some MQTT clients use WebSockets to reach a + broker through normal web network paths. -**Spreading Factor (SF)** -: A LoRa setting that affects range, airtime, and data rate. MeshCore Canada baseline raw settings use `SF7`. +## Related pages -**WebSockets** -: A transport used by the MeshCore.ca MQTT brokers on port `443`, which is often easier to pass through normal internet connections. +- [Compare MeshCore roles](../start/choose-a-goal.md) +- [Read common questions](../meshcore/general-faq.md) +- [Open the region standard](../config/standard.md) +- [Choose an observer method](../analyzer/intro.md) diff --git a/docs/resources/links.fr.md b/docs/resources/links.fr.md new file mode 100644 index 0000000..9096d0d --- /dev/null +++ b/docs/resources/links.fr.md @@ -0,0 +1,60 @@ +--- +title: Liens et services +description: Trouvez les services de MeshCore Canada, les ressources officielles de MeshCore et les références canadiennes en matière de radio. +audience: + - meshcore-user + - mesh-operator + - documentation-contributor +task: find-meshcore-resource +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +evidence: External destinations opened successfully on 2026-07-19 +link_checked: 2026-07-19 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +--- + +# Liens et services + +Ces liens ouvrent des services à l’extérieur de ce site de documentation. + +## MeshCore Canada + +| Service | Utilité | Lien | +|---|---|---| +| Forum | Rechercher une discussion ou demander de l’aide. | [Ouvrir le forum (site externe)](https://forum.meshcore.ca/){ target="_blank" rel="noopener" } | +| CoreScope | Consulter les observateurs, les paquets, les nœuds et les données cartographiques publics. | [Ouvrir CoreScope (site externe)](https://live.meshcore.ca/){ target="_blank" rel="noopener" } | +| GitHub | Consulter le code source, les billets et les changements apportés au site. | [Ouvrir le dépôt (site externe)](https://github.com/MeshCore-ca/MeshCore-Canada){ target="_blank" rel="noopener" } | + +## Projet MeshCore officiel + +| Service | Utilité | Lien | +|---|---|---| +| Site Web de MeshCore | Découvrir le projet officiel. | [Ouvrir MeshCore.io (site externe)](https://meshcore.io/){ target="_blank" rel="noopener" } | +| Documentation | Lire les guides officiels sur les appareils, les applications et le protocole. | [Ouvrir la documentation (site externe)](https://docs.meshcore.io/){ target="_blank" rel="noopener" } | +| Code source | Consulter le code source et les versions officielles du micrologiciel. | [Ouvrir le code source (site externe)](https://github.com/meshcore-dev/MeshCore){ target="_blank" rel="noopener" } | +| Outil de reprogrammation | Installer un micrologiciel sur un appareil pris en charge dans un navigateur compatible. | [Ouvrir l’outil de reprogrammation (site externe)](https://flasher.meshcore.io/){ target="_blank" rel="noopener" } | +| Configuration USB | Configurer un répéteur ou un serveur de salon pris en charge. | [Ouvrir l’outil de configuration USB (site externe)](https://config.meshcore.io/){ target="_blank" rel="noopener" } | +| Application Web | Utiliser MeshCore avec un appareil compagnon pris en charge. | [Ouvrir l’application Web (site externe)](https://app.meshcore.nz/){ target="_blank" rel="noopener" } | + +## Radio et réglementation { #radio-and-regulatory-references } + +| Organisme | Utilité | Lien | +|---|---|---| +| Innovation, Sciences et Développement économique Canada (ISDE) | Consulter l’information canadienne actuelle sur le spectre et le matériel. | [Ouvrir le site d’ISDE (site externe)](https://ised-isde.canada.ca/){ target="_blank" rel="noopener" } | +| Radio Amateurs du Canada | Consulter de l’information canadienne sur la radio amateur. | [Ouvrir le site de RAC (site externe)](https://www.rac.ca/){ target="_blank" rel="noopener" } | + +
+ +Ce site ne fournit aucun avis juridique. Consultez les règles actuelles d’ISDE +et les conditions de votre licence avant de modifier la fréquence, la +puissance, le gain de l’antenne ou le cycle d’utilisation. + +
+ +Vous avez trouvé un lien brisé ou trompeur? +[Signalez-le-nous](../submit-idea.md). diff --git a/docs/resources/links.md b/docs/resources/links.md index 669a799..2a1ed58 100644 --- a/docs/resources/links.md +++ b/docs/resources/links.md @@ -1,61 +1,58 @@ -# Useful Links - -This page collects common MeshCore, MeshCore Canada, and supporting radio resources. +--- +title: Links and services +description: Find MeshCore Canada services, official MeshCore resources, and Canadian radio references. +audience: + - meshcore-user + - mesh-operator + - documentation-contributor +task: find-meshcore-resource +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +evidence: External destinations opened successfully on 2026-07-19 +link_checked: 2026-07-19 +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +--- + +# Links and services + +These links open services outside this documentation site. ## MeshCore Canada -| Resource | Link | -|----------|------| -| MeshCore Canada site | | -| MeshCore Canada forum | | -| Live CoreScope tools | | -| MeshCore Canada GitHub | | -| Add or update a community | [Contributing](../contributing.md) | -| Mesh Directory | [Browse provinces and territories](../provinces/index.md) | +| Service | Use | Link | +|---|---|---| +| Forum | Search discussions or ask for help. | [Open the forum (external)](https://forum.meshcore.ca/){ target="_blank" rel="noopener" } | +| CoreScope | View public observers, packets, nodes, and map data. | [Open CoreScope (external)](https://live.meshcore.ca/){ target="_blank" rel="noopener" } | +| GitHub | View the source, issues, and site changes. | [Open the repository (external)](https://github.com/MeshCore-ca/MeshCore-Canada){ target="_blank" rel="noopener" } | ## Official MeshCore -| Resource | Link | -|----------|------| -| MeshCore website | | -| MeshCore docs | | -| MeshCore source | | -| MeshCore GitHub organization | | -| MeshCore Flasher | | -| Repeater / room-server USB config | | -| MeshCore web app | | - -## MeshCore.ca Analyzer - -| Resource | Link | -|----------|------| -| Observer setup overview | [Analyzer & MQTT](../analyzer/intro.md) | -| MQTT data collection and access | [Data Collection & Access](../analyzer/data-collection-access.md) | -| Direct MQTT firmware | [MQTT Firmware](../analyzer/builds/mqtt-firmware.md) | -| MCtoMQTT host bridge | [MCtoMQTT](../analyzer/builds/mctomqtt.md) | -| Home Assistant integration setup | [MeshCore-HA](../analyzer/builds/meshcore-ha.md) | -| PyMC setup | [PyMC](../analyzer/builds/pymc.md) | -| RemoteTerm setup | [RemoteTerm](../analyzer/remoteterm.md) | -| IATA code reference | [IATA Region Codes](../analyzer/iata-codes.md) | - -## Radio And Regulatory References - -| Resource | Link | -|----------|------| -| Innovation, Science and Economic Development Canada | | -| Radio Amateurs of Canada | | - -!!! note "Regulatory links" - MeshCore Canada docs are not legal advice. Check current ISED rules and any licence conditions that apply to your station before changing power, antenna gain, frequency, or duty cycle. - -## Tools - -| Tool | Use | -|------|-----| -| MeshCore Flasher | Flash supported MeshCore firmware and custom binaries | -| MeshCore USB config | Configure repeaters and room servers over serial | -| RemoteTerm | Manage radios and optionally publish packets through Community MQTT | -| CoreScope | Check observers, packets, and map visibility | -| MQTT clients | Advanced broker diagnostics when troubleshooting observer paths | - -When in doubt, start with [Getting Started](getting-started.md), then move to the hardware or observer guide that matches your device role. +| Service | Use | Link | +|---|---|---| +| MeshCore website | Read about the official project. | [Open MeshCore.io (external)](https://meshcore.io/){ target="_blank" rel="noopener" } | +| Documentation | Read official device, app, and protocol guides. | [Open the documentation (external)](https://docs.meshcore.io/){ target="_blank" rel="noopener" } | +| Source code | View official firmware source and releases. | [Open the source (external)](https://github.com/meshcore-dev/MeshCore){ target="_blank" rel="noopener" } | +| Flasher | Flash a supported device in a compatible browser. | [Open the flasher (external)](https://flasher.meshcore.io/){ target="_blank" rel="noopener" } | +| USB configuration | Configure a supported repeater or room server. | [Open USB configuration (external)](https://config.meshcore.io/){ target="_blank" rel="noopener" } | +| Web app | Use MeshCore with a supported companion. | [Open the web app (external)](https://app.meshcore.nz/){ target="_blank" rel="noopener" } | + +## Radio and regulatory references + +| Organization | Use | Link | +|---|---|---| +| Innovation, Science and Economic Development Canada (ISED) | Find current Canadian spectrum and equipment information. | [Open ISED (external)](https://ised-isde.canada.ca/){ target="_blank" rel="noopener" } | +| Radio Amateurs of Canada | Find Canadian amateur-radio information. | [Open RAC (external)](https://www.rac.ca/){ target="_blank" rel="noopener" } | + +
+ +This site is not legal advice. Check current ISED rules and licence conditions +before changing frequency, power, antenna gain, or duty cycle. + +
+ +Found a broken or misleading link? [Tell us](../submit-idea.md). diff --git a/docs/robots.txt b/docs/robots.txt new file mode 100644 index 0000000..0ace24e --- /dev/null +++ b/docs/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://meshcore.ca/sitemap.xml diff --git a/docs/start/choose-a-goal.fr.md b/docs/start/choose-a-goal.fr.md new file mode 100644 index 0000000..a3a8265 --- /dev/null +++ b/docs/start/choose-a-goal.fr.md @@ -0,0 +1,37 @@ +--- +title: Choisir un type d’appareil MeshCore +description: Comparez les appareils compagnons, les répéteurs, les serveurs de salon et les observateurs selon ce que vous voulez accomplir. +audience: + - newcomer + - first-time-user +task: choose-device-role +scope: upstream-meshcore +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 2-3 minutes +destructive: false +--- + +# Choisir un type d’appareil MeshCore + +Choisissez l’appareil selon ce qu’il doit faire. + +| Type | À quoi sert-il? | Relaie le trafic du réseau? | Utilisation habituelle | Configuration | +|---|---|---:|---|---| +| Appareil compagnon | Envoyer et recevoir des messages | Non | Mobile; souvent jumelé à un téléphone | [Configurer un compagnon](companion.md) | +| Répéteur | Améliorer la couverture locale | Oui | Fixe et alimenté en continu | [Configurer un répéteur](repeater.md) | +| Serveur de salon | Garder un salon partagé accessible | Non | Fixe et alimenté en continu | [Configurer un serveur de salon](room-server.md) | +| Observateur | Transmettre à CoreScope les données radio captées | Non | Fixe; les besoins varient selon la méthode | [Configurer un observateur](observer.md) | + +Vous découvrez MeshCore? Commencez par un +[appareil compagnon](companion.md). Avant d’acheter du matériel ou d’installer +un micrologiciel, vérifiez que le guide de configuration prend votre appareil +en charge. + +Si aucun de ces choix ne convient, +[demandez conseil à la communauté](get-help.md) avant d’acheter du matériel. diff --git a/docs/start/choose-a-goal.md b/docs/start/choose-a-goal.md new file mode 100644 index 0000000..3e23a59 --- /dev/null +++ b/docs/start/choose-a-goal.md @@ -0,0 +1,35 @@ +--- +title: Choose a MeshCore role +description: Compare companions, repeaters, room servers, and observers by the job you want to do. +audience: + - newcomer + - first-time-user +task: choose-device-role +scope: upstream-meshcore +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 2-3 minutes +destructive: false +--- + +# Choose a MeshCore role + +Choose a role based on the job the device will do. + +| Role | Use it for | Relays mesh traffic? | Usually | Setup | +|---|---|---:|---|---| +| Companion | Send and receive messages | No | Mobile; often pairs with a phone | [Set up a companion](companion.md) | +| Repeater | Improve local coverage | Yes | Fixed with continuous power | [Set up a repeater](repeater.md) | +| Room server | Keep a shared room available | No | Fixed with continuous power | [Set up a room server](room-server.md) | +| Observer | Send heard network data to CoreScope | No | Fixed; requirements vary | [Set up an observer](observer.md) | + +New to MeshCore? Start with a [companion](companion.md). Before buying or +flashing, confirm that the setup guide supports your device. + +If none of these roles fits, [ask the community](get-help.md) before buying +hardware. diff --git a/docs/start/companion.fr.md b/docs/start/companion.fr.md new file mode 100644 index 0000000..b6f140a --- /dev/null +++ b/docs/start/companion.fr.md @@ -0,0 +1,91 @@ +--- +title: Commencer avec un appareil compagnon +description: Préparez, configurez et vérifiez un appareil compagnon MeshCore personnel pour une communauté canadienne. +audience: + - first-time-user + - companion-owner +task: start-companion +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 20-30 minutes +destructive: false +requires: + - supported-companion + - data-capable-usb-cable +--- + +# Commencer avec un appareil compagnon + +Un appareil compagnon sert à envoyer et à recevoir des messages personnels. Il +ne relaie pas le trafic des autres personnes. La plupart se jumellent à une +application; certains possèdent leur propre écran et leurs propres commandes. + +## Avant de commencer + +- Consultez les [appareils compagnons recommandés](../hardware/recommended-companions.md). +- Confirmez que le guide détaillé de reprogrammation indique votre appareil. +- Utilisez un câble USB de données et un navigateur pris en charge. +- Notez l’identité et les paramètres dont vous aurez besoin avant toute étape + d’effacement. + +## Ce qui sera modifié + +Le guide lié remplace le micrologiciel de l’appareil et le configure comme +compagnon. Arrêtez-vous avant l’effacement si vous ne pouvez pas récupérer les +renseignements dont vous avez besoin. + +
+

Liste de configuration

+

Cette liste est enregistrée uniquement dans ce navigateur.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ +## Installer le micrologiciel du compagnon + +Suivez le guide [Reprogrammer et configurer un appareil +compagnon](../meshcore/flash-companion.md). Il explique comment choisir +l’appareil, établir la connexion dans le navigateur, installer le micrologiciel +et récupérer l’appareil en cas de problème. + +## Choisir les bons paramètres radio + +Utilisez le préréglage **USA/Canada (Recommended)** et le hachage des chemins +sur **3 octets**, sauf si votre communauté indique d’autres paramètres. + +!!! warning "Utilisez les mêmes paramètres que votre communauté" + Consultez le [répertoire des communautés](../provinces/index.md). Si votre + communauté publie d’autres paramètres, utilisez-les. + +Redémarrez l’appareil après avoir modifié les paramètres radio, puis envoyez +une annonce. + +## Vérifier que tout fonctionne + +Le compagnon est prêt lorsque : + +1. l’appareil affiche les paramètres du Canada ou ceux de votre communauté; +2. un appareil fiable ou un membre de la communauté à proximité voit son annonce. + +Utilisez la [liste de vérification du compagnon](verify.md#companion). + +## Et ensuite? + +Conservez une copie des paramètres locaux et revérifiez-les après toute +modification du micrologiciel. Consultez les +[guides pratiques de MeshCore](../meshcore/general-howto.md) pour les tâches de +messagerie courantes. [Trouvez votre communauté](../provinces/index.md), puis +échangez un message d’essai avec une personne à proximité. Si personne ne voit +l’annonce, [obtenez de l’aide](get-help.md). diff --git a/docs/start/companion.md b/docs/start/companion.md new file mode 100644 index 0000000..f4fcb24 --- /dev/null +++ b/docs/start/companion.md @@ -0,0 +1,84 @@ +--- +title: Start with a companion +description: Prepare, configure, and verify a personal MeshCore companion for a Canadian community. +audience: + - first-time-user + - companion-owner +task: start-companion +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 20-30 minutes +destructive: false +requires: + - supported-companion + - data-capable-usb-cable +--- + +# Start with a companion + +A companion is a personal messaging device. It does not route traffic for +other users. Most pair with an app; some have their own screen and controls. + +## Before you start + +- Check the [recommended companion options](../hardware/recommended-companions.md). +- Confirm the detailed flashing guide lists your device. +- Use a data-capable USB cable and a supported browser. +- Record any identity or settings you need before following an erase step. + +## Know what flashing changes + +The linked flashing guide replaces the device firmware and configures it as a +companion. Stop before erasing if you cannot recover information you need. + +
+

Setup checklist

+

Checks are saved only in this browser.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ +## Flash the companion + +Follow [Flash and configure a companion](../meshcore/flash-companion.md). Use +that guide for device selection, browser connection, flashing, and recovery +steps. + +## Use the right radio settings + +Use **USA/Canada (Recommended)** with the **3-byte** path setting unless your +community lists different settings. + +!!! warning "Match your local mesh" + Check the [community directory](../provinces/index.md). If your community + publishes different settings, use those settings instead. + +Reboot after changing the radio settings, then send an advert. + +## Make sure it works + +The companion is ready when: + +1. the device shows the Canada settings or your community's settings; and +2. a nearby known-good device or community member can see its advert. + +Use the [companion verification checklist](verify.md#companion). + +## What's next + +Keep the local settings recorded and check them again after firmware changes. +Use [MeshCore how-to guides](../meshcore/general-howto.md) for common messaging +tasks. [Find your community](../provinces/index.md), then exchange a test message +with a nearby user. If nobody can see the advert, [get help](get-help.md). diff --git a/docs/start/get-help.fr.md b/docs/start/get-help.fr.md new file mode 100644 index 0000000..d1abc31 --- /dev/null +++ b/docs/start/get-help.fr.md @@ -0,0 +1,63 @@ +--- +title: Obtenir de l’aide pour une configuration MeshCore +description: Obtenez de l’aide pour un appareil MeshCore, des paramètres locaux, un observateur ou un problème de documentation. +audience: + - first-time-user + - meshcore-operator +task: get-setup-help +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +--- + +# Obtenir de l’aide + +Commencez par décrire le problème. Ne publiez aucun mot de passe, aucune clé +privée, aucun emplacement précis ni aucune sortie de commande qui contient des +renseignements sensibles. + +| Quel est le problème? | Commencez ici | +|---|---| +| Vous ne savez pas quel type d’appareil ou quel matériel choisir | [Comparer les types d’appareils](choose-a-goal.md) | +| Un appareil à proximité ne voit pas votre annonce | Revérifiez les [étapes de vérification](verify.md) et les paramètres locaux | +| Vous avez besoin des paramètres locaux ou d’une autre personne pour faire un essai | [Trouver une communauté](../provinces/index.md) | +| Un observateur est absent ou n’affiche aucune activité récente | [Dépanner un observateur](../analyzer/troubleshooting.md) | +| Vous cherchez une réponse courante sur MeshCore | [Consulter la FAQ MeshCore](../meshcore/general-faq.md) | +| Une page manque ou contient une erreur | [Partager une idée](../submit-idea.md) | + +## Demander à la communauté + +- Le [forum MeshCore Canada](https://forum.meshcore.ca/) offre des discussions + publiques faciles à rechercher et de l’aide. +- Le [Discord de MeshCore Canada](https://discord.gg/BESFVMt7yk) permet de + discuter directement avec la communauté. + +Indiquez : + +- le type et le modèle de l’appareil; +- le guide suivi et l’étape où vous vous êtes arrêté; +- ce que vous vous attendiez à voir; +- ce qui s’est plutôt produit; +- le nom du réglage canadien ou local, sans aucun secret; +- le texte de l’erreur, après avoir retiré les valeurs sensibles. + +## Arrêtez-vous avant de réessayer si + +- la prochaine étape effacerait des données qui ne sont pas sauvegardées; +- le matériel est chaud, endommagé, mouillé ou câblé de façon inattendue; +- vous risquez de perdre le seul moyen d’accéder à un nœud éloigné; +- une personne responsable du réseau local indique que les paramètres proposés + entrent en conflit avec le réseau. + +## Essayez un seul changement à la fois + +Retournez à la page [Vérifier votre configuration](verify.md) après chaque +changement. Il est beaucoup plus facile de comprendre le résultat lorsque vous +ne modifiez qu’une seule chose à la fois. diff --git a/docs/start/get-help.md b/docs/start/get-help.md new file mode 100644 index 0000000..b15889f --- /dev/null +++ b/docs/start/get-help.md @@ -0,0 +1,60 @@ +--- +title: Get help with a MeshCore setup +description: Get help with a MeshCore device, local settings, observer, or documentation problem. +audience: + - first-time-user + - meshcore-operator +task: get-setup-help +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +--- + +# Get help + +Start with the symptom. Do not post passwords, private keys, precise locations, +or unredacted command output. + +| What is wrong? | Start here | +|---|---| +| You are unsure which role or hardware fits | [Compare roles](choose-a-goal.md) | +| A nearby device cannot see your advert | Recheck the [verification steps](verify.md) and local settings | +| You need local settings or another person to test | [Find a community](../provinces/index.md) | +| An observer is missing or has no recent activity | [Observer troubleshooting](../analyzer/troubleshooting.md) | +| You need a common MeshCore answer | [MeshCore FAQ](../meshcore/general-faq.md) | +| A page is missing or incorrect | [Share an idea](../submit-idea.md) | + +## Ask the community + +- [MeshCore Canada forum](https://forum.meshcore.ca/) — searchable public + discussions and support. +- [MeshCore Canada Discord](https://discord.gg/BESFVMt7yk) — live community + chat. + +Include: + +- the device role and model; +- the guide and step where you stopped; +- what you expected; +- what happened instead; +- the Canada or local setting name, without secrets; +- any error text after removing sensitive values. + +## Stop before retrying when + +- the next step would erase data you have not backed up; +- the hardware is hot, damaged, wet, or wired unexpectedly; +- you would lose the only way to reach a remote node; +- a local operator says the proposed settings conflict with the mesh. + +## Try one change at a time + +Return to [Check your setup](verify.md) after making one change. Testing one +change at a time makes the result easier to understand. diff --git a/docs/start/index.fr.md b/docs/start/index.fr.md new file mode 100644 index 0000000..637f353 --- /dev/null +++ b/docs/start/index.fr.md @@ -0,0 +1,37 @@ +--- +title: Commencer avec MeshCore au Canada +description: Choisissez un type d’appareil MeshCore et suivez un guide de configuration avec les paramètres du Canada ou de votre communauté. +audience: + - newcomer + - first-time-user +task: start-meshcore +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +--- + +# Commencer avec MeshCore + +Choisissez le guide qui correspond à ce que vous voulez faire avec l’appareil : + +- [Envoyer et recevoir des messages](companion.md) avec un appareil compagnon. +- [Améliorer la couverture locale](repeater.md) avec un répéteur. +- [Garder un salon partagé accessible](room-server.md) avec un serveur de salon. +- [Transmettre des données à CoreScope](observer.md) avec un observateur. + +Vous hésitez? [Comparez les types d’appareils](choose-a-goal.md). + +## Avant de commencer + +Consultez le [répertoire des communautés](../provinces/index.md) pour connaître +les paramètres radio de votre région. Si aucun paramètre n’est indiqué, +utilisez les [valeurs par défaut au Canada](../index.md#canada-baseline). +Chaque guide explique comment vous préparer, configurer l’appareil et vérifier +rapidement que tout fonctionne. diff --git a/docs/start/index.md b/docs/start/index.md new file mode 100644 index 0000000..c79fb2c --- /dev/null +++ b/docs/start/index.md @@ -0,0 +1,35 @@ +--- +title: Start with MeshCore in Canada +description: Choose a MeshCore role and follow a setup guide using Canada or local network settings. +audience: + - newcomer + - first-time-user +task: start-meshcore +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2027-07-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 2-5 minutes +destructive: false +--- + +# Start with MeshCore + +Pick the guide that matches what you want the device to do: + +- [Send and receive messages](companion.md) with a companion. +- [Improve local coverage](repeater.md) with a repeater. +- [Host a persistent room](room-server.md) with a room server. +- [Share data with CoreScope](observer.md) with an observer. + +Not sure which one fits? [Compare the roles](choose-a-goal.md). + +## Before setup + +Check the [community directory](../provinces/index.md) for local radio settings. +If none are listed, use the [Canada defaults](../index.md#canada-baseline). +Each setup guide covers preparation, configuration, and a quick working check. diff --git a/docs/start/observer.fr.md b/docs/start/observer.fr.md new file mode 100644 index 0000000..f5ecfcd --- /dev/null +++ b/docs/start/observer.fr.md @@ -0,0 +1,96 @@ +--- +title: Commencer avec un observateur +description: Choisissez, configurez et vérifiez un observateur MeshCore qui transmet des données du réseau à CoreScope. +audience: + - observer-operator + - service-operator +task: start-observer +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: intermediate +estimated_time: varies by method +destructive: false +requires: + - supported-observer-method + - internet-connection +--- + +# Commencer avec un observateur + +Un observateur écoute le réseau au moyen d’une radio MeshCore et transmet les +données captées à CoreScope. Il ne relaie pas le trafic du réseau. Il peut +fonctionner dans le micrologiciel d’une radio, sur un ordinateur qui reste +allumé ou sur un système de domotique. + +Avant de l’activer, lisez +[quelles données le service recueille et qui peut y accéder](../analyzer/data-collection-access.md). + +## Avant de commencer + +- Ouvrez l’outil [Choisir une méthode d’observation](../analyzer/intro.md). +- Choisissez une méthode adaptée à votre radio, à votre système d’exploitation + et au système hôte que vous utilisez déjà. +- Sauvegardez la configuration avant de remplacer un micrologiciel ou + d’installer un service. +- Lorsque la méthode le demande, utilisez un vrai code de localisation à + proximité. + +## Ce qui sera modifié + +Selon la méthode choisie, la configuration peut remplacer le micrologiciel de +la radio, installer un service sur le système hôte ou ajouter une intégration +domotique. Elle permet aussi la transmission publique de données du réseau. + +
+

Liste de configuration

+

Cette liste est enregistrée uniquement dans ce navigateur.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
  7. +
+
+ +## Configurer l’observateur + +Utilisez l’outil [Choisir une méthode d’observation](../analyzer/intro.md), puis +suivez uniquement le guide de la méthode choisie. + +## Choisir les bons paramètres radio + +La radio connectée doit utiliser les mêmes paramètres que le réseau à +proximité. Commencez avec le préréglage **USA/Canada (Recommended)** et le +hachage des chemins sur **3 octets**, sauf si votre communauté indique +d’autres paramètres. + +!!! warning "Utilisez les mêmes paramètres que votre communauté" + Consultez le [répertoire des communautés](../provinces/index.md). Un + observateur peut être en ligne sans capter de trafic utile si ses paramètres + radio diffèrent de ceux des appareils à proximité. + +## Vérifier que tout fonctionne + +L’observateur fonctionne lorsque : + +1. sa radio capte une activité MeshCore connue à proximité; +2. il apparaît dans [CoreScope Observers](https://live.meshcore.ca/#/observers); +3. une activité récente apparaît après une transmission à proximité. + +Utilisez la [liste de vérification de l’observateur](verify.md#observer). + +## Et ensuite? + +Vérifiez l’observateur après tout changement de radio, de réseau, +d’identifiants, de système hôte ou de micrologiciel. Ouvrez +[Vérifier votre observateur](../analyzer/verify.md) pour consulter son état +détaillé. S’il est absent ou silencieux, +[obtenez de l’aide](get-help.md). diff --git a/docs/start/observer.md b/docs/start/observer.md new file mode 100644 index 0000000..cfb1364 --- /dev/null +++ b/docs/start/observer.md @@ -0,0 +1,91 @@ +--- +title: Start with an observer +description: Choose, configure, and verify a MeshCore observer that sends network data to CoreScope. +audience: + - observer-operator + - service-operator +task: start-observer +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: intermediate +estimated_time: varies by method +destructive: false +requires: + - supported-observer-method + - internet-connection +--- + +# Start with an observer + +An observer listens through a MeshCore radio and sends network data to +CoreScope. It does not route mesh traffic. It can run on radio firmware, an +always-on computer, or a home-automation host. + +Read [what the service collects and who can access +it](../analyzer/data-collection-access.md) before enabling an observer. + +## Before you start + +- Open the [observer method chooser](../analyzer/intro.md). +- Pick a method that matches the radio, operating system, and host you already + have. +- Back up configuration before replacing firmware or installing a service. +- Use a real nearby location code when the chosen method asks for one. + +## What setup changes + +Depending on the method, setup may replace radio firmware, install a host +service, or add a home-automation integration. It also enables public +network data publishing. + +
+

Setup checklist

+

Checks are saved only in this browser.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
  7. +
+
+ +## Set up the observer + +Use the [observer method chooser](../analyzer/intro.md), then follow only the +guide for the selected method. + +## Use the right radio settings + +The connected radio must use the same settings as the nearby mesh. Start with +**USA/Canada (Recommended)** and the **3-byte** path setting unless your +community lists different settings. + +!!! warning "Match your local mesh" + Check the [community directory](../provinces/index.md). A connected + observer can be online while hearing no useful traffic if its radio + settings do not match nearby devices. + +## Make sure it works + +The observer is working when: + +1. its radio hears known nearby mesh activity; +2. it appears in [CoreScope Observers](https://live.meshcore.ca/#/observers); + and +3. recent activity appears after a nearby transmission. + +Use the [observer verification checklist](verify.md#observer). + +## What's next + +Check the observer after radio, network, credential, host, or firmware changes. +Open [Check your observer](../analyzer/verify.md) for the detailed health +view. If it is missing or quiet, [get help](get-help.md). diff --git a/docs/start/repeater.fr.md b/docs/start/repeater.fr.md new file mode 100644 index 0000000..0f66218 --- /dev/null +++ b/docs/start/repeater.fr.md @@ -0,0 +1,99 @@ +--- +title: Commencer avec un répéteur +description: Préparez, configurez et vérifiez un répéteur MeshCore pour une communauté canadienne. +audience: + - repeater-builder + - repeater-operator +task: start-repeater +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: intermediate +estimated_time: varies by build +destructive: false +requires: + - supported-repeater + - data-capable-usb-cable +--- + +# Commencer avec un répéteur + +Un répéteur améliore la couverture en relayant le trafic des autres personnes. +Puisqu’il s’agit d’une installation fixe, planifiez l’alimentation, l’antenne, +l’accès et l’entretien avant de l’installer. + +## Avant de commencer + +- Consultez les [répéteurs recommandés](../hardware/recommended-repeaters.md). +- Choisissez l’emplacement et les paramètres avec la communauté locale. +- Gardez un accès physique à l’appareil pendant les essais. +- Notez l’identité et les paramètres actuels avant tout effacement ou toute + mise à jour. + +## Ce qui sera modifié + +Le guide installe le micrologiciel du répéteur et configure la radio, +l’identité, les annonces et la région. Faites les essais sur l’établi avant +toute installation difficile d’accès. + +
+

Liste de configuration

+

Cette liste est enregistrée uniquement dans ce navigateur.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ +## Installer le micrologiciel du répéteur + +Suivez le guide [Reprogrammer et configurer un +répéteur](../meshcore/flash-repeater.md). Il présente les choix propres à chaque +carte, les sauvegardes à faire, les situations où il faut s’arrêter et la +méthode de récupération. + +## Choisir les bons paramètres radio et régionaux + +Utilisez le préréglage **USA/Canada (Recommended)** et le hachage des chemins +sur **3 octets**, sauf si votre communauté indique d’autres paramètres. + +!!! warning "Coordonnez les changements avant l’installation" + Consultez le [répertoire des communautés](../provinces/index.md). Les + répéteurs à proximité doivent utiliser les mêmes paramètres locaux. Les + personnes responsables doivent s’entendre sur tout changement qui touche + le trafic partagé. + +Utilisez le [configurateur de répéteur](../config/index.md) pour trouver les +paramètres régionaux. Relisez les commandes avant de les appliquer. + +## Vérifier que tout fonctionne + +Le répéteur est prêt à être installé lorsque : + +1. il conserve les paramètres prévus après un redémarrage; +2. il envoie une annonce; +3. un appareil compagnon fiable à proximité reçoit cette annonce. + +Utilisez la [liste de vérification du répéteur](verify.md#repeater). + +## Et ensuite? + +Conservez le nom de l’emplacement, la personne responsable, le moyen d’accès en +cas de panne, les paramètres et la date de la dernière vérification réussie. +Revérifiez l’appareil après tout changement de micrologiciel, d’antenne, +d’alimentation ou de région. Avec le micrologiciel 1.14 ou plus récent, +consultez le [paramètre de détection des boucles](../meshcore/flash-repeater.md#loop-detection) +avant de le modifier sur un répéteur communautaire. + +Consultez les conseils sur les [antennes](../hardware/recommended-antenna.md) et +les [méthodes de fixation](../hardware/repeater-mounting-options.md) avant +l’installation finale. Si une vérification échoue, +[obtenez de l’aide](get-help.md). diff --git a/docs/start/repeater.md b/docs/start/repeater.md new file mode 100644 index 0000000..f6ee7d1 --- /dev/null +++ b/docs/start/repeater.md @@ -0,0 +1,93 @@ +--- +title: Start with a repeater +description: Prepare, configure, and check a MeshCore repeater for a Canadian community. +audience: + - repeater-builder + - repeater-operator +task: start-repeater +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: intermediate +estimated_time: varies by build +destructive: false +requires: + - supported-repeater + - data-capable-usb-cable +--- + +# Start with a repeater + +A repeater extends coverage by routing traffic for other users. Because it is +fixed infrastructure, plan its power, antenna, access, and maintenance before +installation. + +## Before you start + +- Check [recommended repeater options](../hardware/recommended-repeaters.md). +- Agree on the site and settings with the local community. +- Keep physical recovery access while testing. +- Record the current identity and settings before any erase or update. + +## Know what flashing changes + +The guide installs repeater firmware and sets radio, identity, adverts, and +regional settings. Test on the bench before a difficult installation. + +
+

Setup checklist

+

Checks are saved only in this browser.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ +## Flash the repeater + +Follow [Flash and configure a repeater](../meshcore/flash-repeater.md). Use the +board-specific decisions, backup guidance, stop conditions, and recovery path +in that guide. + +## Use the right radio and region settings + +Use **USA/Canada (Recommended)** with the **3-byte** path setting unless your +community lists different settings. + +!!! warning "Coordinate before installation" + Check the [community directory](../provinces/index.md). Nearby repeaters + need matching local settings, and operators should agree on changes that + affect shared traffic. + +Use the [repeater configurator](../config/index.md) to find the regional +settings and review its commands before applying them. + +## Make sure it works + +The repeater is ready to install when: + +1. it keeps the intended settings after a reboot; +2. it sends an advert; and +3. a nearby known-good companion receives that advert. + +Use the [repeater verification checklist](verify.md#repeater). + +## What's next + +Keep a record of the site, owner, recovery access, settings, and last +successful check. Recheck it after firmware, antenna, power, or region changes. +For firmware 1.14 or newer, review the coordinated +[loop-detection setting](../meshcore/flash-repeater.md#loop-detection) before +changing a community repeater. + +Review [antenna](../hardware/recommended-antenna.md) and +[mounting](../hardware/repeater-mounting-options.md) guidance before final +installation. If a check fails, [get help](get-help.md). diff --git a/docs/start/room-server.fr.md b/docs/start/room-server.fr.md new file mode 100644 index 0000000..e7cdf3a --- /dev/null +++ b/docs/start/room-server.fr.md @@ -0,0 +1,91 @@ +--- +title: Commencer avec un serveur de salon +description: Préparez, configurez et vérifiez un serveur de salon MeshCore persistant pour une communauté canadienne. +audience: + - room-server-operator +task: start-room-server +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: intermediate +estimated_time: 20-30 minutes +destructive: false +requires: + - supported-room-server + - data-capable-usb-cable +--- + +# Commencer avec un serveur de salon + +Un serveur de salon garde un salon partagé accessible. Il ne remplace pas un +répéteur : utilisez un répéteur pour acheminer le trafic et améliorer la +couverture. + +## Avant de commencer + +- Confirmez que l’outil de reprogrammation offre le micrologiciel + **Room Server** pour votre appareil. +- Décidez qui entretiendra le salon et conservera un accès physique en cas de + panne. +- Notez l’identité et les paramètres dont vous aurez besoin avant toute étape + d’effacement. +- Préparez des identifiants différents pour les invités et l’administrateur. + +## Ce qui sera modifié + +Le guide lié remplace le micrologiciel et configure l’identité du serveur de +salon, les identifiants d’accès et les paramètres radio. + +
+

Liste de configuration

+

Cette liste est enregistrée uniquement dans ce navigateur.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ +## Installer le micrologiciel du serveur de salon + +Suivez le guide [Reprogrammer et configurer un serveur de +salon](../meshcore/flash-room-server.md). Il explique comment choisir +l’appareil, installer le micrologiciel, configurer les accès et récupérer +l’appareil en cas de problème. + +## Choisir les bons paramètres radio + +Utilisez le préréglage **USA/Canada (Recommended)** et le hachage des chemins +sur **3 octets**, sauf si votre communauté indique d’autres paramètres. + +!!! warning "Utilisez les mêmes paramètres que votre communauté" + Consultez le [répertoire des communautés](../provinces/index.md). Si votre + communauté publie d’autres paramètres, utilisez-les. + +Gardez les identifiants de l’administrateur confidentiels. Partagez uniquement +les renseignements d’accès destinés aux personnes invitées dans le salon. + +## Vérifier que tout fonctionne + +Le serveur de salon est prêt lorsque : + +1. un appareil compagnon à proximité découvre son annonce; +2. le compagnon peut entrer avec les identifiants des invités; +3. l’administrateur peut toujours le récupérer et l’entretenir. + +Utilisez la [liste de vérification du serveur de salon](verify.md#room-server). + +## Et ensuite? + +Notez qui entretient le salon et comment obtenir de l’aide. Demandez à un +membre de la communauté à proximité de faire un essai avec un deuxième +compagnon. Revérifiez la découverte et l’accès des invités après tout +changement de micrologiciel, d’identifiants ou de paramètres radio. En cas +d’échec, [obtenez de l’aide](get-help.md). diff --git a/docs/start/room-server.md b/docs/start/room-server.md new file mode 100644 index 0000000..04e0d5a --- /dev/null +++ b/docs/start/room-server.md @@ -0,0 +1,84 @@ +--- +title: Start with a room server +description: Prepare, configure, and verify a persistent MeshCore room server for a Canadian community. +audience: + - room-server-operator +task: start-room-server +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: intermediate +estimated_time: 20-30 minutes +destructive: false +requires: + - supported-room-server + - data-capable-usb-cable +--- + +# Start with a room server + +A room server keeps a shared room available. It does not replace a repeater; +use a repeater for routing and coverage. + +## Before you start + +- Confirm the flasher offers room-server firmware for the device. +- Decide who will maintain the room and retain recovery access. +- Record any identity or settings you need before following an erase step. +- Prepare separate guest and administrator credentials. + +## Know what flashing changes + +The linked guide replaces the firmware and sets room-server identity, access +credentials, and radio settings. + +
+

Setup checklist

+

Checks are saved only in this browser.

+
    +
  1. +
  2. +
  3. +
  4. +
  5. +
  6. +
+
+ +## Flash the room server + +Follow [Flash and configure a room server](../meshcore/flash-room-server.md). +Use that guide for device selection, flashing, access setup, and recovery. + +## Use the right radio settings + +Use **USA/Canada (Recommended)** with the **3-byte** path setting unless your +community lists different settings. + +!!! warning "Match your local mesh" + Check the [community directory](../provinces/index.md). If your community + publishes different settings, use those settings instead. + +Keep administrator credentials private. Share only the guest access +information intended for room users. + +## Make sure it works + +The room server is ready when: + +1. a nearby companion discovers its advert; +2. the companion can enter with the guest credentials; and +3. the administrator can still recover and maintain it. + +Use the [room-server verification checklist](verify.md#room-server). + +## What's next + +Record who maintains the room and how users get support. Ask a nearby community +member to test it from a second companion. Recheck discovery and guest access +after firmware, credential, or radio-setting changes. If +discovery or access fails, [get help](get-help.md). diff --git a/docs/start/verify.fr.md b/docs/start/verify.fr.md new file mode 100644 index 0000000..d5d2f2c --- /dev/null +++ b/docs/start/verify.fr.md @@ -0,0 +1,90 @@ +--- +title: Vérifier une configuration MeshCore +description: Vérifiez si un appareil compagnon, un répéteur, un serveur de salon ou un observateur accomplit sa première tâche. +audience: + - first-time-user + - meshcore-operator +task: verify-first-success +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 5-10 minutes +destructive: false +--- + +# Vérifier votre configuration + +Un appareil allumé ou un état « Connected » ne garantit pas que la +configuration est terminée. Effectuez la vérification qui correspond à votre +appareil. + +Avant l’essai, confirmez que l’appareil affiche toujours les paramètres du +Canada ou ceux de votre communauté. + +## Appareil compagnon { #companion } + +1. Redémarrez l’appareil après le dernier changement de paramètres radio. +2. Envoyez une annonce. +3. Demandez à une personne à proximité, avec un appareil dont le bon + fonctionnement a été confirmé, de vérifier si l’annonce apparaît. +4. Échangez un message d’essai lorsqu’une autre personne est disponible. + +!!! success "Le compagnon est prêt" + Un appareil fiable à proximité voit l’annonce et les deux appareils peuvent + échanger un message d’essai. + +Si l’annonce n’apparaît pas, comparez les paramètres radio et les chemins avant +de réinstaller le micrologiciel. + +## Répéteur { #repeater } + +1. Gardez le répéteur accessible sur l’établi. +2. Redémarrez-le et confirmez que les paramètres prévus sont toujours présents. +3. Envoyez une annonce à partir du répéteur. +4. Confirmez qu’un appareil compagnon fiable à proximité la reçoit. +5. Recommencez la vérification après tout changement d’antenne, d’alimentation + ou d’installation. + +!!! success "Le répéteur est prêt" + Le répéteur conserve ses paramètres et un appareil compagnon fiable à + proximité reçoit son annonce. + +Ne l’installez pas dans un endroit difficile d’accès avant que l’essai sur +l’établi soit réussi. + +## Serveur de salon { #room-server } + +1. Redémarrez le serveur de salon. +2. Envoyez son annonce. +3. Confirmez qu’un appareil compagnon à proximité le découvre. +4. Entrez dans le salon avec les identifiants d’accès des invités. +5. Confirmez que l’administrateur peut toujours y accéder pour l’entretenir. + +!!! success "Le serveur de salon est prêt" + Un appareil compagnon découvre le salon et l’accès des invités fonctionne + sans exposer les identifiants de l’administrateur. + +## Observateur { #observer } + +1. Créez de l’activité MeshCore à proximité avec un appareil fiable. +2. Confirmez que la radio de l’observateur utilise les mêmes paramètres. +3. Ouvrez [CoreScope Observers](https://live.meshcore.ca/#/observers). +4. Trouvez votre observateur et vérifiez son activité récente. +5. Consultez [Vérifier votre observateur](../analyzer/verify.md) pour effectuer + la vérification complète. + +!!! success "L’observateur fonctionne" + Votre observateur apparaît dans CoreScope et affiche une activité récente + après une transmission à proximité. + +Une connexion au serveur MQTT ne suffit pas à confirmer que tout fonctionne. + +## Si une vérification échoue + +Si la vérification réussit, vous avez terminé. Sinon, +[obtenez de l’aide](get-help.md) et indiquez ce qui manque. diff --git a/docs/start/verify.md b/docs/start/verify.md new file mode 100644 index 0000000..418c98d --- /dev/null +++ b/docs/start/verify.md @@ -0,0 +1,86 @@ +--- +title: Check a MeshCore setup +description: Check whether a companion, repeater, room server, or observer completed its first working task. +audience: + - first-time-user + - meshcore-operator +task: verify-first-success +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-19 +tested_with: + content_baseline: f608cfe +difficulty: beginner +estimated_time: 5-10 minutes +destructive: false +--- + +# Check your setup + +A powered-on device or a connected status does not mean setup is finished. Use +the check for your device below. + +Before testing, confirm the device still shows the Canada settings or your +community's settings. + +## Companion + +1. Reboot after the last radio-setting change. +2. Send an advert. +3. Ask a nearby user with a known-good device to confirm that the advert + appears. +4. Exchange a test message when another user is available. + +!!! success "The companion is ready" + A nearby known-good device sees the advert, and the two devices can + exchange a test message. + +If the advert is missing, compare radio and path settings before reflashing. + +## Repeater + +1. Keep the repeater accessible on the bench. +2. Reboot it and confirm the intended settings remain. +3. Send an advert from the repeater. +4. Confirm a nearby known-good companion receives it. +5. Repeat the check after any antenna, power, or installation change. + +!!! success "The repeater is ready" + The repeater retains its settings and a nearby known-good companion + receives its advert. + +Keep it off hard-to-reach spots until the bench check passes. + +## Room server + +1. Reboot the room server. +2. Send its advert. +3. Confirm a nearby companion discovers it. +4. Enter using the guest credentials. +5. Confirm the administrator still has maintenance access. + +!!! success "The room server is ready" + A companion discovers the room and guest access works without exposing the + administrator credentials. + +## Observer + +1. Create nearby mesh activity with a known-good device. +2. Confirm the observer radio is using matching settings. +3. Open [CoreScope Observers](https://live.meshcore.ca/#/observers). +4. Find your observer and check for recent activity. +5. Use [Check your observer](../analyzer/verify.md) for the full health + checklist. + +!!! success "The observer is working" + Your observer appears in CoreScope and shows recent activity after + a nearby transmission. + +An online broker connection alone is not the final check. + +## If a check fails + +If the check passes, you're done. If it does not, [get help](get-help.md) and +describe what was missing. diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index def267d..c9e95be 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -1,13 +1,8 @@ /* Home page community links use the built-in grid cards styling. */ :root { - --submission-error-color: #b42318; - --submission-error-bg: rgb(180 35 24 / 8%); -} - -[data-md-color-scheme="slate"] { - --submission-error-color: #ffb4ab; - --submission-error-bg: rgb(255 180 171 / 8%); + --submission-error-color: var(--mc-color-danger); + --submission-error-bg: color-mix(in srgb, var(--mc-color-danger) 8%, transparent); } .submission-intro { @@ -26,7 +21,7 @@ padding: 0.75rem 0.9rem; border-left: 3px solid var(--md-default-fg-color--lighter); background: var(--md-code-bg-color); - color: var(--md-default-fg-color--light); + color: var(--mc-color-text); font-size: 0.82rem; } @@ -34,6 +29,56 @@ color: var(--md-default-fg-color); } +.submission-stepper { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + max-width: 48rem; + margin: 0 0 1.25rem; + padding: 0; + list-style: none; +} + +.submission-stepper li { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.1rem 0.55rem; + align-items: center; + padding: 0.7rem; + border: 1px solid var(--mc-color-border); + border-radius: var(--mc-radius-md); + color: var(--mc-color-text-muted); +} + +.submission-stepper li > span { + display: inline-grid; + grid-row: span 2; + place-items: center; + width: 1.8rem; + height: 1.8rem; + border-radius: 50%; + background: var(--mc-color-surface-raised); + font-weight: 750; +} + +.submission-stepper li[aria-current="step"] { + border-color: var(--mc-color-action); + background: var(--mc-color-surface-raised); + color: var(--mc-color-text); +} + +.submission-stepper li[data-complete="true"] > span { + background: var(--mc-color-success); + color: var(--mc-color-bg); +} + +.submission-stepper small { + color: var(--mc-color-text); + font-size: 0.7rem; + line-height: 1.25; + opacity: 1; +} + .submission-form { display: grid; gap: 1.2rem; @@ -79,6 +124,44 @@ font-weight: 650; } +.submission-error-summary { + padding: 0.8rem 1rem; + border: 2px solid var(--submission-error-color); + border-radius: var(--mc-radius-md); + background: var(--submission-error-bg); +} + +.submission-error-summary h2 { + margin: 0 0 0.4rem; + font-size: 1rem; +} + +.submission-error-summary ul { + margin: 0; +} + +.submission-field-error { + margin: 0; + color: var(--submission-error-color); + font-size: 0.76rem; +} + +.submission-form [aria-invalid="true"] { + border-color: var(--submission-error-color); +} + +.submission-character-count { + justify-self: end; + margin: -0.15rem 0 0; + color: var(--mc-color-text-muted); + font-size: 0.7rem; +} + +.submission-character-count[data-near-limit="true"] { + color: var(--submission-error-color); + font-weight: 700; +} + .submission-no-script, .submission-github-note { margin: 0; @@ -172,6 +255,20 @@ font-size: 0.84rem; } +.submission-draft-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; +} + +.submission-draft-actions p { + flex-basis: 100%; + margin: 0; + color: var(--mc-color-text-muted); + font-size: 0.76rem; +} + .submission-consent input { width: auto; min-height: auto; @@ -202,6 +299,10 @@ gap: 0.6rem; } +.submission-public-summary { + flex-basis: 100%; +} + .submission-review-action .md-button, .submission-actions .md-button { margin: 0; @@ -289,6 +390,12 @@ overflow-wrap: anywhere; } +.submission-preview-note { + margin: -0.8rem 0 0; + color: var(--mc-color-text-muted); + font-size: 0.78rem; +} + .submission-alternatives { margin: 1.25rem 0 2rem; padding: 0.8rem 0; @@ -303,6 +410,14 @@ } @media screen and (max-width: 52em) { + .submission-stepper { + grid-template-columns: 1fr; + } + + .submission-stepper small { + display: block; + } + .submission-form__grid { grid-template-columns: 1fr; } diff --git a/docs/submit-idea.fr.md b/docs/submit-idea.fr.md new file mode 100644 index 0000000..27cdaa7 --- /dev/null +++ b/docs/submit-idea.fr.md @@ -0,0 +1,176 @@ +--- +title: Partager une idée +description: Envoyez une idée ou signalez un problème publiquement à MeshCore Canada, sans compte GitHub. +audience: + - community-member +task: share-an-idea +scope: canada-baseline +status: verified +owner: maintainers-gateway +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + submission_gateway: v1 +difficulty: beginner +estimated_time: 3-5 minutes +page_styles: + - stylesheets/extra.css?v=20260722-2 +page_scripts: + - javascripts/submission-form.js?v=20260722-2 +hide: + - toc +--- +# Partager une idée + +
+

Partagez une idée ou signalez un problème. Aucun compte GitHub n’est nécessaire.

+

Ce formulaire est public. N’incluez aucun mot de passe, aucune clé, aucune adresse ni aucune coordonnée privée.

+
+ +
    +
  1. + 1RelireVérifier ce qui sera public +
  2. +
  3. + 2VérifierEffectuer la vérification antipourriel +
  4. +
  5. + 3SoumettreCréer le billet public +
  6. +
+ +
+ + + + + +
+

Décrivez votre idée

+

Remplissez les cinq champs obligatoires.

+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ Ajouter des précisions Facultatif +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ + + +
+ + +

Confirmez que le contenu peut être public avant d’enregistrer un brouillon.

+
+ + + +
+ +

+
+ + + + + + + + + +
+
+ +
+ Autres façons de nous joindre +

Formulaire GitHub (compte requis) · Forum communautaire · Discord

+
+ +## Et ensuite? + +L’équipe de maintenance examinera le billet et répondra directement sur GitHub. diff --git a/docs/submit-idea.md b/docs/submit-idea.md index c1eb6b8..aa71361 100644 --- a/docs/submit-idea.md +++ b/docs/submit-idea.md @@ -1,4 +1,22 @@ --- +title: Share an idea +description: Send a public idea or problem to MeshCore Canada without needing a GitHub account. +audience: + - community-member +task: share-an-idea +scope: canada-baseline +status: verified +owner: maintainers-gateway +last_reviewed: 2026-07-19 +review_by: 2026-10-19 +tested_with: + submission_gateway: v1 +difficulty: beginner +estimated_time: 3-5 minutes +page_styles: + - stylesheets/extra.css?v=20260722-2 +page_scripts: + - javascripts/submission-form.js?v=20260722-2 hide: - toc --- @@ -9,6 +27,18 @@ hide:

Public form. Do not include passwords, keys, addresses, or private coordinates.

+
    +
  1. + 1ReviewCheck what will be public +
  2. +
  3. + 2VerifyComplete the anti-spam check +
  4. +
  5. + 3SubmitCreate the public issue +
  6. +
+
@@ -17,17 +47,21 @@ hide: JavaScript is off. Continue on GitHub (account required). +

Describe your idea

-

Five fields are required.

+

Fill five required fields.

+ +
- - + +
@@ -92,9 +126,15 @@ hide: +
+ + +

Check the public-content statement before saving a draft.

+
+ + -
+
diff --git a/docs/tools/index.fr.md b/docs/tools/index.fr.md new file mode 100644 index 0000000..7036695 --- /dev/null +++ b/docs/tools/index.fr.md @@ -0,0 +1,71 @@ +--- +title: Outils réseau +description: Choisissez l’outil MeshCore Canada adapté aux régions des répéteurs, au réseau en direct ou à la configuration et à la vérification d’un observateur. +audience: + - meshcore-user + - repeater-operator + - observer-operator +task: choose-network-tool +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-22 +evidence: Internal task routes and the external CoreScope destination reviewed on 2026-07-22 +difficulty: beginner +estimated_time: 2 minutes +destructive: false +--- + +# Outils réseau + +Choisissez l’outil qui correspond à ce que vous voulez faire. + +
+ +- :material-tune-variant:{ .lg .middle } **Configurer un répéteur** + + --- + + Trouvez la région canadienne d’un emplacement et générez les commandes du + répéteur. Relisez les commandes avant de les appliquer. + + [:octicons-arrow-right-24: Ouvrir le configurateur de répéteur](../config/index.md) + +- :material-map-search:{ .lg .middle } **Explorer les régions canadiennes** + + --- + + Trouvez les régions canadiennes et les chemins voisins sur une carte. + + [:octicons-arrow-right-24: Ouvrir la carte des régions](../config/map.md) + +- :material-chart-timeline-variant:{ .lg .middle } **Voir le réseau en direct** + + --- + + Consultez les observateurs, les paquets, les nœuds et les données + cartographiques publics dans CoreScope. + + [:octicons-arrow-right-24: Ouvrir CoreScope (site externe)](https://live.meshcore.ca/){ target="_blank" rel="noopener" } + +- :material-access-point-network:{ .lg .middle } **Configurer un observateur** + + --- + + Choisissez une méthode d’observation, puis confirmez que les paquets + apparaissent dans CoreScope. + + [:octicons-arrow-right-24: Choisir une méthode d’observation](../analyzer/intro.md) + +
+ +## Avant de configurer un observateur + +Les observateurs publient les données des paquets qu’ils captent. Avant d’en +configurer un, lisez [quelles données sont recueillies et qui peut y +accéder](../analyzer/data-collection-access.md). + +Besoin d’aide? Consultez la page de +[dépannage des observateurs](../analyzer/troubleshooting.md) ou +[demandez à la communauté](../start/get-help.md). diff --git a/docs/tools/index.md b/docs/tools/index.md new file mode 100644 index 0000000..ea8ac9d --- /dev/null +++ b/docs/tools/index.md @@ -0,0 +1,67 @@ +--- +title: Network tools +description: Choose the MeshCore Canada tool for repeater regions, live network visibility, or observer setup and verification. +audience: + - meshcore-user + - repeater-operator + - observer-operator +task: choose-network-tool +scope: canada-baseline +status: verified +owner: docs-ux +last_reviewed: 2026-07-22 +review_by: 2026-10-22 +evidence: Internal task routes and the external CoreScope destination reviewed on 2026-07-22 +difficulty: beginner +estimated_time: 2 minutes +destructive: false +--- + +# Network tools + +Choose the tool that matches your task. + +
+ +- :material-tune-variant:{ .lg .middle } **Configure a repeater** + + --- + + Find a location's Canadian region and generate repeater commands. Review + the commands before applying them. + + [:octicons-arrow-right-24: Open the repeater configurator](../config/index.md) + +- :material-map-search:{ .lg .middle } **Explore Canadian regions** + + --- + + Find Canadian regions and neighbouring paths on a map. + + [:octicons-arrow-right-24: Open the region map](../config/map.md) + +- :material-chart-timeline-variant:{ .lg .middle } **View the live network** + + --- + + View public observers, packets, nodes, and map data in CoreScope. + + [:octicons-arrow-right-24: Open CoreScope (external)](https://live.meshcore.ca/){ target="_blank" rel="noopener" } + +- :material-access-point-network:{ .lg .middle } **Set up an observer** + + --- + + Choose an observer method, then check that packets appear in CoreScope. + + [:octicons-arrow-right-24: Choose an observer method](../analyzer/intro.md) + +
+ +## Before setting up an observer + +Observers publish heard packet data. Read [what is collected and who can access +it](../analyzer/data-collection-access.md) before setting one up. + +Need help? See [observer troubleshooting](../analyzer/troubleshooting.md) or +[ask the community](../start/get-help.md). diff --git a/mkdocs.preview.yml b/mkdocs.preview.yml new file mode 100644 index 0000000..6efaaca --- /dev/null +++ b/mkdocs.preview.yml @@ -0,0 +1,5 @@ +INHERIT: mkdocs.yml +site_url: https://canadaverse.org/meshcore-canada/ + +extra: + preview: true diff --git a/mkdocs.yml b/mkdocs.yml index c378791..fcc0e03 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,36 +1,189 @@ site_name: MeshCore Canada site_url: https://meshcore.ca +site_description: Practical, community-reviewed guidance for joining and operating MeshCore networks in Canada. repo_name: MeshCore Canada repo_url: https://github.com/MeshCore-ca/MeshCore-Canada +edit_uri: edit/main/docs/ +use_directory_urls: true +copyright: Community-run documentation for MeshCore users in Canada. Not affiliated with the upstream MeshCore project. theme: name: material - favicon: assets/MeshCore-Canada-Favicon.png # optional - logo: assets/MeshCore-Canada-Favicon.png # optional + custom_dir: overrides + language: en + favicon: assets/MeshCore-Canada-Favicon.png + logo: assets/MeshCore-Canada-Favicon.png + font: false palette: - scheme: slate - primary: indigo - accent: blue + - scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Use light theme + - scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Use dark theme features: + - navigation.tabs + - navigation.tabs.sticky + - navigation.sections + - navigation.path + - navigation.footer - navigation.top + - search.suggest + - search.highlight + - content.code.copy + - content.action.edit - toc.follow extra_css: - - stylesheets/extra.css - - assets/regions/regions.css + - assets/styles/tokens.css?v=20260722-2 + - assets/styles/base.css?v=20260722-3 + - assets/styles/shell.css?v=20260722-3 + - assets/styles/components.css?v=20260722-3 extra_javascript: - - assets/regions/regions.js - - javascripts/submission-form.js + - assets/javascripts/i18n-runtime.js?v=20260723-1 + - assets/javascripts/bootstrap.js?v=20260722-2 + +extra: + social: + - icon: fontawesome/brands/discord + link: https://discord.gg/BESFVMt7yk + name: MeshCore Canada Discord + - icon: fontawesome/solid/comments + link: https://forum.meshcore.ca/ + name: MeshCore Canada forum + - icon: material/access-point-network + link: https://live.meshcore.ca/ + name: Live MeshCore Canada network tools + - icon: fontawesome/brands/github + link: https://github.com/MeshCore-ca/MeshCore-Canada + name: MeshCore Canada on GitHub plugins: - search + - i18n: + docs_structure: suffix + fallback_to_default: true + reconfigure_material: true + reconfigure_search: true + languages: + - locale: en + name: English + default: true + build: true + - locale: fr + name: Français + build: true + site_description: Des guides pratiques, relus par la communauté, pour rejoindre et exploiter les réseaux MeshCore au Canada. + copyright: Documentation communautaire pour les utilisateurs de MeshCore au Canada. Sans affiliation avec le projet MeshCore principal. + theme: + palette: + - scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Utiliser le thème clair + - scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Utiliser le thème sombre + nav_translations: + Start: Commencer + Welcome: Bienvenue + Start here: Commencer + Choose a role: Choisir un type d'appareil + Companion: Appareil compagnon + Repeater: Répéteur + Room server: Serveur de salon + Observer: Observateur + Finish and support: Terminer et obtenir de l'aide + Check your setup: Vérifier votre configuration + Get help: Obtenir de l'aide + Communities: Communautés + Find a community: Trouver une communauté + Western Canada: Ouest canadien + British Columbia: Colombie-Britannique + Central Canada: Centre du Canada + Quebec: Québec + Atlantic Canada: Canada atlantique + New Brunswick: Nouveau-Brunswick + Nova Scotia: Nouvelle-Écosse + Prince Edward Island: Île-du-Prince-Édouard + Newfoundland and Labrador: Terre-Neuve-et-Labrador + Territories: Territoires + "Devices & Builds": Appareils et projets + Choose hardware: Choisir du matériel + Companion devices: Appareils compagnons + Repeater devices: Répéteurs + Antennas: Antennes + Solar repeater build: Répéteur solaire + Build a 300 mW repeater: Monter un répéteur de 300 mW + Install a repeater: Installer un répéteur + Mounting options: Options de montage + Firmware: Micrologiciel + Flash a companion: Installer le micrologiciel d'un compagnon + Flash a repeater: Installer le micrologiciel d'un répéteur + Flash a room server: Installer le micrologiciel d'un serveur de salon + Update a repeater over the air: Mettre un répéteur à jour à distance + Network Tools: Outils réseau + Choose a network tool: Choisir un outil réseau + Configure a repeater: Configurer un répéteur + Explore the region map: Explorer la carte des régions + Set up an observer: Configurer un observateur + Check an observer: Vérifier un observateur + Troubleshoot network data: Dépanner les données du réseau + Observer methods: Méthodes d'observation + Observer data and privacy: Données d'observation et confidentialité + MQTT firmware: Micrologiciel MQTT + References: Références + Broker reference: Référence des serveurs MQTT + Location codes: Codes d'emplacement + Learn: Comprendre MeshCore + What MeshCore is: Comprendre MeshCore + How to use MeshCore: Utiliser MeshCore + Frequently asked questions: Foire aux questions + Glossary: Glossaire + Useful links: Liens utiles + Contribute: Contribuer + About MeshCore Canada: À propos de MeshCore Canada + Ways to contribute: Façons de contribuer + Share an idea: Proposer une idée + Site privacy: Confidentialité du site + admonition_translations: + note: Remarque + abstract: Résumé + info: Information + tip: Conseil + success: Réussite + question: Question + warning: Attention + failure: Échec + danger: Danger + bug: Problème + example: Exemple + quote: Citation - redirects: redirect_maps: regions/index.md: config/index.md regions/setup.md: config/index.md regions/map.md: config/map.md regions/standard.md: config/standard.md + resources/getting-started.md: start/index.md + meshcore/firmware-rak-custom-display.md: meshcore/flash-companion.md + meshcore/firmware-heltec-v3-wifi.md: meshcore/flash-companion.md + hardware/wire-connector-types.md: hardware/recommended-repeaters.md + hardware/repeater-solar-batteries.md: hardware/recommended-repeaters.md + hardware/overview.md: hardware/index.md + meshcore/general-meshcore-roles.md: start/choose-a-goal.md markdown_extensions: - admonition @@ -48,65 +201,72 @@ markdown_extensions: permalink: true nav: - - Welcome: index.md - - Analyzer & MQTT: - - Overview: analyzer/intro.md - - Data Collection & Access: analyzer/data-collection-access.md - - Check Your Observer: analyzer/verify.md - - Troubleshooting: analyzer/troubleshooting.md - - Broker Reference: analyzer/broker-reference.md - - IATA Region Codes: analyzer/iata-codes.md - - RemoteTerm: analyzer/remoteterm.md - - Build Guides: - - MCtoMQTT Script: analyzer/builds/mctomqtt.md - - MeshCore-HA: analyzer/builds/meshcore-ha.md - - MQTT Firmware: analyzer/builds/mqtt-firmware.md + - Start: + - Welcome: index.md + - Start here: start/index.md + - Choose a role: start/choose-a-goal.md + - Companion: start/companion.md + - Repeater: start/repeater.md + - Room server: start/room-server.md + - Observer: start/observer.md + - Finish and support: + - Check your setup: start/verify.md + - Get help: start/get-help.md + - Communities: + - Find a community: provinces/index.md + - Western Canada: + - British Columbia: provinces/british-columbia.md + - Alberta: provinces/alberta.md + - Saskatchewan: provinces/saskatchewan.md + - Manitoba: provinces/manitoba.md + - Central Canada: + - Ontario: provinces/ontario.md + - Quebec: provinces/quebec.md + - Atlantic Canada: + - New Brunswick: provinces/new-brunswick.md + - Nova Scotia: provinces/nova-scotia.md + - Prince Edward Island: provinces/prince-edward-island.md + - Newfoundland and Labrador: provinces/newfoundland-and-labrador.md + - Territories: provinces/territories.md + - Devices & Builds: + - Choose hardware: hardware/index.md + - Companion devices: hardware/recommended-companions.md + - Repeater devices: hardware/recommended-repeaters.md + - Antennas: hardware/recommended-antenna.md + - Solar repeater build: + - Build a 300 mW repeater: hardware/repeater-solar-300mw-diy-build.md + - Install a repeater: + - Mounting options: hardware/repeater-mounting-options.md + - Firmware: + - Flash a companion: meshcore/flash-companion.md + - Flash a repeater: meshcore/flash-repeater.md + - Flash a room server: meshcore/flash-room-server.md + - Update a repeater over the air: meshcore/update-repeater-ota.md + - Network Tools: + - Choose a network tool: tools/index.md + - Configure a repeater: config/index.md + - Explore the region map: config/map.md + - Set up an observer: analyzer/intro.md + - Check an observer: analyzer/verify.md + - Troubleshoot network data: analyzer/troubleshooting.md + - Observer methods: + - Observer data and privacy: analyzer/data-collection-access.md + - RemoteTerm: analyzer/remoteterm.md + - MCtoMQTT: analyzer/builds/mctomqtt.md + - Home Assistant: analyzer/builds/meshcore-ha.md + - MQTT firmware: analyzer/builds/mqtt-firmware.md - PyMC: analyzer/builds/pymc.md - - Hardware: - - Overview: hardware/overview.md - - Antenna: hardware/recommended-antenna.md - - Companion Nodes: hardware/recommended-companions.md - - Repeater Nodes: - - Repeater Nodes: hardware/recommended-repeaters.md - - Repeater Mounting Options: hardware/repeater-mounting-options.md - - Repeater Solar Batteries: hardware/repeater-solar-batteries.md - - 300mW Solar Repeater Build Guide: hardware/repeater-solar-300mw-diy-build.md - - 1W Solar Repeater Build Guide: hardware/repeater-solar-1w-diy-build.md - - Wire Connector Types: hardware/wire-connector-types.md - - - MeshCore: - - General: - - Overview: meshcore/general-overview.md - - MeshCore FAQ: meshcore/general-faq.md - - MeshCore How-To: meshcore/general-howto.md - - MeshCore Roles: meshcore/general-meshcore-roles.md - - Repeater Configurator: /config/ - - Firmware Guides: - - Flashing a Companion: meshcore/flash-companion.md - - Flashing a Repeater: meshcore/flash-repeater.md - - Updating a Repeater (OTA): meshcore/update-repeater-ota.md - - Generating a Repeater ID: meshcore/generate-repeater-id.md - - Flashing a Room Server: meshcore/flash-room-server.md - - RAK4631 Custom Display Firmware: meshcore/firmware-rak-custom-display.md - - Heltec V3 Wi-Fi Firmware: meshcore/firmware-heltec-v3-wifi.md - - - Mesh Directory: - - Overview: provinces/index.md - - British Columbia: provinces/british-columbia.md - - Alberta: provinces/alberta.md - - Saskatchewan: provinces/saskatchewan.md - - Manitoba: provinces/manitoba.md - - Ontario: provinces/ontario.md - - Quebec: provinces/quebec.md - - New Brunswick: provinces/new-brunswick.md - - Nova Scotia: provinces/nova-scotia.md - - Prince Edward Island: provinces/prince-edward-island.md - - Newfoundland and Labrador: provinces/newfoundland-and-labrador.md - - Territories (YT / NT / NU): provinces/territories.md - - Resources: - - Getting Started: resources/getting-started.md - - Useful Links: resources/links.md + - References: + - Broker reference: analyzer/broker-reference.md + - Location codes: analyzer/iata-codes.md + - Learn: + - What MeshCore is: meshcore/general-overview.md + - How to use MeshCore: meshcore/general-howto.md + - Frequently asked questions: meshcore/general-faq.md - Glossary: resources/glossary.md - - Share an Idea: submit-idea.md - - Forum: https://forum.meshcore.ca/ - - Contributing: contributing.md + - Useful links: resources/links.md + - Contribute: + - About MeshCore Canada: about.md + - Ways to contribute: contributing.md + - Share an idea: submit-idea.md + - Site privacy: privacy.md diff --git a/overrides/404.html b/overrides/404.html new file mode 100644 index 0000000..de66b0f --- /dev/null +++ b/overrides/404.html @@ -0,0 +1,26 @@ +{% extends "main.html" %} + +{% block content %} +

Ce lien ne mène nulle part / That link did not land

+

Page introuvable / Page not found

+

La page a peut-être été déplacée. Choisissez la langue et une prochaine étape.

+

The page may have moved. Choose a language and a useful next step.

+
+
+ Configuration guidée / Guided setup + Choisir un appareil et suivre les étapes de configuration. +

Commencer en français · Start in English

+
+
+ Communautés / Communities + Parcourir les groupes et l’aide locale au Canada. +

Communautés en français · Communities in English

+
+
+ Aide / Help + Consulter le forum, Discord ou un guide de dépannage. +

Aide en français · Help in English

+
+
+

+{% endblock %} diff --git a/overrides/main.html b/overrides/main.html new file mode 100644 index 0000000..8597bae --- /dev/null +++ b/overrides/main.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block extrahead %} + {{ super() }} + {% if config.extra.preview %} + + {% elif page and page.meta and page.meta.status in ["experimental", "legacy", "archived"] %} + + {% endif %} + {% if page and page.meta %} + + + + + + + + + {% for stylesheet in page.meta.page_styles or [] %} + + {% endfor %} + {% endif %} +{% endblock %} + +{% block scripts %} + {{ super() }} + {% if page and page.meta %} + {% for script in page.meta.page_scripts or [] %} + + {% endfor %} + {% for module in page.meta.page_modules or [] %} + + {% endfor %} + {% endif %} +{% endblock %} diff --git a/overrides/partials/alternate.html b/overrides/partials/alternate.html new file mode 100644 index 0000000..768a14b --- /dev/null +++ b/overrides/partials/alternate.html @@ -0,0 +1,28 @@ +{# Keep language changes on the equivalent page even when preview sitemaps are empty. #} +
+
+ {% set icon = config.theme.icon.alternate or "material/translate" %} + {% set current_locale = i18n_page_locale | default(config.theme.language, true) %} + +
+ +
+
+
diff --git a/overrides/partials/content.html b/overrides/partials/content.html new file mode 100644 index 0000000..166e0e5 --- /dev/null +++ b/overrides/partials/content.html @@ -0,0 +1,18 @@ +{% include "partials/tags.html" %} +{% include "partials/actions.html" %} + +{% if "\u003ch1" not in page.content %} +

{{ page.title | d(config.site_name, true) }}

+ {% include "partials/page-lifecycle.html" %} + {{ page.content }} +{% else %} + {% set content_parts = page.content.split("", 1) %} + {{ content_parts[0] }} + {% include "partials/page-lifecycle.html" %} + {{ content_parts[1] }} +{% endif %} + +{% include "partials/source-file.html" %} +{% include "partials/page-feedback.html" %} +{% include "partials/feedback.html" %} +{% include "partials/comments.html" %} diff --git a/overrides/partials/page-feedback.html b/overrides/partials/page-feedback.html new file mode 100644 index 0000000..398e2d2 --- /dev/null +++ b/overrides/partials/page-feedback.html @@ -0,0 +1,12 @@ +{% if page and page.meta and not page.meta.hide_feedback %} + {% set page_locale = i18n_page_locale | default(config.theme.language, true) %} + +{% endif %} diff --git a/overrides/partials/page-lifecycle.html b/overrides/partials/page-lifecycle.html new file mode 100644 index 0000000..ed412bc --- /dev/null +++ b/overrides/partials/page-lifecycle.html @@ -0,0 +1,27 @@ +{% if page and page.meta %} + {% set page_locale = i18n_page_locale | default(config.theme.language, true) %} + {% set file_locale = i18n_file_locale | default(page_locale, true) %} + {% set status = page.meta.status %} + + {% if page_locale == "fr" and file_locale != "fr" %} + + {% endif %} + + {% if status in ["draft", "experimental", "legacy", "archived"] and page.meta.status_notice is not false %} + + {% endif %} +{% endif %} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8df43b4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1794 @@ +{ + "name": "meshcore-canada-docs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meshcore-canada-docs", + "version": "1.0.0", + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.1", + "chrome-launcher": "1.2.1", + "lighthouse": "12.6.1" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@formatjs/ecma402-abstract": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", + "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/intl-localematcher": "0.6.2", + "decimal.js": "^10.4.3", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/fast-memoize": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", + "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", + "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/icu-skeleton-parser": "1.8.16", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", + "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "tslib": "^2.8.0" + } + }, + "node_modules/@formatjs/intl-localematcher": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", + "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@paulirish/trace_engine": { + "version": "0.0.53", + "resolved": "https://registry.npmjs.org/@paulirish/trace_engine/-/trace_engine-0.0.53.tgz", + "integrity": "sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "legacy-javascript": "latest", + "third-party-web": "latest" + } + }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.2.tgz", + "integrity": "sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.4", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry-internal/tracing": { + "version": "7.120.4", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.120.4.tgz", + "integrity": "sha512-Fz5+4XCg3akeoFK+K7g+d7HqGMjmnLoY2eJlpONJmaeT9pXY7yfUyXKZMmMajdE2LxxKJgQ2YKvSCaGVamTjHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/core": "7.120.4", + "@sentry/types": "7.120.4", + "@sentry/utils": "7.120.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/core": { + "version": "7.120.4", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.120.4.tgz", + "integrity": "sha512-TXu3Q5kKiq8db9OXGkWyXUbIxMMuttB5vJ031yolOl5T/B69JRyAoKuojLBjRv1XX583gS1rSSoX8YXX7ATFGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/types": "7.120.4", + "@sentry/utils": "7.120.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/integrations": { + "version": "7.120.4", + "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-7.120.4.tgz", + "integrity": "sha512-kkBTLk053XlhDCg7OkBQTIMF4puqFibeRO3E3YiVc4PGLnocXMaVpOSCkMqAc1k1kZ09UgGi8DxfQhnFEjUkpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/core": "7.120.4", + "@sentry/types": "7.120.4", + "@sentry/utils": "7.120.4", + "localforage": "^1.8.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/node": { + "version": "7.120.4", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-7.120.4.tgz", + "integrity": "sha512-qq3wZAXXj2SRWhqErnGCSJKUhPSlZ+RGnCZjhfjHpP49KNpcd9YdPTIUsFMgeyjdh6Ew6aVCv23g1hTP0CHpYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry-internal/tracing": "7.120.4", + "@sentry/core": "7.120.4", + "@sentry/integrations": "7.120.4", + "@sentry/types": "7.120.4", + "@sentry/utils": "7.120.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/types": { + "version": "7.120.4", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.120.4.tgz", + "integrity": "sha512-cUq2hSSe6/qrU6oZsEP4InMI5VVdD86aypE+ENrQ6eZEVLTCYm1w6XhW1NvIu3UuWh7gZec4a9J7AFpYxki88Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@sentry/utils": { + "version": "7.120.4", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.120.4.tgz", + "integrity": "sha512-zCKpyDIWKHwtervNK2ZlaK8mMV7gVUijAgFeJStH+CU/imcdquizV3pFLlSQYRswG+Lbyd6CT/LGRh3IbtkCFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sentry/types": "7.120.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/b4a": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, + "node_modules/bare-events": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.4.tgz", + "integrity": "sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.1.tgz", + "integrity": "sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bare-stream": { + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/chrome-launcher": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.1.tgz", + "integrity": "sha512-qmFR5PLMzHyuNJHwOloHPAHhbaNglkfeV/xDtt5b7xiFFyU1I+AZZX0PYseMuhenJSSirgxELYIbswcoc+5H4A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^2.0.1" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.cjs" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-bidi": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz", + "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/csp_evaluator": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/csp_evaluator/-/csp_evaluator-1.1.5.tgz", + "integrity": "sha512-EL/iN9etCTzw/fBnp0/uj0f5BOOGvZut2mzsiiBZ/FdT6gFQCKRO/tmcKOxn5drWZ2Ndm/xBb1SI4zwWbGtmIw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1467305", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1467305.tgz", + "integrity": "sha512-LxwMLqBoPPGpMdRL4NkLFRNy3QLp6Uqa7GNp1v6JaBheop2QrB9Q7q0A/q/CYYP9sBfZdHOyszVx4gc9zyk7ow==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/http-link-header": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/http-link-header/-/http-link-header-1.1.4.tgz", + "integrity": "sha512-xT3GPW6/ZbGuw4UvwHqErSCEjNUlwbQJuZn9/q5U4WEKfp2kENVCAlousG1zLxHeaQ/ffOHUNpWamvkbBW0eNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/image-ssim": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/image-ssim/-/image-ssim-0.2.0.tgz", + "integrity": "sha512-W7+sO6/yhxy83L0G7xR8YAc5Z5QFtYEXXRV6EaE8tuYBZJnA3gVgp3q7X7muhLZVodeb9UfvjSbwt9VJwjIYAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/intl-messageformat": { + "version": "10.7.18", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", + "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@formatjs/ecma402-abstract": "2.3.6", + "@formatjs/fast-memoize": "2.2.7", + "@formatjs/icu-messageformat-parser": "2.11.4", + "tslib": "^2.8.0" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/js-library-detector": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/js-library-detector/-/js-library-detector-6.7.0.tgz", + "integrity": "sha512-c80Qupofp43y4cJ7+8TTDN/AsDwLi5oOm/plBrWI+iQt485vKXCco+yVmOwEgdo9VOdsYTuV0UlTeetVPTriXA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/legacy-javascript": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/legacy-javascript/-/legacy-javascript-0.0.1.tgz", + "integrity": "sha512-lPyntS4/aS7jpuvOlitZDFifBCb4W8L/3QU0PLbUTUj+zYah8rfVjYic88yG7ZKTxhS5h9iz7duT8oUXKszLhg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lighthouse": { + "version": "12.6.1", + "resolved": "https://registry.npmjs.org/lighthouse/-/lighthouse-12.6.1.tgz", + "integrity": "sha512-85WDkjcXAVdlFem9Y6SSxqoKiz/89UsDZhLpeLJIsJ4LlHxw047XTZhlFJmjYCB7K5S1erSBAf5cYLcfyNbH3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@paulirish/trace_engine": "0.0.53", + "@sentry/node": "^7.0.0", + "axe-core": "^4.10.3", + "chrome-launcher": "^1.2.0", + "configstore": "^5.0.1", + "csp_evaluator": "1.1.5", + "devtools-protocol": "0.0.1467305", + "enquirer": "^2.3.6", + "http-link-header": "^1.1.1", + "intl-messageformat": "^10.5.3", + "jpeg-js": "^0.4.4", + "js-library-detector": "^6.7.0", + "lighthouse-logger": "^2.0.1", + "lighthouse-stack-packs": "1.12.2", + "lodash-es": "^4.17.21", + "lookup-closest-locale": "6.2.0", + "metaviewport-parser": "0.3.0", + "open": "^8.4.0", + "parse-cache-control": "1.0.1", + "puppeteer-core": "^24.10.0", + "robots-parser": "^3.0.1", + "semver": "^5.3.0", + "speedline-core": "^1.4.3", + "third-party-web": "^0.26.6", + "tldts-icann": "^6.1.16", + "ws": "^7.0.0", + "yargs": "^17.3.1", + "yargs-parser": "^21.0.0" + }, + "bin": { + "chrome-debug": "core/scripts/manual-chrome-launcher.js", + "lighthouse": "cli/index.js", + "smokehouse": "cli/test/smokehouse/frontends/smokehouse-bin.js" + }, + "engines": { + "node": ">=18.20" + } + }, + "node_modules/lighthouse-logger": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-2.0.2.tgz", + "integrity": "sha512-vWl2+u5jgOQuZR55Z1WM0XDdrJT6mzMP8zHUct7xTlWhuQs+eV0g+QL0RQdFjT54zVmbhLCP8vIVpy1wGn/gCg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.1", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-stack-packs": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/lighthouse-stack-packs/-/lighthouse-stack-packs-1.12.2.tgz", + "integrity": "sha512-Ug8feS/A+92TMTCK6yHYLwaFMuelK/hAKRMdldYkMNwv+d9PtWxjXEg6rwKtsUXTADajhdrhXyuNCJ5/sfmPFw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/lookup-closest-locale": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/lookup-closest-locale/-/lookup-closest-locale-6.2.0.tgz", + "integrity": "sha512-/c2kL+Vnp1jnV6K6RpDTHK3dgg0Tu2VVp+elEiJpjfS1UyY7AjOYHohRug6wT0OpoX2qFgNORndE9RqesfVxWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/metaviewport-parser": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/metaviewport-parser/-/metaviewport-parser-0.3.0.tgz", + "integrity": "sha512-EoYJ8xfjQ6kpe9VbVHvZTZHiOl4HL1Z18CrZ+qahvLXT7ZO4YTC2JMyt5FaUp9JJp6J4Ybb/z7IsCXZt86/QkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", + "dev": true + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/puppeteer-core": { + "version": "24.43.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.43.1.tgz", + "integrity": "sha512-T5ScUMAsmhdNbgDR41AGESYeS6V9MSgetkSnVhhW+gXvzC42VesKCn5ld87gAZDJ6vLHL9GkRvY9WtQWSnwFbw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.13.2", + "chromium-bidi": "14.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1608973", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core/node_modules/devtools-protocol": { + "version": "0.0.1608973", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz", + "integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/puppeteer-core/node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/robots-parser": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz", + "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speedline-core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/speedline-core/-/speedline-core-1.4.3.tgz", + "integrity": "sha512-DI7/OuAUD+GMpR6dmu8lliO2Wg5zfeh+/xsdyJZCzd8o5JgFUjCeLsBDuZjIQJdwXS3J0L/uZYrELKYqx+PXog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "image-ssim": "^0.2.0", + "jpeg-js": "^0.4.1" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/streamx": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "bare-fs": "^4.5.5", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/third-party-web": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/third-party-web/-/third-party-web-0.26.7.tgz", + "integrity": "sha512-buUzX4sXC4efFX6xg2bw6/eZsCUh8qQwSavC4D9HpONMFlRbcHhD8Je5qwYdCpViR6q0qla2wPP+t91a2vgolg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts-icann": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-icann/-/tldts-icann-6.1.86.tgz", + "integrity": "sha512-NFxmRT2lAEMcCOBgeZ0NuM0zsK/xgmNajnY6n4S1mwAKocft2s2ise1O3nQxrH3c+uY6hgHUV9GGNVp7tUE4Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz", + "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c419f1 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "meshcore-canada-docs", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "docs:build": "python -m mkdocs build --clean --strict --site-dir .tmp/site && node scripts/postprocess-site.mjs", + "docs:build:preview": "python -m mkdocs build --clean --strict --config-file mkdocs.preview.yml --site-dir .tmp/preview-site && node scripts/postprocess-site.mjs .tmp/preview-site --noindex-all", + "check:links": "npm run docs:build && node scripts/check-built-links.mjs", + "test:content": "node --test tests/content/*.test.mjs", + "test:editor": "node --test tests/editor/*.test.mjs", + "test:browser": "playwright test", + "test:browser:chromium": "playwright test --project=chromium", + "test:a11y": "playwright test tests/browser/accessibility.spec.mjs --project=chromium", + "audit:lighthouse": "npm run docs:build && node scripts/run-lighthouse.mjs" + }, + "devDependencies": { + "@axe-core/playwright": "4.12.1", + "@playwright/test": "1.61.1", + "chrome-launcher": "1.2.1", + "lighthouse": "12.6.1" + } +} diff --git a/playwright.config.mjs b/playwright.config.mjs new file mode 100644 index 0000000..7ba3473 --- /dev/null +++ b/playwright.config.mjs @@ -0,0 +1,63 @@ +import { defineConfig, devices } from "@playwright/test"; +import { normalizeSiteBaseUrl } from "./tests/browser/site-route.mjs"; + +const externalBaseUrl = process.env.PLAYWRIGHT_BASE_URL; +const baseURL = normalizeSiteBaseUrl(externalBaseUrl || "http://127.0.0.1:4173/"); + +export default defineConfig({ + testDir: "./tests/browser", + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 2 : undefined, + timeout: 30_000, + expect: { + timeout: 8_000 + }, + outputDir: ".tmp/test-results", + reporter: [ + ["list"], + ["html", { outputFolder: ".tmp/playwright-report", open: "never" }] + ], + use: { + baseURL, + colorScheme: "light", + screenshot: "only-on-failure", + trace: "retain-on-failure", + video: "retain-on-failure" + }, + webServer: externalBaseUrl + ? undefined + : { + command: "python -m http.server 4173 --bind 127.0.0.1 --directory .tmp/site", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 15_000 + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] } + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] } + }, + { + name: "webkit", + use: { ...devices["Desktop Safari"] } + }, + { + name: "chromium-dark", + use: { ...devices["Desktop Chrome"], colorScheme: "dark" } + }, + { + name: "mobile-chromium", + use: { ...devices["Pixel 5"] } + }, + { + name: "mobile-webkit", + use: { ...devices["iPhone 12"] } + } + ] +}); diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 0000000..c811daf --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,7 @@ +mkdocs==1.6.1 +mkdocs-material==9.7.6 +mkdocs-redirects==1.2.2 +mkdocs-static-i18n==1.3.1 +PyYAML==6.0.2 +pymdown-extensions==11.0.1 +shapely==2.0.6 diff --git a/schemas/analyzer-location-codes.schema.json b/schemas/analyzer-location-codes.schema.json new file mode 100644 index 0000000..3ab8d20 --- /dev/null +++ b/schemas/analyzer-location-codes.schema.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://meshcore.ca/schemas/analyzer-location-codes.schema.json", + "title": "MeshCore Canada observer location-code quick list", + "type": "object", + "required": ["schema_version", "version", "last_reviewed", "scope", "locations"], + "properties": { + "schema_version": {"const": 1}, + "version": {"type": "string", "pattern": "^[0-9]{4}\\.[0-9]{2}$"}, + "last_reviewed": {"type": "string", "format": "date"}, + "scope": {"type": "string", "minLength": 24}, + "locations": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "name", "province", "province_code"], + "properties": { + "code": {"type": "string", "pattern": "^[A-Z]{3}$"}, + "name": {"type": "string", "minLength": 2}, + "province": {"type": "string", "minLength": 4}, + "province_code": {"type": "string", "pattern": "^[A-Z]{2}$"} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/schemas/analyzer-observer-config.schema.json b/schemas/analyzer-observer-config.schema.json new file mode 100644 index 0000000..6a21c49 --- /dev/null +++ b/schemas/analyzer-observer-config.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://meshcore.ca/schemas/analyzer-observer-config.schema.json", + "title": "MeshCore Canada observer configuration", + "type": "object", + "required": ["schema_version", "version", "last_reviewed", "network", "brokers", "topics", "verification", "location_codes"], + "properties": { + "schema_version": {"const": 1}, + "version": {"type": "string", "pattern": "^[0-9]{4}\\.[0-9]{2}$"}, + "last_reviewed": {"type": "string", "format": "date"}, + "network": { + "type": "object", + "required": ["preset", "frequency_mhz", "bandwidth_khz", "spreading_factor", "coding_rate", "path_hash_mode", "path_hash_bytes"], + "additionalProperties": false + }, + "brokers": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "object", + "required": ["id", "name", "host", "port", "transport", "tls", "verify_tls", "authentication", "token_audience"], + "properties": { + "id": {"enum": ["primary", "backup"]}, + "host": {"type": "string", "pattern": "^mqtt[12]\\.meshcore\\.ca$"}, + "port": {"const": 443}, + "transport": {"const": "websockets"}, + "tls": {"const": true}, + "verify_tls": {"const": true}, + "token_audience": {"type": "string"} + }, + "additionalProperties": true + } + }, + "topics": { + "type": "object", + "required": ["packets", "status"], + "additionalProperties": false + }, + "verification": { + "type": "object", + "required": ["observers", "packets", "map"], + "additionalProperties": false + }, + "location_codes": {"const": "location-codes.json"} + }, + "additionalProperties": false +} diff --git a/schemas/community-directory.schema.json b/schemas/community-directory.schema.json new file mode 100644 index 0000000..90e7e9d --- /dev/null +++ b/schemas/community-directory.schema.json @@ -0,0 +1,384 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://meshcore.ca/schemas/community-directory.schema.json", + "title": "MeshCore Canada community directory", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "metadata", + "national_defaults", + "directory_pages", + "province_contacts", + "communities" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schema": { + "const": "meshcore-canada-communities/v1" + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "migrated_at", + "source_revision", + "review_by", + "update_route" + ], + "properties": { + "owner": { + "type": "string", + "minLength": 1 + }, + "migrated_at": { + "type": "string", + "format": "date" + }, + "source_revision": { + "type": "string", + "minLength": 7 + }, + "review_by": { + "type": "string", + "format": "date" + }, + "update_route": { + "type": "string", + "minLength": 1 + } + } + }, + "national_defaults": { + "type": "object" + }, + "directory_pages": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/directoryPage" + } + }, + "province_contacts": { + "type": "array", + "items": { + "$ref": "#/$defs/contactWithIdentity" + } + }, + "communities": { + "type": "array", + "items": { + "$ref": "#/$defs/community" + } + } + }, + "$defs": { + "nullableDate": { + "oneOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "null" + } + ] + }, + "directoryPage": { + "type": "object", + "additionalProperties": false, + "required": [ + "slug", + "title", + "codes", + "aliases" + ], + "properties": { + "slug": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "title": { + "type": "string", + "minLength": 1 + }, + "codes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "enum": [ + "AB", + "BC", + "MB", + "NB", + "NL", + "NS", + "NT", + "NU", + "ON", + "PE", + "QC", + "SK", + "YT" + ] + } + }, + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + } + } + }, + "contact": { + "type": "object", + "additionalProperties": {}, + "required": [ + "type", + "label", + "url", + "health", + "last_checked" + ], + "properties": { + "type": { + "enum": [ + "discord", + "facebook", + "instagram", + "meshmapper", + "reddit", + "telegram", + "website", + "x" + ] + }, + "label": { + "type": "string", + "minLength": 1 + }, + "url": { + "oneOf": [ + { + "type": "string", + "format": "uri", + "pattern": "^https?://" + }, + { + "type": "null" + } + ] + }, + "health": { + "enum": [ + "verified", + "needs-review", + "expired" + ] + }, + "last_checked": { + "$ref": "#/$defs/nullableDate" + } + } + }, + "contactWithIdentity": { + "allOf": [ + { + "$ref": "#/$defs/contact" + }, + { + "type": "object", + "required": [ + "id", + "province" + ], + "properties": { + "id": { + "type": "string" + }, + "province": { + "type": "string" + } + } + } + ] + }, + "community": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "province", + "service_area", + "places", + "aliases", + "status", + "languages", + "location", + "settings", + "contacts", + "owner", + "verified_at", + "verify_by", + "canonical_route" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "province": { + "enum": [ + "AB", + "BC", + "MB", + "NB", + "NL", + "NS", + "NT", + "NU", + "ON", + "PE", + "QC", + "SK", + "YT" + ] + }, + "service_area": { + "type": "string", + "minLength": 1 + }, + "summary": { + "type": "string", + "minLength": 1 + }, + "places": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "status": { + "enum": [ + "active", + "forming", + "testing", + "needs-update" + ] + }, + "languages": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z]{2}(?:-[A-Z]{2})?$" + } + }, + "location": { + "type": "object", + "additionalProperties": false, + "required": [ + "latitude", + "longitude", + "precision" + ], + "properties": { + "latitude": { + "type": [ + "number", + "null" + ], + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": [ + "number", + "null" + ], + "minimum": -180, + "maximum": 180 + }, + "precision": { + "enum": [ + "exact", + "approximate", + "service-area" + ] + } + } + }, + "settings": { + "type": "object", + "additionalProperties": false, + "required": [ + "inherit_national", + "overrides" + ], + "properties": { + "inherit_national": { + "type": "boolean" + }, + "overrides": { + "type": "object", + "additionalProperties": false, + "properties": { + "radio_preset": { + "type": "string" + }, + "path_hash_mode": { + "type": "string" + } + } + } + } + }, + "contacts": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/contact" + } + }, + "owner": { + "type": [ + "string", + "null" + ] + }, + "verified_at": { + "$ref": "#/$defs/nullableDate" + }, + "verify_by": { + "$ref": "#/$defs/nullableDate" + }, + "canonical_route": { + "type": "string", + "pattern": "^/provinces/[a-z0-9-]+/#community-[a-z0-9-]+$" + } + } + } + } +} diff --git a/schemas/page-metadata.schema.json b/schemas/page-metadata.schema.json new file mode 100644 index 0000000..09b14ba --- /dev/null +++ b/schemas/page-metadata.schema.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://meshcore.ca/schemas/page-metadata.schema.json", + "title": "MeshCore Canada page metadata", + "type": "object", + "required": [ + "title", + "description", + "audience", + "task", + "scope", + "status", + "owner", + "last_reviewed", + "review_by" + ], + "properties": { + "title": { "type": "string", "minLength": 4 }, + "description": { "type": "string", "minLength": 24, "maxLength": 180 }, + "audience": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9-]+$" } + }, + "task": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "scope": { + "type": "string", + "anyOf": [ + { "enum": [ + "canada-baseline", + "upstream-meshcore", + "ottawa-field-practice", + "experimental", + "legacy", + "regulatory-reference" + ] }, + { "pattern": "^province:[a-z]{2}$" }, + { "pattern": "^community:[a-z0-9-]+$" } + ] + }, + "status": { + "enum": ["verified", "draft", "experimental", "legacy", "archived"] + }, + "owner": { "type": "string", "pattern": "^[a-z0-9-]+$" }, + "last_reviewed": { "type": "string", "format": "date" }, + "review_by": { "type": "string", "format": "date" }, + "difficulty": { "enum": ["beginner", "intermediate", "advanced"] }, + "estimated_time": { "type": "string", "minLength": 3 }, + "destructive": { "type": "boolean" }, + "tested_with": { "type": "object", "minProperties": 1 }, + "evidence": { + "oneOf": [ + { "type": "string", "minLength": 3 }, + { "type": "array", "minItems": 1, "items": { "type": "string" } } + ] + }, + "requires": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z0-9-]+$" } + }, + "aliases": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string" } + }, + "page_styles": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[^/].*\\.css$" } + }, + "page_scripts": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[^/].*\\.js$" } + }, + "page_modules": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[^/].*\\.js$" } + } + }, + "allOf": [ + { + "if": { "properties": { "status": { "const": "verified" } } }, + "then": { "anyOf": [{ "required": ["tested_with"] }, { "required": ["evidence"] }] } + } + ], + "additionalProperties": true +} diff --git a/scripts/check-built-links.mjs b/scripts/check-built-links.mjs new file mode 100644 index 0000000..4a98420 --- /dev/null +++ b/scripts/check-built-links.mjs @@ -0,0 +1,172 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import { extname, join, relative, resolve, sep } from "node:path"; +import process from "node:process"; + +const siteRoot = resolve(process.argv[2] || ".tmp/site"); +const attributePattern = /\b(?:href|src)=["']([^"']+)["']/gi; +const ignoredSchemes = /^(?:data|mailto|tel|javascript|blob):/i; + +async function readSiteIdentity() { + const manifestPath = join(siteRoot, "site-manifest.json"); + let manifest; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf8")); + } catch (error) { + throw new Error(`Cannot read ${manifestPath}: ${error.message}`); + } + + let siteBaseUrl; + try { + siteBaseUrl = new URL(manifest.siteBaseUrl); + } catch (_error) { + throw new Error(`${manifestPath} must contain an absolute siteBaseUrl`); + } + if (siteBaseUrl.search || siteBaseUrl.hash) { + throw new Error(`${manifestPath} siteBaseUrl cannot contain a query or fragment`); + } + + const basePath = siteBaseUrl.pathname.endsWith("/") + ? siteBaseUrl.pathname + : `${siteBaseUrl.pathname}/`; + return { publicOrigin: siteBaseUrl.origin, basePath }; +} + +async function walk(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await walk(path)); + else files.push(path); + } + return files; +} + +function pagePath(file, basePath) { + const local = relative(siteRoot, file).split(sep).join("/"); + if (local === "index.html") return basePath; + if (local.endsWith("/index.html")) return `${basePath}${local.slice(0, -"index.html".length)}`; + return `${basePath}${local}`; +} + +function decodeAttribute(value) { + return value + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +async function isFile(path) { + try { + return (await stat(path)).isFile(); + } catch (_error) { + return false; + } +} + +async function resolveTarget(pathname, basePath) { + let decoded; + try { + decoded = decodeURIComponent(pathname); + } catch (_error) { + return { error: "invalid URL encoding" }; + } + let decodedBase; + try { + decodedBase = decodeURIComponent(basePath); + } catch (_error) { + return { error: "invalid site base URL encoding" }; + } + if (!decoded.startsWith(decodedBase)) { + return { error: `target escapes site base path ${basePath}` }; + } + const local = decoded.slice(decodedBase.length); + const candidates = []; + if (!local) candidates.push(join(siteRoot, "index.html")); + else if (decoded.endsWith("/")) candidates.push(join(siteRoot, local, "index.html")); + else { + candidates.push(join(siteRoot, local)); + if (!extname(local)) { + candidates.push(join(siteRoot, local, "index.html")); + candidates.push(join(siteRoot, `${local}.html`)); + } + } + for (const candidate of candidates) { + if (await isFile(candidate)) return { path: candidate }; + } + return { error: `missing target ${decoded}` }; +} + +async function hasExactCase(path) { + const local = relative(siteRoot, path); + if (!local || local.startsWith("..")) return !local; + let current = siteRoot; + for (const part of local.split(sep)) { + const names = await readdir(current); + if (!names.includes(part)) return false; + current = join(current, part); + } + return true; +} + +function hasAnchor(text, fragment) { + let id; + try { + id = decodeURIComponent(fragment); + } catch (_error) { + return false; + } + const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`\\b(?:id|name)=["']${escaped}["']`).test(text); +} + +async function main() { + const { publicOrigin, basePath } = await readSiteIdentity(); + const files = await walk(siteRoot); + const htmlFiles = files.filter((file) => file.endsWith(".html")); + const failures = new Set(); + let checked = 0; + + for (const file of htmlFiles) { + const html = await readFile(file, "utf8"); + const base = new URL(pagePath(file, basePath), publicOrigin); + for (const match of html.matchAll(attributePattern)) { + const raw = decodeAttribute(match[1].trim()); + if (!raw || raw === "#" || raw.startsWith("//") || ignoredSchemes.test(raw)) continue; + + let url; + try { + url = new URL(raw, base); + } catch (_error) { + failures.add(`${pagePath(file, basePath)} -> ${raw}: invalid URL`); + continue; + } + if (url.origin !== publicOrigin) continue; + checked += 1; + const target = await resolveTarget(url.pathname, basePath); + if (!target.path) { + failures.add(`${pagePath(file, basePath)} -> ${raw}: ${target.error}`); + continue; + } + if (!(await hasExactCase(target.path))) { + failures.add(`${pagePath(file, basePath)} -> ${raw}: path casing differs from disk`); + continue; + } + if (url.hash && [".html", ".svg"].includes(extname(target.path).toLowerCase())) { + const targetText = target.path === file ? html : await readFile(target.path, "utf8"); + if (!hasAnchor(targetText, url.hash.slice(1))) { + failures.add(`${pagePath(file, basePath)} -> ${raw}: missing fragment target`); + } + } + } + } + + if (failures.size) { + console.error(`Built-link validation failed with ${failures.size} problem(s):`); + for (const failure of [...failures].sort()) console.error(`- ${failure}`); + process.exitCode = 1; + return; + } + console.log(`Built-link validation passed for ${htmlFiles.length} pages and ${checked} local references.`); +} + +await main(); diff --git a/scripts/postprocess-site.mjs b/scripts/postprocess-site.mjs new file mode 100644 index 0000000..856d632 --- /dev/null +++ b/scripts/postprocess-site.mjs @@ -0,0 +1,349 @@ +import { createHash } from "node:crypto"; +import { + cp, + copyFile, + mkdir, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { gzipSync } from "node:zlib"; +import process from "node:process"; + +const projectRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +const robotsMetaPattern = + /]*\bname=["']robots["'])[^>]*>/i; +const noIndexPattern = + /]*\bname=["']robots["'])(?=[^>]*\bcontent=["'][^"']*\bnoindex\b)[^>]*>/i; +const canonicalPattern = + /]*>/i; +const alternateTagPattern = + /]*\brel=["']alternate["'])[^>]*>/gi; +const languageMenuAnchorPattern = + /]*\bclass=["'][^"']*\bmd-select__link\b)(?=[^>]*\bhreflang=["'](?:en|fr)["'])[^>]*>/gi; + +function rewriteAlternateLinks(html, siteBaseUrl, pageName) { + if (pageName === "404.html") { + return html + .replace(alternateTagPattern, "") + .replace(languageMenuAnchorPattern, (tag) => { + const language = tag.match(/\bhreflang=["'](en|fr)["']/i)?.[1]?.toLowerCase(); + const route = language === "fr" ? "fr/" : ""; + const absoluteHref = new URL(route, siteBaseUrl).href; + return tag.replace(/\bhref=["'][^"']*["']/i, `href="${absoluteHref}"`); + }); + } + + const equivalentPage = pageName.startsWith("fr/") + ? pageName.slice("fr/".length) + : pageName; + const equivalentRoute = equivalentPage.endsWith("index.html") + ? equivalentPage.slice(0, -"index.html".length) + : equivalentPage; + + return html.replace(alternateTagPattern, (tag) => { + const language = tag.match(/\bhreflang=["'](en|fr)["']/i)?.[1]?.toLowerCase(); + if (!language) return tag; + const route = language === "fr" ? `fr/${equivalentRoute}` : equivalentRoute; + const absoluteHref = new URL(route, siteBaseUrl).href; + return tag.replace(/\bhref=["'][^"']*["']/i, `href="${absoluteHref}"`); + }); +} + +async function pathStat(path) { + try { + return await stat(path); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +async function isDirectory(path) { + return (await pathStat(path))?.isDirectory() || false; +} + +function assertBuildDestination(siteRoot, destination) { + const resolvedRoot = resolve(siteRoot); + const resolvedDestination = resolve(destination); + const fromRoot = relative(resolvedRoot, resolvedDestination); + if ( + !fromRoot || + fromRoot === ".." || + fromRoot.startsWith(`..${sep}`) || + isAbsolute(fromRoot) + ) { + throw new Error(`Bilingual asset destination escapes the site root: ${destination}`); + } +} + +async function mirrorDirectory(siteRoot, source, destination, required) { + const sourceStat = await pathStat(source); + if (!sourceStat) { + if (required) throw new Error(`Missing required built directory: ${source}`); + return false; + } + if (!sourceStat.isDirectory()) { + throw new Error(`Expected built directory: ${source}`); + } + assertBuildDestination(siteRoot, destination); + await rm(destination, { recursive: true, force: true }); + await mkdir(dirname(destination), { recursive: true }); + await cp(source, destination, { recursive: true, force: true }); + return true; +} + +async function copyBuiltFile(siteRoot, source, destination, required) { + const sourceStat = await pathStat(source); + if (!sourceStat) { + if (required) throw new Error(`Missing required built file: ${source}`); + return false; + } + if (!sourceStat.isFile()) { + throw new Error(`Expected built file: ${source}`); + } + assertBuildDestination(siteRoot, destination); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); + return true; +} + +async function prepareBilingualAssets(siteRoot, docsDirectory) { + const realBuild = await isDirectory(join(siteRoot, "assets", "stylesheets")); + const builtRegions = join(siteRoot, "assets", "regions"); + const hasBuiltRegionAssets = await isDirectory(builtRegions); + + if (realBuild && !hasBuiltRegionAssets) { + throw new Error(`Missing required built directory: ${builtRegions}`); + } + + if (realBuild || hasBuiltRegionAssets) { + const source = join( + docsDirectory, + "assets", + "regions", + "canada-region-partition.qa.json", + ); + const sourceStat = await pathStat(source); + if (!sourceStat?.isFile()) { + throw new Error(`Missing required release-audit source: ${source}`); + } + await copyBuiltFile( + siteRoot, + source, + join(builtRegions, "canada-region-partition.qa.json"), + true, + ); + } + + await mirrorDirectory( + siteRoot, + join(siteRoot, "hardware", "images"), + join(siteRoot, "fr", "hardware", "images"), + realBuild, + ); + + const frenchEditor = join(siteRoot, "fr", "config", "editor"); + if (await mirrorDirectory( + siteRoot, + join(siteRoot, "config", "editor"), + frenchEditor, + realBuild, + )) { + const editorIndex = join(frenchEditor, "index.html"); + const html = await readFile(editorIndex, "utf8"); + await writeFile( + editorIndex, + html + .replaceAll("../../assets/", "../../../assets/") + .replace('', '') + .replace( + 'data-editor-language="en" href="./"', + 'data-editor-language="en" href="../../../config/editor/"', + ) + .replace( + 'data-editor-language="fr" href="../../fr/config/editor/"', + 'data-editor-language="fr" href="./"', + ) + .replaceAll( + "../standard/#cross-province-repeater-areas", + "../standard/#zones-de-repeteurs-interprovinciales", + ), + "utf8", + ); + } + + for (const name of ["location-codes.json", "observer-config.json"]) { + await copyBuiltFile( + siteRoot, + join(siteRoot, "analyzer", name), + join(siteRoot, "fr", "analyzer", name), + realBuild, + ); + } +} + +async function walk(directory) { + const files = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) files.push(...await walk(path)); + else files.push(path); + } + return files; +} + +function ensureNoIndex(html, file) { + if (robotsMetaPattern.test(html)) { + return html.replace( + robotsMetaPattern, + '', + ); + } + if (!/<\/head\s*>/i.test(html)) { + throw new Error(`Cannot apply preview noindex; missing in ${file}`); + } + return html.replace( + /<\/head\s*>/i, + ' \n', + ); +} + +function fallbackUrl(siteRoot, file, siteBaseUrl) { + const local = relative(siteRoot, file).split(sep).join("/"); + const route = local === "index.html" + ? "./" + : local.replace(/(?:^|\/)index\.html$/, "/"); + return new URL(route, siteBaseUrl).href; +} + +async function hashArtifact(siteRoot) { + const files = (await walk(siteRoot)) + .map((file) => ({ + file, + name: relative(siteRoot, file).split(sep).join("/"), + })) + .filter(({ name }) => name !== "site-manifest.json") + .sort((left, right) => { + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; + }); + const hash = createHash("sha256"); + + for (const { file, name } of files) { + const content = await readFile(file); + hash.update(name, "utf8"); + hash.update("\0", "utf8"); + hash.update(String(content.byteLength), "utf8"); + hash.update("\0", "utf8"); + hash.update(content); + hash.update("\0", "utf8"); + } + + return hash.digest("hex"); +} + +export async function postprocessSite(siteDirectory, options = {}) { + const siteRoot = resolve(siteDirectory); + const sitemapPath = join(siteRoot, "sitemap.xml"); + if (!(await stat(sitemapPath)).isFile()) { + throw new Error(`Missing sitemap: ${sitemapPath}`); + } + + await prepareBilingualAssets( + siteRoot, + resolve(options.docsDirectory || join(projectRoot, "docs")), + ); + + const htmlFiles = (await walk(siteRoot)).filter((file) => file.endsWith(".html")); + const localePageCounts = htmlFiles.reduce( + (counts, file) => { + const name = relative(siteRoot, file).split(sep).join("/"); + counts[name.startsWith("fr/") ? "fr" : "en"] += 1; + return counts; + }, + { en: 0, fr: 0 }, + ); + const sitemapSource = await readFile(sitemapPath, "utf8"); + const firstLocation = sitemapSource.match(/([^<]+)<\/loc>/)?.[1]; + const rootHtml = await readFile(join(siteRoot, "index.html"), "utf8").catch(() => ""); + const rootCanonical = rootHtml.match(canonicalPattern)?.[1]; + const siteBaseUrl = new URL( + options.siteUrl || rootCanonical || firstLocation || "https://meshcore.ca/", + ); + const origin = siteBaseUrl.origin; + const noIndexUrls = new Set(); + let noIndexPageCount = 0; + + for (const file of htmlFiles) { + let html = await readFile(file, "utf8"); + const originalHtml = html; + const pageName = relative(siteRoot, file).split(sep).join("/"); + html = rewriteAlternateLinks(html, siteBaseUrl, pageName); + if (options.noIndexAll) html = ensureNoIndex(html, file); + if (html !== originalHtml) await writeFile(file, html, "utf8"); + if (!noIndexPattern.test(html)) continue; + noIndexPageCount += 1; + const pageUrl = fallbackUrl(siteRoot, file, siteBaseUrl); + const canonical = html.match(canonicalPattern)?.[1]; + noIndexUrls.add(pageUrl); + if (canonical) noIndexUrls.add(new URL(canonical, pageUrl).href); + } + + const filteredSitemap = sitemapSource.replace( + /\s*\s*([^<]+)<\/loc>[\s\S]*?<\/url>/g, + (block, location) => (noIndexUrls.has(location) ? "" : block), + ); + const sitemapOutput = `${filteredSitemap.trim()}\n`; + await writeFile(sitemapPath, sitemapOutput, "utf8"); + await writeFile( + join(siteRoot, "sitemap.xml.gz"), + gzipSync(Buffer.from(sitemapOutput, "utf8"), { level: 9, mtime: 0 }), + ); + + const manifest = { + schema: "meshcore-canada-site-manifest-v1", + revision: + options.revision || + process.env.MCC_SITE_REVISION || + process.env.GITHUB_SHA || + "local-preview", + generatedAt: options.generatedAt || new Date().toISOString(), + siteOrigin: origin, + siteBaseUrl: siteBaseUrl.href, + pageCount: htmlFiles.length, + localePageCounts, + indexedPageCount: htmlFiles.length - noIndexPageCount, + noIndexPageCount, + sitemapSha256: createHash("sha256").update(sitemapOutput).digest("hex"), + artifactSha256: await hashArtifact(siteRoot), + }; + await writeFile( + join(siteRoot, "site-manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); + return manifest; +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) { + const args = process.argv.slice(2); + const supportedFlags = new Set(["--noindex-all"]); + const unsupportedFlag = args.find( + (argument) => argument.startsWith("--") && !supportedFlags.has(argument), + ); + if (unsupportedFlag) throw new Error(`Unsupported option: ${unsupportedFlag}`); + const siteRoot = args.find((argument) => !argument.startsWith("--")) || ".tmp/site"; + const manifest = await postprocessSite(siteRoot, { + noIndexAll: args.includes("--noindex-all"), + }); + console.log( + `Post-processed ${manifest.pageCount} pages; ${manifest.noIndexPageCount} excluded from indexing.`, + ); +} diff --git a/scripts/run-lighthouse.mjs b/scripts/run-lighthouse.mjs new file mode 100644 index 0000000..6077640 --- /dev/null +++ b/scripts/run-lighthouse.mjs @@ -0,0 +1,109 @@ +import { spawn } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import process from "node:process"; +import { launch } from "chrome-launcher"; +import lighthouse, { desktopConfig } from "lighthouse"; +import { normalizeSiteBaseUrl, resolveSiteRoute } from "../tests/browser/site-route.mjs"; + +const suppliedBaseUrl = process.env.LIGHTHOUSE_BASE_URL; +const baseUrl = normalizeSiteBaseUrl(suppliedBaseUrl || "http://127.0.0.1:4174/"); +const routes = [ + ["home", "/"], + ["start", "/start/"], + ["communities", "/provinces/"], + ["config", "/config/"], + ["submit-idea", "/submit-idea/"], + ["fr-home", "/fr/"], + ["fr-config", "/fr/config/"] +]; +const budgets = { + performance: 0.8, + accessibility: 0.95, + "best-practices": 0.9, + seo: 0.9 +}; + +let server; +let chrome; + +function startServer() { + if (suppliedBaseUrl) return undefined; + const command = process.platform === "win32" ? "python.exe" : "python3"; + return spawn( + command, + ["-m", "http.server", "4174", "--bind", "127.0.0.1", "--directory", ".tmp/site"], + { stdio: "ignore", windowsHide: true } + ); +} + +async function waitForServer(url, attempts = 50) { + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + const response = await fetch(url); + if (response.ok) return; + } catch (_error) { + // The bounded retry below handles server startup. + } + await new Promise((resolve) => setTimeout(resolve, 200)); + } + throw new Error(`Timed out waiting for ${url}`); +} + +function scoreSummary(result) { + return Object.fromEntries( + Object.keys(budgets).map((name) => [name, result.lhr.categories[name]?.score ?? 0]) + ); +} + +function hasNoIndex(html) { + return /]*content=["'][^"']*\bnoindex\b/i.test(html); +} + +async function run() { + server = startServer(); + await waitForServer(baseUrl); + await mkdir(".tmp/lighthouse", { recursive: true }); + chrome = await launch({ + chromePath: process.env.CHROME_PATH || undefined, + chromeFlags: ["--headless=new", "--no-sandbox", "--disable-dev-shm-usage"] + }); + + const failures = []; + for (const [name, route] of routes) { + const url = resolveSiteRoute(baseUrl, route); + const html = await (await fetch(url)).text(); + const intentionallyNoIndex = hasNoIndex(html); + const result = await lighthouse(url, { + port: chrome.port, + output: "json", + logLevel: "error", + onlyCategories: Object.keys(budgets) + }, desktopConfig); + if (!result) throw new Error(`Lighthouse returned no result for ${url}`); + + await writeFile( + `.tmp/lighthouse/${name}.json`, + typeof result.report === "string" ? result.report : JSON.stringify(result.report), + "utf8" + ); + const scores = scoreSummary(result); + console.log(`${name}: ${JSON.stringify(scores)}`); + for (const [category, minimum] of Object.entries(budgets)) { + if (category === "seo" && intentionallyNoIndex) continue; + if (scores[category] < minimum) { + failures.push(`${name} ${category} ${scores[category]} < ${minimum}`); + } + } + } + + if (failures.length) { + throw new Error(`Lighthouse budgets failed:\n- ${failures.join("\n- ")}`); + } +} + +try { + await run(); +} finally { + if (chrome) await chrome.kill(); + if (server) server.kill(); +} diff --git a/scripts/validate-communities.py b/scripts/validate-communities.py new file mode 100644 index 0000000..be3398c --- /dev/null +++ b/scripts/validate-communities.py @@ -0,0 +1,1600 @@ +#!/usr/bin/env python3 +"""Validate community data and keep the public directory pages generated from it.""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import sys +import unicodedata +from collections import Counter +from datetime import date +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + + +ROOT = Path(__file__).resolve().parents[1] +DATA_PATH = ROOT / "data" / "communities.json" +FR_DATA_PATH = ROOT / "data" / "communities.fr.json" +SCHEMA_PATH = ROOT / "schemas" / "community-directory.schema.json" +PROVINCES_DIR = ROOT / "docs" / "provinces" + +SCHEMA_VERSION = "meshcore-canada-communities/v1" +FR_SCHEMA_VERSION = "meshcore-canada-communities-fr/v1" +VALID_CODES = { + "AB", + "BC", + "MB", + "NB", + "NL", + "NS", + "NT", + "NU", + "ON", + "PE", + "QC", + "SK", + "YT", +} +VALID_STATUSES = {"active", "forming", "testing", "needs-update"} +VALID_CONTACT_TYPES = { + "discord", + "facebook", + "instagram", + "meshmapper", + "reddit", + "telegram", + "website", + "x", +} +VALID_CONTACT_HEALTH = {"verified", "needs-review", "expired"} +ID_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +class Validation: + def __init__(self) -> None: + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def error(self, message: str) -> None: + self.errors.append(message) + + def warn(self, message: str) -> None: + self.warnings.append(message) + + +def load_data() -> dict[str, Any]: + return json.loads(DATA_PATH.read_text(encoding="utf-8")) + +def load_french_data() -> dict[str, Any]: + return json.loads(FR_DATA_PATH.read_text(encoding="utf-8")) + + + +def parse_date(value: Any, field: str, check: Validation, *, nullable: bool = False) -> date | None: + if value is None and nullable: + return None + if not isinstance(value, str): + check.error(f"{field} must be an ISO date") + return None + try: + return date.fromisoformat(value) + except ValueError: + check.error(f"{field} must be an ISO date, got {value!r}") + return None + + +def normalized(value: str) -> str: + folded = unicodedata.normalize("NFKD", value) + return " ".join("".join(char for char in folded if not unicodedata.combining(char)).casefold().split()) + + +def route_for_page(page: dict[str, Any]) -> str: + return f"/provinces/{page['slug']}/" + + +def page_by_code(data: dict[str, Any]) -> dict[str, dict[str, Any]]: + return { + code: page + for page in data["directory_pages"] + for code in page["codes"] + } + + +def validate_contact(contact: Any, label: str, check: Validation, *, with_identity: bool = False) -> None: + if not isinstance(contact, dict): + check.error(f"{label} must be an object") + return + required = {"type", "label", "url", "health", "last_checked"} + if with_identity: + required |= {"id", "province"} + missing = sorted(required - contact.keys()) + unexpected = sorted(contact.keys() - required) + if unexpected: + check.error(f"{label} has unsupported fields: {', '.join(unexpected)}") + if missing: + check.error(f"{label} is missing: {', '.join(missing)}") + return + if contact["type"] not in VALID_CONTACT_TYPES: + check.error(f"{label}.type is not allowed: {contact['type']!r}") + if not isinstance(contact["label"], str) or not contact["label"].strip(): + check.error(f"{label}.label must be non-empty") + url = contact["url"] + if url is not None: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + check.error(f"{label}.url must be an absolute HTTP(S) URL") + elif contact["health"] != "needs-review": + check.error(f"{label} without a URL must have needs-review health") + if contact["health"] not in VALID_CONTACT_HEALTH: + check.error(f"{label}.health is not allowed: {contact['health']!r}") + checked = parse_date(contact["last_checked"], f"{label}.last_checked", check, nullable=True) + if contact["health"] == "verified" and checked is None: + check.error(f"{label} marked verified must have last_checked") + if contact["health"] == "expired" and url is None: + check.error(f"{label} marked expired must retain the expired URL for review") + if with_identity: + if not isinstance(contact["id"], str) or not ID_PATTERN.fullmatch(contact["id"]): + check.error(f"{label}.id must be a stable kebab-case ID") + if contact["province"] not in VALID_CODES: + check.error(f"{label}.province is not a Canadian province/territory code") + + +def validate_data(data: dict[str, Any]) -> Validation: + check = Validation() + if data.get("schema") != SCHEMA_VERSION: + check.error(f"schema must be {SCHEMA_VERSION!r}") + if not SCHEMA_PATH.is_file(): + check.error(f"schema file is missing: {SCHEMA_PATH.relative_to(ROOT)}") + + metadata = data.get("metadata") + if not isinstance(metadata, dict): + check.error("metadata must be an object") + metadata = {} + for field in ("owner", "source_revision", "update_route"): + if not isinstance(metadata.get(field), str) or not metadata[field].strip(): + check.error(f"metadata.{field} must be non-empty") + parse_date(metadata.get("migrated_at"), "metadata.migrated_at", check) + parse_date(metadata.get("review_by"), "metadata.review_by", check) + + defaults = data.get("national_defaults") + if not isinstance(defaults, dict): + check.error("national_defaults must be an object") + defaults = {} + for field in ("radio_preset", "raw_radio", "path_hash_mode", "cli_path_setting"): + if field not in defaults: + check.error(f"national_defaults.{field} is required") + + pages = data.get("directory_pages") + if not isinstance(pages, list) or not pages: + check.error("directory_pages must be a non-empty array") + pages = [] + slugs: set[str] = set() + all_codes: list[str] = [] + for index, page in enumerate(pages): + label = f"directory_pages[{index}]" + if not isinstance(page, dict): + check.error(f"{label} must be an object") + continue + slug = page.get("slug") + if not isinstance(slug, str) or not ID_PATTERN.fullmatch(slug): + check.error(f"{label}.slug must be kebab-case") + elif slug in slugs: + check.error(f"duplicate directory page slug: {slug}") + else: + slugs.add(slug) + if not isinstance(page.get("title"), str) or not page["title"].strip(): + check.error(f"{label}.title must be non-empty") + codes = page.get("codes") + if not isinstance(codes, list) or not codes: + check.error(f"{label}.codes must be a non-empty array") + codes = [] + for code in codes: + if code not in VALID_CODES: + check.error(f"{label} has invalid code {code!r}") + all_codes.append(code) + aliases = page.get("aliases") + if not isinstance(aliases, list) or any(not isinstance(alias, str) or not alias.strip() for alias in aliases): + check.error(f"{label}.aliases must contain non-empty strings") + + duplicated_codes = sorted(code for code, count in Counter(all_codes).items() if count > 1) + missing_codes = sorted(VALID_CODES - set(all_codes)) + if duplicated_codes: + check.error(f"jurisdiction codes appear on multiple pages: {', '.join(duplicated_codes)}") + if missing_codes: + check.error(f"jurisdiction codes have no directory page: {', '.join(missing_codes)}") + + code_pages = page_by_code(data) if pages else {} + province_contacts = data.get("province_contacts") + if not isinstance(province_contacts, list): + check.error("province_contacts must be an array") + province_contacts = [] + province_contact_ids: set[str] = set() + for index, contact in enumerate(province_contacts): + label = f"province_contacts[{index}]" + validate_contact(contact, label, check, with_identity=True) + if isinstance(contact, dict) and contact.get("id") in province_contact_ids: + check.error(f"duplicate province contact ID: {contact['id']}") + elif isinstance(contact, dict): + province_contact_ids.add(contact.get("id")) + + communities = data.get("communities") + if not isinstance(communities, list): + check.error("communities must be an array") + communities = [] + ids: set[str] = set() + exact_communities: set[tuple[str, str, str]] = set() + for index, community in enumerate(communities): + label = f"communities[{index}]" + if not isinstance(community, dict): + check.error(f"{label} must be an object") + continue + community_id = community.get("id") + if not isinstance(community_id, str) or not ID_PATTERN.fullmatch(community_id): + check.error(f"{label}.id must be a stable kebab-case ID") + community_id = f"invalid-{index}" + elif community_id in ids: + check.error(f"duplicate community ID: {community_id}") + else: + ids.add(community_id) + + for field in ("name", "service_area"): + if not isinstance(community.get(field), str) or not community[field].strip(): + check.error(f"{label}.{field} must be non-empty") + summary = community.get("summary") + if summary is not None and (not isinstance(summary, str) or not summary.strip()): + check.error(f"{label}.summary must be a non-empty string when provided") + province = community.get("province") + if province not in VALID_CODES: + check.error(f"{label}.province is invalid: {province!r}") + elif province not in code_pages: + check.error(f"{label}.province has no directory page: {province}") + status = community.get("status") + if status not in VALID_STATUSES: + check.error(f"{label}.status is invalid: {status!r}") + + for field in ("places", "aliases", "languages"): + values = community.get(field) + if not isinstance(values, list) or any(not isinstance(value, str) or not value.strip() for value in values): + check.error(f"{label}.{field} must be an array of non-empty strings") + elif len({normalized(value) for value in values}) != len(values): + check.error(f"{label}.{field} contains duplicate normalized values") + + location = community.get("location") + if not isinstance(location, dict): + check.error(f"{label}.location must be an object") + else: + latitude = location.get("latitude") + longitude = location.get("longitude") + if (latitude is None) != (longitude is None): + check.error(f"{label}.location must provide both latitude and longitude or neither") + if latitude is not None and not (-90 <= latitude <= 90): + check.error(f"{label}.location.latitude is out of range") + if longitude is not None and not (-180 <= longitude <= 180): + check.error(f"{label}.location.longitude is out of range") + if location.get("precision") not in {"exact", "approximate", "service-area"}: + check.error(f"{label}.location.precision is invalid") + + settings = community.get("settings") + if not isinstance(settings, dict) or not isinstance(settings.get("inherit_national"), bool): + check.error(f"{label}.settings must declare inherit_national") + settings = {"overrides": {}} + overrides = settings.get("overrides") + if not isinstance(overrides, dict): + check.error(f"{label}.settings.overrides must be an object") + overrides = {} + for field, value in overrides.items(): + if field not in {"radio_preset", "path_hash_mode"}: + check.error(f"{label}.settings.overrides has unsupported field {field!r}") + if value == defaults.get(field): + check.error(f"{label} repeats inherited national {field} as an override") + + contacts = community.get("contacts") + if not isinstance(contacts, list) or not contacts: + check.error(f"{label}.contacts must be a non-empty array") + contacts = [] + contact_keys: set[tuple[Any, Any, Any]] = set() + for contact_index, contact in enumerate(contacts): + contact_label = f"{label}.contacts[{contact_index}]" + validate_contact(contact, contact_label, check) + if isinstance(contact, dict): + key = (contact.get("type"), contact.get("label"), contact.get("url")) + if key in contact_keys: + check.error(f"{label} contains an exact duplicate contact") + contact_keys.add(key) + + if "owner" not in community or ( + community["owner"] is not None + and (not isinstance(community["owner"], str) or not community["owner"].strip()) + ): + check.error(f"{label}.owner must be a non-empty string or null") + verified = parse_date(community.get("verified_at"), f"{label}.verified_at", check, nullable=True) + verify_by = parse_date(community.get("verify_by"), f"{label}.verify_by", check, nullable=True) + if (verified is None) != (verify_by is None): + check.error(f"{label} must set verified_at and verify_by together") + if verified is not None and verify_by is not None and verify_by < verified: + check.error(f"{label}.verify_by cannot precede verified_at") + if verified is None: + check.warn(f"{community.get('name', community_id)} needs a community verification date") + + expected_route = "" + if province in code_pages: + expected_route = f"{route_for_page(code_pages[province])}#community-{community_id}" + if community.get("canonical_route") != expected_route: + check.error( + f"{label}.canonical_route must be {expected_route!r}, " + f"got {community.get('canonical_route')!r}" + ) + + if all(isinstance(community.get(field), str) for field in ("name", "service_area")): + exact_key = (province, normalized(community["name"]), normalized(community["service_area"])) + if exact_key in exact_communities: + check.error(f"exact duplicate community: {community['name']} ({province})") + exact_communities.add(exact_key) + + if defaults.get("path_hash_mode") != "3-byte": + check.error("the national path-hash baseline must remain 3-byte") + for community in communities: + settings = community.get("settings", {}) + if settings.get("inherit_national") is not True: + check.error(f"{community.get('name', 'community')} must inherit the Canada baseline") + + return check + +def validate_french_data( + data: dict[str, Any], french: dict[str, Any], check: Validation +) -> None: + """Validate the French display catalog against the canonical directory data.""" + if not isinstance(french, dict): + check.error("French community catalog must be an object") + return + + expected_top = {"schema", "locale", "directory_pages", "communities"} + unexpected_top = sorted(french.keys() - expected_top) + missing_top = sorted(expected_top - french.keys()) + if unexpected_top: + check.error( + "French community catalog has unsupported fields: " + + ", ".join(unexpected_top) + ) + if missing_top: + check.error( + "French community catalog is missing: " + ", ".join(missing_top) + ) + if french.get("schema") != FR_SCHEMA_VERSION: + check.error(f"French catalog schema must be {FR_SCHEMA_VERSION!r}") + if french.get("locale") != "fr-CA": + check.error("French catalog locale must be 'fr-CA'") + + translated_pages = french.get("directory_pages") + if not isinstance(translated_pages, dict): + check.error("French catalog directory_pages must be an object") + translated_pages = {} + canonical_pages = { + page["slug"]: page for page in data.get("directory_pages", []) + if isinstance(page, dict) and isinstance(page.get("slug"), str) + } + missing_pages = sorted(canonical_pages.keys() - translated_pages.keys()) + extra_pages = sorted(translated_pages.keys() - canonical_pages.keys()) + if missing_pages: + check.error( + "French catalog is missing directory pages: " + ", ".join(missing_pages) + ) + if extra_pages: + check.error( + "French catalog has unknown directory pages: " + ", ".join(extra_pages) + ) + for slug, translation in translated_pages.items(): + label = f"French directory page {slug!r}" + if not isinstance(translation, dict): + check.error(f"{label} must be an object") + continue + expected = {"title", "location_phrase"} + missing = sorted(expected - translation.keys()) + unexpected = sorted(translation.keys() - expected) + if missing: + check.error(f"{label} is missing: {', '.join(missing)}") + if unexpected: + check.error(f"{label} has unsupported fields: {', '.join(unexpected)}") + for field in expected: + value = translation.get(field) + if not isinstance(value, str) or not value.strip(): + check.error(f"{label}.{field} must be non-empty") + + translated_communities = french.get("communities") + if not isinstance(translated_communities, dict): + check.error("French catalog communities must be an object") + translated_communities = {} + canonical_communities = { + community["id"]: community for community in data.get("communities", []) + if isinstance(community, dict) and isinstance(community.get("id"), str) + } + missing_communities = sorted( + canonical_communities.keys() - translated_communities.keys() + ) + extra_communities = sorted( + translated_communities.keys() - canonical_communities.keys() + ) + if missing_communities: + check.error( + "French catalog is missing communities: " + ", ".join(missing_communities) + ) + if extra_communities: + check.error( + "French catalog has unknown communities: " + ", ".join(extra_communities) + ) + for community_id, translation in translated_communities.items(): + canonical = canonical_communities.get(community_id) + if canonical is None: + continue + label = f"French community {community_id!r}" + if not isinstance(translation, dict): + check.error(f"{label} must be an object") + continue + expected = {"service_area"} + if canonical.get("summary"): + expected.add("summary") + missing = sorted(expected - translation.keys()) + unexpected = sorted(translation.keys() - expected) + if missing: + check.error(f"{label} is missing: {', '.join(missing)}") + if unexpected: + check.error(f"{label} has unsupported fields: {', '.join(unexpected)}") + for field in expected: + value = translation.get(field) + if not isinstance(value, str) or not value.strip(): + check.error(f"{label}.{field} must be non-empty") + + + +def front_matter(*, title: str, description: str, task: str, metadata: dict[str, Any], scripts: bool) -> str: + lines = [ + "---", + f"title: {title}", + f"description: {description}", + "audience:", + " - community-seeker", + " - community-maintainer", + f"task: {task}", + "scope: canada-baseline", + "status: draft", + f"owner: {metadata['owner']}", + f"last_reviewed: {metadata['migrated_at']}", + f"review_by: {metadata['review_by']}", + "difficulty: beginner", + "estimated_time: 2-5 minutes", + "destructive: false", + "page_styles:", + " - assets/styles/communities.css?v=20260722-2", + ] + if scripts: + lines.extend( + [ + "page_scripts:", + " - assets/javascripts/communities.js?v=20260722-2", + ] + ) + lines.extend(["---", ""]) + return "\n".join(lines) + + +def status_label(status: str) -> str: + return { + "active": "Active", + "forming": "Forming", + "testing": "Testing", + "needs-update": "Needs update", + }[status] + + +def verification_label(community: dict[str, Any]) -> str: + if community["verified_at"] is None: + return "Not yet verified" + return community["verified_at"] + + +def search_text(community: dict[str, Any], page: dict[str, Any]) -> str: + values = [ + community["name"], + community["service_area"], + community["province"], + page["title"], + *page["aliases"], + *community["places"], + *community["aliases"], + ] + if community.get("summary"): + values.append(community["summary"]) + return " ".join(dict.fromkeys(normalized(value) for value in values)) + + +def contact_type_label(contact_type: str) -> str: + return { + "meshmapper": "MeshMapper", + "x": "X", + }.get(contact_type, contact_type.title()) + + +def render_contacts(community: dict[str, Any], *, indent: str = "") -> list[str]: + lines: list[str] = [] + for contact in community["contacts"]: + if contact["url"]: + value = ( + f'' + f'{html.escape(contact["label"])} ' + '(external)' + ) + else: + value = html.escape(contact["label"]) + lines.append( + f"{indent}
  • {contact_type_label(contact['type'])}: {value}
  • " + ) + return lines + + +def render_settings(community: dict[str, Any], *, compact: bool = False) -> str: + overrides = community["settings"]["overrides"] + if not overrides: + return "Uses the Canada defaults" + values = [] + if "radio_preset" in overrides: + values.append( + f"Radio preset: {html.escape(overrides['radio_preset'])}" + ) + if "path_hash_mode" in overrides: + values.append( + f"Path hash mode: {html.escape(overrides['path_hash_mode'])}" + ) # Cards use raw HTML, so override values must be escaped markup. + prefix = "Different local settings — " if compact else "" + return prefix + "; ".join(values) + + +def render_directory_card(community: dict[str, Any], page: dict[str, Any]) -> str: + search = html.escape(search_text(community, page), quote=True) + has_override = str(bool(community["settings"]["overrides"])).lower() + lines = [ + ( + f'
    ' + ), + '
    ', + ( + f'

    ' + f'{html.escape(community["name"])}

    ' + ), + ( + f'' + f'{status_label(community["status"])}' + ), + "
    ", + f'

    {html.escape(community["service_area"])}

    ', + ] + if community.get("summary"): + lines.append(f'

    {html.escape(community["summary"])}

    ') + lines.extend( + [ + ( + f'

    Province: ' + f'{html.escape(page["title"].title())}

    ' + ), + f"

    Settings: {render_settings(community, compact=True)}

    ", + f"

    Last verified: {verification_label(community)}

    ", + '
      ', + *render_contacts(community), + "
    ", + ( + f'

    ' + "View listing details

    " + ), + "
    ", + ] + ) + return "\n".join(lines) + + +def render_index(data: dict[str, Any]) -> str: + metadata = data["metadata"] + pages = data["directory_pages"] + communities = data["communities"] + code_pages = page_by_code(data) + active = sum(item["status"] == "active" for item in communities) + forming = sum(item["status"] == "forming" for item in communities) + overrides = sum(bool(item["settings"]["overrides"]) for item in communities) + unverified = sum(item["verified_at"] is None for item in communities) + + lines = [ + front_matter( + title="Find a MeshCore community in Canada", + description="Search Canadian MeshCore communities by place, province, community name, or common alias.", + task="find-community", + metadata=metadata, + scripts=True, + ).rstrip(), + "", + "", + "", + "# Find a MeshCore community", + "", + "Search by place, province, community name, or a common alias. The full list", + "works without a map, location permission, or a GitHub account.", + "", + '!!! note "Community information can change"', + f" {unverified} of {len(communities)} listings do not have a recent contact check.", + " Confirm important settings and contacts before relying on them.", + "", + '
    ', + f"{len(communities)} listings", + f"{active} listed active", + f"{forming} listed forming", + f"{overrides} with different local settings", + "
    ", + "", + '
    ', + ' ", + '
    ', + ' ', + ' ", + "
    ", + ' ", + ' ', + ' ', + f" Showing {len(communities)} communities", + " ", + "
    ", + "", + '", + "", + "## Communities", + "", + '
    ', + ] + for community in communities: + lines.append(render_directory_card(community, code_pages[community["province"]])) + lines.extend( + [ + "
    ", + "", + "## Canada defaults { #canada-baseline }", + "", + "Use these settings unless your local community lists different ones.", + "", + "| Setting | Canada default |", + "|---|---|", + f'| Radio preset | `{data["national_defaults"]["radio_preset"]}` |', + ( + "| Raw radio values | " + f'`{data["national_defaults"]["raw_radio"]["frequency_mhz"]} MHz / ' + f'{data["national_defaults"]["raw_radio"]["bandwidth_khz"]} kHz / ' + f'SF{data["national_defaults"]["raw_radio"]["spreading_factor"]} / ' + f'CR{data["national_defaults"]["raw_radio"]["coding_rate"]}` |' + ), + f'| Path hash mode | `{data["national_defaults"]["path_hash_mode"]}` |', + f'| Command-line path setting | `{data["national_defaults"]["cli_path_setting"]}` |', + "", + "!!! warning \"Check local settings first\"", + " Nearby devices need matching settings. A card marked **Different local settings**", + " takes precedence over the Canada defaults after you confirm it with the", + " listed community.", + "", + "## Browse by province or territory", + "", + '
    ', + ] + ) + for page in pages: + page_communities = [ + item for item in communities if item["province"] in page["codes"] + ] + page_active = sum(item["status"] == "active" for item in page_communities) + page_forming = sum(item["status"] == "forming" for item in page_communities) + labels = [] + if page_active: + labels.append(f"{page_active} active") + if page_forming: + labels.append(f"{page_forming} forming") + if not labels: + labels.append("No listing yet") + lines.extend( + [ + '", + ] + ) + lines.extend( + [ + "
    ", + "", + "## Add or update a listing", + "", + "Found missing or outdated information?", + f"[Send a community update]({metadata['update_route']}). No GitHub account needed.", + "", + ] + ) + return "\n".join(lines) + + +def render_community_card(community: dict[str, Any], metadata: dict[str, Any]) -> str: + lines = [ + ( + f'
    ' + ), + '
    ', + f"

    {html.escape(community['name'])}

    ", + ( + f'' + f'{status_label(community["status"])}' + ), + "
    ", + f'

    {html.escape(community["service_area"])}

    ', + ] + if community.get("summary"): + lines.append(f'

    {html.escape(community["summary"])}

    ') + lines.extend( + [ + "
    ", + "
    Settings
    ", + f"
    {render_settings(community)}
    ", + "
    Last verified
    ", + f"
    {verification_label(community)}
    ", + "
    ", + ] + ) + if community["settings"]["overrides"]: + lines.extend( + [ + '
    ', + "Local settings override", + "

    Confirm this setting with the community before changing a node.

    ", + "
    ", + ] + ) + if community["status"] == "forming": + lines.extend( + [ + '

    ', + "This group is forming. Contact it to learn what is working and where help is needed.", + "

    ", + ] + ) + lines.extend( + [ + "

    Contacts

    ", + '
      ', + *render_contacts(community), + "
    ", + '

    ', + "Contact check: Not yet verified", + "

    ", + ( + '

    ' + "Update this listing

    " + ), + "
    ", + ] + ) + return "\n".join(lines) + + +def render_province_page(data: dict[str, Any], page: dict[str, Any]) -> str: + metadata = data["metadata"] + communities = [ + item for item in data["communities"] if item["province"] in page["codes"] + ] + province_contacts = [ + item for item in data["province_contacts"] if item["province"] in page["codes"] + ] + active = sum(item["status"] == "active" for item in communities) + forming = sum(item["status"] == "forming" for item in communities) + title_display = page["title"] + description = ( + f"Find MeshCore community contacts, service areas, and local settings for {title_display}." + ) + lines = [ + front_matter( + title=f"MeshCore communities in {title_display}", + description=description, + task="browse-community-directory", + metadata=metadata, + scripts=False, + ).rstrip(), + "", + "", + "", + f"# MeshCore communities in {title_display}", + "", + ] + if communities: + if active and not forming: + summary = ( + f"{title_display} has **{active} active community listing" + f"{'' if active == 1 else 's'}**." + ) + elif forming and not active: + summary = ( + f"{title_display} has **{forming} forming community listing" + f"{'' if forming == 1 else 's'}**." + ) + else: + summary = ( + f"{title_display} has **{len(communities)} community listings** " + f"({active} active, {forming} forming)." + ) + lines.extend( + [ + summary, + "", + "All listings use the [Canada defaults](index.md#canada-baseline) unless a", + "card lists different local settings.", + "", + ] + ) + else: + lines.extend( + [ + "We don't have a community listed here yet.", + "", + '
    ', + "

    Help add the first listing

    ", + "

    Share the community name, service area, status, and a public contact link.

    ", + '

    ' + "Add a community

    ", + '

    Browse all Canadian communities

    ', + "
    ", + "", + "Until a reviewed local listing gives different settings, start with the", + "[Canada defaults](index.md#canada-baseline) and confirm settings with nearby", + "operators before transmitting.", + "", + ] + ) + + override_communities = [ + item for item in communities if item["settings"]["overrides"] + ] + if override_communities: + lines.extend( + [ + "!!! warning \"This community uses different settings\"", + " One community on this page lists different local settings. Confirm", + " the current setting with its contact before configuring or changing a node.", + "", + ] + ) + + if communities: + lines.extend(["## Community listings", "", '
    ']) + for community in communities: + lines.append(render_community_card(community, metadata)) + lines.extend(["
    ", ""]) + + if province_contacts: + lines.extend(["## Province-wide contacts", "", '
    ']) + for contact in province_contacts: + if contact["url"]: + rendered = ( + f'' + f'{html.escape(contact["label"])} ' + '(external)' + ) + else: + rendered = html.escape(contact["label"]) + lines.extend( + [ + f"

    {contact['type'].title()}: {rendered}

    ", + "

    Contact check: Not yet verified

    ", + ] + ) + lines.extend(["
    ", ""]) + + lines.extend( + [ + "## Add or update a listing", + "", + f"[Send a community update]({metadata['update_route']}). No GitHub account needed.", + "", + "Listings are community-submitted and may be incomplete or out of date.", + "", + ] + ) + return "\n".join(lines) + + +def french_page_translation(french: dict[str, Any], page: dict[str, Any]) -> dict[str, str]: + return french["directory_pages"][page["slug"]] + + +def french_community_translation( + french: dict[str, Any], community: dict[str, Any] +) -> dict[str, str]: + return french["communities"][community["id"]] + + +def status_label_fr(status: str) -> str: + return { + "active": "Active", + "forming": "En formation", + "testing": "En essai", + "needs-update": "À mettre à jour", + }[status] + + +def verification_label_fr(community: dict[str, Any]) -> str: + if community["verified_at"] is None: + return "Pas encore vérifiée" + return community["verified_at"] + + +def contact_type_label_fr(contact_type: str) -> str: + return { + "discord": "Discord", + "facebook": "Facebook", + "instagram": "Instagram", + "meshmapper": "MeshMapper", + "reddit": "Reddit", + "telegram": "Telegram", + "website": "Site Web", + "x": "X", + }[contact_type] + + +def contact_label_fr(label: str) -> str: + """Return a visitor-facing French label while preserving names and URLs.""" + return { + "Salish Mesh website": "Site Web de Salish Mesh", + "Alberta MeshCore Discord": "Discord d’Alberta MeshCore", + "Airdrie regional page": "Page régionale d’Airdrie", + "Calgary regional page": "Page régionale de Calgary", + "Edmonton regional page": "Page régionale d’Edmonton", + "Lethbridge regional page": "Page régionale de Lethbridge", + "Why MeshCore?": "Pourquoi MeshCore?", + "Monitoring tools": "Outils de surveillance", + "Airdrie MeshCore Network": "Réseau MeshCore d’Airdrie", + "Airdrie configuration guide": "Guide de configuration d’Airdrie", + "Airdrie network map": "Carte du réseau d’Airdrie", + "WAeV live map": "Carte en direct de WAeV", + "Calgary topic in Mesh Alberta": "Sujet sur Calgary dans Mesh Alberta", + "Canada - Calgary, Alberta & Area regional channel in the MeshCore Discord": "Canal régional Canada — Calgary, Alberta et environs sur le Discord de MeshCore", + "Calgary MeshCore Network": "Réseau MeshCore de Calgary", + "Calgary community guide": "Guide de la communauté de Calgary", + "Calgary network map": "Carte du réseau de Calgary", + "Recommended Calgary RX channels": "Canaux RX recommandés à Calgary", + "Edmonton on AlbertaMesh.ca": "Edmonton sur AlbertaMesh.ca", + "Edmonton configuration guide": "Guide de configuration d’Edmonton", + "Edmonton network map": "Carte du réseau d’Edmonton", + "Getting started": "Bien démarrer", + "MeshCore defaults": "Réglages par défaut de MeshCore", + "YQLMesh website": "Site Web de YQLMesh", + "Lethbridge network map": "Carte du réseau de Lethbridge", + "YQLMesh on X": "YQLMesh sur X", + "YQLMesh subreddit": "Communauté Reddit de YQLMesh", + "Canada - Southern Alberta regional channel in the MeshCore Discord": "Canal régional Canada — Sud de l’Alberta sur le Discord de MeshCore", + "Cardston topic in Mesh Alberta": "Sujet sur Cardston dans Mesh Alberta", + "YYC MeshCore Discord": "Discord de YYC MeshCore", + "StoonMesh Discord": "Discord de StoonMesh", + "Greater Ottawa Mesh Enthusiasts Discord": "Discord de Greater Ottawa Mesh Enthusiasts", + "Ottawa Mesh website": "Site Web d’Ottawa Mesh", + "GTA+-Lora-Meshes Discord": "Discord de GTA+-Lora-Meshes", + "Quinte Mesh Network Discord": "Discord de Quinte Mesh Network", + "Quinte Mesh Network website": "Site Web de Quinte Mesh Network", + "Mesh Quebec website": "Site Web de Mesh Québec", + "Montreal Mesh website": "Site Web de Montreal Mesh", + "Réseau Mesh de la Capitale YQB Discord": "Discord du Réseau Mesh de la Capitale YQB", + "Réseau Mesh du Saguenay Lac st-Jean YTF Discord": "Discord du Réseau Mesh du Saguenay–Lac-Saint-Jean YTF", + "Réseau Mesh du Saguenay Lac st-Jean YTF Facebook": "Facebook du Réseau Mesh du Saguenay–Lac-Saint-Jean YTF", + "Réseau Mesh du Saguenay Lac st-Jean YTF MeshMapper": "Carte MeshMapper du Réseau Mesh du Saguenay–Lac-Saint-Jean YTF", + "Réseau Libre website": "Site Web de Réseau Libre", + "Lunenburg County Mesh website": "Site Web de Lunenburg County Mesh", + "Alberta topic in MeshCore Canada": "Sujet sur l’Alberta dans MeshCore Canada", + }.get(label, label) + + +def search_text_fr( + community: dict[str, Any], + page: dict[str, Any], + french: dict[str, Any], +) -> str: + page_translation = french_page_translation(french, page) + community_translation = french_community_translation(french, community) + values = [ + community["name"], + community["service_area"], + community_translation["service_area"], + community["province"], + page["title"], + page_translation["title"], + *page["aliases"], + *community["places"], + *community["aliases"], + ] + if community.get("summary"): + values.extend([community["summary"], community_translation["summary"]]) + return " ".join(dict.fromkeys(normalized(value) for value in values)) + + +def render_contacts_fr(community: dict[str, Any], *, indent: str = "") -> list[str]: + lines: list[str] = [] + for contact in community["contacts"]: + if contact["url"]: + value = ( + f'' + f'{html.escape(contact_label_fr(contact["label"]))} ' + '(externe)' + ) + else: + value = html.escape(contact_label_fr(contact["label"])) + lines.append( + f"{indent}
  • {contact_type_label_fr(contact['type'])} : {value}
  • " + ) + return lines + + +def render_settings_fr(community: dict[str, Any], *, compact: bool = False) -> str: + overrides = community["settings"]["overrides"] + if not overrides: + return "Utilise les réglages par défaut du Canada" + values = [] + if "radio_preset" in overrides: + values.append( + f"Préréglage radio : {html.escape(overrides['radio_preset'])}" + ) + if "path_hash_mode" in overrides: + values.append( + "Mode de hachage des parcours : " + f"{html.escape(overrides['path_hash_mode'])}" + ) + prefix = "Réglages locaux différents — " if compact else "" + return prefix + "; ".join(values) + + +def render_directory_card_fr( + community: dict[str, Any], page: dict[str, Any], french: dict[str, Any] +) -> str: + page_translation = french_page_translation(french, page) + community_translation = french_community_translation(french, community) + search = html.escape(search_text_fr(community, page, french), quote=True) + has_override = str(bool(community["settings"]["overrides"])).lower() + lines = [ + ( + f'
    ' + ), + '
    ', + ( + f'

    ' + f'{html.escape(community["name"])}

    ' + ), + ( + f'' + f'{status_label_fr(community["status"])}' + ), + "
    ", + f'

    {html.escape(community_translation["service_area"])}

    ', + ] + if community.get("summary"): + lines.append( + f'

    {html.escape(community_translation["summary"])}

    ' + ) + lines.extend( + [ + ( + f'

    Province ou territoire : ' + f'{html.escape(page_translation["title"])}

    ' + ), + f"

    Réglages : {render_settings_fr(community, compact=True)}

    ", + f"

    Dernière vérification : {verification_label_fr(community)}

    ", + '
      ', + *render_contacts_fr(community), + "
    ", + ( + f'

    ' + "Voir les détails de la fiche

    " + ), + "
    ", + ] + ) + return "\n".join(lines) + + +def render_index_fr(data: dict[str, Any], french: dict[str, Any]) -> str: + metadata = data["metadata"] + pages = data["directory_pages"] + communities = data["communities"] + code_pages = page_by_code(data) + active = sum(item["status"] == "active" for item in communities) + forming = sum(item["status"] == "forming" for item in communities) + overrides = sum(bool(item["settings"]["overrides"]) for item in communities) + unverified = sum(item["verified_at"] is None for item in communities) + if unverified == 1: + verification_summary = ( + f"1 fiche sur {len(communities)} n’a pas fait l’objet d’une vérification récente des coordonnées." + ) + else: + verification_summary = ( + f"{unverified} fiches sur {len(communities)} n’ont pas fait l’objet d’une vérification récente des coordonnées." + ) + + lines = [ + front_matter( + title="Trouver une communauté MeshCore au Canada", + description=( + "Recherchez des communautés MeshCore canadiennes par lieu, province, " + "nom de communauté ou alias courant." + ), + task="find-community", + metadata=metadata, + scripts=True, + ).rstrip(), + "", + ( + "" + ), + "", + "# Trouver une communauté MeshCore au Canada", + "", + "Recherchez par lieu, province, nom de communauté ou alias courant. La liste", + "complète fonctionne sans carte, autorisation de localisation ni compte GitHub.", + "", + '!!! note "Les renseignements sur les communautés peuvent changer"', + f" {verification_summary}", + " Confirmez les réglages et les coordonnées importants avant de vous y fier.", + "", + '
    ', + f"{len(communities)} {'fiche' if len(communities) == 1 else 'fiches'}", + f"{active} {'fiche active' if active == 1 else 'fiches actives'}", + f"{forming} {'fiche en formation' if forming == 1 else 'fiches en formation'}", + ( + f"{overrides} " + f"{'fiche avec des réglages locaux différents' if overrides == 1 else 'fiches avec des réglages locaux différents'}" + ), + "
    ", + "", + '
    ', + ' ", + '
    ', + ' ', + ' ", + "
    ", + ' ", + ' ', + ' ', + f" Affichage de {len(communities)} communautés", + " ", + "
    ", + "", + '", + "", + "## Communautés", + "", + '
    ', + ] + for community in communities: + lines.append( + render_directory_card_fr( + community, code_pages[community["province"]], french + ) + ) + lines.extend( + [ + "
    ", + "", + "## Réglages par défaut du Canada { #canada-baseline }", + "", + "Utilisez ces réglages sauf si votre communauté locale en indique d’autres.", + "", + "| Réglage | Valeur par défaut au Canada |", + "|---|---|", + f'| Préréglage radio | `{data["national_defaults"]["radio_preset"]}` |', + ( + "| Valeurs radio brutes | " + f'`{data["national_defaults"]["raw_radio"]["frequency_mhz"]} MHz / ' + f'{data["national_defaults"]["raw_radio"]["bandwidth_khz"]} kHz / ' + f'SF{data["national_defaults"]["raw_radio"]["spreading_factor"]} / ' + f'CR{data["national_defaults"]["raw_radio"]["coding_rate"]}` |' + ), + f'| Mode de hachage des parcours | `{data["national_defaults"]["path_hash_mode"]}` |', + ( + "| Réglage du parcours en ligne de commande | " + f'`{data["national_defaults"]["cli_path_setting"]}` |' + ), + "", + '!!! warning "Vérifiez d’abord les réglages locaux"', + " Les appareils à proximité doivent utiliser les mêmes réglages. Une fiche marquée", + " **Réglages locaux différents** l’emporte sur les réglages par défaut du Canada", + " après confirmation auprès de la communauté indiquée.", + "", + "## Parcourir par province ou territoire", + "", + '
    ', + ] + ) + for page in pages: + page_translation = french_page_translation(french, page) + page_communities = [ + item for item in communities if item["province"] in page["codes"] + ] + page_active = sum(item["status"] == "active" for item in page_communities) + page_forming = sum(item["status"] == "forming" for item in page_communities) + labels = [] + if page_active: + labels.append( + f"{page_active} {'active' if page_active == 1 else 'actives'}" + ) + if page_forming: + labels.append(f"{page_forming} en formation") + if not labels: + labels.append("Aucune fiche") + lines.extend( + [ + '", + ] + ) + lines.extend( + [ + "
    ", + "", + "## Ajouter ou mettre à jour une fiche", + "", + "Vous avez trouvé des renseignements manquants ou périmés?", + ( + f"[Envoyer une mise à jour de la communauté]({metadata['update_route']}). " + "Aucun compte GitHub requis." + ), + "", + ] + ) + return "\n".join(lines) + + +def render_community_card_fr( + community: dict[str, Any], metadata: dict[str, Any], french: dict[str, Any] +) -> str: + community_translation = french_community_translation(french, community) + lines = [ + ( + f'
    ' + ), + '
    ', + f"

    {html.escape(community['name'])}

    ", + ( + f'' + f'{status_label_fr(community["status"])}' + ), + "
    ", + f'

    {html.escape(community_translation["service_area"])}

    ', + ] + if community.get("summary"): + lines.append( + f'

    {html.escape(community_translation["summary"])}

    ' + ) + lines.extend( + [ + '
    ', + "
    Réglages
    ", + f"
    {render_settings_fr(community)}
    ", + "
    Dernière vérification
    ", + f"
    {verification_label_fr(community)}
    ", + "
    ", + ] + ) + if community["settings"]["overrides"]: + lines.extend( + [ + '
    ', + "Réglages locaux différents", + "

    Confirmez ces réglages auprès de la communauté avant de modifier un nœud.

    ", + "
    ", + ] + ) + if community["status"] == "forming": + lines.extend( + [ + '

    ', + ( + "Ce groupe est en formation. Communiquez avec lui pour savoir ce qui " + "fonctionne et où votre aide serait utile." + ), + "

    ", + ] + ) + lines.extend( + [ + "

    Coordonnées

    ", + '
      ', + *render_contacts_fr(community), + "
    ", + '

    ', + "Vérification du contact : Pas encore vérifié", + "

    ", + ( + '

    ' + "Mettre cette fiche à jour

    " + ), + "
    ", + ] + ) + return "\n".join(lines) + + +def render_province_page_fr( + data: dict[str, Any], page: dict[str, Any], french: dict[str, Any] +) -> str: + metadata = data["metadata"] + communities = [ + item for item in data["communities"] if item["province"] in page["codes"] + ] + province_contacts = [ + item for item in data["province_contacts"] if item["province"] in page["codes"] + ] + active = sum(item["status"] == "active" for item in communities) + forming = sum(item["status"] == "forming" for item in communities) + page_translation = french_page_translation(french, page) + location_phrase = page_translation["location_phrase"] + description = ( + "Trouvez les coordonnées, les zones desservies et les réglages locaux des " + f"communautés MeshCore {location_phrase}." + ) + lines = [ + front_matter( + title=f"Communautés MeshCore {location_phrase}", + description=description, + task="browse-community-directory", + metadata=metadata, + scripts=False, + ).rstrip(), + "", + ( + "" + ), + "", + f"# Communautés MeshCore {location_phrase}", + "", + ] + if communities: + if active and not forming: + listing = "fiche de communauté active" if active == 1 else "fiches de communauté actives" + summary = f"Il y a **{active} {listing}** {location_phrase}." + elif forming and not active: + listing = "fiche de communauté" if forming == 1 else "fiches de communauté" + summary = f"Il y a **{forming} {listing} en formation** {location_phrase}." + else: + summary = ( + f"Il y a **{len(communities)} fiches de communauté** {location_phrase} " + f"({active} actives et {forming} en formation)." + ) + lines.extend( + [ + summary, + "", + "Toutes les fiches utilisent les [réglages par défaut du Canada](index.md#canada-baseline),", + "sauf si une fiche indique des réglages locaux différents.", + "", + ] + ) + else: + lines.extend( + [ + f"Aucune communauté n’est encore répertoriée {location_phrase}.", + "", + '
    ', + "

    Aidez-nous à ajouter la première fiche

    ", + ( + "

    Indiquez le nom de la communauté, la zone desservie, " + "son état et un lien de contact public.

    " + ), + '

    ' + "Ajouter une communauté

    ", + '

    Parcourir toutes les communautés canadiennes

    ', + "
    ", + "", + "Tant qu’aucune fiche locale examinée n’indique d’autres réglages, commencez avec", + "les [réglages par défaut du Canada](index.md#canada-baseline) et confirmez-les", + "auprès des personnes à proximité avant de transmettre.", + "", + ] + ) + + override_communities = [ + item for item in communities if item["settings"]["overrides"] + ] + if override_communities: + lines.extend( + [ + '!!! warning "Cette communauté utilise des réglages différents"', + " Au moins une communauté sur cette page indique des réglages locaux différents.", + " Confirmez les réglages actuels auprès de son contact avant de configurer", + " ou de modifier un nœud.", + "", + ] + ) + + if communities: + lines.extend(["## Fiches des communautés", "", '
    ']) + for community in communities: + lines.append(render_community_card_fr(community, metadata, french)) + lines.extend(["
    ", ""]) + + if province_contacts: + lines.extend( + [ + "## Coordonnées pour toute la province ou le territoire", + "", + '
    ', + ] + ) + for contact in province_contacts: + if contact["url"]: + rendered = ( + f'' + f'{html.escape(contact_label_fr(contact["label"]))} ' + '(externe)' + ) + else: + rendered = html.escape(contact_label_fr(contact["label"])) + lines.extend( + [ + f"

    {contact_type_label_fr(contact['type'])} : {rendered}

    ", + "

    Vérification du contact : Pas encore vérifié

    ", + ] + ) + lines.extend(["
    ", ""]) + + lines.extend( + [ + "## Ajouter ou mettre à jour une fiche", + "", + ( + f"[Envoyer une mise à jour de la communauté]({metadata['update_route']}). " + "Aucun compte GitHub requis." + ), + "", + "Les fiches sont soumises par la communauté et peuvent être incomplètes ou périmées.", + "", + ] + ) + return "\n".join(lines) + + +def generated_pages(data: dict[str, Any], french: dict[str, Any]) -> dict[Path, str]: + pages = {PROVINCES_DIR / "index.md": render_index(data)} + for page in data["directory_pages"]: + pages[PROVINCES_DIR / f"{page['slug']}.md"] = render_province_page(data, page) + pages[PROVINCES_DIR / "index.fr.md"] = render_index_fr(data, french) + for page in data["directory_pages"]: + pages[PROVINCES_DIR / f"{page['slug']}.fr.md"] = render_province_page_fr( + data, page, french + ) + return pages + + +def check_or_write_generated( + data: dict[str, Any], + french: dict[str, Any], + check: Validation, + *, + write: bool, +) -> None: + for path, expected in generated_pages(data, french).items(): + expected = expected.rstrip() + "\n" + if write: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(expected, encoding="utf-8", newline="\n") + continue + if not path.is_file(): + check.error(f"generated page is missing: {path.relative_to(ROOT)}") + continue + actual = path.read_text(encoding="utf-8") + if actual != expected: + check.error( + f"{path.relative_to(ROOT)} is out of date; " + "run: python scripts/validate-communities.py --write" + ) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--write", + action="store_true", + help="write generated province and directory pages after validating data", + ) + args = parser.parse_args() + + try: + data = load_data() + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: cannot read {DATA_PATH.relative_to(ROOT)}: {exc}", file=sys.stderr) + return 1 + + try: + french = load_french_data() + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: cannot read {FR_DATA_PATH.relative_to(ROOT)}: {exc}", file=sys.stderr) + return 1 + + + check = validate_data(data) + if not check.errors: + validate_french_data(data, french, check) + if not check.errors: + check_or_write_generated(data, french, check, write=args.write) + + for warning in check.warnings: + print(f"WARNING: {warning}") + for error in check.errors: + print(f"ERROR: {error}", file=sys.stderr) + + if check.errors: + print( + f"Community directory validation failed with {len(check.errors)} error(s).", + file=sys.stderr, + ) + return 1 + + communities = data["communities"] + active = sum(item["status"] == "active" for item in communities) + forming = sum(item["status"] == "forming" for item in communities) + mode = "generated and validated" if args.write else "validated" + print( + f"Community directory {mode}: {len(communities)} listings " + f"({active} active, {forming} forming), " + f"{len(data['directory_pages'])} directory pages." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-content.py b/scripts/validate-content.py new file mode 100644 index 0000000..1f64bb2 --- /dev/null +++ b/scripts/validate-content.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Fail-closed validation for public MeshCore Canada documentation pages.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import re +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import yaml + + +REQUIRED = ( + "title", + "description", + "audience", + "task", + "scope", + "status", + "owner", + "last_reviewed", + "review_by", +) +STATUSES = {"verified", "draft", "experimental", "legacy", "archived"} +FIXED_SCOPES = { + "canada-baseline", + "upstream-meshcore", + "ottawa-field-practice", + "experimental", + "legacy", + "regulatory-reference", +} +SLUG = re.compile(r"^[a-z0-9-]+$") +H1 = re.compile(r"^#(?!#)\s+(.+?)\s*$", re.MULTILINE) +HEADING = re.compile(r"^#{2,6}\s+(.+?)\s*$", re.MULTILINE) +ASSET_KEYS = ("page_styles", "page_scripts", "page_modules") + + +def parse_date(value: Any) -> dt.date | None: + if isinstance(value, dt.datetime): + return value.date() + if isinstance(value, dt.date): + return value + if isinstance(value, str): + try: + return dt.date.fromisoformat(value) + except ValueError: + return None + return None + + +def read_page(path: Path) -> tuple[dict[str, Any], str, list[str]]: + errors: list[str] = [] + text = path.read_text(encoding="utf-8-sig") + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {}, text, ["missing YAML front matter at the first line"] + + try: + closing = next(index for index, line in enumerate(lines[1:], 1) if line.strip() == "---") + except StopIteration: + return {}, text, ["front matter is not closed with ---"] + + try: + metadata = yaml.safe_load("\n".join(lines[1:closing])) or {} + except yaml.YAMLError as exc: + return {}, "\n".join(lines[closing + 1 :]), [f"invalid YAML front matter: {exc}"] + if not isinstance(metadata, dict): + errors.append("front matter must be a mapping") + metadata = {} + return metadata, "\n".join(lines[closing + 1 :]), errors + + +def validate_page(path: Path, docs_root: Path, today: dt.date) -> tuple[dict[str, Any], list[str]]: + metadata, body, errors = read_page(path) + for key in REQUIRED: + if key not in metadata or metadata[key] in (None, "", []): + errors.append(f"missing required metadata: {key}") + + title = metadata.get("title") + description = metadata.get("description") + audience = metadata.get("audience") + task = metadata.get("task") + scope = metadata.get("scope") + status = metadata.get("status") + owner = metadata.get("owner") + + if isinstance(title, str): + if len(title.strip()) < 4: + errors.append("title must contain at least four characters") + if title.strip().casefold() == "overview": + errors.append("title cannot be the unqualified word Overview") + elif title is not None: + errors.append("title must be a string") + + if isinstance(description, str): + length = len(description.strip()) + if not 24 <= length <= 180: + errors.append("description must be 24-180 characters") + elif description is not None: + errors.append("description must be a string") + + if not isinstance(audience, list) or not audience: + if audience is not None: + errors.append("audience must be a non-empty list") + elif any(not isinstance(value, str) or not SLUG.fullmatch(value) for value in audience): + errors.append("audience values must be lowercase slugs") + + for key, value in (("task", task), ("owner", owner)): + if value is not None and (not isinstance(value, str) or not SLUG.fullmatch(value)): + errors.append(f"{key} must be a lowercase slug") + + if isinstance(scope, str): + valid_scope = ( + scope in FIXED_SCOPES + or re.fullmatch(r"province:[a-z]{2}", scope) + or re.fullmatch(r"community:[a-z0-9-]+", scope) + ) + if not valid_scope: + errors.append(f"unsupported scope: {scope}") + elif scope is not None: + errors.append("scope must be a string") + + if status is not None and status not in STATUSES: + errors.append(f"unsupported status: {status}") + if status == "verified" and not metadata.get("tested_with") and not metadata.get("evidence"): + errors.append("verified pages require tested_with or evidence") + + last_reviewed = parse_date(metadata.get("last_reviewed")) + review_by = parse_date(metadata.get("review_by")) + if metadata.get("last_reviewed") is not None and last_reviewed is None: + errors.append("last_reviewed must be an ISO date") + if metadata.get("review_by") is not None and review_by is None: + errors.append("review_by must be an ISO date") + if last_reviewed and review_by and last_reviewed > review_by: + errors.append("review_by cannot be earlier than last_reviewed") + if status == "verified" and review_by and review_by < today: + errors.append(f"verified page review expired on {review_by.isoformat()}") + + headings = H1.findall(body) + if len(headings) != 1: + errors.append(f"expected exactly one H1, found {len(headings)}") + if len(re.sub(r"[\s#*_`<>-]", "", body)) < 80: + errors.append("page body is empty or materially incomplete") + + if metadata.get("destructive") is True: + section_names = [value.casefold() for value in HEADING.findall(body)] + required_sections = { + "preflight or backup": ( + "before", "preflight", "backup", "avant", "préalable", "sauvegarde" + ), + "verification": ( + "verify", "verification", "check", "make sure", "vérif", "confirmer", "s’assurer" + ), + "recovery": ("recovery", "undo", "restore", "récupération", "restaurer", "retour"), + } + for label, words in required_sections.items(): + if not any(any(word in section for word in words) for section in section_names): + errors.append(f"destructive page is missing a {label} heading") + + for key in ASSET_KEYS: + values = metadata.get(key, []) + if values is None: + continue + if not isinstance(values, list) or any(not isinstance(value, str) for value in values): + errors.append(f"{key} must be a list of relative asset paths") + continue + for value in values: + parsed = urlsplit(value) + asset_path = parsed.path + if ( + parsed.scheme + or parsed.netloc + or asset_path.startswith("/") + or ".." in Path(asset_path).parts + ): + errors.append(f"{key} contains a non-local path: {value}") + continue + if not asset_path or not (docs_root / asset_path).is_file(): + errors.append(f"{key} asset does not exist: {value}") + + return metadata, errors + + +def locale_for_page(path: Path) -> str: + return "fr" if path.name.endswith(".fr.md") else "en" + + +def canonical_page_path(path: Path, docs_root: Path) -> str: + relative = path.relative_to(docs_root).as_posix() + return relative.removesuffix(".fr.md") + ".md" if relative.endswith(".fr.md") else relative + + +def validate_tree( + docs_root: Path, + today: dt.date, + *, + require_french_parity: bool = False, +) -> list[str]: + problems: list[str] = [] + titles: dict[tuple[str, str], list[Path]] = defaultdict(list) + descriptions: dict[tuple[str, str], list[Path]] = defaultdict(list) + pages = sorted(path for path in docs_root.rglob("*.md") if "assets" not in path.relative_to(docs_root).parts) + + for path in pages: + metadata, errors = validate_page(path, docs_root, today) + relative = path.relative_to(docs_root).as_posix() + locale = locale_for_page(path) + problems.extend(f"{relative}: {error}" for error in errors) + if isinstance(metadata.get("title"), str): + titles[(locale, metadata["title"].strip().casefold())].append(path) + if isinstance(metadata.get("description"), str): + descriptions[(locale, metadata["description"].strip().casefold())].append(path) + + for label, values in (("title", titles), ("description", descriptions)): + for (locale, duplicate), paths in values.items(): + if duplicate and len(paths) > 1: + joined = ", ".join(path.relative_to(docs_root).as_posix() for path in paths) + problems.append(f"duplicate {locale} {label}: {joined}") + + if require_french_parity: + english = { + canonical_page_path(path, docs_root) + for path in pages + if locale_for_page(path) == "en" + } + french = { + canonical_page_path(path, docs_root) + for path in pages + if locale_for_page(path) == "fr" + } + for relative in sorted(english - french): + problems.append( + f"missing French translation: {relative.removesuffix('.md')}.fr.md" + ) + for relative in sorted(french - english): + problems.append(f"French translation has no English source: {relative}") + + if not pages: + problems.append("no Markdown pages found") + return problems + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--docs", type=Path, default=Path("docs")) + parser.add_argument("--today", type=dt.date.fromisoformat, default=dt.date.today()) + args = parser.parse_args() + + docs_root = args.docs.resolve() + problems = validate_tree(docs_root, args.today, require_french_parity=True) + if problems: + print(f"Content validation failed with {len(problems)} problem(s):", file=sys.stderr) + for problem in problems: + print(f"- {problem}", file=sys.stderr) + return 1 + + pages = [ + path for path in docs_root.rglob("*.md") + if "assets" not in path.relative_to(docs_root).parts + ] + english_count = sum(locale_for_page(path) == "en" for path in pages) + french_count = sum(locale_for_page(path) == "fr" for path in pages) + print( + f"Content validation passed for {len(pages)} pages " + f"({english_count} English, {french_count} French)." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate-regions.js b/scripts/validate-regions.cjs similarity index 99% rename from scripts/validate-regions.js rename to scripts/validate-regions.cjs index 0e9a1e0..efd6c55 100644 --- a/scripts/validate-regions.js +++ b/scripts/validate-regions.cjs @@ -814,7 +814,7 @@ function validate(data, meshMapper, partition, digitalPartition, qa, municipalOv "command generation must fall back to bounded region put lines" ); check(scriptText.includes("Provinces and territories may be mixed."), "wide-coverage UI does not support cross-province selection"); - check(scriptText.includes("Large or cross-border coverage"), "UI does not offer large and cross-border forwarding"); + check(/(?:Large or cross-border coverage|Add nearby or cross-border paths)/.test(scriptText), "UI does not offer large and cross-border forwarding"); check(scriptText.includes("Different repeaters can carry different paths to spread traffic."), "UI does not explain path distribution"); check(scriptText.includes("Neighbouring network paths"), "UI does not expose traffic-evidenced U.S. paths"); check(scriptText.includes("requestedExternalPaths"), "map does not restore neighbouring path selections"); diff --git a/site/404.html b/site/404.html deleted file mode 100644 index 4e97f51..0000000 --- a/site/404.html +++ /dev/null @@ -1,1346 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - -
    -
    -
    - - - -
    -
    -
    - - - -
    - -
    - -

    404 - Not found

    - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/analyzer/builds/mctomqtt/index.html b/site/analyzer/builds/mctomqtt/index.html deleted file mode 100644 index 79deb46..0000000 --- a/site/analyzer/builds/mctomqtt/index.html +++ /dev/null @@ -1,1789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - MCtoMQTT Script - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - - - - - -
    - -
    - - - - - - - - -

    MCtoMQTT Script (USB Serial Host)

    -

    Use meshcoretomqtt to bridge a USB-connected MeshCore node (repeater, room server, or any packet-log device) to MQTT brokers. This runs on a Linux or macOS host with the device connected over USB serial.

    -

    What It Does

    -

    The MCtoMQTT service reads packets from a MeshCore device over its serial port and publishes them to one or more MQTT brokers. A config drop-in file tells it where to send the data.

    -

    Prerequisites

    -
      -
    • A MeshCore device with packet logging enabled, connected via USB serial
    • -
    • Linux or macOS host
    • -
    • curl installed
    • -
    • Your 3-character IATA region code (e.g. YOW for Ottawa, YYZ for Toronto)
    • -
    -

    Quick Setup (One-Liner)

    -

    If meshcoretomqtt is already installed, run:

    -
    bash <(curl -fsSL https://live.meshcore.ca/scripts/add-canadaverse-meshcore-broker.sh) --device serial-host
    -
    -

    This creates a config drop-in at /etc/mctomqtt/config.d/20-meshcore-ca.toml pointing at the MeshCore.ca broker pair, then restarts the service.

    -

    Fresh Install

    -

    If meshcoretomqtt is not yet installed, add --install-mctomqtt and the script will run the upstream installer first, then apply the broker config:

    -
    bash <(curl -fsSL https://live.meshcore.ca/scripts/add-canadaverse-meshcore-broker.sh) --device serial-host --install-mctomqtt
    -
    -

    The upstream installer will walk you through serial port selection.

    -

    Specifying Your Region

    -

    Pass your IATA code via the --iata flag or the MESHCORE_CA_IATA environment variable:

    -
    bash <(curl -fsSL https://live.meshcore.ca/scripts/add-canadaverse-meshcore-broker.sh) --device serial-host --iata YOW
    -
    -

    Or:

    -
    MESHCORE_CA_IATA=YOW bash <(curl -fsSL https://live.meshcore.ca/scripts/add-canadaverse-meshcore-broker.sh) --device serial-host
    -
    -

    If omitted, the script will prompt interactively.

    -

    What the Script Creates

    -

    The drop-in config at /etc/mctomqtt/config.d/20-meshcore-ca.toml looks like this:

    -
    [general]
    -iata = "YOW"
    -
    -[[broker]]
    -name = "meshcore-ca-1"
    -enabled = true
    -server = "mqtt1.meshcore.ca"
    -port = 443
    -transport = "websockets"
    -keepalive = 60
    -qos = 0
    -retain = true
    -
    -[broker.tls]
    -enabled = true
    -verify = true
    -
    -[broker.auth]
    -method = "token"
    -audience = "mqtt1.meshcore.ca"
    -
    -[[broker]]
    -name = "meshcore-ca-2"
    -enabled = true
    -server = "mqtt2.meshcore.ca"
    -port = 443
    -transport = "websockets"
    -keepalive = 60
    -qos = 0
    -retain = true
    -
    -[broker.tls]
    -enabled = true
    -verify = true
    -
    -[broker.auth]
    -method = "token"
    -audience = "mqtt2.meshcore.ca"
    -
    -

    Companion Devices (BLE/Serial/TCP)

    -

    For companion radios (not packet-log serial hosts), use the companion path instead:

    -
    -
    -
    -
    bash <(curl -fsSL https://live.meshcore.ca/scripts/add-canadaverse-meshcore-broker.sh) --device companion
    -
    -

    Add --install-packetcapture for a fresh install. The upstream installer will walk you through BLE, serial, or TCP connection setup.

    -
    -
    -
    powershell -NoProfile -ExecutionPolicy Bypass -Command "iwr https://live.meshcore.ca/scripts/add-canadaverse-packetcapture-broker.ps1 -UseBasicParsing | iex"
    -
    -

    Config is stored under %USERPROFILE%\.meshcore-packet-capture\.env.local.

    -
    -
    -
    - - - - - - - - - - - - - - - - - - - - - - - -
    PathManagerConnectionConfig Location
    Serial HostmeshcoretomqttUSB serial/etc/mctomqtt/config.d/20-meshcore-ca.toml
    Companionmeshcore-packet-captureBLE, serial, or TCP~/.meshcore-packet-capture/.env.local
    -
    -

    Windows and Serial Hosts

    -

    There is no upstream meshcoretomqtt Windows installer. Keep packet-log serial hosts on Linux or macOS. The Windows PowerShell helper is for companion radios only.

    -
    -

    Additional Script Options

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    FlagDescription
    --iata CODE3-character region code
    --device TYPEserial-host or companion
    --install-mctomqttInstall meshcoretomqtt if not present
    --install-packetcaptureInstall meshcore-packet-capture if not present
    --no-restartPatch config without restarting the service
    --config-dir PATHOverride config dir (default: /etc/mctomqtt)
    --service NAMEOverride systemd service name (default: mctomqtt)
    -

    Verify

    -

    Once your service is running, head to Verify Observer Status to confirm it's reporting correctly.

    - - - - - - - - - - - - - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/analyzer/builds/meshcore-ha/index.html b/site/analyzer/builds/meshcore-ha/index.html deleted file mode 100644 index 2652f31..0000000 --- a/site/analyzer/builds/meshcore-ha/index.html +++ /dev/null @@ -1,1780 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - MeshCore-HA - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - - - - - -
    - -
    - - - - - - - - -

    MeshCore-HA (Home Assistant)

    -

    Add the MeshCore.ca broker pair to your Home Assistant MeshCore integration. This connects your HA instance to the Canadian mesh telemetry network.

    -

    Prerequisites

    -
      -
    • Home Assistant with the MeshCore integration installed
    • -
    • A MeshCore node connected to HA via USB, BLE, or TCP
    • -
    • Your 3-character IATA region code (e.g. YOW for Ottawa, YYZ for Toronto)
    • -
    -

    Setup

    -

    1. Open Broker Settings

    -

    Navigate to:

    -

    Settings > Devices & Services > MeshCore > Configure > Manage MQTT Brokers

    -

    2. Configure the Primary Broker

    -

    Enter the following settings:

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    FieldValue
    Servermqtt1.meshcore.ca
    Port443
    Transportwebsockets
    Use TLSEnabled
    TLS VerifyEnabled
    Username / PasswordLeave blank
    Use MeshCore Auth TokenEnabled
    Token Audiencemqtt1.meshcore.ca
    Auth Token TTLLeave default
    Payload Modepacket
    Status Topicmeshcore/{IATA}/{PUBLIC_KEY}/status
    Packets Topicmeshcore/{IATA}/{PUBLIC_KEY}/packets
    Client ID PrefixOptional
    Owner Public KeyOptional (TLS verified only)
    Owner EmailOptional (TLS verified only)
    -

    3. Configure the Backup Broker

    -

    Add a second broker with the same settings, changing only:

    - - - - - - - - - - - - - - - - - -
    FieldValue
    Servermqtt2.meshcore.ca
    Token Audiencemqtt2.meshcore.ca
    -

    All other fields remain the same as the primary broker.

    -

    4. Set Your Region

    -

    Make sure your IATA region code is set in the integration. The {IATA} placeholder in the topic templates will be replaced with your code (e.g. YOW).

    -

    Quick Reference

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    SettingPrimaryBackup
    Servermqtt1.meshcore.camqtt2.meshcore.ca
    Port443443
    Transportwebsocketswebsockets
    TLSEnabled + VerifiedEnabled + Verified
    AuthMeshCore JWT TokenMeshCore JWT Token
    Audiencemqtt1.meshcore.camqtt2.meshcore.ca
    Payload Modepacketpacket
    -

    Verify

    -

    After saving, head to Verify Observer Status to confirm it's reporting correctly.

    - - - - - - - - - - - - - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/analyzer/builds/mqtt-firmware/index.html b/site/analyzer/builds/mqtt-firmware/index.html deleted file mode 100644 index 7e28100..0000000 --- a/site/analyzer/builds/mqtt-firmware/index.html +++ /dev/null @@ -1,2644 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - MQTT Firmware - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - - - - - -
    - -
    - - - - - - - - -

    MQTT Firmware (Standalone Observer)

    -

    Flash standalone MQTT observer firmware with the MeshCore observer flasher. MeshCore Canada no longer hosts separate observer firmware binaries on this page.

    -
    -

    MeshCore Canada presets verified

    -

    The observer firmware currently offered by observer.gessaman.com is built from Adam Gessaman's mqtt-bridge-implementation-flex branch at commit c0c845f5. That branch includes the built-in presets meshcore-ca-1 and meshcore-ca-2, pointing to mqtt1.meshcore.ca and mqtt2.meshcore.ca.

    -
    -

    Flash The Observer

    -
      -
    1. Open observer.gessaman.com.
    2. -
    3. Pick your board under MQTT Observer Firmware.
    4. -
    5. Choose Repeater or Room Server.
    6. -
    7. For a new board or a board you are repurposing, enable Erase device and flash the merged image.
    8. -
    9. When flashing finishes, use Configure via USB for the repeater or room server setup screen, or use Console for CLI setup.
    10. -
    -
    -

    First flash can erase settings

    -

    First flashing observer firmware, especially on boards with a changed partition layout, can wipe stored settings and identity data. Back up an existing device private key before repurposing it.

    -
    -

    Required MeshCore Canada Settings

    -

    Use the repeater or room server setup screen where possible. If a setting is not exposed in the setup screen, use the console on the flasher page and paste the CLI commands below.

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    SettingValue
    Radio presetUSA/Canada (Recommended)
    Raw radio values910.525 MHz / 62.5 kHz / SF7 / CR5
    CLI radio commandset radio 910.525,62.5,7,5
    Path hash mode3-byte advert path hashes: set path.hash.mode 2
    MQTT slot 1meshcore-ca-1
    MQTT slot 2meshcore-ca-2
    IATAA real 3-letter IATA airport code near the observer
    WiFi2.4 GHz network credentials
    -
    -

    IATA codes

    -

    Use a real airport code such as YOW, YYZ, YUL, YVR, or YYC. Do not use placeholders such as XXX or HOME. Do not use CAN as shorthand for Canada; it is an airport code for Guangzhou. See the IATA region code list for Canadian quick choices.

    -
    -

    Command Builder

    -

    Use this builder to create a CLI block for the flasher Console. It runs only in your browser.

    -
    -
    - - - - - - -
    -
    - - -
    -
    -
    - - - - - -

    Manual CLI Reference

    -

    If you prefer to type commands manually, replace YOW, YourWiFiNetwork, and YourWiFiPassword with your own values:

    -
    set name YOW-Repeater-01
    -set radio 910.525,62.5,7,5
    -set path.hash.mode 2
    -set mqtt.iata YOW
    -set wifi.ssid YourWiFiNetwork
    -set wifi.pwd YourWiFiPassword
    -set wifi.powersave none
    -set mqtt1.preset meshcore-ca-1
    -set mqtt2.preset meshcore-ca-2
    -set mqtt3.preset none
    -set mqtt4.preset none
    -set mqtt5.preset none
    -set mqtt6.preset none
    -set mqtt.status on
    -set mqtt.packets on
    -set mqtt.raw off
    -set mqtt.rx on
    -set mqtt.tx advert
    -set bridge.enabled on
    -set repeat on
    -advert
    -reboot
    -
    -

    For an observe-only node that should not repeat mesh traffic, use:

    -
    set repeat off
    -
    -

    Verify

    -

    After the device reboots, reopen the flasher Console and run:

    -
    get wifi.status
    -get mqtt.iata
    -get mqtt1.preset
    -get mqtt2.preset
    -get mqtt.status
    -get path.hash.mode
    -
    -

    Expected broker presets:

    -
    get mqtt1.preset
    -> meshcore-ca-1
    -get mqtt2.preset
    -> meshcore-ca-2
    -
    -

    Once WiFi and MQTT are connected, use Check Your Observer to confirm packets are reaching MeshCore Canada.

    - -
    -
      -
    • -

      Observer Flasher

      -
      -

      Flash MQTT observer firmware and open the serial console.

      -

      observer.gessaman.com

      -
    • -
    • -

      IATA Region Codes

      -
      -

      Pick the real 3-letter airport code nearest to the observer.

      -

      IATA codes

      -
    • -
    • -

      Check Your Observer

      -
      -

      Confirm that the observer is online and reporting.

      -

      verify status

      -
    • -
    -
    - - - - - - - - - - - - - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/analyzer/builds/pymc/index.html b/site/analyzer/builds/pymc/index.html deleted file mode 100644 index 835bec0..0000000 --- a/site/analyzer/builds/pymc/index.html +++ /dev/null @@ -1,1720 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - PyMC - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - - - - - -
    - -
    - - - - - - - - -

    PyMC (Python MeshCore)

    -

    Add the MeshCore.ca broker pair to an existing pyMC repeater installation. PyMC connects to your MeshCore device and forwards traffic to MQTT brokers using a YAML config file.

    -

    Prerequisites

    -
      -
    • A working pyMC repeater installation
    • -
    • Your 3-character IATA region code (e.g. YOW for Ottawa, YYZ for Toronto)
    • -
    -

    Configuration

    -

    1. Set Your IATA Code

    -

    In /etc/pymc_repeater/config.yaml, set your region code under the MQTT section:

    -
    mqtt:
    -  iata_code: YOW
    -
    -

    2. Add the Broker Block

    -

    Paste the following under mqtt.brokers in your config file:

    -
    - name: Canadaverse
    -  enabled: true
    -  host: mqtt1.meshcore.ca
    -  port: 443
    -  transport: websockets
    -  format: letsmesh
    -  disallowed_packet_types: []
    -  retain_status: false
    -  tls:
    -    enabled: true
    -    insecure: false
    -  use_jwt_auth: true
    -  audience: mqtt1.meshcore.ca
    -- name: Canadaverse Backup
    -  enabled: true
    -  host: mqtt2.meshcore.ca
    -  port: 443
    -  transport: websockets
    -  format: letsmesh
    -  disallowed_packet_types: []
    -  retain_status: false
    -  tls:
    -    enabled: true
    -    insecure: false
    -  use_jwt_auth: true
    -  audience: mqtt2.meshcore.ca
    -
    -

    3. Optional Fields

    -

    You can also set these optional fields in the mqtt section:

    -
    mqtt:
    -  owner: "your-public-key"
    -  email: "you@example.com"
    -
    -

    4. Restart the Service

    -
    sudo systemctl restart pymc-repeater
    -
    -

    Quick Reference

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    SettingValue
    Config file/etc/pymc_repeater/config.yaml
    Primary brokermqtt1.meshcore.ca
    Backup brokermqtt2.meshcore.ca
    Port443
    Transportwebsockets
    TLSEnabled, verified
    AuthJWT token (use_jwt_auth: true)
    Formatletsmesh
    -

    Verify

    -

    After restarting, head to Verify Observer Status to confirm it's reporting correctly.

    - - - - - - - - - - - - - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/analyzer/intro/index.html b/site/analyzer/intro/index.html deleted file mode 100644 index 4566f85..0000000 --- a/site/analyzer/intro/index.html +++ /dev/null @@ -1,2004 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Overview - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - -
    -
    -
    - - - -
    -
    -
    - - - -
    - -
    - - - - - - - - -

    Analyzer & MQTT Packet Broker

    -

    MeshCore observers capture mesh traffic and publish it to MQTT brokers, feeding telemetry dashboards, maps, and packet inspectors. Pick one of the four observer paths below to start reporting to the MeshCore.ca network.

    -

    Choose Your Observer Path

    -
    -
      -
    • -

      MQTT Firmware

      -
      -

      Flash observer firmware directly onto a board. Connects to WiFi and reports to MQTT with no host computer needed.

      -

      Best for: Heltec V3, V4, and other standalone ESP32 devices.

      -

      MQTT Firmware Guide

      -
    • -
    • -

      MCtoMQTT (USB Host)

      -
      -

      Bridge a USB-connected MeshCore node to MQTT via a Linux or macOS host. Also supports companion radios over BLE, serial, or TCP.

      -

      Best for: Repeaters and room servers with a host machine.

      -

      MCtoMQTT Guide

      -
    • -
    • -

      PyMC

      -
      -

      Add the MeshCore.ca broker pair to an existing pyMC repeater installation via YAML config.

      -

      Best for: Python-based repeater setups.

      -

      PyMC Guide

      -
    • -
    • -

      Home Assistant

      -
      -

      Add MeshCore.ca brokers to the HA MeshCore integration for mesh telemetry on your dashboards.

      -

      Best for: Home Assistant users with a connected MeshCore node.

      -

      MeshCore-HA Guide

      -
    • -
    -
    -
    -
    -
      -
    • -

      Verify Your Observer

      -
      -

      Confirm your observer is online and reporting to the MeshCore.ca network.

      -

      Verify Observer Status

      -
    • -
    • -

      Broker Details

      -
      -

      All paths use the same redundant pair. JWT auth, TLS, no password needed.

      -

      mqtt1.meshcore.ca mqtt2.meshcore.ca (port 443, WSS)

      -
    • -
    -
    -
    -

    IATA Region Codes

    -

    Each observer identifies its region with a 3-character IATA code. Use the airport code nearest to your location. The following codes are supported on live.meshcore.ca today.

    -
    -Ontario - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YYZToronto (Pearson)
    YTZToronto (Billy Bishop)
    YOWOttawa
    YHMHamilton
    YKFKitchener / Waterloo
    YXULondon
    YOOOshawa
    YKZButtonville / Markham
    YAMSault Ste. Marie
    YQTThunder Bay
    YSBSudbury
    YTSTimmins
    YQGWindsor
    YYBNorth Bay
    YGKKingston
    YPQPeterborough
    YHDDryden
    YPLPickle Lake
    YNDGatineau (Ottawa area)
    -
    -
    -Quebec - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YULMontreal (Trudeau)
    YMXMontreal (Mirabel)
    YQBQuebec City
    YBGBagotville / Saguenay
    YVOVal-d'Or
    YHUMontreal (St-Hubert)
    YRJRoberval
    YGLLa Grande Riviere
    YSCSherbrooke
    YTQTasiujaq
    YUYRouyn-Noranda
    YZVSept-Iles
    YGPGaspe
    YRQTrois-Rivieres
    -
    -
    -British Columbia - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YVRVancouver
    YYJVictoria
    YXXAbbotsford / Fraser Valley
    YLWKelowna
    YXSPrince George
    YPRPrince Rupert
    YXTTerrace
    YQQComox / Courtenay
    YCDNanaimo
    YYDSmithers
    YDQDawson Creek
    YXJFort St. John
    YYFPenticton
    YCGCastlegar
    YKAKamloops
    YXCCranbrook
    YBCBaie-Comeau
    -
    -
    -Alberta - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YYCCalgary
    YEGEdmonton
    YMMFort McMurray
    YQUGrande Prairie
    YQLLethbridge
    YXHMedicine Hat
    -
    -
    -Saskatchewan - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YQRRegina
    YXESaskatoon
    YPAPrince Albert
    -
    -
    -Manitoba - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YWGWinnipeg
    YBRBrandon
    YTHThompson
    YDNDauphin
    YPGPortage la Prairie
    -
    -
    -New Brunswick - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YFCFredericton
    YSJSaint John
    YQMMoncton
    ZBFBathurst
    -
    -
    -Nova Scotia - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YHZHalifax
    YQYSydney
    YQIYarmouth
    -
    -
    -Prince Edward Island - - - - - - - - - - - - - -
    CodeRegion
    YYGCharlottetown
    -
    -
    -Newfoundland and Labrador - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YYTSt. John's
    YQXGander
    YDFDeer Lake
    YYRGoose Bay
    YWKWabush
    -
    -
    -Territories (YT / NT / NU) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CodeRegion
    YXYWhitehorse (Yukon)
    YZFYellowknife (NWT)
    YFBIqaluit (Nunavut)
    YEVInuvik (NWT)
    YHYHay River (NWT)
    -
    - - - - - - - - - - - - - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/analyzer/verify/index.html b/site/analyzer/verify/index.html deleted file mode 100644 index 048cbd0..0000000 --- a/site/analyzer/verify/index.html +++ /dev/null @@ -1,1704 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Verify Observer Status - MeshCore Canada - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - - - -
    - - -
    - -
    - - - - - - -
    -
    - - - -
    -
    -
    - - - - - -
    -
    -
    - - - - - - - -
    - -
    - - - - - - - - -

    Verify Observer Status

    -

    After setting up your observer using any of the four paths (MQTT Firmware, MCtoMQTT, PyMC, or Home Assistant), use the links below to confirm it's online and reporting.

    -

    Check Your Observer

    -
    -
      -
    • -

      CoreScope Observers

      -
      -

      See all connected observers and their current status.

      -

      View Observers

      -
    • -
    • -

      CoreScope Packets

      -
      -

      Watch live packet traffic flowing through observers in real time.

      -

      View Packets

      -
    • -
    • -

      MeshCore Map

      -
      -

      See observers and nodes plotted on the map.

      -

      View Map

      -
    • -
    -
    -

    Your observer should appear within a few minutes of coming online.

    -

    Troubleshooting

    -

    If your observer doesn't show up, work through these checks based on your setup path.

    -

    MQTT Firmware

    -

    Run these in the device's admin CLI:

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    CommandWhat to check
    get wifi.statusShould show connected to your 2.4 GHz network
    get mqtt.statusShould show an active broker connection
    get mqtt.iataShould return your 3-character IATA code
    get mqtt1.presetShould show custom (not none)
    get mqtt2.presetShould show custom (not none)
    get nameShould return the node name you set
    -
    -Full verify command block -
    get name
    -get mqtt.origin
    -get mqtt.iata
    -get wifi.status
    -get mqtt.packets
    -get bridge.enabled
    -get mqtt.rx
    -get mqtt.tx
    -get mqtt.status
    -get mqtt1.preset
    -get mqtt2.preset
    -get mqtt3.preset
    -
    -
    -

    MCtoMQTT / Companion (USB Host)

    -

    Check that the systemd service is running:

    -
    # Serial host
    -sudo systemctl status mctomqtt
    -
    -# Companion
    -sudo systemctl status meshcore-capture
    -
    -

    If the service is running but nothing appears, check the config drop-in:

    -
    # Serial host
    -cat /etc/mctomqtt/config.d/20-meshcore-ca.toml
    -
    -# Companion
    -cat ~/.meshcore-packet-capture/.env.local
    -
    -

    Confirm the broker hosts are mqtt1.meshcore.ca and mqtt2.meshcore.ca.

    -

    PyMC

    -
    sudo systemctl status pymc-repeater
    -
    -

    Check that your mqtt.iata_code is set and the broker block is present in /etc/pymc_repeater/config.yaml.

    -

    Home Assistant

    -

    Go to Settings > Devices & Services > MeshCore > Configure > Manage MQTT Brokers and confirm both brokers show as connected. Make sure your IATA code is set in the integration.

    -

    Still Not Working?

    -

    If everything looks correct but your observer still doesn't appear, double check that your device has internet access and can reach mqtt1.meshcore.ca on port 443. Firewalls or network restrictions on outbound WebSocket connections are the most common blocker.

    - - - - - - - - - - - - - -
    -
    - - - -
    - - - -
    - - - -
    -
    -
    -
    - - - - - - - - - - - - - \ No newline at end of file diff --git a/site/assets/MeshCore-Canada-Favicon.png b/site/assets/MeshCore-Canada-Favicon.png deleted file mode 100644 index f4bfec6..0000000 Binary files a/site/assets/MeshCore-Canada-Favicon.png and /dev/null differ diff --git a/site/assets/MeshCore-Canada-Logo.png b/site/assets/MeshCore-Canada-Logo.png deleted file mode 100644 index c70397e..0000000 Binary files a/site/assets/MeshCore-Canada-Logo.png and /dev/null differ diff --git a/site/assets/images/favicon.png b/site/assets/images/favicon.png deleted file mode 100644 index 1cf13b9..0000000 Binary files a/site/assets/images/favicon.png and /dev/null differ diff --git a/site/assets/javascripts/bundle.79ae519e.min.js b/site/assets/javascripts/bundle.79ae519e.min.js deleted file mode 100644 index 3df3e5e..0000000 --- a/site/assets/javascripts/bundle.79ae519e.min.js +++ /dev/null @@ -1,16 +0,0 @@ -"use strict";(()=>{var Zi=Object.create;var _r=Object.defineProperty;var ea=Object.getOwnPropertyDescriptor;var ta=Object.getOwnPropertyNames,Bt=Object.getOwnPropertySymbols,ra=Object.getPrototypeOf,Ar=Object.prototype.hasOwnProperty,bo=Object.prototype.propertyIsEnumerable;var ho=(e,t,r)=>t in e?_r(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,P=(e,t)=>{for(var r in t||(t={}))Ar.call(t,r)&&ho(e,r,t[r]);if(Bt)for(var r of Bt(t))bo.call(t,r)&&ho(e,r,t[r]);return e};var vo=(e,t)=>{var r={};for(var o in e)Ar.call(e,o)&&t.indexOf(o)<0&&(r[o]=e[o]);if(e!=null&&Bt)for(var o of Bt(e))t.indexOf(o)<0&&bo.call(e,o)&&(r[o]=e[o]);return r};var Cr=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var oa=(e,t,r,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of ta(t))!Ar.call(e,n)&&n!==r&&_r(e,n,{get:()=>t[n],enumerable:!(o=ea(t,n))||o.enumerable});return e};var $t=(e,t,r)=>(r=e!=null?Zi(ra(e)):{},oa(t||!e||!e.__esModule?_r(r,"default",{value:e,enumerable:!0}):r,e));var go=(e,t,r)=>new Promise((o,n)=>{var i=c=>{try{a(r.next(c))}catch(p){n(p)}},s=c=>{try{a(r.throw(c))}catch(p){n(p)}},a=c=>c.done?o(c.value):Promise.resolve(c.value).then(i,s);a((r=r.apply(e,t)).next())});var xo=Cr((kr,yo)=>{(function(e,t){typeof kr=="object"&&typeof yo!="undefined"?t():typeof define=="function"&&define.amd?define(t):t()})(kr,(function(){"use strict";function e(r){var o=!0,n=!1,i=null,s={text:!0,search:!0,url:!0,tel:!0,email:!0,password:!0,number:!0,date:!0,month:!0,week:!0,time:!0,datetime:!0,"datetime-local":!0};function a(k){return!!(k&&k!==document&&k.nodeName!=="HTML"&&k.nodeName!=="BODY"&&"classList"in k&&"contains"in k.classList)}function c(k){var ut=k.type,je=k.tagName;return!!(je==="INPUT"&&s[ut]&&!k.readOnly||je==="TEXTAREA"&&!k.readOnly||k.isContentEditable)}function p(k){k.classList.contains("focus-visible")||(k.classList.add("focus-visible"),k.setAttribute("data-focus-visible-added",""))}function l(k){k.hasAttribute("data-focus-visible-added")&&(k.classList.remove("focus-visible"),k.removeAttribute("data-focus-visible-added"))}function f(k){k.metaKey||k.altKey||k.ctrlKey||(a(r.activeElement)&&p(r.activeElement),o=!0)}function u(k){o=!1}function d(k){a(k.target)&&(o||c(k.target))&&p(k.target)}function v(k){a(k.target)&&(k.target.classList.contains("focus-visible")||k.target.hasAttribute("data-focus-visible-added"))&&(n=!0,window.clearTimeout(i),i=window.setTimeout(function(){n=!1},100),l(k.target))}function S(k){document.visibilityState==="hidden"&&(n&&(o=!0),X())}function X(){document.addEventListener("mousemove",ee),document.addEventListener("mousedown",ee),document.addEventListener("mouseup",ee),document.addEventListener("pointermove",ee),document.addEventListener("pointerdown",ee),document.addEventListener("pointerup",ee),document.addEventListener("touchmove",ee),document.addEventListener("touchstart",ee),document.addEventListener("touchend",ee)}function re(){document.removeEventListener("mousemove",ee),document.removeEventListener("mousedown",ee),document.removeEventListener("mouseup",ee),document.removeEventListener("pointermove",ee),document.removeEventListener("pointerdown",ee),document.removeEventListener("pointerup",ee),document.removeEventListener("touchmove",ee),document.removeEventListener("touchstart",ee),document.removeEventListener("touchend",ee)}function ee(k){k.target.nodeName&&k.target.nodeName.toLowerCase()==="html"||(o=!1,re())}document.addEventListener("keydown",f,!0),document.addEventListener("mousedown",u,!0),document.addEventListener("pointerdown",u,!0),document.addEventListener("touchstart",u,!0),document.addEventListener("visibilitychange",S,!0),X(),r.addEventListener("focus",d,!0),r.addEventListener("blur",v,!0),r.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&r.host?r.host.setAttribute("data-js-focus-visible",""):r.nodeType===Node.DOCUMENT_NODE&&(document.documentElement.classList.add("js-focus-visible"),document.documentElement.setAttribute("data-js-focus-visible",""))}if(typeof window!="undefined"&&typeof document!="undefined"){window.applyFocusVisiblePolyfill=e;var t;try{t=new CustomEvent("focus-visible-polyfill-ready")}catch(r){t=document.createEvent("CustomEvent"),t.initCustomEvent("focus-visible-polyfill-ready",!1,!1,{})}window.dispatchEvent(t)}typeof document!="undefined"&&e(document)}))});var ro=Cr((jy,Rn)=>{"use strict";/*! - * escape-html - * Copyright(c) 2012-2013 TJ Holowaychuk - * Copyright(c) 2015 Andreas Lubbe - * Copyright(c) 2015 Tiancheng "Timothy" Gu - * MIT Licensed - */var qa=/["'&<>]/;Rn.exports=Ka;function Ka(e){var t=""+e,r=qa.exec(t);if(!r)return t;var o,n="",i=0,s=0;for(i=r.index;i{/*! - * clipboard.js v2.0.11 - * https://clipboardjs.com/ - * - * Licensed MIT © Zeno Rocha - */(function(t,r){typeof Nt=="object"&&typeof io=="object"?io.exports=r():typeof define=="function"&&define.amd?define([],r):typeof Nt=="object"?Nt.ClipboardJS=r():t.ClipboardJS=r()})(Nt,function(){return(function(){var e={686:(function(o,n,i){"use strict";i.d(n,{default:function(){return Xi}});var s=i(279),a=i.n(s),c=i(370),p=i.n(c),l=i(817),f=i.n(l);function u(q){try{return document.execCommand(q)}catch(C){return!1}}var d=function(C){var _=f()(C);return u("cut"),_},v=d;function S(q){var C=document.documentElement.getAttribute("dir")==="rtl",_=document.createElement("textarea");_.style.fontSize="12pt",_.style.border="0",_.style.padding="0",_.style.margin="0",_.style.position="absolute",_.style[C?"right":"left"]="-9999px";var D=window.pageYOffset||document.documentElement.scrollTop;return _.style.top="".concat(D,"px"),_.setAttribute("readonly",""),_.value=q,_}var X=function(C,_){var D=S(C);_.container.appendChild(D);var N=f()(D);return u("copy"),D.remove(),N},re=function(C){var _=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body},D="";return typeof C=="string"?D=X(C,_):C instanceof HTMLInputElement&&!["text","search","url","tel","password"].includes(C==null?void 0:C.type)?D=X(C.value,_):(D=f()(C),u("copy")),D},ee=re;function k(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?k=function(_){return typeof _}:k=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},k(q)}var ut=function(){var C=arguments.length>0&&arguments[0]!==void 0?arguments[0]:{},_=C.action,D=_===void 0?"copy":_,N=C.container,G=C.target,We=C.text;if(D!=="copy"&&D!=="cut")throw new Error('Invalid "action" value, use either "copy" or "cut"');if(G!==void 0)if(G&&k(G)==="object"&&G.nodeType===1){if(D==="copy"&&G.hasAttribute("disabled"))throw new Error('Invalid "target" attribute. Please use "readonly" instead of "disabled" attribute');if(D==="cut"&&(G.hasAttribute("readonly")||G.hasAttribute("disabled")))throw new Error(`Invalid "target" attribute. You can't cut text from elements with "readonly" or "disabled" attributes`)}else throw new Error('Invalid "target" value, use a valid Element');if(We)return ee(We,{container:N});if(G)return D==="cut"?v(G):ee(G,{container:N})},je=ut;function R(q){"@babel/helpers - typeof";return typeof Symbol=="function"&&typeof Symbol.iterator=="symbol"?R=function(_){return typeof _}:R=function(_){return _&&typeof Symbol=="function"&&_.constructor===Symbol&&_!==Symbol.prototype?"symbol":typeof _},R(q)}function se(q,C){if(!(q instanceof C))throw new TypeError("Cannot call a class as a function")}function ce(q,C){for(var _=0;_0&&arguments[0]!==void 0?arguments[0]:{};this.action=typeof N.action=="function"?N.action:this.defaultAction,this.target=typeof N.target=="function"?N.target:this.defaultTarget,this.text=typeof N.text=="function"?N.text:this.defaultText,this.container=R(N.container)==="object"?N.container:document.body}},{key:"listenClick",value:function(N){var G=this;this.listener=p()(N,"click",function(We){return G.onClick(We)})}},{key:"onClick",value:function(N){var G=N.delegateTarget||N.currentTarget,We=this.action(G)||"copy",Yt=je({action:We,container:this.container,target:this.target(G),text:this.text(G)});this.emit(Yt?"success":"error",{action:We,text:Yt,trigger:G,clearSelection:function(){G&&G.focus(),window.getSelection().removeAllRanges()}})}},{key:"defaultAction",value:function(N){return Mr("action",N)}},{key:"defaultTarget",value:function(N){var G=Mr("target",N);if(G)return document.querySelector(G)}},{key:"defaultText",value:function(N){return Mr("text",N)}},{key:"destroy",value:function(){this.listener.destroy()}}],[{key:"copy",value:function(N){var G=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{container:document.body};return ee(N,G)}},{key:"cut",value:function(N){return v(N)}},{key:"isSupported",value:function(){var N=arguments.length>0&&arguments[0]!==void 0?arguments[0]:["copy","cut"],G=typeof N=="string"?[N]:N,We=!!document.queryCommandSupported;return G.forEach(function(Yt){We=We&&!!document.queryCommandSupported(Yt)}),We}}]),_})(a()),Xi=Ji}),828:(function(o){var n=9;if(typeof Element!="undefined"&&!Element.prototype.matches){var i=Element.prototype;i.matches=i.matchesSelector||i.mozMatchesSelector||i.msMatchesSelector||i.oMatchesSelector||i.webkitMatchesSelector}function s(a,c){for(;a&&a.nodeType!==n;){if(typeof a.matches=="function"&&a.matches(c))return a;a=a.parentNode}}o.exports=s}),438:(function(o,n,i){var s=i(828);function a(l,f,u,d,v){var S=p.apply(this,arguments);return l.addEventListener(u,S,v),{destroy:function(){l.removeEventListener(u,S,v)}}}function c(l,f,u,d,v){return typeof l.addEventListener=="function"?a.apply(null,arguments):typeof u=="function"?a.bind(null,document).apply(null,arguments):(typeof l=="string"&&(l=document.querySelectorAll(l)),Array.prototype.map.call(l,function(S){return a(S,f,u,d,v)}))}function p(l,f,u,d){return function(v){v.delegateTarget=s(v.target,f),v.delegateTarget&&d.call(l,v)}}o.exports=c}),879:(function(o,n){n.node=function(i){return i!==void 0&&i instanceof HTMLElement&&i.nodeType===1},n.nodeList=function(i){var s=Object.prototype.toString.call(i);return i!==void 0&&(s==="[object NodeList]"||s==="[object HTMLCollection]")&&"length"in i&&(i.length===0||n.node(i[0]))},n.string=function(i){return typeof i=="string"||i instanceof String},n.fn=function(i){var s=Object.prototype.toString.call(i);return s==="[object Function]"}}),370:(function(o,n,i){var s=i(879),a=i(438);function c(u,d,v){if(!u&&!d&&!v)throw new Error("Missing required arguments");if(!s.string(d))throw new TypeError("Second argument must be a String");if(!s.fn(v))throw new TypeError("Third argument must be a Function");if(s.node(u))return p(u,d,v);if(s.nodeList(u))return l(u,d,v);if(s.string(u))return f(u,d,v);throw new TypeError("First argument must be a String, HTMLElement, HTMLCollection, or NodeList")}function p(u,d,v){return u.addEventListener(d,v),{destroy:function(){u.removeEventListener(d,v)}}}function l(u,d,v){return Array.prototype.forEach.call(u,function(S){S.addEventListener(d,v)}),{destroy:function(){Array.prototype.forEach.call(u,function(S){S.removeEventListener(d,v)})}}}function f(u,d,v){return a(document.body,u,d,v)}o.exports=c}),817:(function(o){function n(i){var s;if(i.nodeName==="SELECT")i.focus(),s=i.value;else if(i.nodeName==="INPUT"||i.nodeName==="TEXTAREA"){var a=i.hasAttribute("readonly");a||i.setAttribute("readonly",""),i.select(),i.setSelectionRange(0,i.value.length),a||i.removeAttribute("readonly"),s=i.value}else{i.hasAttribute("contenteditable")&&i.focus();var c=window.getSelection(),p=document.createRange();p.selectNodeContents(i),c.removeAllRanges(),c.addRange(p),s=c.toString()}return s}o.exports=n}),279:(function(o){function n(){}n.prototype={on:function(i,s,a){var c=this.e||(this.e={});return(c[i]||(c[i]=[])).push({fn:s,ctx:a}),this},once:function(i,s,a){var c=this;function p(){c.off(i,p),s.apply(a,arguments)}return p._=s,this.on(i,p,a)},emit:function(i){var s=[].slice.call(arguments,1),a=((this.e||(this.e={}))[i]||[]).slice(),c=0,p=a.length;for(c;c0&&i[i.length-1])&&(p[0]===6||p[0]===2)){r=0;continue}if(p[0]===3&&(!i||p[1]>i[0]&&p[1]=e.length&&(e=void 0),{value:e&&e[o++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function K(e,t){var r=typeof Symbol=="function"&&e[Symbol.iterator];if(!r)return e;var o=r.call(e),n,i=[],s;try{for(;(t===void 0||t-- >0)&&!(n=o.next()).done;)i.push(n.value)}catch(a){s={error:a}}finally{try{n&&!n.done&&(r=o.return)&&r.call(o)}finally{if(s)throw s.error}}return i}function B(e,t,r){if(r||arguments.length===2)for(var o=0,n=t.length,i;o1||c(d,S)})},v&&(n[d]=v(n[d])))}function c(d,v){try{p(o[d](v))}catch(S){u(i[0][3],S)}}function p(d){d.value instanceof dt?Promise.resolve(d.value.v).then(l,f):u(i[0][2],d)}function l(d){c("next",d)}function f(d){c("throw",d)}function u(d,v){d(v),i.shift(),i.length&&c(i[0][0],i[0][1])}}function To(e){if(!Symbol.asyncIterator)throw new TypeError("Symbol.asyncIterator is not defined.");var t=e[Symbol.asyncIterator],r;return t?t.call(e):(e=typeof Oe=="function"?Oe(e):e[Symbol.iterator](),r={},o("next"),o("throw"),o("return"),r[Symbol.asyncIterator]=function(){return this},r);function o(i){r[i]=e[i]&&function(s){return new Promise(function(a,c){s=e[i](s),n(a,c,s.done,s.value)})}}function n(i,s,a,c){Promise.resolve(c).then(function(p){i({value:p,done:a})},s)}}function I(e){return typeof e=="function"}function yt(e){var t=function(o){Error.call(o),o.stack=new Error().stack},r=e(t);return r.prototype=Object.create(Error.prototype),r.prototype.constructor=r,r}var Jt=yt(function(e){return function(r){e(this),this.message=r?r.length+` errors occurred during unsubscription: -`+r.map(function(o,n){return n+1+") "+o.toString()}).join(` - `):"",this.name="UnsubscriptionError",this.errors=r}});function Ze(e,t){if(e){var r=e.indexOf(t);0<=r&&e.splice(r,1)}}var qe=(function(){function e(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}return e.prototype.unsubscribe=function(){var t,r,o,n,i;if(!this.closed){this.closed=!0;var s=this._parentage;if(s)if(this._parentage=null,Array.isArray(s))try{for(var a=Oe(s),c=a.next();!c.done;c=a.next()){var p=c.value;p.remove(this)}}catch(S){t={error:S}}finally{try{c&&!c.done&&(r=a.return)&&r.call(a)}finally{if(t)throw t.error}}else s.remove(this);var l=this.initialTeardown;if(I(l))try{l()}catch(S){i=S instanceof Jt?S.errors:[S]}var f=this._finalizers;if(f){this._finalizers=null;try{for(var u=Oe(f),d=u.next();!d.done;d=u.next()){var v=d.value;try{So(v)}catch(S){i=i!=null?i:[],S instanceof Jt?i=B(B([],K(i)),K(S.errors)):i.push(S)}}}catch(S){o={error:S}}finally{try{d&&!d.done&&(n=u.return)&&n.call(u)}finally{if(o)throw o.error}}}if(i)throw new Jt(i)}},e.prototype.add=function(t){var r;if(t&&t!==this)if(this.closed)So(t);else{if(t instanceof e){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(r=this._finalizers)!==null&&r!==void 0?r:[]).push(t)}},e.prototype._hasParent=function(t){var r=this._parentage;return r===t||Array.isArray(r)&&r.includes(t)},e.prototype._addParent=function(t){var r=this._parentage;this._parentage=Array.isArray(r)?(r.push(t),r):r?[r,t]:t},e.prototype._removeParent=function(t){var r=this._parentage;r===t?this._parentage=null:Array.isArray(r)&&Ze(r,t)},e.prototype.remove=function(t){var r=this._finalizers;r&&Ze(r,t),t instanceof e&&t._removeParent(this)},e.EMPTY=(function(){var t=new e;return t.closed=!0,t})(),e})();var $r=qe.EMPTY;function Xt(e){return e instanceof qe||e&&"closed"in e&&I(e.remove)&&I(e.add)&&I(e.unsubscribe)}function So(e){I(e)?e():e.unsubscribe()}var De={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1};var xt={setTimeout:function(e,t){for(var r=[],o=2;o0},enumerable:!1,configurable:!0}),t.prototype._trySubscribe=function(r){return this._throwIfClosed(),e.prototype._trySubscribe.call(this,r)},t.prototype._subscribe=function(r){return this._throwIfClosed(),this._checkFinalizedStatuses(r),this._innerSubscribe(r)},t.prototype._innerSubscribe=function(r){var o=this,n=this,i=n.hasError,s=n.isStopped,a=n.observers;return i||s?$r:(this.currentObservers=null,a.push(r),new qe(function(){o.currentObservers=null,Ze(a,r)}))},t.prototype._checkFinalizedStatuses=function(r){var o=this,n=o.hasError,i=o.thrownError,s=o.isStopped;n?r.error(i):s&&r.complete()},t.prototype.asObservable=function(){var r=new F;return r.source=this,r},t.create=function(r,o){return new Ho(r,o)},t})(F);var Ho=(function(e){ie(t,e);function t(r,o){var n=e.call(this)||this;return n.destination=r,n.source=o,n}return t.prototype.next=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.next)===null||n===void 0||n.call(o,r)},t.prototype.error=function(r){var o,n;(n=(o=this.destination)===null||o===void 0?void 0:o.error)===null||n===void 0||n.call(o,r)},t.prototype.complete=function(){var r,o;(o=(r=this.destination)===null||r===void 0?void 0:r.complete)===null||o===void 0||o.call(r)},t.prototype._subscribe=function(r){var o,n;return(n=(o=this.source)===null||o===void 0?void 0:o.subscribe(r))!==null&&n!==void 0?n:$r},t})(T);var jr=(function(e){ie(t,e);function t(r){var o=e.call(this)||this;return o._value=r,o}return Object.defineProperty(t.prototype,"value",{get:function(){return this.getValue()},enumerable:!1,configurable:!0}),t.prototype._subscribe=function(r){var o=e.prototype._subscribe.call(this,r);return!o.closed&&r.next(this._value),o},t.prototype.getValue=function(){var r=this,o=r.hasError,n=r.thrownError,i=r._value;if(o)throw n;return this._throwIfClosed(),i},t.prototype.next=function(r){e.prototype.next.call(this,this._value=r)},t})(T);var Rt={now:function(){return(Rt.delegate||Date).now()},delegate:void 0};var It=(function(e){ie(t,e);function t(r,o,n){r===void 0&&(r=1/0),o===void 0&&(o=1/0),n===void 0&&(n=Rt);var i=e.call(this)||this;return i._bufferSize=r,i._windowTime=o,i._timestampProvider=n,i._buffer=[],i._infiniteTimeWindow=!0,i._infiniteTimeWindow=o===1/0,i._bufferSize=Math.max(1,r),i._windowTime=Math.max(1,o),i}return t.prototype.next=function(r){var o=this,n=o.isStopped,i=o._buffer,s=o._infiniteTimeWindow,a=o._timestampProvider,c=o._windowTime;n||(i.push(r),!s&&i.push(a.now()+c)),this._trimBuffer(),e.prototype.next.call(this,r)},t.prototype._subscribe=function(r){this._throwIfClosed(),this._trimBuffer();for(var o=this._innerSubscribe(r),n=this,i=n._infiniteTimeWindow,s=n._buffer,a=s.slice(),c=0;c0?e.prototype.schedule.call(this,r,o):(this.delay=o,this.state=r,this.scheduler.flush(this),this)},t.prototype.execute=function(r,o){return o>0||this.closed?e.prototype.execute.call(this,r,o):this._execute(r,o)},t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!=null&&n>0||n==null&&this.delay>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.flush(this),0)},t})(St);var Ro=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t})(Ot);var Dr=new Ro(Po);var Io=(function(e){ie(t,e);function t(r,o){var n=e.call(this,r,o)||this;return n.scheduler=r,n.work=o,n}return t.prototype.requestAsyncId=function(r,o,n){return n===void 0&&(n=0),n!==null&&n>0?e.prototype.requestAsyncId.call(this,r,o,n):(r.actions.push(this),r._scheduled||(r._scheduled=Tt.requestAnimationFrame(function(){return r.flush(void 0)})))},t.prototype.recycleAsyncId=function(r,o,n){var i;if(n===void 0&&(n=0),n!=null?n>0:this.delay>0)return e.prototype.recycleAsyncId.call(this,r,o,n);var s=r.actions;o!=null&&o===r._scheduled&&((i=s[s.length-1])===null||i===void 0?void 0:i.id)!==o&&(Tt.cancelAnimationFrame(o),r._scheduled=void 0)},t})(St);var Fo=(function(e){ie(t,e);function t(){return e!==null&&e.apply(this,arguments)||this}return t.prototype.flush=function(r){this._active=!0;var o;r?o=r.id:(o=this._scheduled,this._scheduled=void 0);var n=this.actions,i;r=r||n.shift();do if(i=r.execute(r.state,r.delay))break;while((r=n[0])&&r.id===o&&n.shift());if(this._active=!1,i){for(;(r=n[0])&&r.id===o&&n.shift();)r.unsubscribe();throw i}},t})(Ot);var ye=new Fo(Io);var y=new F(function(e){return e.complete()});function tr(e){return e&&I(e.schedule)}function Vr(e){return e[e.length-1]}function pt(e){return I(Vr(e))?e.pop():void 0}function Fe(e){return tr(Vr(e))?e.pop():void 0}function rr(e,t){return typeof Vr(e)=="number"?e.pop():t}var Lt=(function(e){return e&&typeof e.length=="number"&&typeof e!="function"});function or(e){return I(e==null?void 0:e.then)}function nr(e){return I(e[wt])}function ir(e){return Symbol.asyncIterator&&I(e==null?void 0:e[Symbol.asyncIterator])}function ar(e){return new TypeError("You provided "+(e!==null&&typeof e=="object"?"an invalid object":"'"+e+"'")+" where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.")}function fa(){return typeof Symbol!="function"||!Symbol.iterator?"@@iterator":Symbol.iterator}var sr=fa();function cr(e){return I(e==null?void 0:e[sr])}function pr(e){return wo(this,arguments,function(){var r,o,n,i;return Gt(this,function(s){switch(s.label){case 0:r=e.getReader(),s.label=1;case 1:s.trys.push([1,,9,10]),s.label=2;case 2:return[4,dt(r.read())];case 3:return o=s.sent(),n=o.value,i=o.done,i?[4,dt(void 0)]:[3,5];case 4:return[2,s.sent()];case 5:return[4,dt(n)];case 6:return[4,s.sent()];case 7:return s.sent(),[3,2];case 8:return[3,10];case 9:return r.releaseLock(),[7];case 10:return[2]}})})}function lr(e){return I(e==null?void 0:e.getReader)}function U(e){if(e instanceof F)return e;if(e!=null){if(nr(e))return ua(e);if(Lt(e))return da(e);if(or(e))return ha(e);if(ir(e))return jo(e);if(cr(e))return ba(e);if(lr(e))return va(e)}throw ar(e)}function ua(e){return new F(function(t){var r=e[wt]();if(I(r.subscribe))return r.subscribe(t);throw new TypeError("Provided object does not correctly implement Symbol.observable")})}function da(e){return new F(function(t){for(var r=0;r=2;return function(o){return o.pipe(e?g(function(n,i){return e(n,i,o)}):be,Ee(1),r?Qe(t):tn(function(){return new fr}))}}function Yr(e){return e<=0?function(){return y}:E(function(t,r){var o=[];t.subscribe(w(r,function(n){o.push(n),e=2,!0))}function le(e){e===void 0&&(e={});var t=e.connector,r=t===void 0?function(){return new T}:t,o=e.resetOnError,n=o===void 0?!0:o,i=e.resetOnComplete,s=i===void 0?!0:i,a=e.resetOnRefCountZero,c=a===void 0?!0:a;return function(p){var l,f,u,d=0,v=!1,S=!1,X=function(){f==null||f.unsubscribe(),f=void 0},re=function(){X(),l=u=void 0,v=S=!1},ee=function(){var k=l;re(),k==null||k.unsubscribe()};return E(function(k,ut){d++,!S&&!v&&X();var je=u=u!=null?u:r();ut.add(function(){d--,d===0&&!S&&!v&&(f=Br(ee,c))}),je.subscribe(ut),!l&&d>0&&(l=new bt({next:function(R){return je.next(R)},error:function(R){S=!0,X(),f=Br(re,n,R),je.error(R)},complete:function(){v=!0,X(),f=Br(re,s),je.complete()}}),U(k).subscribe(l))})(p)}}function Br(e,t){for(var r=[],o=2;oe.next(document)),e}function M(e,t=document){return Array.from(t.querySelectorAll(e))}function j(e,t=document){let r=ue(e,t);if(typeof r=="undefined")throw new ReferenceError(`Missing element: expected "${e}" to be present`);return r}function ue(e,t=document){return t.querySelector(e)||void 0}function Ne(){var e,t,r,o;return(o=(r=(t=(e=document.activeElement)==null?void 0:e.shadowRoot)==null?void 0:t.activeElement)!=null?r:document.activeElement)!=null?o:void 0}var Ra=L(h(document.body,"focusin"),h(document.body,"focusout")).pipe(Ae(1),Q(void 0),m(()=>Ne()||document.body),Z(1));function Ye(e){return Ra.pipe(m(t=>e.contains(t)),Y())}function it(e,t){return H(()=>L(h(e,"mouseenter").pipe(m(()=>!0)),h(e,"mouseleave").pipe(m(()=>!1))).pipe(t?jt(r=>He(+!r*t)):be,Q(e.matches(":hover"))))}function sn(e,t){if(typeof t=="string"||typeof t=="number")e.innerHTML+=t.toString();else if(t instanceof Node)e.appendChild(t);else if(Array.isArray(t))for(let r of t)sn(e,r)}function x(e,t,...r){let o=document.createElement(e);if(t)for(let n of Object.keys(t))typeof t[n]!="undefined"&&(typeof t[n]!="boolean"?o.setAttribute(n,t[n]):o.setAttribute(n,""));for(let n of r)sn(o,n);return o}function br(e){if(e>999){let t=+((e-950)%1e3>99);return`${((e+1e-6)/1e3).toFixed(t)}k`}else return e.toString()}function _t(e){let t=x("script",{src:e});return H(()=>(document.head.appendChild(t),L(h(t,"load"),h(t,"error").pipe(b(()=>Nr(()=>new ReferenceError(`Invalid script: ${e}`))))).pipe(m(()=>{}),A(()=>document.head.removeChild(t)),Ee(1))))}var cn=new T,Ia=H(()=>typeof ResizeObserver=="undefined"?_t("https://unpkg.com/resize-observer-polyfill"):$(void 0)).pipe(m(()=>new ResizeObserver(e=>e.forEach(t=>cn.next(t)))),b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function de(e){return{width:e.offsetWidth,height:e.offsetHeight}}function Le(e){let t=e;for(;t.clientWidth===0&&t.parentElement;)t=t.parentElement;return Ia.pipe(O(r=>r.observe(t)),b(r=>cn.pipe(g(o=>o.target===t),A(()=>r.unobserve(t)))),m(()=>de(e)),Q(de(e)))}function At(e){return{width:e.scrollWidth,height:e.scrollHeight}}function vr(e){let t=e.parentElement;for(;t&&(e.scrollWidth<=t.scrollWidth&&e.scrollHeight<=t.scrollHeight);)t=(e=t).parentElement;return t?e:void 0}function pn(e){let t=[],r=e.parentElement;for(;r;)(e.clientWidth>r.clientWidth||e.clientHeight>r.clientHeight)&&t.push(r),r=(e=r).parentElement;return t.length===0&&t.push(document.documentElement),t}function Be(e){return{x:e.offsetLeft,y:e.offsetTop}}function ln(e){let t=e.getBoundingClientRect();return{x:t.x+window.scrollX,y:t.y+window.scrollY}}function mn(e){return L(h(window,"load"),h(window,"resize")).pipe($e(0,ye),m(()=>Be(e)),Q(Be(e)))}function gr(e){return{x:e.scrollLeft,y:e.scrollTop}}function Ge(e){return L(h(e,"scroll"),h(window,"scroll"),h(window,"resize")).pipe($e(0,ye),m(()=>gr(e)),Q(gr(e)))}var fn=new T,Fa=H(()=>$(new IntersectionObserver(e=>{for(let t of e)fn.next(t)},{threshold:0}))).pipe(b(e=>L(tt,$(e)).pipe(A(()=>e.disconnect()))),Z(1));function mt(e){return Fa.pipe(O(t=>t.observe(e)),b(t=>fn.pipe(g(({target:r})=>r===e),A(()=>t.unobserve(e)),m(({isIntersecting:r})=>r))))}function un(e,t=16){return Ge(e).pipe(m(({y:r})=>{let o=de(e),n=At(e);return r>=n.height-o.height-t}),Y())}var yr={drawer:j("[data-md-toggle=drawer]"),search:j("[data-md-toggle=search]")};function dn(e){return yr[e].checked}function at(e,t){yr[e].checked!==t&&yr[e].click()}function Je(e){let t=yr[e];return h(t,"change").pipe(m(()=>t.checked),Q(t.checked))}function ja(e,t){switch(e.constructor){case HTMLInputElement:return e.type==="radio"?/^Arrow/.test(t):!0;case HTMLSelectElement:case HTMLTextAreaElement:return!0;default:return e.isContentEditable}}function Ua(){return L(h(window,"compositionstart").pipe(m(()=>!0)),h(window,"compositionend").pipe(m(()=>!1))).pipe(Q(!1))}function hn(){let e=h(window,"keydown").pipe(g(t=>!(t.metaKey||t.ctrlKey)),m(t=>({mode:dn("search")?"search":"global",type:t.key,claim(){t.preventDefault(),t.stopPropagation()}})),g(({mode:t,type:r})=>{if(t==="global"){let o=Ne();if(typeof o!="undefined")return!ja(o,r)}return!0}),le());return Ua().pipe(b(t=>t?y:e))}function we(){return new URL(location.href)}function st(e,t=!1){if(V("navigation.instant")&&!t){let r=x("a",{href:e.href});document.body.appendChild(r),r.click(),r.remove()}else location.href=e.href}function bn(){return new T}function vn(){return location.hash.slice(1)}function gn(e){let t=x("a",{href:e});t.addEventListener("click",r=>r.stopPropagation()),t.click()}function Zr(e){return L(h(window,"hashchange"),e).pipe(m(vn),Q(vn()),g(t=>t.length>0),Z(1))}function yn(e){return Zr(e).pipe(m(t=>ue(`[id="${t}"]`)),g(t=>typeof t!="undefined"))}function Wt(e){let t=matchMedia(e);return ur(r=>t.addListener(()=>r(t.matches))).pipe(Q(t.matches))}function xn(){let e=matchMedia("print");return L(h(window,"beforeprint").pipe(m(()=>!0)),h(window,"afterprint").pipe(m(()=>!1))).pipe(Q(e.matches))}function eo(e,t){return e.pipe(b(r=>r?t():y))}function to(e,t){return new F(r=>{let o=new XMLHttpRequest;return o.open("GET",`${e}`),o.responseType="blob",o.addEventListener("load",()=>{o.status>=200&&o.status<300?(r.next(o.response),r.complete()):r.error(new Error(o.statusText))}),o.addEventListener("error",()=>{r.error(new Error("Network error"))}),o.addEventListener("abort",()=>{r.complete()}),typeof(t==null?void 0:t.progress$)!="undefined"&&(o.addEventListener("progress",n=>{var i;if(n.lengthComputable)t.progress$.next(n.loaded/n.total*100);else{let s=(i=o.getResponseHeader("Content-Length"))!=null?i:0;t.progress$.next(n.loaded/+s*100)}}),t.progress$.next(5)),o.send(),()=>o.abort()})}function ze(e,t){return to(e,t).pipe(b(r=>r.text()),m(r=>JSON.parse(r)),Z(1))}function xr(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/html")),Z(1))}function En(e,t){let r=new DOMParser;return to(e,t).pipe(b(o=>o.text()),m(o=>r.parseFromString(o,"text/xml")),Z(1))}function wn(){return{x:Math.max(0,scrollX),y:Math.max(0,scrollY)}}function Tn(){return L(h(window,"scroll",{passive:!0}),h(window,"resize",{passive:!0})).pipe(m(wn),Q(wn()))}function Sn(){return{width:innerWidth,height:innerHeight}}function On(){return h(window,"resize",{passive:!0}).pipe(m(Sn),Q(Sn()))}function Ln(){return z([Tn(),On()]).pipe(m(([e,t])=>({offset:e,size:t})),Z(1))}function Er(e,{viewport$:t,header$:r}){let o=t.pipe(ne("size")),n=z([o,r]).pipe(m(()=>Be(e)));return z([r,t,n]).pipe(m(([{height:i},{offset:s,size:a},{x:c,y:p}])=>({offset:{x:s.x-c,y:s.y-p+i},size:a})))}function Wa(e){return h(e,"message",t=>t.data)}function Da(e){let t=new T;return t.subscribe(r=>e.postMessage(r)),t}function Mn(e,t=new Worker(e)){let r=Wa(t),o=Da(t),n=new T;n.subscribe(o);let i=o.pipe(oe(),ae(!0));return n.pipe(oe(),Ve(r.pipe(W(i))),le())}var Va=j("#__config"),Ct=JSON.parse(Va.textContent);Ct.base=`${new URL(Ct.base,we())}`;function Te(){return Ct}function V(e){return Ct.features.includes(e)}function Me(e,t){return typeof t!="undefined"?Ct.translations[e].replace("#",t.toString()):Ct.translations[e]}function Ce(e,t=document){return j(`[data-md-component=${e}]`,t)}function me(e,t=document){return M(`[data-md-component=${e}]`,t)}function Na(e){let t=j(".md-typeset > :first-child",e);return h(t,"click",{once:!0}).pipe(m(()=>j(".md-typeset",e)),m(r=>({hash:__md_hash(r.innerHTML)})))}function _n(e){if(!V("announce.dismiss")||!e.childElementCount)return y;if(!e.hidden){let t=j(".md-typeset",e);__md_hash(t.innerHTML)===__md_get("__announce")&&(e.hidden=!0)}return H(()=>{let t=new T;return t.subscribe(({hash:r})=>{e.hidden=!0,__md_set("__announce",r)}),Na(e).pipe(O(r=>t.next(r)),A(()=>t.complete()),m(r=>P({ref:e},r)))})}function za(e,{target$:t}){return t.pipe(m(r=>({hidden:r!==e})))}function An(e,t){let r=new T;return r.subscribe(({hidden:o})=>{e.hidden=o}),za(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))}function Dt(e,t){return t==="inline"?x("div",{class:"md-tooltip md-tooltip--inline",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"})):x("div",{class:"md-tooltip",id:e,role:"tooltip"},x("div",{class:"md-tooltip__inner md-typeset"}))}function wr(...e){return x("div",{class:"md-tooltip2",role:"dialog"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function Cn(...e){return x("div",{class:"md-tooltip2",role:"tooltip"},x("div",{class:"md-tooltip2__inner md-typeset"},e))}function kn(e,t){if(t=t?`${t}_annotation_${e}`:void 0,t){let r=t?`#${t}`:void 0;return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("a",{href:r,class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}else return x("aside",{class:"md-annotation",tabIndex:0},Dt(t),x("span",{class:"md-annotation__index",tabIndex:-1},x("span",{"data-md-annotation-id":e})))}function Hn(e){return x("button",{class:"md-code__button",title:Me("clipboard.copy"),"data-clipboard-target":`#${e} > code`,"data-md-type":"copy"})}function $n(){return x("button",{class:"md-code__button",title:"Toggle line selection","data-md-type":"select"})}function Pn(){return x("nav",{class:"md-code__nav"})}var In=$t(ro());function oo(e,t){let r=t&2,o=t&1,n=Object.keys(e.terms).filter(c=>!e.terms[c]).reduce((c,p)=>[...c,x("del",null,(0,In.default)(p))," "],[]).slice(0,-1),i=Te(),s=new URL(e.location,i.base);V("search.highlight")&&s.searchParams.set("h",Object.entries(e.terms).filter(([,c])=>c).reduce((c,[p])=>`${c} ${p}`.trim(),""));let{tags:a}=Te();return x("a",{href:`${s}`,class:"md-search-result__link",tabIndex:-1},x("article",{class:"md-search-result__article md-typeset","data-md-score":e.score.toFixed(2)},r>0&&x("div",{class:"md-search-result__icon md-icon"}),r>0&&x("h1",null,e.title),r<=0&&x("h2",null,e.title),o>0&&e.text.length>0&&e.text,e.tags&&x("nav",{class:"md-tags"},e.tags.map(c=>{let p=a?c in a?`md-tag-icon md-tag--${a[c]}`:"md-tag-icon":"";return x("span",{class:`md-tag ${p}`},c)})),o>0&&n.length>0&&x("p",{class:"md-search-result__terms"},Me("search.result.term.missing"),": ",...n)))}function Fn(e){let t=e[0].score,r=[...e],o=Te(),n=r.findIndex(l=>!`${new URL(l.location,o.base)}`.includes("#")),[i]=r.splice(n,1),s=r.findIndex(l=>l.scoreoo(l,1)),...c.length?[x("details",{class:"md-search-result__more"},x("summary",{tabIndex:-1},x("div",null,c.length>0&&c.length===1?Me("search.result.more.one"):Me("search.result.more.other",c.length))),...c.map(l=>oo(l,1)))]:[]];return x("li",{class:"md-search-result__item"},p)}function jn(e){return x("ul",{class:"md-source__facts"},Object.entries(e).map(([t,r])=>x("li",{class:`md-source__fact md-source__fact--${t}`},typeof r=="number"?br(r):r)))}function no(e){let t=`tabbed-control tabbed-control--${e}`;return x("div",{class:t,hidden:!0},x("button",{class:"tabbed-button",tabIndex:-1,"aria-hidden":"true"}))}function Un(e){return x("div",{class:"md-typeset__scrollwrap"},x("div",{class:"md-typeset__table"},e))}function Qa(e){var o;let t=Te(),r=new URL(`../${e.version}/`,t.base);return x("li",{class:"md-version__item"},x("a",{href:`${r}`,class:"md-version__link"},e.title,((o=t.version)==null?void 0:o.alias)&&e.aliases.length>0&&x("span",{class:"md-version__alias"},e.aliases[0])))}function Wn(e,t){var o;let r=Te();return e=e.filter(n=>{var i;return!((i=n.properties)!=null&&i.hidden)}),x("div",{class:"md-version"},x("button",{class:"md-version__current","aria-label":Me("select.version")},t.title,((o=r.version)==null?void 0:o.alias)&&t.aliases.length>0&&x("span",{class:"md-version__alias"},t.aliases[0])),x("ul",{class:"md-version__list"},e.map(Qa)))}var Ya=0;function Ba(e,t=250){let r=z([Ye(e),it(e,t)]).pipe(m(([n,i])=>n||i),Y()),o=H(()=>pn(e)).pipe(J(Ge),gt(1),Pe(r),m(()=>ln(e)));return r.pipe(Re(n=>n),b(()=>z([r,o])),m(([n,i])=>({active:n,offset:i})),le())}function Vt(e,t,r=250){let{content$:o,viewport$:n}=t,i=`__tooltip2_${Ya++}`;return H(()=>{let s=new T,a=new jr(!1);s.pipe(oe(),ae(!1)).subscribe(a);let c=a.pipe(jt(l=>He(+!l*250,Dr)),Y(),b(l=>l?o:y),O(l=>l.id=i),le());z([s.pipe(m(({active:l})=>l)),c.pipe(b(l=>it(l,250)),Q(!1))]).pipe(m(l=>l.some(f=>f))).subscribe(a);let p=a.pipe(g(l=>l),te(c,n),m(([l,f,{size:u}])=>{let d=e.getBoundingClientRect(),v=d.width/2;if(f.role==="tooltip")return{x:v,y:8+d.height};if(d.y>=u.height/2){let{height:S}=de(f);return{x:v,y:-16-S}}else return{x:v,y:16+d.height}}));return z([c,s,p]).subscribe(([l,{offset:f},u])=>{l.style.setProperty("--md-tooltip-host-x",`${f.x}px`),l.style.setProperty("--md-tooltip-host-y",`${f.y}px`),l.style.setProperty("--md-tooltip-x",`${u.x}px`),l.style.setProperty("--md-tooltip-y",`${u.y}px`),l.classList.toggle("md-tooltip2--top",u.y<0),l.classList.toggle("md-tooltip2--bottom",u.y>=0)}),a.pipe(g(l=>l),te(c,(l,f)=>f),g(l=>l.role==="tooltip")).subscribe(l=>{let f=de(j(":scope > *",l));l.style.setProperty("--md-tooltip-width",`${f.width}px`),l.style.setProperty("--md-tooltip-tail","0px")}),a.pipe(Y(),xe(ye),te(c)).subscribe(([l,f])=>{f.classList.toggle("md-tooltip2--active",l)}),z([a.pipe(g(l=>l)),c]).subscribe(([l,f])=>{f.role==="dialog"?(e.setAttribute("aria-controls",i),e.setAttribute("aria-haspopup","dialog")):e.setAttribute("aria-describedby",i)}),a.pipe(g(l=>!l)).subscribe(()=>{e.removeAttribute("aria-controls"),e.removeAttribute("aria-describedby"),e.removeAttribute("aria-haspopup")}),Ba(e,r).pipe(O(l=>s.next(l)),A(()=>s.complete()),m(l=>P({ref:e},l)))})}function Xe(e,{viewport$:t},r=document.body){return Vt(e,{content$:new F(o=>{let n=e.title,i=Cn(n);return o.next(i),e.removeAttribute("title"),r.append(i),()=>{i.remove(),e.setAttribute("title",n)}}),viewport$:t},0)}function Ga(e,t){let r=H(()=>z([mn(e),Ge(t)])).pipe(m(([{x:o,y:n},i])=>{let{width:s,height:a}=de(e);return{x:o-i.x+s/2,y:n-i.y+a/2}}));return Ye(e).pipe(b(o=>r.pipe(m(n=>({active:o,offset:n})),Ee(+!o||1/0))))}function Dn(e,t,{target$:r}){let[o,n]=Array.from(e.children);return H(()=>{let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({offset:a}){e.style.setProperty("--md-tooltip-x",`${a.x}px`),e.style.setProperty("--md-tooltip-y",`${a.y}px`)},complete(){e.style.removeProperty("--md-tooltip-x"),e.style.removeProperty("--md-tooltip-y")}}),mt(e).pipe(W(s)).subscribe(a=>{e.toggleAttribute("data-md-visible",a)}),L(i.pipe(g(({active:a})=>a)),i.pipe(Ae(250),g(({active:a})=>!a))).subscribe({next({active:a}){a?e.prepend(o):o.remove()},complete(){e.prepend(o)}}),i.pipe($e(16,ye)).subscribe(({active:a})=>{o.classList.toggle("md-tooltip--active",a)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:a})=>a)).subscribe({next(a){a?e.style.setProperty("--md-tooltip-0",`${-a}px`):e.style.removeProperty("--md-tooltip-0")},complete(){e.style.removeProperty("--md-tooltip-0")}}),h(n,"click").pipe(W(s),g(a=>!(a.metaKey||a.ctrlKey))).subscribe(a=>{a.stopPropagation(),a.preventDefault()}),h(n,"mousedown").pipe(W(s),te(i)).subscribe(([a,{active:c}])=>{var p;if(a.button!==0||a.metaKey||a.ctrlKey)a.preventDefault();else if(c){a.preventDefault();let l=e.parentElement.closest(".md-annotation");l instanceof HTMLElement?l.focus():(p=Ne())==null||p.blur()}}),r.pipe(W(s),g(a=>a===o),nt(125)).subscribe(()=>e.focus()),Ga(e,t).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function Ja(e){let t=Te();if(e.tagName!=="CODE")return[e];let r=[".c",".c1",".cm"];if(t.annotate&&typeof t.annotate=="object"){let o=e.closest("[class|=language]");if(o)for(let n of Array.from(o.classList)){if(!n.startsWith("language-"))continue;let[,i]=n.split("-");i in t.annotate&&r.push(...t.annotate[i])}}return M(r.join(", "),e)}function Xa(e){let t=[];for(let r of Ja(e)){let o=[],n=document.createNodeIterator(r,NodeFilter.SHOW_TEXT);for(let i=n.nextNode();i;i=n.nextNode())o.push(i);for(let i of o){let s;for(;s=/(\(\d+\))(!)?/.exec(i.textContent);){let[,a,c]=s;if(typeof c=="undefined"){let p=i.splitText(s.index);i=p.splitText(a.length),t.push(p)}else{i.textContent=a,t.push(i);break}}}}return t}function Vn(e,t){t.append(...Array.from(e.childNodes))}function Tr(e,t,{target$:r,print$:o}){let n=t.closest("[id]"),i=n==null?void 0:n.id,s=new Map;for(let a of Xa(t)){let[,c]=a.textContent.match(/\((\d+)\)/);ue(`:scope > li:nth-child(${c})`,e)&&(s.set(c,kn(c,i)),a.replaceWith(s.get(c)))}return s.size===0?y:H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=[];for(let[l,f]of s)p.push([j(".md-typeset",f),j(`:scope > li:nth-child(${l})`,e)]);return o.pipe(W(c)).subscribe(l=>{e.hidden=!l,e.classList.toggle("md-annotation-list",l);for(let[f,u]of p)l?Vn(f,u):Vn(u,f)}),L(...[...s].map(([,l])=>Dn(l,t,{target$:r}))).pipe(A(()=>a.complete()),le())})}function Nn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Nn(t)}}function zn(e,t){return H(()=>{let r=Nn(e);return typeof r!="undefined"?Tr(r,e,t):y})}var Kn=$t(ao());var Za=0,qn=L(h(window,"keydown").pipe(m(()=>!0)),L(h(window,"keyup"),h(window,"contextmenu")).pipe(m(()=>!1))).pipe(Q(!1),Z(1));function Qn(e){if(e.nextElementSibling){let t=e.nextElementSibling;if(t.tagName==="OL")return t;if(t.tagName==="P"&&!t.children.length)return Qn(t)}}function es(e){return Le(e).pipe(m(({width:t})=>({scrollable:At(e).width>t})),ne("scrollable"))}function Yn(e,t){let{matches:r}=matchMedia("(hover)"),o=H(()=>{let n=new T,i=n.pipe(Yr(1));n.subscribe(({scrollable:d})=>{d&&r?e.setAttribute("tabindex","0"):e.removeAttribute("tabindex")});let s=[],a=e.closest("pre"),c=a.closest("[id]"),p=c?c.id:Za++;a.id=`__code_${p}`;let l=[],f=e.closest(".highlight");if(f instanceof HTMLElement){let d=Qn(f);if(typeof d!="undefined"&&(f.classList.contains("annotate")||V("content.code.annotate"))){let v=Tr(d,e,t);l.push(Le(f).pipe(W(i),m(({width:S,height:X})=>S&&X),Y(),b(S=>S?v:y)))}}let u=M(":scope > span[id]",e);if(u.length&&(e.classList.add("md-code__content"),e.closest(".select")||V("content.code.select")&&!e.closest(".no-select"))){let d=+u[0].id.split("-").pop(),v=$n();s.push(v),V("content.tooltips")&&l.push(Xe(v,{viewport$}));let S=h(v,"click").pipe(Ut(R=>!R,!1),O(()=>v.blur()),le());S.subscribe(R=>{v.classList.toggle("md-code__button--active",R)});let X=fe(u).pipe(J(R=>it(R).pipe(m(se=>[R,se]))));S.pipe(b(R=>R?X:y)).subscribe(([R,se])=>{let ce=ue(".hll.select",R);if(ce&&!se)ce.replaceWith(...Array.from(ce.childNodes));else if(!ce&&se){let he=document.createElement("span");he.className="hll select",he.append(...Array.from(R.childNodes).slice(1)),R.append(he)}});let re=fe(u).pipe(J(R=>h(R,"mousedown").pipe(O(se=>se.preventDefault()),m(()=>R)))),ee=S.pipe(b(R=>R?re:y),te(qn),m(([R,se])=>{var he;let ce=u.indexOf(R)+d;if(se===!1)return[ce,ce];{let Se=M(".hll",e).map(Ue=>u.indexOf(Ue.parentElement)+d);return(he=window.getSelection())==null||he.removeAllRanges(),[Math.min(ce,...Se),Math.max(ce,...Se)]}})),k=Zr(y).pipe(g(R=>R.startsWith(`__codelineno-${p}-`)));k.subscribe(R=>{let[,,se]=R.split("-"),ce=se.split(":").map(Se=>+Se-d+1);ce.length===1&&ce.push(ce[0]);for(let Se of M(".hll:not(.select)",e))Se.replaceWith(...Array.from(Se.childNodes));let he=u.slice(ce[0]-1,ce[1]);for(let Se of he){let Ue=document.createElement("span");Ue.className="hll",Ue.append(...Array.from(Se.childNodes).slice(1)),Se.append(Ue)}}),k.pipe(Ee(1),xe(pe)).subscribe(R=>{if(R.includes(":")){let se=document.getElementById(R.split(":")[0]);se&&setTimeout(()=>{let ce=se,he=-64;for(;ce!==document.body;)he+=ce.offsetTop,ce=ce.offsetParent;window.scrollTo({top:he})},1)}});let je=fe(M('a[href^="#__codelineno"]',f)).pipe(J(R=>h(R,"click").pipe(O(se=>se.preventDefault()),m(()=>R)))).pipe(W(i),te(qn),m(([R,se])=>{let he=+j(`[id="${R.hash.slice(1)}"]`).parentElement.id.split("-").pop();if(se===!1)return[he,he];{let Se=M(".hll",e).map(Ue=>+Ue.parentElement.id.split("-").pop());return[Math.min(he,...Se),Math.max(he,...Se)]}}));L(ee,je).subscribe(R=>{let se=`#__codelineno-${p}-`;R[0]===R[1]?se+=R[0]:se+=`${R[0]}:${R[1]}`,history.replaceState({},"",se),window.dispatchEvent(new HashChangeEvent("hashchange",{newURL:window.location.origin+window.location.pathname+se,oldURL:window.location.href}))})}if(Kn.default.isSupported()&&(e.closest(".copy")||V("content.code.copy")&&!e.closest(".no-copy"))){let d=Hn(a.id);s.push(d),V("content.tooltips")&&l.push(Xe(d,{viewport$}))}if(s.length){let d=Pn();d.append(...s),a.insertBefore(d,e)}return es(e).pipe(O(d=>n.next(d)),A(()=>n.complete()),m(d=>P({ref:e},d)),Ve(L(...l).pipe(W(i))))});return V("content.lazy")?mt(e).pipe(g(n=>n),Ee(1),b(()=>o)):o}function ts(e,{target$:t,print$:r}){let o=!0;return L(t.pipe(m(n=>n.closest("details:not([open])")),g(n=>e===n),m(()=>({action:"open",reveal:!0}))),r.pipe(g(n=>n||!o),O(()=>o=e.open),m(n=>({action:n?"open":"close"}))))}function Bn(e,t){return H(()=>{let r=new T;return r.subscribe(({action:o,reveal:n})=>{e.toggleAttribute("open",o==="open"),n&&e.scrollIntoView()}),ts(e,t).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}var Gn=0;function rs(e){let t=document.createElement("h3");t.innerHTML=e.innerHTML;let r=[t],o=e.nextElementSibling;for(;o&&!(o instanceof HTMLHeadingElement);)r.push(o),o=o.nextElementSibling;return r}function os(e,t){for(let r of M("[href], [src]",e))for(let o of["href","src"]){let n=r.getAttribute(o);if(n&&!/^(?:[a-z]+:)?\/\//i.test(n)){r[o]=new URL(r.getAttribute(o),t).toString();break}}for(let r of M("[name^=__], [for]",e))for(let o of["id","for","name"]){let n=r.getAttribute(o);n&&r.setAttribute(o,`${n}$preview_${Gn}`)}return Gn++,$(e)}function Jn(e,t){let{sitemap$:r}=t;if(!(e instanceof HTMLAnchorElement))return y;if(!(V("navigation.instant.preview")||e.hasAttribute("data-preview")))return y;e.removeAttribute("title");let o=z([Ye(e),it(e)]).pipe(m(([i,s])=>i||s),Y(),g(i=>i));return rt([r,o]).pipe(b(([i])=>{let s=new URL(e.href);return s.search=s.hash="",i.has(`${s}`)?$(s):y}),b(i=>xr(i).pipe(b(s=>os(s,i)))),b(i=>{let s=e.hash?`article [id="${e.hash.slice(1)}"]`:"article h1",a=ue(s,i);return typeof a=="undefined"?y:$(rs(a))})).pipe(b(i=>{let s=new F(a=>{let c=wr(...i);return a.next(c),document.body.append(c),()=>c.remove()});return Vt(e,P({content$:s},t))}))}var Xn=".node circle,.node ellipse,.node path,.node polygon,.node rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}marker{fill:var(--md-mermaid-edge-color)!important}.edgeLabel .label rect{fill:#0000}.flowchartTitleText{fill:var(--md-mermaid-label-fg-color)}.label{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.label foreignObject{line-height:normal;overflow:visible}.label div .edgeLabel{color:var(--md-mermaid-label-fg-color)}.edgeLabel,.edgeLabel p,.label div .edgeLabel{background-color:var(--md-mermaid-label-bg-color)}.edgeLabel,.edgeLabel p{fill:var(--md-mermaid-label-bg-color);color:var(--md-mermaid-edge-color)}.edgePath .path,.flowchart-link{stroke:var(--md-mermaid-edge-color)}.edgePath .arrowheadPath{fill:var(--md-mermaid-edge-color);stroke:none}.cluster rect{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}.cluster span{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}g #flowchart-circleEnd,g #flowchart-circleStart,g #flowchart-crossEnd,g #flowchart-crossStart,g #flowchart-pointEnd,g #flowchart-pointStart{stroke:none}.classDiagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.classGroup line,g.classGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.classGroup text{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.classLabel .box{fill:var(--md-mermaid-label-bg-color);background-color:var(--md-mermaid-label-bg-color);opacity:1}.classLabel .label{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.node .divider{stroke:var(--md-mermaid-node-fg-color)}.relation{stroke:var(--md-mermaid-edge-color)}.cardinality{fill:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}.cardinality text{fill:inherit!important}defs marker.marker.composition.class path,defs marker.marker.dependency.class path,defs marker.marker.extension.class path{fill:var(--md-mermaid-edge-color)!important;stroke:var(--md-mermaid-edge-color)!important}defs marker.marker.aggregation.class path{fill:var(--md-mermaid-label-bg-color)!important;stroke:var(--md-mermaid-edge-color)!important}.statediagramTitleText{fill:var(--md-mermaid-label-fg-color)}g.stateGroup rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}g.stateGroup .state-title{fill:var(--md-mermaid-label-fg-color)!important;font-family:var(--md-mermaid-font-family)}g.stateGroup .composit{fill:var(--md-mermaid-label-bg-color)}.nodeLabel,.nodeLabel p{color:var(--md-mermaid-label-fg-color);font-family:var(--md-mermaid-font-family)}a .nodeLabel{text-decoration:underline}.node circle.state-end,.node circle.state-start,.start-state{fill:var(--md-mermaid-edge-color);stroke:none}.end-state-inner,.end-state-outer{fill:var(--md-mermaid-edge-color)}.end-state-inner,.node circle.state-end{stroke:var(--md-mermaid-label-bg-color)}.transition{stroke:var(--md-mermaid-edge-color)}[id^=state-fork] rect,[id^=state-join] rect{fill:var(--md-mermaid-edge-color)!important;stroke:none!important}.statediagram-cluster.statediagram-cluster .inner{fill:var(--md-default-bg-color)}.statediagram-cluster rect{fill:var(--md-mermaid-node-bg-color);stroke:var(--md-mermaid-node-fg-color)}.statediagram-state rect.divider{fill:var(--md-default-fg-color--lightest);stroke:var(--md-default-fg-color--lighter)}defs #statediagram-barbEnd{stroke:var(--md-mermaid-edge-color)}[id^=entity] path,[id^=entity] rect{fill:var(--md-default-bg-color)}.relationshipLine{stroke:var(--md-mermaid-edge-color)}defs .marker.oneOrMore.er *,defs .marker.onlyOne.er *,defs .marker.zeroOrMore.er *,defs .marker.zeroOrOne.er *{stroke:var(--md-mermaid-edge-color)!important}text:not([class]):last-child{fill:var(--md-mermaid-label-fg-color)}.actor{fill:var(--md-mermaid-sequence-actor-bg-color);stroke:var(--md-mermaid-sequence-actor-border-color)}text.actor>tspan{fill:var(--md-mermaid-sequence-actor-fg-color);font-family:var(--md-mermaid-font-family)}line{stroke:var(--md-mermaid-sequence-actor-line-color)}.actor-man circle,.actor-man line{fill:var(--md-mermaid-sequence-actorman-bg-color);stroke:var(--md-mermaid-sequence-actorman-line-color)}.messageLine0,.messageLine1{stroke:var(--md-mermaid-sequence-message-line-color)}.note{fill:var(--md-mermaid-sequence-note-bg-color);stroke:var(--md-mermaid-sequence-note-border-color)}.loopText,.loopText>tspan,.messageText,.noteText>tspan{stroke:none;font-family:var(--md-mermaid-font-family)!important}.messageText{fill:var(--md-mermaid-sequence-message-fg-color)}.loopText,.loopText>tspan{fill:var(--md-mermaid-sequence-loop-fg-color)}.noteText>tspan{fill:var(--md-mermaid-sequence-note-fg-color)}#arrowhead path{fill:var(--md-mermaid-sequence-message-line-color);stroke:none}.loopLine{fill:var(--md-mermaid-sequence-loop-bg-color);stroke:var(--md-mermaid-sequence-loop-border-color)}.labelBox{fill:var(--md-mermaid-sequence-label-bg-color);stroke:none}.labelText,.labelText>span{fill:var(--md-mermaid-sequence-label-fg-color);font-family:var(--md-mermaid-font-family)}.sequenceNumber{fill:var(--md-mermaid-sequence-number-fg-color)}rect.rect{fill:var(--md-mermaid-sequence-box-bg-color);stroke:none}rect.rect+text.text{fill:var(--md-mermaid-sequence-box-fg-color)}defs #sequencenumber{fill:var(--md-mermaid-sequence-number-bg-color)!important}";var so,is=0;function as(){return typeof mermaid=="undefined"||mermaid instanceof Element?_t("https://unpkg.com/mermaid@11/dist/mermaid.min.js"):$(void 0)}function Zn(e){return e.classList.remove("mermaid"),so||(so=as().pipe(O(()=>mermaid.initialize({startOnLoad:!1,themeCSS:Xn,sequence:{actorFontSize:"16px",messageFontSize:"16px",noteFontSize:"16px"}})),m(()=>{}),Z(1))),so.subscribe(()=>go(null,null,function*(){e.classList.add("mermaid");let t=`__mermaid_${is++}`,r=x("div",{class:"mermaid"}),o=e.textContent,{svg:n,fn:i}=yield mermaid.render(t,o),s=r.attachShadow({mode:"closed"});s.innerHTML=n,e.replaceWith(r),i==null||i(s)})),so.pipe(m(()=>({ref:e})))}var ei=x("table");function ti(e){return e.replaceWith(ei),ei.replaceWith(Un(e)),$({ref:e})}function ss(e){let t=e.find(r=>r.checked)||e[0];return L(...e.map(r=>h(r,"change").pipe(m(()=>j(`label[for="${r.id}"]`))))).pipe(Q(j(`label[for="${t.id}"]`)),m(r=>({active:r})))}function ri(e,{viewport$:t,target$:r}){let o=j(".tabbed-labels",e),n=M(":scope > input",e),i=no("prev");e.append(i);let s=no("next");return e.append(s),H(()=>{let a=new T,c=a.pipe(oe(),ae(!0));z([a,Le(e),mt(e)]).pipe(W(c),$e(1,ye)).subscribe({next([{active:p},l]){let f=Be(p),{width:u}=de(p);e.style.setProperty("--md-indicator-x",`${f.x}px`),e.style.setProperty("--md-indicator-width",`${u}px`);let d=gr(o);(f.xd.x+l.width)&&o.scrollTo({left:Math.max(0,f.x-16),behavior:"smooth"})},complete(){e.style.removeProperty("--md-indicator-x"),e.style.removeProperty("--md-indicator-width")}}),z([Ge(o),Le(o)]).pipe(W(c)).subscribe(([p,l])=>{let f=At(o);i.hidden=p.x<16,s.hidden=p.x>f.width-l.width-16}),L(h(i,"click").pipe(m(()=>-1)),h(s,"click").pipe(m(()=>1))).pipe(W(c)).subscribe(p=>{let{width:l}=de(o);o.scrollBy({left:l*p,behavior:"smooth"})}),r.pipe(W(c),g(p=>n.includes(p))).subscribe(p=>p.click()),o.classList.add("tabbed-labels--linked");for(let p of n){let l=j(`label[for="${p.id}"]`);l.replaceChildren(x("a",{href:`#${l.htmlFor}`,tabIndex:-1},...Array.from(l.childNodes))),h(l.firstElementChild,"click").pipe(W(c),g(f=>!(f.metaKey||f.ctrlKey)),O(f=>{f.preventDefault(),f.stopPropagation()})).subscribe(()=>{history.replaceState({},"",`#${l.htmlFor}`),l.click()})}return V("content.tabs.link")&&a.pipe(Ie(1),te(t)).subscribe(([{active:p},{offset:l}])=>{let f=p.innerText.trim();if(p.hasAttribute("data-md-switching"))p.removeAttribute("data-md-switching");else{let u=e.offsetTop-l.y;for(let v of M("[data-tabs]"))for(let S of M(":scope > input",v)){let X=j(`label[for="${S.id}"]`);if(X!==p&&X.innerText.trim()===f){X.setAttribute("data-md-switching",""),S.click();break}}window.scrollTo({top:e.offsetTop-u});let d=__md_get("__tabs")||[];__md_set("__tabs",[...new Set([f,...d])])}}),a.pipe(W(c)).subscribe(()=>{for(let p of M("audio, video",e))p.offsetWidth&&p.autoplay?p.play().catch(()=>{}):p.pause()}),ss(n).pipe(O(p=>a.next(p)),A(()=>a.complete()),m(p=>P({ref:e},p)))}).pipe(et(pe))}function oi(e,t){let{viewport$:r,target$:o,print$:n}=t;return L(...M(".annotate:not(.highlight)",e).map(i=>zn(i,{target$:o,print$:n})),...M("pre:not(.mermaid) > code",e).map(i=>Yn(i,{target$:o,print$:n})),...M("a",e).map(i=>Jn(i,t)),...M("pre.mermaid",e).map(i=>Zn(i)),...M("table:not([class])",e).map(i=>ti(i)),...M("details",e).map(i=>Bn(i,{target$:o,print$:n})),...M("[data-tabs]",e).map(i=>ri(i,{viewport$:r,target$:o})),...M("[title]:not([data-preview])",e).filter(()=>V("content.tooltips")).map(i=>Xe(i,{viewport$:r})),...M(".footnote-ref",e).filter(()=>V("content.footnote.tooltips")).map(i=>Vt(i,{content$:new F(s=>{let a=new URL(i.href).hash.slice(1),c=Array.from(document.getElementById(a).cloneNode(!0).children),p=wr(...c);return s.next(p),document.body.append(p),()=>p.remove()}),viewport$:r})))}function cs(e,{alert$:t}){return t.pipe(b(r=>L($(!0),$(!1).pipe(nt(2e3))).pipe(m(o=>({message:r,active:o})))))}function ni(e,t){let r=j(".md-typeset",e);return H(()=>{let o=new T;return o.subscribe(({message:n,active:i})=>{e.classList.toggle("md-dialog--active",i),r.textContent=n}),cs(e,t).pipe(O(n=>o.next(n)),A(()=>o.complete()),m(n=>P({ref:e},n)))})}var ps=0;function ls(e,t){document.body.append(e);let{width:r}=de(e);e.style.setProperty("--md-tooltip-width",`${r}px`),e.remove();let o=vr(t),n=typeof o!="undefined"?Ge(o):$({x:0,y:0}),i=L(Ye(t),it(t)).pipe(Y());return z([i,n]).pipe(m(([s,a])=>{let{x:c,y:p}=Be(t),l=de(t),f=t.closest("table");return f&&t.parentElement&&(c+=f.offsetLeft+t.parentElement.offsetLeft,p+=f.offsetTop+t.parentElement.offsetTop),{active:s,offset:{x:c-a.x+l.width/2-r/2,y:p-a.y+l.height+8}}}))}function ii(e){let t=e.title;if(!t.length)return y;let r=`__tooltip_${ps++}`,o=Dt(r,"inline"),n=j(".md-typeset",o);return n.innerHTML=t,H(()=>{let i=new T;return i.subscribe({next({offset:s}){o.style.setProperty("--md-tooltip-x",`${s.x}px`),o.style.setProperty("--md-tooltip-y",`${s.y}px`)},complete(){o.style.removeProperty("--md-tooltip-x"),o.style.removeProperty("--md-tooltip-y")}}),L(i.pipe(g(({active:s})=>s)),i.pipe(Ae(250),g(({active:s})=>!s))).subscribe({next({active:s}){s?(e.insertAdjacentElement("afterend",o),e.setAttribute("aria-describedby",r),e.removeAttribute("title")):(o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t))},complete(){o.remove(),e.removeAttribute("aria-describedby"),e.setAttribute("title",t)}}),i.pipe($e(16,ye)).subscribe(({active:s})=>{o.classList.toggle("md-tooltip--active",s)}),i.pipe(gt(125,ye),g(()=>!!e.offsetParent),m(()=>e.offsetParent.getBoundingClientRect()),m(({x:s})=>s)).subscribe({next(s){s?o.style.setProperty("--md-tooltip-0",`${-s}px`):o.style.removeProperty("--md-tooltip-0")},complete(){o.style.removeProperty("--md-tooltip-0")}}),ls(o,e).pipe(O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))}).pipe(et(pe))}function ms({viewport$:e}){if(!V("header.autohide"))return $(!1);let t=e.pipe(m(({offset:{y:n}})=>n),ot(2,1),m(([n,i])=>[nMath.abs(i-n.y)>100),m(([,[n]])=>n),Y()),o=Je("search");return z([e,o]).pipe(m(([{offset:n},i])=>n.y>400&&!i),Y(),b(n=>n?r:$(!1)),Q(!1))}function ai(e,t){return H(()=>z([Le(e),ms(t)])).pipe(m(([{height:r},o])=>({height:r,hidden:o})),Y((r,o)=>r.height===o.height&&r.hidden===o.hidden),Z(1))}function si(e,{header$:t,main$:r}){return H(()=>{let o=new T,n=o.pipe(oe(),ae(!0));o.pipe(ne("active"),Pe(t)).subscribe(([{active:s},{hidden:a}])=>{e.classList.toggle("md-header--shadow",s&&!a),e.hidden=a});let i=fe(M("[title]",e)).pipe(g(()=>V("content.tooltips")),J(s=>ii(s)));return r.subscribe(o),t.pipe(W(n),m(s=>P({ref:e},s)),Ve(i.pipe(W(n))))})}function fs(e,{viewport$:t,header$:r}){return Er(e,{viewport$:t,header$:r}).pipe(m(({offset:{y:o}})=>{let{height:n}=de(e);return{active:n>0&&o>=n}}),ne("active"))}function ci(e,t){return H(()=>{let r=new T;r.subscribe({next({active:n}){e.classList.toggle("md-header__title--active",n)},complete(){e.classList.remove("md-header__title--active")}});let o=ue(".md-content h1");return typeof o=="undefined"?y:fs(o,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))})}function pi(e,{viewport$:t,header$:r}){let o=r.pipe(m(({height:i})=>i),Y()),n=o.pipe(b(()=>Le(e).pipe(m(({height:i})=>({top:e.offsetTop,bottom:e.offsetTop+i})),ne("bottom"))));return z([o,n,t]).pipe(m(([i,{top:s,bottom:a},{offset:{y:c},size:{height:p}}])=>(p=Math.max(0,p-Math.max(0,s-c,i)-Math.max(0,p+c-a)),{offset:s-i,height:p,active:s-i<=c})),Y((i,s)=>i.offset===s.offset&&i.height===s.height&&i.active===s.active))}function us(e){let t=__md_get("__palette")||{index:e.findIndex(o=>matchMedia(o.getAttribute("data-md-color-media")).matches)},r=Math.max(0,Math.min(t.index,e.length-1));return $(...e).pipe(J(o=>h(o,"change").pipe(m(()=>o))),Q(e[r]),m(o=>({index:e.indexOf(o),color:{media:o.getAttribute("data-md-color-media"),scheme:o.getAttribute("data-md-color-scheme"),primary:o.getAttribute("data-md-color-primary"),accent:o.getAttribute("data-md-color-accent")}})),Z(1))}function li(e){let t=M("input",e),r=x("meta",{name:"theme-color"});document.head.appendChild(r);let o=x("meta",{name:"color-scheme"});document.head.appendChild(o);let n=Wt("(prefers-color-scheme: light)");return H(()=>{let i=new T;return i.subscribe(s=>{if(document.body.setAttribute("data-md-color-switching",""),s.color.media==="(prefers-color-scheme)"){let a=matchMedia("(prefers-color-scheme: light)"),c=document.querySelector(a.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");s.color.scheme=c.getAttribute("data-md-color-scheme"),s.color.primary=c.getAttribute("data-md-color-primary"),s.color.accent=c.getAttribute("data-md-color-accent")}for(let[a,c]of Object.entries(s.color))document.body.setAttribute(`data-md-color-${a}`,c);for(let a=0;as.key==="Enter"),te(i,(s,a)=>a)).subscribe(({index:s})=>{s=(s+1)%t.length,t[s].click(),t[s].focus()}),i.pipe(m(()=>{let s=Ce("header"),a=window.getComputedStyle(s);return o.content=a.colorScheme,a.backgroundColor.match(/\d+/g).map(c=>(+c).toString(16).padStart(2,"0")).join("")})).subscribe(s=>r.content=`#${s}`),i.pipe(xe(pe)).subscribe(()=>{document.body.removeAttribute("data-md-color-switching")}),us(t).pipe(W(n.pipe(Ie(1))),vt(),O(s=>i.next(s)),A(()=>i.complete()),m(s=>P({ref:e},s)))})}function mi(e,{progress$:t}){return H(()=>{let r=new T;return r.subscribe(({value:o})=>{e.style.setProperty("--md-progress-value",`${o}`)}),t.pipe(O(o=>r.next({value:o})),A(()=>r.complete()),m(o=>({ref:e,value:o})))})}function fi(e,t){return e.protocol=t.protocol,e.hostname=t.hostname,e}function ds(e,t){let r=new Map;for(let o of M("url",e)){let n=j("loc",o),i=[fi(new URL(n.textContent),t)];r.set(`${i[0]}`,i);for(let s of M("[rel=alternate]",o)){let a=s.getAttribute("href");a!=null&&i.push(fi(new URL(a),t))}}return r}function kt(e){return En(new URL("sitemap.xml",e)).pipe(m(t=>ds(t,new URL(e))),ve(()=>$(new Map)),le())}function ui({document$:e}){let t=new Map;e.pipe(b(()=>M("link[rel=alternate]")),m(r=>new URL(r.href)),g(r=>!t.has(r.toString())),J(r=>kt(r).pipe(m(o=>[r,o]),ve(()=>y)))).subscribe(([r,o])=>{t.set(r.toString().replace(/\/$/,""),o)}),h(document.body,"click").pipe(g(r=>!r.metaKey&&!r.ctrlKey),b(r=>{if(r.target instanceof Element){let o=r.target.closest("a");if(o&&!o.target){let n=[...t].find(([f])=>o.href.startsWith(`${f}/`));if(typeof n=="undefined")return y;let[i,s]=n,a=we();if(a.href.startsWith(i))return y;let c=Te(),p=a.href.replace(c.base,"");p=`${i}/${p}`;let l=s.has(p.split("#")[0])?new URL(p,c.base):new URL(i);return r.preventDefault(),$(l)}}return y})).subscribe(r=>st(r,!0))}var co=$t(ao());function hs(e){e.setAttribute("data-md-copying","");let t=e.closest("[data-copy]"),r=t?t.getAttribute("data-copy"):e.innerText;return e.removeAttribute("data-md-copying"),r.trimEnd()}function di({alert$:e}){co.default.isSupported()&&new F(t=>{new co.default("[data-clipboard-target], [data-clipboard-text]",{text:r=>r.getAttribute("data-clipboard-text")||hs(j(r.getAttribute("data-clipboard-target")))}).on("success",r=>t.next(r))}).pipe(O(t=>{t.trigger.focus()}),m(()=>Me("clipboard.copied"))).subscribe(e)}function hi(e,t){if(!(e.target instanceof Element))return y;let r=e.target.closest("a");if(r===null)return y;if(r.target||e.metaKey||e.ctrlKey)return y;let o=new URL(r.href);return o.search=o.hash="",t.has(`${o}`)?(e.preventDefault(),$(r)):y}function bi(e){let t=new Map;for(let r of M(":scope > *",e.head))t.set(r.outerHTML,r);return t}function vi(e){for(let t of M("[href], [src]",e))for(let r of["href","src"]){let o=t.getAttribute(r);if(o&&!/^(?:[a-z]+:)?\/\//i.test(o)){t[r]=t[r];break}}return $(e)}function bs(e){for(let o of["[data-md-component=announce]","[data-md-component=container]","[data-md-component=header-topic]","[data-md-component=outdated]","[data-md-component=logo]","[data-md-component=skip]",...V("navigation.tabs.sticky")?["[data-md-component=tabs]"]:[]]){let n=ue(o),i=ue(o,e);typeof n!="undefined"&&typeof i!="undefined"&&n.replaceWith(i)}let t=bi(document);for(let[o,n]of bi(e))t.has(o)?t.delete(o):document.head.appendChild(n);for(let o of t.values()){let n=o.getAttribute("name");n!=="theme-color"&&n!=="color-scheme"&&o.remove()}let r=Ce("container");return Ke(M("script",r)).pipe(b(o=>{let n=e.createElement("script");if(o.src){for(let i of o.getAttributeNames())n.setAttribute(i,o.getAttribute(i));return o.replaceWith(n),new F(i=>{n.onload=()=>i.complete()})}else return n.textContent=o.textContent,o.replaceWith(n),y}),oe(),ae(document))}function gi({sitemap$:e,location$:t,viewport$:r,progress$:o}){if(location.protocol==="file:")return y;$(document).subscribe(vi);let n=h(document.body,"click").pipe(Pe(e),b(([a,c])=>hi(a,c)),m(({href:a})=>new URL(a)),le()),i=h(window,"popstate").pipe(m(we),le());n.pipe(te(r)).subscribe(([a,{offset:c}])=>{history.replaceState(c,""),history.pushState(null,"",a)}),L(n,i).subscribe(t);let s=t.pipe(ne("pathname"),b(a=>xr(a,{progress$:o}).pipe(ve(()=>(st(a,!0),y)))),b(vi),b(bs),le());return L(s.pipe(te(t,(a,c)=>c)),s.pipe(b(()=>t),ne("hash")),t.pipe(Y((a,c)=>a.pathname===c.pathname&&a.hash===c.hash),b(()=>n),O(()=>history.back()))).subscribe(a=>{var c,p;history.state!==null||!a.hash?window.scrollTo(0,(p=(c=history.state)==null?void 0:c.y)!=null?p:0):(history.scrollRestoration="auto",gn(a.hash),history.scrollRestoration="manual")}),t.subscribe(()=>{history.scrollRestoration="manual"}),h(window,"beforeunload").subscribe(()=>{history.scrollRestoration="auto"}),r.pipe(ne("offset"),Ae(100)).subscribe(({offset:a})=>{history.replaceState(a,"")}),V("navigation.instant.prefetch")&&L(h(document.body,"mousemove"),h(document.body,"focusin")).pipe(Pe(e),b(([a,c])=>hi(a,c)),Ae(25),Qr(({href:a})=>a),hr(a=>{let c=document.createElement("link");return c.rel="prefetch",c.href=a.toString(),document.head.appendChild(c),h(c,"load").pipe(m(()=>c),Ee(1))})).subscribe(a=>a.remove()),s}var yi=$t(ro());function xi(e){let t=e.separator.split("|").map(n=>n.replace(/(\(\?[!=<][^)]+\))/g,"").length===0?"\uFFFD":n).join("|"),r=new RegExp(t,"img"),o=(n,i,s)=>`${i}${s}`;return n=>{n=n.replace(/[\s*+\-:~^]+/g," ").replace(/&/g,"&").trim();let i=new RegExp(`(^|${e.separator}|)(${n.replace(/[|\\{}()[\]^$+*?.-]/g,"\\$&").replace(r,"|")})`,"img");return s=>(0,yi.default)(s).replace(i,o).replace(/<\/mark>(\s+)]*>/img,"$1")}}function zt(e){return e.type===1}function Sr(e){return e.type===3}function Ei(e,t){let r=Mn(e);return L($(location.protocol!=="file:"),Je("search")).pipe(Re(o=>o),b(()=>t)).subscribe(({config:o,docs:n})=>r.next({type:0,data:{config:o,docs:n,options:{suggest:V("search.suggest")}}})),r}function wi(e){var l;let{selectedVersionSitemap:t,selectedVersionBaseURL:r,currentLocation:o,currentBaseURL:n}=e,i=(l=po(n))==null?void 0:l.pathname;if(i===void 0)return;let s=ys(o.pathname,i);if(s===void 0)return;let a=Es(t.keys());if(!t.has(a))return;let c=po(s,a);if(!c||!t.has(c.href))return;let p=po(s,r);if(p)return p.hash=o.hash,p.search=o.search,p}function po(e,t){try{return new URL(e,t)}catch(r){return}}function ys(e,t){if(e.startsWith(t))return e.slice(t.length)}function xs(e,t){let r=Math.min(e.length,t.length),o;for(o=0;oy)),o=r.pipe(m(n=>{let[,i]=t.base.match(/([^/]+)\/?$/);return n.find(({version:s,aliases:a})=>s===i||a.includes(i))||n[0]}));r.pipe(m(n=>new Map(n.map(i=>[`${new URL(`../${i.version}/`,t.base)}`,i]))),b(n=>h(document.body,"click").pipe(g(i=>!i.metaKey&&!i.ctrlKey),te(o),b(([i,s])=>{if(i.target instanceof Element){let a=i.target.closest("a");if(a&&!a.target&&n.has(a.href)){let c=a.href;return!i.target.closest(".md-version")&&n.get(c)===s?y:(i.preventDefault(),$(new URL(c)))}}return y}),b(i=>kt(i).pipe(m(s=>{var a;return(a=wi({selectedVersionSitemap:s,selectedVersionBaseURL:i,currentLocation:we(),currentBaseURL:t.base}))!=null?a:i})))))).subscribe(n=>st(n,!0)),z([r,o]).subscribe(([n,i])=>{j(".md-header__topic").appendChild(Wn(n,i))}),e.pipe(b(()=>o)).subscribe(n=>{var a;let i=new URL(t.base),s=__md_get("__outdated",sessionStorage,i);if(s===null){s=!0;let c=((a=t.version)==null?void 0:a.default)||"latest";Array.isArray(c)||(c=[c]);e:for(let p of c)for(let l of n.aliases.concat(n.version))if(new RegExp(p,"i").test(l)){s=!1;break e}__md_set("__outdated",s,sessionStorage,i)}if(s)for(let c of me("outdated"))c.hidden=!1})}function ws(e,{worker$:t}){let{searchParams:r}=we();r.has("q")&&(at("search",!0),e.value=r.get("q"),e.focus(),Je("search").pipe(Re(i=>!i)).subscribe(()=>{let i=we();i.searchParams.delete("q"),history.replaceState({},"",`${i}`)}));let o=Ye(e),n=L(t.pipe(Re(zt)),h(e,"keyup"),o).pipe(m(()=>e.value),Y());return z([n,o]).pipe(m(([i,s])=>({value:i,focus:s})),Z(1))}function Si(e,{worker$:t}){let r=new T,o=r.pipe(oe(),ae(!0));z([t.pipe(Re(zt)),r],(i,s)=>s).pipe(ne("value")).subscribe(({value:i})=>t.next({type:2,data:i})),r.pipe(ne("focus")).subscribe(({focus:i})=>{i&&at("search",i)}),h(e.form,"reset").pipe(W(o)).subscribe(()=>e.focus());let n=j("header [for=__search]");return h(n,"click").subscribe(()=>e.focus()),ws(e,{worker$:t}).pipe(O(i=>r.next(i)),A(()=>r.complete()),m(i=>P({ref:e},i)),Z(1))}function Oi(e,{worker$:t,query$:r}){let o=new T,n=un(e.parentElement).pipe(g(Boolean)),i=e.parentElement,s=j(":scope > :first-child",e),a=j(":scope > :last-child",e);Je("search").subscribe(l=>{a.setAttribute("role",l?"list":"presentation"),a.hidden=!l}),o.pipe(te(r),Gr(t.pipe(Re(zt)))).subscribe(([{items:l},{value:f}])=>{switch(l.length){case 0:s.textContent=f.length?Me("search.result.none"):Me("search.result.placeholder");break;case 1:s.textContent=Me("search.result.one");break;default:let u=br(l.length);s.textContent=Me("search.result.other",u)}});let c=o.pipe(O(()=>a.innerHTML=""),b(({items:l})=>L($(...l.slice(0,10)),$(...l.slice(10)).pipe(ot(4),Xr(n),b(([f])=>f)))),m(Fn),le());return c.subscribe(l=>a.appendChild(l)),c.pipe(J(l=>{let f=ue("details",l);return typeof f=="undefined"?y:h(f,"toggle").pipe(W(o),m(()=>f))})).subscribe(l=>{l.open===!1&&l.offsetTop<=i.scrollTop&&i.scrollTo({top:l.offsetTop})}),t.pipe(g(Sr),m(({data:l})=>l)).pipe(O(l=>o.next(l)),A(()=>o.complete()),m(l=>P({ref:e},l)))}function Ts(e,{query$:t}){return t.pipe(m(({value:r})=>{let o=we();return o.hash="",r=r.replace(/\s+/g,"+").replace(/&/g,"%26").replace(/=/g,"%3D"),o.search=`q=${r}`,{url:o}}))}function Li(e,t){let r=new T,o=r.pipe(oe(),ae(!0));return r.subscribe(({url:n})=>{e.setAttribute("data-clipboard-text",e.href),e.href=`${n}`}),h(e,"click").pipe(W(o)).subscribe(n=>n.preventDefault()),Ts(e,t).pipe(O(n=>r.next(n)),A(()=>r.complete()),m(n=>P({ref:e},n)))}function Mi(e,{worker$:t,keyboard$:r}){let o=new T,n=Ce("search-query"),i=L(h(n,"keydown"),h(n,"focus")).pipe(xe(pe),m(()=>n.value),Y());return o.pipe(Pe(i),m(([{suggest:a},c])=>{let p=c.split(/([\s-]+)/);if(a!=null&&a.length&&p[p.length-1]){let l=a[a.length-1];l.startsWith(p[p.length-1])&&(p[p.length-1]=l)}else p.length=0;return p})).subscribe(a=>e.innerHTML=a.join("").replace(/\s/g," ")),r.pipe(g(({mode:a})=>a==="search")).subscribe(a=>{a.type==="ArrowRight"&&e.innerText.length&&n.selectionStart===n.value.length&&(n.value=e.innerText)}),t.pipe(g(Sr),m(({data:a})=>a)).pipe(O(a=>o.next(a)),A(()=>o.complete()),m(()=>({ref:e})))}function _i(e,{index$:t,keyboard$:r}){let o=Te();try{let n=Ei(o.search,t),i=Ce("search-query",e),s=Ce("search-result",e);h(e,"click").pipe(g(({target:c})=>c instanceof Element&&!!c.closest("a"))).subscribe(()=>at("search",!1)),r.pipe(g(({mode:c})=>c==="search")).subscribe(c=>{let p=Ne();switch(c.type){case"Enter":if(p===i){let l=new Map;for(let f of M(":first-child [href]",s)){let u=f.firstElementChild;l.set(f,parseFloat(u.getAttribute("data-md-score")))}if(l.size){let[[f]]=[...l].sort(([,u],[,d])=>d-u);f.click()}c.claim()}break;case"Escape":case"Tab":at("search",!1),i.blur();break;case"ArrowUp":case"ArrowDown":if(typeof p=="undefined")i.focus();else{let l=[i,...M(":not(details) > [href], summary, details[open] [href]",s)],f=Math.max(0,(Math.max(0,l.indexOf(p))+l.length+(c.type==="ArrowUp"?-1:1))%l.length);l[f].focus()}c.claim();break;default:i!==Ne()&&i.focus()}}),r.pipe(g(({mode:c})=>c==="global")).subscribe(c=>{switch(c.type){case"f":case"s":case"/":i.focus(),i.select(),c.claim();break}});let a=Si(i,{worker$:n});return L(a,Oi(s,{worker$:n,query$:a})).pipe(Ve(...me("search-share",e).map(c=>Li(c,{query$:a})),...me("search-suggest",e).map(c=>Mi(c,{worker$:n,keyboard$:r}))))}catch(n){return e.hidden=!0,tt}}function Ai(e,{index$:t,location$:r}){return z([t,r.pipe(Q(we()),g(o=>!!o.searchParams.get("h")))]).pipe(m(([o,n])=>xi(o.config)(n.searchParams.get("h"))),m(o=>{var s;let n=new Map,i=document.createNodeIterator(e,NodeFilter.SHOW_TEXT);for(let a=i.nextNode();a;a=i.nextNode())if((s=a.parentElement)!=null&&s.offsetHeight){let c=a.textContent,p=o(c);p.length>c.length&&n.set(a,p)}for(let[a,c]of n){let{childNodes:p}=x("span",null,c);a.replaceWith(...Array.from(p))}return{ref:e,nodes:n}}))}function Ss(e,{viewport$:t,main$:r}){let o=e.closest(".md-grid"),n=o.offsetTop-o.parentElement.offsetTop;return z([r,t]).pipe(m(([{offset:i,height:s},{offset:{y:a}}])=>(s=s+Math.min(n,Math.max(0,a-i))-n,{height:s,locked:a>=i+n})),Y((i,s)=>i.height===s.height&&i.locked===s.locked))}function lo(e,o){var n=o,{header$:t}=n,r=vo(n,["header$"]);let i=j(".md-sidebar__scrollwrap",e),{y:s}=Be(i);return H(()=>{let a=new T,c=a.pipe(oe(),ae(!0)),p=a.pipe($e(0,ye));return p.pipe(te(t)).subscribe({next([{height:l},{height:f}]){i.style.height=`${l-2*s}px`,e.style.top=`${f}px`},complete(){i.style.height="",e.style.top=""}}),p.pipe(Re()).subscribe(()=>{for(let l of M(".md-nav__link--active[href]",e)){if(!l.clientHeight)continue;let f=l.closest(".md-sidebar__scrollwrap");if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2})}}}),fe(M("label[tabindex]",e)).pipe(J(l=>h(l,"click").pipe(xe(pe),m(()=>l),W(c)))).subscribe(l=>{let f=j(`[id="${l.htmlFor}"]`);j(`[aria-labelledby="${l.id}"]`).setAttribute("aria-expanded",`${f.checked}`)}),V("content.tooltips")&&fe(M("abbr[title]",e)).pipe(J(l=>Xe(l,{viewport$})),W(c)).subscribe(),Ss(e,r).pipe(O(l=>a.next(l)),A(()=>a.complete()),m(l=>P({ref:e},l)))})}function Ci(e,t){if(typeof t!="undefined"){let r=`https://api.github.com/repos/${e}/${t}`;return rt(ze(`${r}/releases/latest`).pipe(ve(()=>y),m(o=>({version:o.tag_name})),Qe({})),ze(r).pipe(ve(()=>y),m(o=>({stars:o.stargazers_count,forks:o.forks_count})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}else{let r=`https://api.github.com/users/${e}`;return ze(r).pipe(m(o=>({repositories:o.public_repos})),Qe({}))}}function ki(e,t){let r=`https://${e}/api/v4/projects/${encodeURIComponent(t)}`;return rt(ze(`${r}/releases/permalink/latest`).pipe(ve(()=>y),m(({tag_name:o})=>({version:o})),Qe({})),ze(r).pipe(ve(()=>y),m(({star_count:o,forks_count:n})=>({stars:o,forks:n})),Qe({}))).pipe(m(([o,n])=>P(P({},o),n)))}function Hi(e){let t=e.match(/^.+github\.com\/([^/]+)\/?([^/]+)?/i);if(t){let[,r,o]=t;return Ci(r,o)}if(t=e.match(/^.+?([^/]*gitlab[^/]+)\/(.+?)\/?$/i),t){let[,r,o]=t;return ki(r,o)}return y}var Os;function Ls(e){return Os||(Os=H(()=>{let t=__md_get("__source",sessionStorage);if(t)return $(t);if(me("consent").length){let o=__md_get("__consent");if(!(o&&o.github))return y}return Hi(e.href).pipe(O(o=>__md_set("__source",o,sessionStorage)))}).pipe(ve(()=>y),g(t=>Object.keys(t).length>0),m(t=>({facts:t})),Z(1)))}function $i(e){let t=j(":scope > :last-child",e);return H(()=>{let r=new T;return r.subscribe(({facts:o})=>{t.appendChild(jn(o)),t.classList.add("md-source__repository--active")}),Ls(e).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function Ms(e,{viewport$:t,header$:r}){return Le(document.body).pipe(b(()=>Er(e,{header$:r,viewport$:t})),m(({offset:{y:o}})=>({hidden:o>=10})),ne("hidden"))}function Pi(e,t){return H(()=>{let r=new T;return r.subscribe({next({hidden:o}){e.hidden=o},complete(){e.hidden=!1}}),(V("navigation.tabs.sticky")?$({hidden:!1}):Ms(e,t)).pipe(O(o=>r.next(o)),A(()=>r.complete()),m(o=>P({ref:e},o)))})}function _s(e,{viewport$:t,header$:r}){let o=new Map,n=M(".md-nav__link",e);for(let a of n){let c=decodeURIComponent(a.hash.substring(1)),p=ue(`[id="${c}"]`);typeof p!="undefined"&&o.set(a,p)}let i=r.pipe(ne("height"),m(({height:a})=>{let c=Ce("main"),p=j(":scope > :first-child",c);return a+.8*(p.offsetTop-c.offsetTop)}),le());return Le(document.body).pipe(ne("height"),b(a=>H(()=>{let c=[];return $([...o].reduce((p,[l,f])=>{for(;c.length&&o.get(c[c.length-1]).tagName>=f.tagName;)c.pop();let u=f.offsetTop;for(;!u&&f.parentElement;)f=f.parentElement,u=f.offsetTop;let d=f.offsetParent;for(;d;d=d.offsetParent)u+=d.offsetTop;return p.set([...c=[...c,l]].reverse(),u)},new Map))}).pipe(m(c=>new Map([...c].sort(([,p],[,l])=>p-l))),Pe(i),b(([c,p])=>t.pipe(Ut(([l,f],{offset:{y:u},size:d})=>{let v=u+d.height>=Math.floor(a.height);for(;f.length;){let[,S]=f[0];if(S-p=u&&!v)f=[l.pop(),...f];else break}return[l,f]},[[],[...c]]),Y((l,f)=>l[0]===f[0]&&l[1]===f[1])))))).pipe(m(([a,c])=>({prev:a.map(([p])=>p),next:c.map(([p])=>p)})),Q({prev:[],next:[]}),ot(2,1),m(([a,c])=>a.prev.length{let i=new T,s=i.pipe(oe(),ae(!0));if(i.subscribe(({prev:a,next:c})=>{for(let[p]of c)p.classList.remove("md-nav__link--passed"),p.classList.remove("md-nav__link--active");for(let[p,[l]]of a.entries())l.classList.add("md-nav__link--passed"),l.classList.toggle("md-nav__link--active",p===a.length-1)}),V("toc.follow")){let a=L(t.pipe(Ae(1),m(()=>{})),t.pipe(Ae(250),m(()=>"smooth")));i.pipe(g(({prev:c})=>c.length>0),Pe(o.pipe(xe(pe))),te(a)).subscribe(([[{prev:c}],p])=>{let[l]=c[c.length-1];if(l.offsetHeight){let f=vr(l);if(typeof f!="undefined"){let u=l.offsetTop-f.offsetTop,{height:d}=de(f);f.scrollTo({top:u-d/2,behavior:p})}}})}return V("navigation.tracking")&&t.pipe(W(s),ne("offset"),Ae(250),Ie(1),W(n.pipe(Ie(1))),vt({delay:250}),te(i)).subscribe(([,{prev:a}])=>{let c=we(),p=a[a.length-1];if(p&&p.length){let[l]=p,{hash:f}=new URL(l.href);c.hash!==f&&(c.hash=f,history.replaceState({},"",`${c}`))}else c.hash="",history.replaceState({},"",`${c}`)}),_s(e,{viewport$:t,header$:r}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))})}function As(e,{viewport$:t,main$:r,target$:o}){let n=t.pipe(m(({offset:{y:s}})=>s),ot(2,1),m(([s,a])=>s>a&&a>0),Y()),i=r.pipe(m(({active:s})=>s));return z([i,n]).pipe(m(([s,a])=>!(s&&a)),Y(),W(o.pipe(Ie(1))),ae(!0),vt({delay:250}),m(s=>({hidden:s})))}function Ii(e,{viewport$:t,header$:r,main$:o,target$:n}){let i=new T,s=i.pipe(oe(),ae(!0));return i.subscribe({next({hidden:a}){e.hidden=a,a?(e.setAttribute("tabindex","-1"),e.blur()):e.removeAttribute("tabindex")},complete(){e.style.top="",e.hidden=!0,e.removeAttribute("tabindex")}}),r.pipe(W(s),ne("height")).subscribe(({height:a})=>{e.style.top=`${a+16}px`}),h(e,"click").subscribe(a=>{a.preventDefault(),window.scrollTo({top:0})}),As(e,{viewport$:t,main$:o,target$:n}).pipe(O(a=>i.next(a)),A(()=>i.complete()),m(a=>P({ref:e},a)))}function Fi({document$:e,viewport$:t}){e.pipe(b(()=>M(".md-ellipsis")),J(r=>mt(r).pipe(W(e.pipe(Ie(1))),g(o=>o),m(()=>r),Ee(1))),g(r=>r.offsetWidth{let o=r.innerText,n=r.closest("a")||r;return n.title=o,V("content.tooltips")?Xe(n,{viewport$:t}).pipe(W(e.pipe(Ie(1))),A(()=>n.removeAttribute("title"))):y})).subscribe(),V("content.tooltips")&&e.pipe(b(()=>M(".md-status")),J(r=>Xe(r,{viewport$:t}))).subscribe()}function ji({document$:e,tablet$:t}){e.pipe(b(()=>M(".md-toggle--indeterminate")),O(r=>{r.indeterminate=!0,r.checked=!1}),J(r=>h(r,"change").pipe(Jr(()=>r.classList.contains("md-toggle--indeterminate")),m(()=>r))),te(t)).subscribe(([r,o])=>{r.classList.remove("md-toggle--indeterminate"),o&&(r.checked=!1)})}function Cs(){return/(iPad|iPhone|iPod)/.test(navigator.userAgent)}function Ui({document$:e}){e.pipe(b(()=>M("[data-md-scrollfix]")),O(t=>t.removeAttribute("data-md-scrollfix")),g(Cs),J(t=>h(t,"touchstart").pipe(m(()=>t)))).subscribe(t=>{let r=t.scrollTop;r===0?t.scrollTop=1:r+t.offsetHeight===t.scrollHeight&&(t.scrollTop=r-1)})}function Wi({viewport$:e,tablet$:t}){z([Je("search"),t]).pipe(m(([r,o])=>r&&!o),b(r=>$(r).pipe(nt(r?400:100))),te(e)).subscribe(([r,{offset:{y:o}}])=>{if(r)document.body.setAttribute("data-md-scrolllock",""),document.body.style.top=`-${o}px`;else{let n=-1*parseInt(document.body.style.top,10);document.body.removeAttribute("data-md-scrolllock"),document.body.style.top="",n&&window.scrollTo(0,n)}})}Object.entries||(Object.entries=function(e){let t=[];for(let r of Object.keys(e))t.push([r,e[r]]);return t});Object.values||(Object.values=function(e){let t=[];for(let r of Object.keys(e))t.push(e[r]);return t});typeof Element!="undefined"&&(Element.prototype.scrollTo||(Element.prototype.scrollTo=function(e,t){typeof e=="object"?(this.scrollLeft=e.left,this.scrollTop=e.top):(this.scrollLeft=e,this.scrollTop=t)}),Element.prototype.replaceWith||(Element.prototype.replaceWith=function(...e){let t=this.parentNode;if(t){e.length===0&&t.removeChild(this);for(let r=e.length-1;r>=0;r--){let o=e[r];typeof o=="string"?o=document.createTextNode(o):o.parentNode&&o.parentNode.removeChild(o),r?t.insertBefore(this.previousSibling,o):t.replaceChild(o,this)}}}));function ks(){return location.protocol==="file:"?_t(`${new URL("search/search_index.js",Or.base)}`).pipe(m(()=>__index),Z(1)):ze(new URL("search/search_index.json",Or.base))}document.documentElement.classList.remove("no-js");document.documentElement.classList.add("js");var ct=an(),Kt=bn(),Ht=yn(Kt),mo=hn(),ke=Ln(),Lr=Wt("(min-width: 60em)"),Vi=Wt("(min-width: 76.25em)"),Ni=xn(),Or=Te(),zi=document.forms.namedItem("search")?ks():tt,fo=new T;di({alert$:fo});ui({document$:ct});var uo=new T,qi=kt(Or.base);V("navigation.instant")&&gi({sitemap$:qi,location$:Kt,viewport$:ke,progress$:uo}).subscribe(ct);var Di;((Di=Or.version)==null?void 0:Di.provider)==="mike"&&Ti({document$:ct});L(Kt,Ht).pipe(nt(125)).subscribe(()=>{at("drawer",!1),at("search",!1)});mo.pipe(g(({mode:e})=>e==="global")).subscribe(e=>{switch(e.type){case"p":case",":let t=ue("link[rel=prev]");typeof t!="undefined"&&st(t);break;case"n":case".":let r=ue("link[rel=next]");typeof r!="undefined"&&st(r);break;case"Enter":let o=Ne();o instanceof HTMLLabelElement&&o.click()}});Fi({viewport$:ke,document$:ct});ji({document$:ct,tablet$:Lr});Ui({document$:ct});Wi({viewport$:ke,tablet$:Lr});var ft=ai(Ce("header"),{viewport$:ke}),qt=ct.pipe(m(()=>Ce("main")),b(e=>pi(e,{viewport$:ke,header$:ft})),Z(1)),Hs=L(...me("consent").map(e=>An(e,{target$:Ht})),...me("dialog").map(e=>ni(e,{alert$:fo})),...me("palette").map(e=>li(e)),...me("progress").map(e=>mi(e,{progress$:uo})),...me("search").map(e=>_i(e,{index$:zi,keyboard$:mo})),...me("source").map(e=>$i(e))),$s=H(()=>L(...me("announce").map(e=>_n(e)),...me("content").map(e=>oi(e,{sitemap$:qi,viewport$:ke,target$:Ht,print$:Ni})),...me("content").map(e=>V("search.highlight")?Ai(e,{index$:zi,location$:Kt}):y),...me("header").map(e=>si(e,{viewport$:ke,header$:ft,main$:qt})),...me("header-title").map(e=>ci(e,{viewport$:ke,header$:ft})),...me("sidebar").map(e=>e.getAttribute("data-md-type")==="navigation"?eo(Vi,()=>lo(e,{viewport$:ke,header$:ft,main$:qt})):eo(Lr,()=>lo(e,{viewport$:ke,header$:ft,main$:qt}))),...me("tabs").map(e=>Pi(e,{viewport$:ke,header$:ft})),...me("toc").map(e=>Ri(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})),...me("top").map(e=>Ii(e,{viewport$:ke,header$:ft,main$:qt,target$:Ht})))),Ki=ct.pipe(b(()=>$s),Ve(Hs),Z(1));Ki.subscribe();window.document$=ct;window.location$=Kt;window.target$=Ht;window.keyboard$=mo;window.viewport$=ke;window.tablet$=Lr;window.screen$=Vi;window.print$=Ni;window.alert$=fo;window.progress$=uo;window.component$=Ki;})(); -//# sourceMappingURL=bundle.79ae519e.min.js.map - diff --git a/site/assets/javascripts/bundle.79ae519e.min.js.map b/site/assets/javascripts/bundle.79ae519e.min.js.map deleted file mode 100644 index 5cf0289..0000000 --- a/site/assets/javascripts/bundle.79ae519e.min.js.map +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": 3, - "sources": ["node_modules/focus-visible/dist/focus-visible.js", "node_modules/escape-html/index.js", "node_modules/clipboard/dist/clipboard.js", "src/templates/assets/javascripts/bundle.ts", "node_modules/tslib/tslib.es6.mjs", "node_modules/rxjs/src/internal/util/isFunction.ts", "node_modules/rxjs/src/internal/util/createErrorClass.ts", "node_modules/rxjs/src/internal/util/UnsubscriptionError.ts", "node_modules/rxjs/src/internal/util/arrRemove.ts", "node_modules/rxjs/src/internal/Subscription.ts", "node_modules/rxjs/src/internal/config.ts", "node_modules/rxjs/src/internal/scheduler/timeoutProvider.ts", "node_modules/rxjs/src/internal/util/reportUnhandledError.ts", "node_modules/rxjs/src/internal/util/noop.ts", "node_modules/rxjs/src/internal/NotificationFactories.ts", "node_modules/rxjs/src/internal/util/errorContext.ts", "node_modules/rxjs/src/internal/Subscriber.ts", "node_modules/rxjs/src/internal/symbol/observable.ts", "node_modules/rxjs/src/internal/util/identity.ts", "node_modules/rxjs/src/internal/util/pipe.ts", "node_modules/rxjs/src/internal/Observable.ts", "node_modules/rxjs/src/internal/util/lift.ts", "node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts", "node_modules/rxjs/src/internal/scheduler/animationFrameProvider.ts", "node_modules/rxjs/src/internal/util/ObjectUnsubscribedError.ts", "node_modules/rxjs/src/internal/Subject.ts", "node_modules/rxjs/src/internal/BehaviorSubject.ts", "node_modules/rxjs/src/internal/scheduler/dateTimestampProvider.ts", "node_modules/rxjs/src/internal/ReplaySubject.ts", "node_modules/rxjs/src/internal/scheduler/Action.ts", "node_modules/rxjs/src/internal/scheduler/intervalProvider.ts", "node_modules/rxjs/src/internal/scheduler/AsyncAction.ts", "node_modules/rxjs/src/internal/Scheduler.ts", "node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts", "node_modules/rxjs/src/internal/scheduler/async.ts", "node_modules/rxjs/src/internal/scheduler/QueueAction.ts", "node_modules/rxjs/src/internal/scheduler/QueueScheduler.ts", "node_modules/rxjs/src/internal/scheduler/queue.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameAction.ts", "node_modules/rxjs/src/internal/scheduler/AnimationFrameScheduler.ts", "node_modules/rxjs/src/internal/scheduler/animationFrame.ts", "node_modules/rxjs/src/internal/observable/empty.ts", "node_modules/rxjs/src/internal/util/isScheduler.ts", "node_modules/rxjs/src/internal/util/args.ts", "node_modules/rxjs/src/internal/util/isArrayLike.ts", "node_modules/rxjs/src/internal/util/isPromise.ts", "node_modules/rxjs/src/internal/util/isInteropObservable.ts", "node_modules/rxjs/src/internal/util/isAsyncIterable.ts", "node_modules/rxjs/src/internal/util/throwUnobservableError.ts", "node_modules/rxjs/src/internal/symbol/iterator.ts", "node_modules/rxjs/src/internal/util/isIterable.ts", "node_modules/rxjs/src/internal/util/isReadableStreamLike.ts", "node_modules/rxjs/src/internal/observable/innerFrom.ts", "node_modules/rxjs/src/internal/util/executeSchedule.ts", "node_modules/rxjs/src/internal/operators/observeOn.ts", "node_modules/rxjs/src/internal/operators/subscribeOn.ts", "node_modules/rxjs/src/internal/scheduled/scheduleObservable.ts", "node_modules/rxjs/src/internal/scheduled/schedulePromise.ts", "node_modules/rxjs/src/internal/scheduled/scheduleArray.ts", "node_modules/rxjs/src/internal/scheduled/scheduleIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleAsyncIterable.ts", "node_modules/rxjs/src/internal/scheduled/scheduleReadableStreamLike.ts", "node_modules/rxjs/src/internal/scheduled/scheduled.ts", "node_modules/rxjs/src/internal/observable/from.ts", "node_modules/rxjs/src/internal/observable/of.ts", "node_modules/rxjs/src/internal/observable/throwError.ts", "node_modules/rxjs/src/internal/util/EmptyError.ts", "node_modules/rxjs/src/internal/util/isDate.ts", "node_modules/rxjs/src/internal/operators/map.ts", "node_modules/rxjs/src/internal/util/mapOneOrManyArgs.ts", "node_modules/rxjs/src/internal/util/argsArgArrayOrObject.ts", "node_modules/rxjs/src/internal/util/createObject.ts", "node_modules/rxjs/src/internal/observable/combineLatest.ts", "node_modules/rxjs/src/internal/operators/mergeInternals.ts", "node_modules/rxjs/src/internal/operators/mergeMap.ts", "node_modules/rxjs/src/internal/operators/mergeAll.ts", "node_modules/rxjs/src/internal/operators/concatAll.ts", "node_modules/rxjs/src/internal/observable/concat.ts", "node_modules/rxjs/src/internal/observable/defer.ts", "node_modules/rxjs/src/internal/observable/fromEvent.ts", "node_modules/rxjs/src/internal/observable/fromEventPattern.ts", "node_modules/rxjs/src/internal/observable/timer.ts", "node_modules/rxjs/src/internal/observable/merge.ts", "node_modules/rxjs/src/internal/observable/never.ts", "node_modules/rxjs/src/internal/util/argsOrArgArray.ts", "node_modules/rxjs/src/internal/operators/filter.ts", "node_modules/rxjs/src/internal/observable/zip.ts", "node_modules/rxjs/src/internal/operators/audit.ts", "node_modules/rxjs/src/internal/operators/auditTime.ts", "node_modules/rxjs/src/internal/operators/bufferCount.ts", "node_modules/rxjs/src/internal/operators/catchError.ts", "node_modules/rxjs/src/internal/operators/scanInternals.ts", "node_modules/rxjs/src/internal/operators/combineLatest.ts", "node_modules/rxjs/src/internal/operators/combineLatestWith.ts", "node_modules/rxjs/src/internal/operators/debounce.ts", "node_modules/rxjs/src/internal/operators/debounceTime.ts", "node_modules/rxjs/src/internal/operators/defaultIfEmpty.ts", "node_modules/rxjs/src/internal/operators/take.ts", "node_modules/rxjs/src/internal/operators/ignoreElements.ts", "node_modules/rxjs/src/internal/operators/mapTo.ts", "node_modules/rxjs/src/internal/operators/delayWhen.ts", "node_modules/rxjs/src/internal/operators/delay.ts", "node_modules/rxjs/src/internal/operators/distinct.ts", "node_modules/rxjs/src/internal/operators/distinctUntilChanged.ts", "node_modules/rxjs/src/internal/operators/distinctUntilKeyChanged.ts", "node_modules/rxjs/src/internal/operators/throwIfEmpty.ts", "node_modules/rxjs/src/internal/operators/endWith.ts", "node_modules/rxjs/src/internal/operators/exhaustMap.ts", "node_modules/rxjs/src/internal/operators/finalize.ts", "node_modules/rxjs/src/internal/operators/first.ts", "node_modules/rxjs/src/internal/operators/takeLast.ts", "node_modules/rxjs/src/internal/operators/merge.ts", "node_modules/rxjs/src/internal/operators/mergeWith.ts", "node_modules/rxjs/src/internal/operators/repeat.ts", "node_modules/rxjs/src/internal/operators/scan.ts", "node_modules/rxjs/src/internal/operators/share.ts", "node_modules/rxjs/src/internal/operators/shareReplay.ts", "node_modules/rxjs/src/internal/operators/skip.ts", "node_modules/rxjs/src/internal/operators/skipUntil.ts", "node_modules/rxjs/src/internal/operators/startWith.ts", "node_modules/rxjs/src/internal/operators/switchMap.ts", "node_modules/rxjs/src/internal/operators/takeUntil.ts", "node_modules/rxjs/src/internal/operators/takeWhile.ts", "node_modules/rxjs/src/internal/operators/tap.ts", "node_modules/rxjs/src/internal/operators/throttle.ts", "node_modules/rxjs/src/internal/operators/throttleTime.ts", "node_modules/rxjs/src/internal/operators/withLatestFrom.ts", "node_modules/rxjs/src/internal/operators/zip.ts", "node_modules/rxjs/src/internal/operators/zipWith.ts", "src/templates/assets/javascripts/browser/document/index.ts", "src/templates/assets/javascripts/browser/element/_/index.ts", "src/templates/assets/javascripts/browser/element/focus/index.ts", "src/templates/assets/javascripts/browser/element/hover/index.ts", "src/templates/assets/javascripts/utilities/h/index.ts", "src/templates/assets/javascripts/utilities/round/index.ts", "src/templates/assets/javascripts/browser/script/index.ts", "src/templates/assets/javascripts/browser/element/size/_/index.ts", "src/templates/assets/javascripts/browser/element/size/content/index.ts", "src/templates/assets/javascripts/browser/element/offset/_/index.ts", "src/templates/assets/javascripts/browser/element/offset/content/index.ts", "src/templates/assets/javascripts/browser/element/visibility/index.ts", "src/templates/assets/javascripts/browser/toggle/index.ts", "src/templates/assets/javascripts/browser/keyboard/index.ts", "src/templates/assets/javascripts/browser/location/_/index.ts", "src/templates/assets/javascripts/browser/location/hash/index.ts", "src/templates/assets/javascripts/browser/media/index.ts", "src/templates/assets/javascripts/browser/request/index.ts", "src/templates/assets/javascripts/browser/viewport/offset/index.ts", "src/templates/assets/javascripts/browser/viewport/size/index.ts", "src/templates/assets/javascripts/browser/viewport/_/index.ts", "src/templates/assets/javascripts/browser/viewport/at/index.ts", "src/templates/assets/javascripts/browser/worker/index.ts", "src/templates/assets/javascripts/_/index.ts", "src/templates/assets/javascripts/components/_/index.ts", "src/templates/assets/javascripts/components/announce/index.ts", "src/templates/assets/javascripts/components/consent/index.ts", "src/templates/assets/javascripts/templates/tooltip/index.tsx", "src/templates/assets/javascripts/templates/annotation/index.tsx", "src/templates/assets/javascripts/templates/clipboard/index.tsx", "src/templates/assets/javascripts/templates/search/index.tsx", "src/templates/assets/javascripts/templates/source/index.tsx", "src/templates/assets/javascripts/templates/tabbed/index.tsx", "src/templates/assets/javascripts/templates/table/index.tsx", "src/templates/assets/javascripts/templates/version/index.tsx", "src/templates/assets/javascripts/components/tooltip2/index.ts", "src/templates/assets/javascripts/components/content/annotation/_/index.ts", "src/templates/assets/javascripts/components/content/annotation/list/index.ts", "src/templates/assets/javascripts/components/content/annotation/block/index.ts", "src/templates/assets/javascripts/components/content/code/_/index.ts", "src/templates/assets/javascripts/components/content/details/index.ts", "src/templates/assets/javascripts/components/content/link/index.ts", "src/templates/assets/javascripts/components/content/mermaid/index.css", "src/templates/assets/javascripts/components/content/mermaid/index.ts", "src/templates/assets/javascripts/components/content/table/index.ts", "src/templates/assets/javascripts/components/content/tabs/index.ts", "src/templates/assets/javascripts/components/content/_/index.ts", "src/templates/assets/javascripts/components/dialog/index.ts", "src/templates/assets/javascripts/components/tooltip/index.ts", "src/templates/assets/javascripts/components/header/_/index.ts", "src/templates/assets/javascripts/components/header/title/index.ts", "src/templates/assets/javascripts/components/main/index.ts", "src/templates/assets/javascripts/components/palette/index.ts", "src/templates/assets/javascripts/components/progress/index.ts", "src/templates/assets/javascripts/integrations/sitemap/index.ts", "src/templates/assets/javascripts/integrations/alternate/index.ts", "src/templates/assets/javascripts/integrations/clipboard/index.ts", "src/templates/assets/javascripts/integrations/instant/index.ts", "src/templates/assets/javascripts/integrations/search/highlighter/index.ts", "src/templates/assets/javascripts/integrations/search/worker/message/index.ts", "src/templates/assets/javascripts/integrations/search/worker/_/index.ts", "src/templates/assets/javascripts/integrations/version/findurl/index.ts", "src/templates/assets/javascripts/integrations/version/index.ts", "src/templates/assets/javascripts/components/search/query/index.ts", "src/templates/assets/javascripts/components/search/result/index.ts", "src/templates/assets/javascripts/components/search/share/index.ts", "src/templates/assets/javascripts/components/search/suggest/index.ts", "src/templates/assets/javascripts/components/search/_/index.ts", "src/templates/assets/javascripts/components/search/highlight/index.ts", "src/templates/assets/javascripts/components/sidebar/index.ts", "src/templates/assets/javascripts/components/source/facts/github/index.ts", "src/templates/assets/javascripts/components/source/facts/gitlab/index.ts", "src/templates/assets/javascripts/components/source/facts/_/index.ts", "src/templates/assets/javascripts/components/source/_/index.ts", "src/templates/assets/javascripts/components/tabs/index.ts", "src/templates/assets/javascripts/components/toc/index.ts", "src/templates/assets/javascripts/components/top/index.ts", "src/templates/assets/javascripts/patches/ellipsis/index.ts", "src/templates/assets/javascripts/patches/indeterminate/index.ts", "src/templates/assets/javascripts/patches/scrollfix/index.ts", "src/templates/assets/javascripts/patches/scrolllock/index.ts", "src/templates/assets/javascripts/polyfills/index.ts"], - "sourcesContent": ["(function (global, factory) {\n typeof exports === 'object' && typeof module !== 'undefined' ? factory() :\n typeof define === 'function' && define.amd ? define(factory) :\n (factory());\n}(this, (function () { 'use strict';\n\n /**\n * Applies the :focus-visible polyfill at the given scope.\n * A scope in this case is either the top-level Document or a Shadow Root.\n *\n * @param {(Document|ShadowRoot)} scope\n * @see https://github.com/WICG/focus-visible\n */\n function applyFocusVisiblePolyfill(scope) {\n var hadKeyboardEvent = true;\n var hadFocusVisibleRecently = false;\n var hadFocusVisibleRecentlyTimeout = null;\n\n var inputTypesAllowlist = {\n text: true,\n search: true,\n url: true,\n tel: true,\n email: true,\n password: true,\n number: true,\n date: true,\n month: true,\n week: true,\n time: true,\n datetime: true,\n 'datetime-local': true\n };\n\n /**\n * Helper function for legacy browsers and iframes which sometimes focus\n * elements like document, body, and non-interactive SVG.\n * @param {Element} el\n */\n function isValidFocusTarget(el) {\n if (\n el &&\n el !== document &&\n el.nodeName !== 'HTML' &&\n el.nodeName !== 'BODY' &&\n 'classList' in el &&\n 'contains' in el.classList\n ) {\n return true;\n }\n return false;\n }\n\n /**\n * Computes whether the given element should automatically trigger the\n * `focus-visible` class being added, i.e. whether it should always match\n * `:focus-visible` when focused.\n * @param {Element} el\n * @return {boolean}\n */\n function focusTriggersKeyboardModality(el) {\n var type = el.type;\n var tagName = el.tagName;\n\n if (tagName === 'INPUT' && inputTypesAllowlist[type] && !el.readOnly) {\n return true;\n }\n\n if (tagName === 'TEXTAREA' && !el.readOnly) {\n return true;\n }\n\n if (el.isContentEditable) {\n return true;\n }\n\n return false;\n }\n\n /**\n * Add the `focus-visible` class to the given element if it was not added by\n * the author.\n * @param {Element} el\n */\n function addFocusVisibleClass(el) {\n if (el.classList.contains('focus-visible')) {\n return;\n }\n el.classList.add('focus-visible');\n el.setAttribute('data-focus-visible-added', '');\n }\n\n /**\n * Remove the `focus-visible` class from the given element if it was not\n * originally added by the author.\n * @param {Element} el\n */\n function removeFocusVisibleClass(el) {\n if (!el.hasAttribute('data-focus-visible-added')) {\n return;\n }\n el.classList.remove('focus-visible');\n el.removeAttribute('data-focus-visible-added');\n }\n\n /**\n * If the most recent user interaction was via the keyboard;\n * and the key press did not include a meta, alt/option, or control key;\n * then the modality is keyboard. Otherwise, the modality is not keyboard.\n * Apply `focus-visible` to any current active element and keep track\n * of our keyboard modality state with `hadKeyboardEvent`.\n * @param {KeyboardEvent} e\n */\n function onKeyDown(e) {\n if (e.metaKey || e.altKey || e.ctrlKey) {\n return;\n }\n\n if (isValidFocusTarget(scope.activeElement)) {\n addFocusVisibleClass(scope.activeElement);\n }\n\n hadKeyboardEvent = true;\n }\n\n /**\n * If at any point a user clicks with a pointing device, ensure that we change\n * the modality away from keyboard.\n * This avoids the situation where a user presses a key on an already focused\n * element, and then clicks on a different element, focusing it with a\n * pointing device, while we still think we're in keyboard modality.\n * @param {Event} e\n */\n function onPointerDown(e) {\n hadKeyboardEvent = false;\n }\n\n /**\n * On `focus`, add the `focus-visible` class to the target if:\n * - the target received focus as a result of keyboard navigation, or\n * - the event target is an element that will likely require interaction\n * via the keyboard (e.g. a text box)\n * @param {Event} e\n */\n function onFocus(e) {\n // Prevent IE from focusing the document or HTML element.\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (hadKeyboardEvent || focusTriggersKeyboardModality(e.target)) {\n addFocusVisibleClass(e.target);\n }\n }\n\n /**\n * On `blur`, remove the `focus-visible` class from the target.\n * @param {Event} e\n */\n function onBlur(e) {\n if (!isValidFocusTarget(e.target)) {\n return;\n }\n\n if (\n e.target.classList.contains('focus-visible') ||\n e.target.hasAttribute('data-focus-visible-added')\n ) {\n // To detect a tab/window switch, we look for a blur event followed\n // rapidly by a visibility change.\n // If we don't see a visibility change within 100ms, it's probably a\n // regular focus change.\n hadFocusVisibleRecently = true;\n window.clearTimeout(hadFocusVisibleRecentlyTimeout);\n hadFocusVisibleRecentlyTimeout = window.setTimeout(function() {\n hadFocusVisibleRecently = false;\n }, 100);\n removeFocusVisibleClass(e.target);\n }\n }\n\n /**\n * If the user changes tabs, keep track of whether or not the previously\n * focused element had .focus-visible.\n * @param {Event} e\n */\n function onVisibilityChange(e) {\n if (document.visibilityState === 'hidden') {\n // If the tab becomes active again, the browser will handle calling focus\n // on the element (Safari actually calls it twice).\n // If this tab change caused a blur on an element with focus-visible,\n // re-apply the class when the user switches back to the tab.\n if (hadFocusVisibleRecently) {\n hadKeyboardEvent = true;\n }\n addInitialPointerMoveListeners();\n }\n }\n\n /**\n * Add a group of listeners to detect usage of any pointing devices.\n * These listeners will be added when the polyfill first loads, and anytime\n * the window is blurred, so that they are active when the window regains\n * focus.\n */\n function addInitialPointerMoveListeners() {\n document.addEventListener('mousemove', onInitialPointerMove);\n document.addEventListener('mousedown', onInitialPointerMove);\n document.addEventListener('mouseup', onInitialPointerMove);\n document.addEventListener('pointermove', onInitialPointerMove);\n document.addEventListener('pointerdown', onInitialPointerMove);\n document.addEventListener('pointerup', onInitialPointerMove);\n document.addEventListener('touchmove', onInitialPointerMove);\n document.addEventListener('touchstart', onInitialPointerMove);\n document.addEventListener('touchend', onInitialPointerMove);\n }\n\n function removeInitialPointerMoveListeners() {\n document.removeEventListener('mousemove', onInitialPointerMove);\n document.removeEventListener('mousedown', onInitialPointerMove);\n document.removeEventListener('mouseup', onInitialPointerMove);\n document.removeEventListener('pointermove', onInitialPointerMove);\n document.removeEventListener('pointerdown', onInitialPointerMove);\n document.removeEventListener('pointerup', onInitialPointerMove);\n document.removeEventListener('touchmove', onInitialPointerMove);\n document.removeEventListener('touchstart', onInitialPointerMove);\n document.removeEventListener('touchend', onInitialPointerMove);\n }\n\n /**\n * When the polfyill first loads, assume the user is in keyboard modality.\n * If any event is received from a pointing device (e.g. mouse, pointer,\n * touch), turn off keyboard modality.\n * This accounts for situations where focus enters the page from the URL bar.\n * @param {Event} e\n */\n function onInitialPointerMove(e) {\n // Work around a Safari quirk that fires a mousemove on whenever the\n // window blurs, even if you're tabbing out of the page. \u00AF\\_(\u30C4)_/\u00AF\n if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {\n return;\n }\n\n hadKeyboardEvent = false;\n removeInitialPointerMoveListeners();\n }\n\n // For some kinds of state, we are interested in changes at the global scope\n // only. For example, global pointer input, global key presses and global\n // visibility change should affect the state at every scope:\n document.addEventListener('keydown', onKeyDown, true);\n document.addEventListener('mousedown', onPointerDown, true);\n document.addEventListener('pointerdown', onPointerDown, true);\n document.addEventListener('touchstart', onPointerDown, true);\n document.addEventListener('visibilitychange', onVisibilityChange, true);\n\n addInitialPointerMoveListeners();\n\n // For focus and blur, we specifically care about state changes in the local\n // scope. This is because focus / blur events that originate from within a\n // shadow root are not re-dispatched from the host element if it was already\n // the active element in its own scope:\n scope.addEventListener('focus', onFocus, true);\n scope.addEventListener('blur', onBlur, true);\n\n // We detect that a node is a ShadowRoot by ensuring that it is a\n // DocumentFragment and also has a host property. This check covers native\n // implementation and polyfill implementation transparently. If we only cared\n // about the native implementation, we could just check if the scope was\n // an instance of a ShadowRoot.\n if (scope.nodeType === Node.DOCUMENT_FRAGMENT_NODE && scope.host) {\n // Since a ShadowRoot is a special kind of DocumentFragment, it does not\n // have a root element to add a class to. So, we add this attribute to the\n // host element instead:\n scope.host.setAttribute('data-js-focus-visible', '');\n } else if (scope.nodeType === Node.DOCUMENT_NODE) {\n document.documentElement.classList.add('js-focus-visible');\n document.documentElement.setAttribute('data-js-focus-visible', '');\n }\n }\n\n // It is important to wrap all references to global window and document in\n // these checks to support server-side rendering use cases\n // @see https://github.com/WICG/focus-visible/issues/199\n if (typeof window !== 'undefined' && typeof document !== 'undefined') {\n // Make the polyfill helper globally available. This can be used as a signal\n // to interested libraries that wish to coordinate with the polyfill for e.g.,\n // applying the polyfill to a shadow root:\n window.applyFocusVisiblePolyfill = applyFocusVisiblePolyfill;\n\n // Notify interested libraries of the polyfill's presence, in case the\n // polyfill was loaded lazily:\n var event;\n\n try {\n event = new CustomEvent('focus-visible-polyfill-ready');\n } catch (error) {\n // IE11 does not support using CustomEvent as a constructor directly:\n event = document.createEvent('CustomEvent');\n event.initCustomEvent('focus-visible-polyfill-ready', false, false, {});\n }\n\n window.dispatchEvent(event);\n }\n\n if (typeof document !== 'undefined') {\n // Apply the polyfill to the global document, so that no JavaScript\n // coordination is required to use the polyfill in the top-level document:\n applyFocusVisiblePolyfill(document);\n }\n\n})));\n", "/*!\n * escape-html\n * Copyright(c) 2012-2013 TJ Holowaychuk\n * Copyright(c) 2015 Andreas Lubbe\n * Copyright(c) 2015 Tiancheng \"Timothy\" Gu\n * MIT Licensed\n */\n\n'use strict';\n\n/**\n * Module variables.\n * @private\n */\n\nvar matchHtmlRegExp = /[\"'&<>]/;\n\n/**\n * Module exports.\n * @public\n */\n\nmodule.exports = escapeHtml;\n\n/**\n * Escape special characters in the given string of html.\n *\n * @param {string} string The string to escape for inserting into HTML\n * @return {string}\n * @public\n */\n\nfunction escapeHtml(string) {\n var str = '' + string;\n var match = matchHtmlRegExp.exec(str);\n\n if (!match) {\n return str;\n }\n\n var escape;\n var html = '';\n var index = 0;\n var lastIndex = 0;\n\n for (index = match.index; index < str.length; index++) {\n switch (str.charCodeAt(index)) {\n case 34: // \"\n escape = '"';\n break;\n case 38: // &\n escape = '&';\n break;\n case 39: // '\n escape = ''';\n break;\n case 60: // <\n escape = '<';\n break;\n case 62: // >\n escape = '>';\n break;\n default:\n continue;\n }\n\n if (lastIndex !== index) {\n html += str.substring(lastIndex, index);\n }\n\n lastIndex = index + 1;\n html += escape;\n }\n\n return lastIndex !== index\n ? html + str.substring(lastIndex, index)\n : html;\n}\n", "/*!\n * clipboard.js v2.0.11\n * https://clipboardjs.com/\n *\n * Licensed MIT \u00A9 Zeno Rocha\n */\n(function webpackUniversalModuleDefinition(root, factory) {\n\tif(typeof exports === 'object' && typeof module === 'object')\n\t\tmodule.exports = factory();\n\telse if(typeof define === 'function' && define.amd)\n\t\tdefine([], factory);\n\telse if(typeof exports === 'object')\n\t\texports[\"ClipboardJS\"] = factory();\n\telse\n\t\troot[\"ClipboardJS\"] = factory();\n})(this, function() {\nreturn /******/ (function() { // webpackBootstrap\n/******/ \tvar __webpack_modules__ = ({\n\n/***/ 686:\n/***/ (function(__unused_webpack_module, __webpack_exports__, __webpack_require__) {\n\n\"use strict\";\n\n// EXPORTS\n__webpack_require__.d(__webpack_exports__, {\n \"default\": function() { return /* binding */ clipboard; }\n});\n\n// EXTERNAL MODULE: ./node_modules/tiny-emitter/index.js\nvar tiny_emitter = __webpack_require__(279);\nvar tiny_emitter_default = /*#__PURE__*/__webpack_require__.n(tiny_emitter);\n// EXTERNAL MODULE: ./node_modules/good-listener/src/listen.js\nvar listen = __webpack_require__(370);\nvar listen_default = /*#__PURE__*/__webpack_require__.n(listen);\n// EXTERNAL MODULE: ./node_modules/select/src/select.js\nvar src_select = __webpack_require__(817);\nvar select_default = /*#__PURE__*/__webpack_require__.n(src_select);\n;// CONCATENATED MODULE: ./src/common/command.js\n/**\n * Executes a given operation type.\n * @param {String} type\n * @return {Boolean}\n */\nfunction command(type) {\n try {\n return document.execCommand(type);\n } catch (err) {\n return false;\n }\n}\n;// CONCATENATED MODULE: ./src/actions/cut.js\n\n\n/**\n * Cut action wrapper.\n * @param {String|HTMLElement} target\n * @return {String}\n */\n\nvar ClipboardActionCut = function ClipboardActionCut(target) {\n var selectedText = select_default()(target);\n command('cut');\n return selectedText;\n};\n\n/* harmony default export */ var actions_cut = (ClipboardActionCut);\n;// CONCATENATED MODULE: ./src/common/create-fake-element.js\n/**\n * Creates a fake textarea element with a value.\n * @param {String} value\n * @return {HTMLElement}\n */\nfunction createFakeElement(value) {\n var isRTL = document.documentElement.getAttribute('dir') === 'rtl';\n var fakeElement = document.createElement('textarea'); // Prevent zooming on iOS\n\n fakeElement.style.fontSize = '12pt'; // Reset box model\n\n fakeElement.style.border = '0';\n fakeElement.style.padding = '0';\n fakeElement.style.margin = '0'; // Move element out of screen horizontally\n\n fakeElement.style.position = 'absolute';\n fakeElement.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically\n\n var yPosition = window.pageYOffset || document.documentElement.scrollTop;\n fakeElement.style.top = \"\".concat(yPosition, \"px\");\n fakeElement.setAttribute('readonly', '');\n fakeElement.value = value;\n return fakeElement;\n}\n;// CONCATENATED MODULE: ./src/actions/copy.js\n\n\n\n/**\n * Create fake copy action wrapper using a fake element.\n * @param {String} target\n * @param {Object} options\n * @return {String}\n */\n\nvar fakeCopyAction = function fakeCopyAction(value, options) {\n var fakeElement = createFakeElement(value);\n options.container.appendChild(fakeElement);\n var selectedText = select_default()(fakeElement);\n command('copy');\n fakeElement.remove();\n return selectedText;\n};\n/**\n * Copy action wrapper.\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @return {String}\n */\n\n\nvar ClipboardActionCopy = function ClipboardActionCopy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n var selectedText = '';\n\n if (typeof target === 'string') {\n selectedText = fakeCopyAction(target, options);\n } else if (target instanceof HTMLInputElement && !['text', 'search', 'url', 'tel', 'password'].includes(target === null || target === void 0 ? void 0 : target.type)) {\n // If input type doesn't support `setSelectionRange`. Simulate it. https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/setSelectionRange\n selectedText = fakeCopyAction(target.value, options);\n } else {\n selectedText = select_default()(target);\n command('copy');\n }\n\n return selectedText;\n};\n\n/* harmony default export */ var actions_copy = (ClipboardActionCopy);\n;// CONCATENATED MODULE: ./src/actions/default.js\nfunction _typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return _typeof(obj); }\n\n\n\n/**\n * Inner function which performs selection from either `text` or `target`\n * properties and then executes copy or cut operations.\n * @param {Object} options\n */\n\nvar ClipboardActionDefault = function ClipboardActionDefault() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n // Defines base properties passed from constructor.\n var _options$action = options.action,\n action = _options$action === void 0 ? 'copy' : _options$action,\n container = options.container,\n target = options.target,\n text = options.text; // Sets the `action` to be performed which can be either 'copy' or 'cut'.\n\n if (action !== 'copy' && action !== 'cut') {\n throw new Error('Invalid \"action\" value, use either \"copy\" or \"cut\"');\n } // Sets the `target` property using an element that will be have its content copied.\n\n\n if (target !== undefined) {\n if (target && _typeof(target) === 'object' && target.nodeType === 1) {\n if (action === 'copy' && target.hasAttribute('disabled')) {\n throw new Error('Invalid \"target\" attribute. Please use \"readonly\" instead of \"disabled\" attribute');\n }\n\n if (action === 'cut' && (target.hasAttribute('readonly') || target.hasAttribute('disabled'))) {\n throw new Error('Invalid \"target\" attribute. You can\\'t cut text from elements with \"readonly\" or \"disabled\" attributes');\n }\n } else {\n throw new Error('Invalid \"target\" value, use a valid Element');\n }\n } // Define selection strategy based on `text` property.\n\n\n if (text) {\n return actions_copy(text, {\n container: container\n });\n } // Defines which selection strategy based on `target` property.\n\n\n if (target) {\n return action === 'cut' ? actions_cut(target) : actions_copy(target, {\n container: container\n });\n }\n};\n\n/* harmony default export */ var actions_default = (ClipboardActionDefault);\n;// CONCATENATED MODULE: ./src/clipboard.js\nfunction clipboard_typeof(obj) { \"@babel/helpers - typeof\"; if (typeof Symbol === \"function\" && typeof Symbol.iterator === \"symbol\") { clipboard_typeof = function _typeof(obj) { return typeof obj; }; } else { clipboard_typeof = function _typeof(obj) { return obj && typeof Symbol === \"function\" && obj.constructor === Symbol && obj !== Symbol.prototype ? \"symbol\" : typeof obj; }; } return clipboard_typeof(obj); }\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\nfunction _inherits(subClass, superClass) { if (typeof superClass !== \"function\" && superClass !== null) { throw new TypeError(\"Super expression must either be null or a function\"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, writable: true, configurable: true } }); if (superClass) _setPrototypeOf(subClass, superClass); }\n\nfunction _setPrototypeOf(o, p) { _setPrototypeOf = Object.setPrototypeOf || function _setPrototypeOf(o, p) { o.__proto__ = p; return o; }; return _setPrototypeOf(o, p); }\n\nfunction _createSuper(Derived) { var hasNativeReflectConstruct = _isNativeReflectConstruct(); return function _createSuperInternal() { var Super = _getPrototypeOf(Derived), result; if (hasNativeReflectConstruct) { var NewTarget = _getPrototypeOf(this).constructor; result = Reflect.construct(Super, arguments, NewTarget); } else { result = Super.apply(this, arguments); } return _possibleConstructorReturn(this, result); }; }\n\nfunction _possibleConstructorReturn(self, call) { if (call && (clipboard_typeof(call) === \"object\" || typeof call === \"function\")) { return call; } return _assertThisInitialized(self); }\n\nfunction _assertThisInitialized(self) { if (self === void 0) { throw new ReferenceError(\"this hasn't been initialised - super() hasn't been called\"); } return self; }\n\nfunction _isNativeReflectConstruct() { if (typeof Reflect === \"undefined\" || !Reflect.construct) return false; if (Reflect.construct.sham) return false; if (typeof Proxy === \"function\") return true; try { Date.prototype.toString.call(Reflect.construct(Date, [], function () {})); return true; } catch (e) { return false; } }\n\nfunction _getPrototypeOf(o) { _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf : function _getPrototypeOf(o) { return o.__proto__ || Object.getPrototypeOf(o); }; return _getPrototypeOf(o); }\n\n\n\n\n\n\n/**\n * Helper function to retrieve attribute value.\n * @param {String} suffix\n * @param {Element} element\n */\n\nfunction getAttributeValue(suffix, element) {\n var attribute = \"data-clipboard-\".concat(suffix);\n\n if (!element.hasAttribute(attribute)) {\n return;\n }\n\n return element.getAttribute(attribute);\n}\n/**\n * Base class which takes one or more elements, adds event listeners to them,\n * and instantiates a new `ClipboardAction` on each click.\n */\n\n\nvar Clipboard = /*#__PURE__*/function (_Emitter) {\n _inherits(Clipboard, _Emitter);\n\n var _super = _createSuper(Clipboard);\n\n /**\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n * @param {Object} options\n */\n function Clipboard(trigger, options) {\n var _this;\n\n _classCallCheck(this, Clipboard);\n\n _this = _super.call(this);\n\n _this.resolveOptions(options);\n\n _this.listenClick(trigger);\n\n return _this;\n }\n /**\n * Defines if attributes would be resolved using internal setter functions\n * or custom functions that were passed in the constructor.\n * @param {Object} options\n */\n\n\n _createClass(Clipboard, [{\n key: \"resolveOptions\",\n value: function resolveOptions() {\n var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};\n this.action = typeof options.action === 'function' ? options.action : this.defaultAction;\n this.target = typeof options.target === 'function' ? options.target : this.defaultTarget;\n this.text = typeof options.text === 'function' ? options.text : this.defaultText;\n this.container = clipboard_typeof(options.container) === 'object' ? options.container : document.body;\n }\n /**\n * Adds a click event listener to the passed trigger.\n * @param {String|HTMLElement|HTMLCollection|NodeList} trigger\n */\n\n }, {\n key: \"listenClick\",\n value: function listenClick(trigger) {\n var _this2 = this;\n\n this.listener = listen_default()(trigger, 'click', function (e) {\n return _this2.onClick(e);\n });\n }\n /**\n * Defines a new `ClipboardAction` on each click event.\n * @param {Event} e\n */\n\n }, {\n key: \"onClick\",\n value: function onClick(e) {\n var trigger = e.delegateTarget || e.currentTarget;\n var action = this.action(trigger) || 'copy';\n var text = actions_default({\n action: action,\n container: this.container,\n target: this.target(trigger),\n text: this.text(trigger)\n }); // Fires an event based on the copy operation result.\n\n this.emit(text ? 'success' : 'error', {\n action: action,\n text: text,\n trigger: trigger,\n clearSelection: function clearSelection() {\n if (trigger) {\n trigger.focus();\n }\n\n window.getSelection().removeAllRanges();\n }\n });\n }\n /**\n * Default `action` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultAction\",\n value: function defaultAction(trigger) {\n return getAttributeValue('action', trigger);\n }\n /**\n * Default `target` lookup function.\n * @param {Element} trigger\n */\n\n }, {\n key: \"defaultTarget\",\n value: function defaultTarget(trigger) {\n var selector = getAttributeValue('target', trigger);\n\n if (selector) {\n return document.querySelector(selector);\n }\n }\n /**\n * Allow fire programmatically a copy action\n * @param {String|HTMLElement} target\n * @param {Object} options\n * @returns Text copied.\n */\n\n }, {\n key: \"defaultText\",\n\n /**\n * Default `text` lookup function.\n * @param {Element} trigger\n */\n value: function defaultText(trigger) {\n return getAttributeValue('text', trigger);\n }\n /**\n * Destroy lifecycle.\n */\n\n }, {\n key: \"destroy\",\n value: function destroy() {\n this.listener.destroy();\n }\n }], [{\n key: \"copy\",\n value: function copy(target) {\n var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {\n container: document.body\n };\n return actions_copy(target, options);\n }\n /**\n * Allow fire programmatically a cut action\n * @param {String|HTMLElement} target\n * @returns Text cutted.\n */\n\n }, {\n key: \"cut\",\n value: function cut(target) {\n return actions_cut(target);\n }\n /**\n * Returns the support of the given action, or all actions if no action is\n * given.\n * @param {String} [action]\n */\n\n }, {\n key: \"isSupported\",\n value: function isSupported() {\n var action = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ['copy', 'cut'];\n var actions = typeof action === 'string' ? [action] : action;\n var support = !!document.queryCommandSupported;\n actions.forEach(function (action) {\n support = support && !!document.queryCommandSupported(action);\n });\n return support;\n }\n }]);\n\n return Clipboard;\n}((tiny_emitter_default()));\n\n/* harmony default export */ var clipboard = (Clipboard);\n\n/***/ }),\n\n/***/ 828:\n/***/ (function(module) {\n\nvar DOCUMENT_NODE_TYPE = 9;\n\n/**\n * A polyfill for Element.matches()\n */\nif (typeof Element !== 'undefined' && !Element.prototype.matches) {\n var proto = Element.prototype;\n\n proto.matches = proto.matchesSelector ||\n proto.mozMatchesSelector ||\n proto.msMatchesSelector ||\n proto.oMatchesSelector ||\n proto.webkitMatchesSelector;\n}\n\n/**\n * Finds the closest parent that matches a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @return {Function}\n */\nfunction closest (element, selector) {\n while (element && element.nodeType !== DOCUMENT_NODE_TYPE) {\n if (typeof element.matches === 'function' &&\n element.matches(selector)) {\n return element;\n }\n element = element.parentNode;\n }\n}\n\nmodule.exports = closest;\n\n\n/***/ }),\n\n/***/ 438:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar closest = __webpack_require__(828);\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction _delegate(element, selector, type, callback, useCapture) {\n var listenerFn = listener.apply(this, arguments);\n\n element.addEventListener(type, listenerFn, useCapture);\n\n return {\n destroy: function() {\n element.removeEventListener(type, listenerFn, useCapture);\n }\n }\n}\n\n/**\n * Delegates event to a selector.\n *\n * @param {Element|String|Array} [elements]\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @param {Boolean} useCapture\n * @return {Object}\n */\nfunction delegate(elements, selector, type, callback, useCapture) {\n // Handle the regular Element usage\n if (typeof elements.addEventListener === 'function') {\n return _delegate.apply(null, arguments);\n }\n\n // Handle Element-less usage, it defaults to global delegation\n if (typeof type === 'function') {\n // Use `document` as the first parameter, then apply arguments\n // This is a short way to .unshift `arguments` without running into deoptimizations\n return _delegate.bind(null, document).apply(null, arguments);\n }\n\n // Handle Selector-based usage\n if (typeof elements === 'string') {\n elements = document.querySelectorAll(elements);\n }\n\n // Handle Array-like based usage\n return Array.prototype.map.call(elements, function (element) {\n return _delegate(element, selector, type, callback, useCapture);\n });\n}\n\n/**\n * Finds closest match and invokes callback.\n *\n * @param {Element} element\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Function}\n */\nfunction listener(element, selector, type, callback) {\n return function(e) {\n e.delegateTarget = closest(e.target, selector);\n\n if (e.delegateTarget) {\n callback.call(element, e);\n }\n }\n}\n\nmodule.exports = delegate;\n\n\n/***/ }),\n\n/***/ 879:\n/***/ (function(__unused_webpack_module, exports) {\n\n/**\n * Check if argument is a HTML element.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.node = function(value) {\n return value !== undefined\n && value instanceof HTMLElement\n && value.nodeType === 1;\n};\n\n/**\n * Check if argument is a list of HTML elements.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.nodeList = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return value !== undefined\n && (type === '[object NodeList]' || type === '[object HTMLCollection]')\n && ('length' in value)\n && (value.length === 0 || exports.node(value[0]));\n};\n\n/**\n * Check if argument is a string.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.string = function(value) {\n return typeof value === 'string'\n || value instanceof String;\n};\n\n/**\n * Check if argument is a function.\n *\n * @param {Object} value\n * @return {Boolean}\n */\nexports.fn = function(value) {\n var type = Object.prototype.toString.call(value);\n\n return type === '[object Function]';\n};\n\n\n/***/ }),\n\n/***/ 370:\n/***/ (function(module, __unused_webpack_exports, __webpack_require__) {\n\nvar is = __webpack_require__(879);\nvar delegate = __webpack_require__(438);\n\n/**\n * Validates all params and calls the right\n * listener function based on its target type.\n *\n * @param {String|HTMLElement|HTMLCollection|NodeList} target\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listen(target, type, callback) {\n if (!target && !type && !callback) {\n throw new Error('Missing required arguments');\n }\n\n if (!is.string(type)) {\n throw new TypeError('Second argument must be a String');\n }\n\n if (!is.fn(callback)) {\n throw new TypeError('Third argument must be a Function');\n }\n\n if (is.node(target)) {\n return listenNode(target, type, callback);\n }\n else if (is.nodeList(target)) {\n return listenNodeList(target, type, callback);\n }\n else if (is.string(target)) {\n return listenSelector(target, type, callback);\n }\n else {\n throw new TypeError('First argument must be a String, HTMLElement, HTMLCollection, or NodeList');\n }\n}\n\n/**\n * Adds an event listener to a HTML element\n * and returns a remove listener function.\n *\n * @param {HTMLElement} node\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNode(node, type, callback) {\n node.addEventListener(type, callback);\n\n return {\n destroy: function() {\n node.removeEventListener(type, callback);\n }\n }\n}\n\n/**\n * Add an event listener to a list of HTML elements\n * and returns a remove listener function.\n *\n * @param {NodeList|HTMLCollection} nodeList\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenNodeList(nodeList, type, callback) {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.addEventListener(type, callback);\n });\n\n return {\n destroy: function() {\n Array.prototype.forEach.call(nodeList, function(node) {\n node.removeEventListener(type, callback);\n });\n }\n }\n}\n\n/**\n * Add an event listener to a selector\n * and returns a remove listener function.\n *\n * @param {String} selector\n * @param {String} type\n * @param {Function} callback\n * @return {Object}\n */\nfunction listenSelector(selector, type, callback) {\n return delegate(document.body, selector, type, callback);\n}\n\nmodule.exports = listen;\n\n\n/***/ }),\n\n/***/ 817:\n/***/ (function(module) {\n\nfunction select(element) {\n var selectedText;\n\n if (element.nodeName === 'SELECT') {\n element.focus();\n\n selectedText = element.value;\n }\n else if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') {\n var isReadOnly = element.hasAttribute('readonly');\n\n if (!isReadOnly) {\n element.setAttribute('readonly', '');\n }\n\n element.select();\n element.setSelectionRange(0, element.value.length);\n\n if (!isReadOnly) {\n element.removeAttribute('readonly');\n }\n\n selectedText = element.value;\n }\n else {\n if (element.hasAttribute('contenteditable')) {\n element.focus();\n }\n\n var selection = window.getSelection();\n var range = document.createRange();\n\n range.selectNodeContents(element);\n selection.removeAllRanges();\n selection.addRange(range);\n\n selectedText = selection.toString();\n }\n\n return selectedText;\n}\n\nmodule.exports = select;\n\n\n/***/ }),\n\n/***/ 279:\n/***/ (function(module) {\n\nfunction E () {\n // Keep this empty so it's easier to inherit from\n // (via https://github.com/lipsmack from https://github.com/scottcorgan/tiny-emitter/issues/3)\n}\n\nE.prototype = {\n on: function (name, callback, ctx) {\n var e = this.e || (this.e = {});\n\n (e[name] || (e[name] = [])).push({\n fn: callback,\n ctx: ctx\n });\n\n return this;\n },\n\n once: function (name, callback, ctx) {\n var self = this;\n function listener () {\n self.off(name, listener);\n callback.apply(ctx, arguments);\n };\n\n listener._ = callback\n return this.on(name, listener, ctx);\n },\n\n emit: function (name) {\n var data = [].slice.call(arguments, 1);\n var evtArr = ((this.e || (this.e = {}))[name] || []).slice();\n var i = 0;\n var len = evtArr.length;\n\n for (i; i < len; i++) {\n evtArr[i].fn.apply(evtArr[i].ctx, data);\n }\n\n return this;\n },\n\n off: function (name, callback) {\n var e = this.e || (this.e = {});\n var evts = e[name];\n var liveEvents = [];\n\n if (evts && callback) {\n for (var i = 0, len = evts.length; i < len; i++) {\n if (evts[i].fn !== callback && evts[i].fn._ !== callback)\n liveEvents.push(evts[i]);\n }\n }\n\n // Remove event from queue to prevent memory leak\n // Suggested by https://github.com/lazd\n // Ref: https://github.com/scottcorgan/tiny-emitter/commit/c6ebfaa9bc973b33d110a84a307742b7cf94c953#commitcomment-5024910\n\n (liveEvents.length)\n ? e[name] = liveEvents\n : delete e[name];\n\n return this;\n }\n};\n\nmodule.exports = E;\nmodule.exports.TinyEmitter = E;\n\n\n/***/ })\n\n/******/ \t});\n/************************************************************************/\n/******/ \t// The module cache\n/******/ \tvar __webpack_module_cache__ = {};\n/******/ \t\n/******/ \t// The require function\n/******/ \tfunction __webpack_require__(moduleId) {\n/******/ \t\t// Check if module is in cache\n/******/ \t\tif(__webpack_module_cache__[moduleId]) {\n/******/ \t\t\treturn __webpack_module_cache__[moduleId].exports;\n/******/ \t\t}\n/******/ \t\t// Create a new module (and put it into the cache)\n/******/ \t\tvar module = __webpack_module_cache__[moduleId] = {\n/******/ \t\t\t// no module.id needed\n/******/ \t\t\t// no module.loaded needed\n/******/ \t\t\texports: {}\n/******/ \t\t};\n/******/ \t\n/******/ \t\t// Execute the module function\n/******/ \t\t__webpack_modules__[moduleId](module, module.exports, __webpack_require__);\n/******/ \t\n/******/ \t\t// Return the exports of the module\n/******/ \t\treturn module.exports;\n/******/ \t}\n/******/ \t\n/************************************************************************/\n/******/ \t/* webpack/runtime/compat get default export */\n/******/ \t!function() {\n/******/ \t\t// getDefaultExport function for compatibility with non-harmony modules\n/******/ \t\t__webpack_require__.n = function(module) {\n/******/ \t\t\tvar getter = module && module.__esModule ?\n/******/ \t\t\t\tfunction() { return module['default']; } :\n/******/ \t\t\t\tfunction() { return module; };\n/******/ \t\t\t__webpack_require__.d(getter, { a: getter });\n/******/ \t\t\treturn getter;\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/define property getters */\n/******/ \t!function() {\n/******/ \t\t// define getter functions for harmony exports\n/******/ \t\t__webpack_require__.d = function(exports, definition) {\n/******/ \t\t\tfor(var key in definition) {\n/******/ \t\t\t\tif(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {\n/******/ \t\t\t\t\tObject.defineProperty(exports, key, { enumerable: true, get: definition[key] });\n/******/ \t\t\t\t}\n/******/ \t\t\t}\n/******/ \t\t};\n/******/ \t}();\n/******/ \t\n/******/ \t/* webpack/runtime/hasOwnProperty shorthand */\n/******/ \t!function() {\n/******/ \t\t__webpack_require__.o = function(obj, prop) { return Object.prototype.hasOwnProperty.call(obj, prop); }\n/******/ \t}();\n/******/ \t\n/************************************************************************/\n/******/ \t// module exports must be returned from runtime so entry inlining is disabled\n/******/ \t// startup\n/******/ \t// Load entry module and return exports\n/******/ \treturn __webpack_require__(686);\n/******/ })()\n.default;\n});", "/*\n * Copyright (c) 2016-2025 Martin Donath \n *\n * Permission is hereby granted, free of charge, to any person obtaining a copy\n * of this software and associated documentation files (the \"Software\"), to\n * deal in the Software without restriction, including without limitation the\n * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or\n * sell copies of the Software, and to permit persons to whom the Software is\n * furnished to do so, subject to the following conditions:\n *\n * The above copyright notice and this permission notice shall be included in\n * all copies or substantial portions of the Software.\n *\n * THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n * FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE\n * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\n * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS\n * IN THE SOFTWARE.\n */\n\nimport \"focus-visible\"\n\nimport {\n EMPTY,\n NEVER,\n Observable,\n Subject,\n defer,\n delay,\n filter,\n map,\n merge,\n mergeWith,\n shareReplay,\n switchMap\n} from \"rxjs\"\n\nimport { configuration, feature } from \"./_\"\nimport {\n at,\n getActiveElement,\n getOptionalElement,\n requestJSON,\n setLocation,\n setToggle,\n watchDocument,\n watchKeyboard,\n watchLocation,\n watchLocationTarget,\n watchMedia,\n watchPrint,\n watchScript,\n watchViewport\n} from \"./browser\"\nimport {\n getComponentElement,\n getComponentElements,\n mountAnnounce,\n mountBackToTop,\n mountConsent,\n mountContent,\n mountDialog,\n mountHeader,\n mountHeaderTitle,\n mountPalette,\n mountProgress,\n mountSearch,\n mountSearchHiglight,\n mountSidebar,\n mountSource,\n mountTableOfContents,\n mountTabs,\n watchHeader,\n watchMain\n} from \"./components\"\nimport {\n SearchIndex,\n fetchSitemap,\n setupAlternate,\n setupClipboardJS,\n setupInstantNavigation,\n setupVersionSelector\n} from \"./integrations\"\nimport {\n patchEllipsis,\n patchIndeterminate,\n patchScrollfix,\n patchScrolllock\n} from \"./patches\"\nimport \"./polyfills\"\n\n/* ----------------------------------------------------------------------------\n * Functions - @todo refactor\n * ------------------------------------------------------------------------- */\n\n/**\n * Fetch search index\n *\n * @returns Search index observable\n */\nfunction fetchSearchIndex(): Observable {\n if (location.protocol === \"file:\") {\n return watchScript(\n `${new URL(\"search/search_index.js\", config.base)}`\n )\n .pipe(\n // @ts-ignore - @todo fix typings\n map(() => __index),\n shareReplay(1)\n )\n } else {\n return requestJSON(\n new URL(\"search/search_index.json\", config.base)\n )\n }\n}\n\n/* ----------------------------------------------------------------------------\n * Application\n * ------------------------------------------------------------------------- */\n\n/* Yay, JavaScript is available */\ndocument.documentElement.classList.remove(\"no-js\")\ndocument.documentElement.classList.add(\"js\")\n\n/* Set up navigation observables and subjects */\nconst document$ = watchDocument()\nconst location$ = watchLocation()\nconst target$ = watchLocationTarget(location$)\nconst keyboard$ = watchKeyboard()\n\n/* Set up media observables */\nconst viewport$ = watchViewport()\nconst tablet$ = watchMedia(\"(min-width: 60em)\")\nconst screen$ = watchMedia(\"(min-width: 76.25em)\")\nconst print$ = watchPrint()\n\n/* Retrieve search index, if search is enabled */\nconst config = configuration()\nconst index$ = document.forms.namedItem(\"search\")\n ? fetchSearchIndex()\n : NEVER\n\n/* Set up Clipboard.js integration */\nconst alert$ = new Subject()\nsetupClipboardJS({ alert$ })\n\n/* Set up language selector */\nsetupAlternate({ document$ })\n\n/* Set up progress indicator */\nconst progress$ = new Subject()\n\n/* Set up sitemap for instant navigation and previews */\nconst sitemap$ = fetchSitemap(config.base)\n\n/* Set up instant navigation, if enabled */\nif (feature(\"navigation.instant\"))\n setupInstantNavigation({ sitemap$, location$, viewport$, progress$ })\n .subscribe(document$)\n\n/* Set up version selector */\nif (config.version?.provider === \"mike\")\n setupVersionSelector({ document$ })\n\n/* Always close drawer and search on navigation */\nmerge(location$, target$)\n .pipe(\n delay(125)\n )\n .subscribe(() => {\n setToggle(\"drawer\", false)\n setToggle(\"search\", false)\n })\n\n/* Set up global keyboard handlers */\nkeyboard$\n .pipe(\n filter(({ mode }) => mode === \"global\")\n )\n .subscribe(key => {\n switch (key.type) {\n\n /* Go to previous page */\n case \"p\":\n case \",\":\n const prev = getOptionalElement(\"link[rel=prev]\")\n if (typeof prev !== \"undefined\")\n setLocation(prev)\n break\n\n /* Go to next page */\n case \"n\":\n case \".\":\n const next = getOptionalElement(\"link[rel=next]\")\n if (typeof next !== \"undefined\")\n setLocation(next)\n break\n\n /* Expand navigation, see https://bit.ly/3ZjG5io */\n case \"Enter\":\n const active = getActiveElement()\n if (active instanceof HTMLLabelElement)\n active.click()\n }\n })\n\n/* Set up patches */\npatchEllipsis({ viewport$, document$ })\npatchIndeterminate({ document$, tablet$ })\npatchScrollfix({ document$ })\npatchScrolllock({ viewport$, tablet$ })\n\n/* Set up header and main area observable */\nconst header$ = watchHeader(getComponentElement(\"header\"), { viewport$ })\nconst main$ = document$\n .pipe(\n map(() => getComponentElement(\"main\")),\n switchMap(el => watchMain(el, { viewport$, header$ })),\n shareReplay(1)\n )\n\n/* Set up control component observables */\nconst control$ = merge(\n\n /* Consent */\n ...getComponentElements(\"consent\")\n .map(el => mountConsent(el, { target$ })),\n\n /* Dialog */\n ...getComponentElements(\"dialog\")\n .map(el => mountDialog(el, { alert$ })),\n\n /* Color palette */\n ...getComponentElements(\"palette\")\n .map(el => mountPalette(el)),\n\n /* Progress bar */\n ...getComponentElements(\"progress\")\n .map(el => mountProgress(el, { progress$ })),\n\n /* Search */\n ...getComponentElements(\"search\")\n .map(el => mountSearch(el, { index$, keyboard$ })),\n\n /* Repository information */\n ...getComponentElements(\"source\")\n .map(el => mountSource(el))\n)\n\n/* Set up content component observables */\nconst content$ = defer(() => merge(\n\n /* Announcement bar */\n ...getComponentElements(\"announce\")\n .map(el => mountAnnounce(el)),\n\n /* Content */\n ...getComponentElements(\"content\")\n .map(el => mountContent(el, { sitemap$, viewport$, target$, print$ })),\n\n /* Search highlighting */\n ...getComponentElements(\"content\")\n .map(el => feature(\"search.highlight\")\n ? mountSearchHiglight(el, { index$, location$ })\n : EMPTY\n ),\n\n /* Header */\n ...getComponentElements(\"header\")\n .map(el => mountHeader(el, { viewport$, header$, main$ })),\n\n /* Header title */\n ...getComponentElements(\"header-title\")\n .map(el => mountHeaderTitle(el, { viewport$, header$ })),\n\n /* Sidebar */\n ...getComponentElements(\"sidebar\")\n .map(el => el.getAttribute(\"data-md-type\") === \"navigation\"\n ? at(screen$, () => mountSidebar(el, { viewport$, header$, main$ }))\n : at(tablet$, () => mountSidebar(el, { viewport$, header$, main$ }))\n ),\n\n /* Navigation tabs */\n ...getComponentElements(\"tabs\")\n .map(el => mountTabs(el, { viewport$, header$ })),\n\n /* Table of contents */\n ...getComponentElements(\"toc\")\n .map(el => mountTableOfContents(el, {\n viewport$, header$, main$, target$\n })),\n\n /* Back-to-top button */\n ...getComponentElements(\"top\")\n .map(el => mountBackToTop(el, { viewport$, header$, main$, target$ }))\n))\n\n/* Set up component observables */\nconst component$ = document$\n .pipe(\n switchMap(() => content$),\n mergeWith(control$),\n shareReplay(1)\n )\n\n/* Subscribe to all components */\ncomponent$.subscribe()\n\n/* ----------------------------------------------------------------------------\n * Exports\n * ------------------------------------------------------------------------- */\n\nwindow.document$ = document$ /* Document observable */\nwindow.location$ = location$ /* Location subject */\nwindow.target$ = target$ /* Location target observable */\nwindow.keyboard$ = keyboard$ /* Keyboard observable */\nwindow.viewport$ = viewport$ /* Viewport observable */\nwindow.tablet$ = tablet$ /* Media tablet observable */\nwindow.screen$ = screen$ /* Media screen observable */\nwindow.print$ = print$ /* Media print observable */\nwindow.alert$ = alert$ /* Alert subject */\nwindow.progress$ = progress$ /* Progress indicator subject */\nwindow.component$ = component$ /* Component observable */\n", "/******************************************************************************\nCopyright (c) Microsoft Corporation.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\nPERFORMANCE OF THIS SOFTWARE.\n***************************************************************************** */\n/* global Reflect, Promise, SuppressedError, Symbol, Iterator */\n\nvar extendStatics = function(d, b) {\n extendStatics = Object.setPrototypeOf ||\n ({ __proto__: [] } instanceof Array && function (d, b) { d.__proto__ = b; }) ||\n function (d, b) { for (var p in b) if (Object.prototype.hasOwnProperty.call(b, p)) d[p] = b[p]; };\n return extendStatics(d, b);\n};\n\nexport function __extends(d, b) {\n if (typeof b !== \"function\" && b !== null)\n throw new TypeError(\"Class extends value \" + String(b) + \" is not a constructor or null\");\n extendStatics(d, b);\n function __() { this.constructor = d; }\n d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __());\n}\n\nexport var __assign = function() {\n __assign = Object.assign || function __assign(t) {\n for (var s, i = 1, n = arguments.length; i < n; i++) {\n s = arguments[i];\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\n }\n return t;\n }\n return __assign.apply(this, arguments);\n}\n\nexport function __rest(s, e) {\n var t = {};\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)\n t[p] = s[p];\n if (s != null && typeof Object.getOwnPropertySymbols === \"function\")\n for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {\n if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))\n t[p[i]] = s[p[i]];\n }\n return t;\n}\n\nexport function __decorate(decorators, target, key, desc) {\n var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;\n if (typeof Reflect === \"object\" && typeof Reflect.decorate === \"function\") r = Reflect.decorate(decorators, target, key, desc);\n else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;\n return c > 3 && r && Object.defineProperty(target, key, r), r;\n}\n\nexport function __param(paramIndex, decorator) {\n return function (target, key) { decorator(target, key, paramIndex); }\n}\n\nexport function __esDecorate(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {\n function accept(f) { if (f !== void 0 && typeof f !== \"function\") throw new TypeError(\"Function expected\"); return f; }\n var kind = contextIn.kind, key = kind === \"getter\" ? \"get\" : kind === \"setter\" ? \"set\" : \"value\";\n var target = !descriptorIn && ctor ? contextIn[\"static\"] ? ctor : ctor.prototype : null;\n var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});\n var _, done = false;\n for (var i = decorators.length - 1; i >= 0; i--) {\n var context = {};\n for (var p in contextIn) context[p] = p === \"access\" ? {} : contextIn[p];\n for (var p in contextIn.access) context.access[p] = contextIn.access[p];\n context.addInitializer = function (f) { if (done) throw new TypeError(\"Cannot add initializers after decoration has completed\"); extraInitializers.push(accept(f || null)); };\n var result = (0, decorators[i])(kind === \"accessor\" ? { get: descriptor.get, set: descriptor.set } : descriptor[key], context);\n if (kind === \"accessor\") {\n if (result === void 0) continue;\n if (result === null || typeof result !== \"object\") throw new TypeError(\"Object expected\");\n if (_ = accept(result.get)) descriptor.get = _;\n if (_ = accept(result.set)) descriptor.set = _;\n if (_ = accept(result.init)) initializers.unshift(_);\n }\n else if (_ = accept(result)) {\n if (kind === \"field\") initializers.unshift(_);\n else descriptor[key] = _;\n }\n }\n if (target) Object.defineProperty(target, contextIn.name, descriptor);\n done = true;\n};\n\nexport function __runInitializers(thisArg, initializers, value) {\n var useValue = arguments.length > 2;\n for (var i = 0; i < initializers.length; i++) {\n value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);\n }\n return useValue ? value : void 0;\n};\n\nexport function __propKey(x) {\n return typeof x === \"symbol\" ? x : \"\".concat(x);\n};\n\nexport function __setFunctionName(f, name, prefix) {\n if (typeof name === \"symbol\") name = name.description ? \"[\".concat(name.description, \"]\") : \"\";\n return Object.defineProperty(f, \"name\", { configurable: true, value: prefix ? \"\".concat(prefix, \" \", name) : name });\n};\n\nexport function __metadata(metadataKey, metadataValue) {\n if (typeof Reflect === \"object\" && typeof Reflect.metadata === \"function\") return Reflect.metadata(metadataKey, metadataValue);\n}\n\nexport function __awaiter(thisArg, _arguments, P, generator) {\n function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }\n return new (P || (P = Promise))(function (resolve, reject) {\n function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }\n function rejected(value) { try { step(generator[\"throw\"](value)); } catch (e) { reject(e); } }\n function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }\n step((generator = generator.apply(thisArg, _arguments || [])).next());\n });\n}\n\nexport function __generator(thisArg, body) {\n var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === \"function\" ? Iterator : Object).prototype);\n return g.next = verb(0), g[\"throw\"] = verb(1), g[\"return\"] = verb(2), typeof Symbol === \"function\" && (g[Symbol.iterator] = function() { return this; }), g;\n function verb(n) { return function (v) { return step([n, v]); }; }\n function step(op) {\n if (f) throw new TypeError(\"Generator is already executing.\");\n while (g && (g = 0, op[0] && (_ = 0)), _) try {\n if (f = 1, y && (t = op[0] & 2 ? y[\"return\"] : op[0] ? y[\"throw\"] || ((t = y[\"return\"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;\n if (y = 0, t) op = [op[0] & 2, t.value];\n switch (op[0]) {\n case 0: case 1: t = op; break;\n case 4: _.label++; return { value: op[1], done: false };\n case 5: _.label++; y = op[1]; op = [0]; continue;\n case 7: op = _.ops.pop(); _.trys.pop(); continue;\n default:\n if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }\n if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }\n if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }\n if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }\n if (t[2]) _.ops.pop();\n _.trys.pop(); continue;\n }\n op = body.call(thisArg, _);\n } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }\n if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };\n }\n}\n\nexport var __createBinding = Object.create ? (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n var desc = Object.getOwnPropertyDescriptor(m, k);\n if (!desc || (\"get\" in desc ? !m.__esModule : desc.writable || desc.configurable)) {\n desc = { enumerable: true, get: function() { return m[k]; } };\n }\n Object.defineProperty(o, k2, desc);\n}) : (function(o, m, k, k2) {\n if (k2 === undefined) k2 = k;\n o[k2] = m[k];\n});\n\nexport function __exportStar(m, o) {\n for (var p in m) if (p !== \"default\" && !Object.prototype.hasOwnProperty.call(o, p)) __createBinding(o, m, p);\n}\n\nexport function __values(o) {\n var s = typeof Symbol === \"function\" && Symbol.iterator, m = s && o[s], i = 0;\n if (m) return m.call(o);\n if (o && typeof o.length === \"number\") return {\n next: function () {\n if (o && i >= o.length) o = void 0;\n return { value: o && o[i++], done: !o };\n }\n };\n throw new TypeError(s ? \"Object is not iterable.\" : \"Symbol.iterator is not defined.\");\n}\n\nexport function __read(o, n) {\n var m = typeof Symbol === \"function\" && o[Symbol.iterator];\n if (!m) return o;\n var i = m.call(o), r, ar = [], e;\n try {\n while ((n === void 0 || n-- > 0) && !(r = i.next()).done) ar.push(r.value);\n }\n catch (error) { e = { error: error }; }\n finally {\n try {\n if (r && !r.done && (m = i[\"return\"])) m.call(i);\n }\n finally { if (e) throw e.error; }\n }\n return ar;\n}\n\n/** @deprecated */\nexport function __spread() {\n for (var ar = [], i = 0; i < arguments.length; i++)\n ar = ar.concat(__read(arguments[i]));\n return ar;\n}\n\n/** @deprecated */\nexport function __spreadArrays() {\n for (var s = 0, i = 0, il = arguments.length; i < il; i++) s += arguments[i].length;\n for (var r = Array(s), k = 0, i = 0; i < il; i++)\n for (var a = arguments[i], j = 0, jl = a.length; j < jl; j++, k++)\n r[k] = a[j];\n return r;\n}\n\nexport function __spreadArray(to, from, pack) {\n if (pack || arguments.length === 2) for (var i = 0, l = from.length, ar; i < l; i++) {\n if (ar || !(i in from)) {\n if (!ar) ar = Array.prototype.slice.call(from, 0, i);\n ar[i] = from[i];\n }\n }\n return to.concat(ar || Array.prototype.slice.call(from));\n}\n\nexport function __await(v) {\n return this instanceof __await ? (this.v = v, this) : new __await(v);\n}\n\nexport function __asyncGenerator(thisArg, _arguments, generator) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var g = generator.apply(thisArg, _arguments || []), i, q = [];\n return i = Object.create((typeof AsyncIterator === \"function\" ? AsyncIterator : Object).prototype), verb(\"next\"), verb(\"throw\"), verb(\"return\", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;\n function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }\n function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }\n function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }\n function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }\n function fulfill(value) { resume(\"next\", value); }\n function reject(value) { resume(\"throw\", value); }\n function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }\n}\n\nexport function __asyncDelegator(o) {\n var i, p;\n return i = {}, verb(\"next\"), verb(\"throw\", function (e) { throw e; }), verb(\"return\"), i[Symbol.iterator] = function () { return this; }, i;\n function verb(n, f) { i[n] = o[n] ? function (v) { return (p = !p) ? { value: __await(o[n](v)), done: false } : f ? f(v) : v; } : f; }\n}\n\nexport function __asyncValues(o) {\n if (!Symbol.asyncIterator) throw new TypeError(\"Symbol.asyncIterator is not defined.\");\n var m = o[Symbol.asyncIterator], i;\n return m ? m.call(o) : (o = typeof __values === \"function\" ? __values(o) : o[Symbol.iterator](), i = {}, verb(\"next\"), verb(\"throw\"), verb(\"return\"), i[Symbol.asyncIterator] = function () { return this; }, i);\n function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }\n function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }\n}\n\nexport function __makeTemplateObject(cooked, raw) {\n if (Object.defineProperty) { Object.defineProperty(cooked, \"raw\", { value: raw }); } else { cooked.raw = raw; }\n return cooked;\n};\n\nvar __setModuleDefault = Object.create ? (function(o, v) {\n Object.defineProperty(o, \"default\", { enumerable: true, value: v });\n}) : function(o, v) {\n o[\"default\"] = v;\n};\n\nexport function __importStar(mod) {\n if (mod && mod.__esModule) return mod;\n var result = {};\n if (mod != null) for (var k in mod) if (k !== \"default\" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);\n __setModuleDefault(result, mod);\n return result;\n}\n\nexport function __importDefault(mod) {\n return (mod && mod.__esModule) ? mod : { default: mod };\n}\n\nexport function __classPrivateFieldGet(receiver, state, kind, f) {\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a getter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot read private member from an object whose class did not declare it\");\n return kind === \"m\" ? f : kind === \"a\" ? f.call(receiver) : f ? f.value : state.get(receiver);\n}\n\nexport function __classPrivateFieldSet(receiver, state, value, kind, f) {\n if (kind === \"m\") throw new TypeError(\"Private method is not writable\");\n if (kind === \"a\" && !f) throw new TypeError(\"Private accessor was defined without a setter\");\n if (typeof state === \"function\" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError(\"Cannot write private member to an object whose class did not declare it\");\n return (kind === \"a\" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;\n}\n\nexport function __classPrivateFieldIn(state, receiver) {\n if (receiver === null || (typeof receiver !== \"object\" && typeof receiver !== \"function\")) throw new TypeError(\"Cannot use 'in' operator on non-object\");\n return typeof state === \"function\" ? receiver === state : state.has(receiver);\n}\n\nexport function __addDisposableResource(env, value, async) {\n if (value !== null && value !== void 0) {\n if (typeof value !== \"object\" && typeof value !== \"function\") throw new TypeError(\"Object expected.\");\n var dispose, inner;\n if (async) {\n if (!Symbol.asyncDispose) throw new TypeError(\"Symbol.asyncDispose is not defined.\");\n dispose = value[Symbol.asyncDispose];\n }\n if (dispose === void 0) {\n if (!Symbol.dispose) throw new TypeError(\"Symbol.dispose is not defined.\");\n dispose = value[Symbol.dispose];\n if (async) inner = dispose;\n }\n if (typeof dispose !== \"function\") throw new TypeError(\"Object not disposable.\");\n if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };\n env.stack.push({ value: value, dispose: dispose, async: async });\n }\n else if (async) {\n env.stack.push({ async: true });\n }\n return value;\n}\n\nvar _SuppressedError = typeof SuppressedError === \"function\" ? SuppressedError : function (error, suppressed, message) {\n var e = new Error(message);\n return e.name = \"SuppressedError\", e.error = error, e.suppressed = suppressed, e;\n};\n\nexport function __disposeResources(env) {\n function fail(e) {\n env.error = env.hasError ? new _SuppressedError(e, env.error, \"An error was suppressed during disposal.\") : e;\n env.hasError = true;\n }\n var r, s = 0;\n function next() {\n while (r = env.stack.pop()) {\n try {\n if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);\n if (r.dispose) {\n var result = r.dispose.call(r.value);\n if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });\n }\n else s |= 1;\n }\n catch (e) {\n fail(e);\n }\n }\n if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();\n if (env.hasError) throw env.error;\n }\n return next();\n}\n\nexport default {\n __extends,\n __assign,\n __rest,\n __decorate,\n __param,\n __metadata,\n __awaiter,\n __generator,\n __createBinding,\n __exportStar,\n __values,\n __read,\n __spread,\n __spreadArrays,\n __spreadArray,\n __await,\n __asyncGenerator,\n __asyncDelegator,\n __asyncValues,\n __makeTemplateObject,\n __importStar,\n __importDefault,\n __classPrivateFieldGet,\n __classPrivateFieldSet,\n __classPrivateFieldIn,\n __addDisposableResource,\n __disposeResources,\n};\n", "/**\n * Returns true if the object is a function.\n * @param value The value to check\n */\nexport function isFunction(value: any): value is (...args: any[]) => any {\n return typeof value === 'function';\n}\n", "/**\n * Used to create Error subclasses until the community moves away from ES5.\n *\n * This is because compiling from TypeScript down to ES5 has issues with subclassing Errors\n * as well as other built-in types: https://github.com/Microsoft/TypeScript/issues/12123\n *\n * @param createImpl A factory function to create the actual constructor implementation. The returned\n * function should be a named function that calls `_super` internally.\n */\nexport function createErrorClass(createImpl: (_super: any) => any): T {\n const _super = (instance: any) => {\n Error.call(instance);\n instance.stack = new Error().stack;\n };\n\n const ctorFunc = createImpl(_super);\n ctorFunc.prototype = Object.create(Error.prototype);\n ctorFunc.prototype.constructor = ctorFunc;\n return ctorFunc;\n}\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface UnsubscriptionError extends Error {\n readonly errors: any[];\n}\n\nexport interface UnsubscriptionErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (errors: any[]): UnsubscriptionError;\n}\n\n/**\n * An error thrown when one or more errors have occurred during the\n * `unsubscribe` of a {@link Subscription}.\n */\nexport const UnsubscriptionError: UnsubscriptionErrorCtor = createErrorClass(\n (_super) =>\n function UnsubscriptionErrorImpl(this: any, errors: (Error | string)[]) {\n _super(this);\n this.message = errors\n ? `${errors.length} errors occurred during unsubscription:\n${errors.map((err, i) => `${i + 1}) ${err.toString()}`).join('\\n ')}`\n : '';\n this.name = 'UnsubscriptionError';\n this.errors = errors;\n }\n);\n", "/**\n * Removes an item from an array, mutating it.\n * @param arr The array to remove the item from\n * @param item The item to remove\n */\nexport function arrRemove(arr: T[] | undefined | null, item: T) {\n if (arr) {\n const index = arr.indexOf(item);\n 0 <= index && arr.splice(index, 1);\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { UnsubscriptionError } from './util/UnsubscriptionError';\nimport { SubscriptionLike, TeardownLogic, Unsubscribable } from './types';\nimport { arrRemove } from './util/arrRemove';\n\n/**\n * Represents a disposable resource, such as the execution of an Observable. A\n * Subscription has one important method, `unsubscribe`, that takes no argument\n * and just disposes the resource held by the subscription.\n *\n * Additionally, subscriptions may be grouped together through the `add()`\n * method, which will attach a child Subscription to the current Subscription.\n * When a Subscription is unsubscribed, all its children (and its grandchildren)\n * will be unsubscribed as well.\n */\nexport class Subscription implements SubscriptionLike {\n public static EMPTY = (() => {\n const empty = new Subscription();\n empty.closed = true;\n return empty;\n })();\n\n /**\n * A flag to indicate whether this Subscription has already been unsubscribed.\n */\n public closed = false;\n\n private _parentage: Subscription[] | Subscription | null = null;\n\n /**\n * The list of registered finalizers to execute upon unsubscription. Adding and removing from this\n * list occurs in the {@link #add} and {@link #remove} methods.\n */\n private _finalizers: Exclude[] | null = null;\n\n /**\n * @param initialTeardown A function executed first as part of the finalization\n * process that is kicked off when {@link #unsubscribe} is called.\n */\n constructor(private initialTeardown?: () => void) {}\n\n /**\n * Disposes the resources held by the subscription. May, for instance, cancel\n * an ongoing Observable execution or cancel any other type of work that\n * started when the Subscription was created.\n */\n unsubscribe(): void {\n let errors: any[] | undefined;\n\n if (!this.closed) {\n this.closed = true;\n\n // Remove this from it's parents.\n const { _parentage } = this;\n if (_parentage) {\n this._parentage = null;\n if (Array.isArray(_parentage)) {\n for (const parent of _parentage) {\n parent.remove(this);\n }\n } else {\n _parentage.remove(this);\n }\n }\n\n const { initialTeardown: initialFinalizer } = this;\n if (isFunction(initialFinalizer)) {\n try {\n initialFinalizer();\n } catch (e) {\n errors = e instanceof UnsubscriptionError ? e.errors : [e];\n }\n }\n\n const { _finalizers } = this;\n if (_finalizers) {\n this._finalizers = null;\n for (const finalizer of _finalizers) {\n try {\n execFinalizer(finalizer);\n } catch (err) {\n errors = errors ?? [];\n if (err instanceof UnsubscriptionError) {\n errors = [...errors, ...err.errors];\n } else {\n errors.push(err);\n }\n }\n }\n }\n\n if (errors) {\n throw new UnsubscriptionError(errors);\n }\n }\n }\n\n /**\n * Adds a finalizer to this subscription, so that finalization will be unsubscribed/called\n * when this subscription is unsubscribed. If this subscription is already {@link #closed},\n * because it has already been unsubscribed, then whatever finalizer is passed to it\n * will automatically be executed (unless the finalizer itself is also a closed subscription).\n *\n * Closed Subscriptions cannot be added as finalizers to any subscription. Adding a closed\n * subscription to a any subscription will result in no operation. (A noop).\n *\n * Adding a subscription to itself, or adding `null` or `undefined` will not perform any\n * operation at all. (A noop).\n *\n * `Subscription` instances that are added to this instance will automatically remove themselves\n * if they are unsubscribed. Functions and {@link Unsubscribable} objects that you wish to remove\n * will need to be removed manually with {@link #remove}\n *\n * @param teardown The finalization logic to add to this subscription.\n */\n add(teardown: TeardownLogic): void {\n // Only add the finalizer if it's not undefined\n // and don't add a subscription to itself.\n if (teardown && teardown !== this) {\n if (this.closed) {\n // If this subscription is already closed,\n // execute whatever finalizer is handed to it automatically.\n execFinalizer(teardown);\n } else {\n if (teardown instanceof Subscription) {\n // We don't add closed subscriptions, and we don't add the same subscription\n // twice. Subscription unsubscribe is idempotent.\n if (teardown.closed || teardown._hasParent(this)) {\n return;\n }\n teardown._addParent(this);\n }\n (this._finalizers = this._finalizers ?? []).push(teardown);\n }\n }\n }\n\n /**\n * Checks to see if a this subscription already has a particular parent.\n * This will signal that this subscription has already been added to the parent in question.\n * @param parent the parent to check for\n */\n private _hasParent(parent: Subscription) {\n const { _parentage } = this;\n return _parentage === parent || (Array.isArray(_parentage) && _parentage.includes(parent));\n }\n\n /**\n * Adds a parent to this subscription so it can be removed from the parent if it\n * unsubscribes on it's own.\n *\n * NOTE: THIS ASSUMES THAT {@link _hasParent} HAS ALREADY BEEN CHECKED.\n * @param parent The parent subscription to add\n */\n private _addParent(parent: Subscription) {\n const { _parentage } = this;\n this._parentage = Array.isArray(_parentage) ? (_parentage.push(parent), _parentage) : _parentage ? [_parentage, parent] : parent;\n }\n\n /**\n * Called on a child when it is removed via {@link #remove}.\n * @param parent The parent to remove\n */\n private _removeParent(parent: Subscription) {\n const { _parentage } = this;\n if (_parentage === parent) {\n this._parentage = null;\n } else if (Array.isArray(_parentage)) {\n arrRemove(_parentage, parent);\n }\n }\n\n /**\n * Removes a finalizer from this subscription that was previously added with the {@link #add} method.\n *\n * Note that `Subscription` instances, when unsubscribed, will automatically remove themselves\n * from every other `Subscription` they have been added to. This means that using the `remove` method\n * is not a common thing and should be used thoughtfully.\n *\n * If you add the same finalizer instance of a function or an unsubscribable object to a `Subscription` instance\n * more than once, you will need to call `remove` the same number of times to remove all instances.\n *\n * All finalizer instances are removed to free up memory upon unsubscription.\n *\n * @param teardown The finalizer to remove from this subscription\n */\n remove(teardown: Exclude): void {\n const { _finalizers } = this;\n _finalizers && arrRemove(_finalizers, teardown);\n\n if (teardown instanceof Subscription) {\n teardown._removeParent(this);\n }\n }\n}\n\nexport const EMPTY_SUBSCRIPTION = Subscription.EMPTY;\n\nexport function isSubscription(value: any): value is Subscription {\n return (\n value instanceof Subscription ||\n (value && 'closed' in value && isFunction(value.remove) && isFunction(value.add) && isFunction(value.unsubscribe))\n );\n}\n\nfunction execFinalizer(finalizer: Unsubscribable | (() => void)) {\n if (isFunction(finalizer)) {\n finalizer();\n } else {\n finalizer.unsubscribe();\n }\n}\n", "import { Subscriber } from './Subscriber';\nimport { ObservableNotification } from './types';\n\n/**\n * The {@link GlobalConfig} object for RxJS. It is used to configure things\n * like how to react on unhandled errors.\n */\nexport const config: GlobalConfig = {\n onUnhandledError: null,\n onStoppedNotification: null,\n Promise: undefined,\n useDeprecatedSynchronousErrorHandling: false,\n useDeprecatedNextContext: false,\n};\n\n/**\n * The global configuration object for RxJS, used to configure things\n * like how to react on unhandled errors. Accessible via {@link config}\n * object.\n */\nexport interface GlobalConfig {\n /**\n * A registration point for unhandled errors from RxJS. These are errors that\n * cannot were not handled by consuming code in the usual subscription path. For\n * example, if you have this configured, and you subscribe to an observable without\n * providing an error handler, errors from that subscription will end up here. This\n * will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onUnhandledError: ((err: any) => void) | null;\n\n /**\n * A registration point for notifications that cannot be sent to subscribers because they\n * have completed, errored or have been explicitly unsubscribed. By default, next, complete\n * and error notifications sent to stopped subscribers are noops. However, sometimes callers\n * might want a different behavior. For example, with sources that attempt to report errors\n * to stopped subscribers, a caller can configure RxJS to throw an unhandled error instead.\n * This will _always_ be called asynchronously on another job in the runtime. This is because\n * we do not want errors thrown in this user-configured handler to interfere with the\n * behavior of the library.\n */\n onStoppedNotification: ((notification: ObservableNotification, subscriber: Subscriber) => void) | null;\n\n /**\n * The promise constructor used by default for {@link Observable#toPromise toPromise} and {@link Observable#forEach forEach}\n * methods.\n *\n * @deprecated As of version 8, RxJS will no longer support this sort of injection of a\n * Promise constructor. If you need a Promise implementation other than native promises,\n * please polyfill/patch Promise as you see appropriate. Will be removed in v8.\n */\n Promise?: PromiseConstructorLike;\n\n /**\n * If true, turns on synchronous error rethrowing, which is a deprecated behavior\n * in v6 and higher. This behavior enables bad patterns like wrapping a subscribe\n * call in a try/catch block. It also enables producer interference, a nasty bug\n * where a multicast can be broken for all observers by a downstream consumer with\n * an unhandled error. DO NOT USE THIS FLAG UNLESS IT'S NEEDED TO BUY TIME\n * FOR MIGRATION REASONS.\n *\n * @deprecated As of version 8, RxJS will no longer support synchronous throwing\n * of unhandled errors. All errors will be thrown on a separate call stack to prevent bad\n * behaviors described above. Will be removed in v8.\n */\n useDeprecatedSynchronousErrorHandling: boolean;\n\n /**\n * If true, enables an as-of-yet undocumented feature from v5: The ability to access\n * `unsubscribe()` via `this` context in `next` functions created in observers passed\n * to `subscribe`.\n *\n * This is being removed because the performance was severely problematic, and it could also cause\n * issues when types other than POJOs are passed to subscribe as subscribers, as they will likely have\n * their `this` context overwritten.\n *\n * @deprecated As of version 8, RxJS will no longer support altering the\n * context of next functions provided as part of an observer to Subscribe. Instead,\n * you will have access to a subscription or a signal or token that will allow you to do things like\n * unsubscribe and test closed status. Will be removed in v8.\n */\n useDeprecatedNextContext: boolean;\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetTimeoutFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearTimeoutFunction = (handle: TimerHandle) => void;\n\ninterface TimeoutProvider {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n delegate:\n | {\n setTimeout: SetTimeoutFunction;\n clearTimeout: ClearTimeoutFunction;\n }\n | undefined;\n}\n\nexport const timeoutProvider: TimeoutProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setTimeout(handler: () => void, timeout?: number, ...args) {\n const { delegate } = timeoutProvider;\n if (delegate?.setTimeout) {\n return delegate.setTimeout(handler, timeout, ...args);\n }\n return setTimeout(handler, timeout, ...args);\n },\n clearTimeout(handle) {\n const { delegate } = timeoutProvider;\n return (delegate?.clearTimeout || clearTimeout)(handle as any);\n },\n delegate: undefined,\n};\n", "import { config } from '../config';\nimport { timeoutProvider } from '../scheduler/timeoutProvider';\n\n/**\n * Handles an error on another job either with the user-configured {@link onUnhandledError},\n * or by throwing it on that new job so it can be picked up by `window.onerror`, `process.on('error')`, etc.\n *\n * This should be called whenever there is an error that is out-of-band with the subscription\n * or when an error hits a terminal boundary of the subscription and no error handler was provided.\n *\n * @param err the error to report\n */\nexport function reportUnhandledError(err: any) {\n timeoutProvider.setTimeout(() => {\n const { onUnhandledError } = config;\n if (onUnhandledError) {\n // Execute the user-configured error handler.\n onUnhandledError(err);\n } else {\n // Throw so it is picked up by the runtime's uncaught error mechanism.\n throw err;\n }\n });\n}\n", "/* tslint:disable:no-empty */\nexport function noop() { }\n", "import { CompleteNotification, NextNotification, ErrorNotification } from './types';\n\n/**\n * A completion object optimized for memory use and created to be the\n * same \"shape\" as other notifications in v8.\n * @internal\n */\nexport const COMPLETE_NOTIFICATION = (() => createNotification('C', undefined, undefined) as CompleteNotification)();\n\n/**\n * Internal use only. Creates an optimized error notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function errorNotification(error: any): ErrorNotification {\n return createNotification('E', undefined, error) as any;\n}\n\n/**\n * Internal use only. Creates an optimized next notification that is the same \"shape\"\n * as other notifications.\n * @internal\n */\nexport function nextNotification(value: T) {\n return createNotification('N', value, undefined) as NextNotification;\n}\n\n/**\n * Ensures that all notifications created internally have the same \"shape\" in v8.\n *\n * TODO: This is only exported to support a crazy legacy test in `groupBy`.\n * @internal\n */\nexport function createNotification(kind: 'N' | 'E' | 'C', value: any, error: any) {\n return {\n kind,\n value,\n error,\n };\n}\n", "import { config } from '../config';\n\nlet context: { errorThrown: boolean; error: any } | null = null;\n\n/**\n * Handles dealing with errors for super-gross mode. Creates a context, in which\n * any synchronously thrown errors will be passed to {@link captureError}. Which\n * will record the error such that it will be rethrown after the call back is complete.\n * TODO: Remove in v8\n * @param cb An immediately executed function.\n */\nexport function errorContext(cb: () => void) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n const isRoot = !context;\n if (isRoot) {\n context = { errorThrown: false, error: null };\n }\n cb();\n if (isRoot) {\n const { errorThrown, error } = context!;\n context = null;\n if (errorThrown) {\n throw error;\n }\n }\n } else {\n // This is the general non-deprecated path for everyone that\n // isn't crazy enough to use super-gross mode (useDeprecatedSynchronousErrorHandling)\n cb();\n }\n}\n\n/**\n * Captures errors only in super-gross mode.\n * @param err the error to capture\n */\nexport function captureError(err: any) {\n if (config.useDeprecatedSynchronousErrorHandling && context) {\n context.errorThrown = true;\n context.error = err;\n }\n}\n", "import { isFunction } from './util/isFunction';\nimport { Observer, ObservableNotification } from './types';\nimport { isSubscription, Subscription } from './Subscription';\nimport { config } from './config';\nimport { reportUnhandledError } from './util/reportUnhandledError';\nimport { noop } from './util/noop';\nimport { nextNotification, errorNotification, COMPLETE_NOTIFICATION } from './NotificationFactories';\nimport { timeoutProvider } from './scheduler/timeoutProvider';\nimport { captureError } from './util/errorContext';\n\n/**\n * Implements the {@link Observer} interface and extends the\n * {@link Subscription} class. While the {@link Observer} is the public API for\n * consuming the values of an {@link Observable}, all Observers get converted to\n * a Subscriber, in order to provide Subscription-like capabilities such as\n * `unsubscribe`. Subscriber is a common type in RxJS, and crucial for\n * implementing operators, but it is rarely used as a public API.\n */\nexport class Subscriber extends Subscription implements Observer {\n /**\n * A static factory for a Subscriber, given a (potentially partial) definition\n * of an Observer.\n * @param next The `next` callback of an Observer.\n * @param error The `error` callback of an\n * Observer.\n * @param complete The `complete` callback of an\n * Observer.\n * @return A Subscriber wrapping the (partially defined)\n * Observer represented by the given arguments.\n * @deprecated Do not use. Will be removed in v8. There is no replacement for this\n * method, and there is no reason to be creating instances of `Subscriber` directly.\n * If you have a specific use case, please file an issue.\n */\n static create(next?: (x?: T) => void, error?: (e?: any) => void, complete?: () => void): Subscriber {\n return new SafeSubscriber(next, error, complete);\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected isStopped: boolean = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n protected destination: Subscriber | Observer; // this `any` is the escape hatch to erase extra type param (e.g. R)\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * There is no reason to directly create an instance of Subscriber. This type is exported for typings reasons.\n */\n constructor(destination?: Subscriber | Observer) {\n super();\n if (destination) {\n this.destination = destination;\n // Automatically chain subscriptions together here.\n // if destination is a Subscription, then it is a Subscriber.\n if (isSubscription(destination)) {\n destination.add(this);\n }\n } else {\n this.destination = EMPTY_OBSERVER;\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `next` from\n * the Observable, with a value. The Observable may call this method 0 or more\n * times.\n * @param value The `next` value.\n */\n next(value: T): void {\n if (this.isStopped) {\n handleStoppedNotification(nextNotification(value), this);\n } else {\n this._next(value!);\n }\n }\n\n /**\n * The {@link Observer} callback to receive notifications of type `error` from\n * the Observable, with an attached `Error`. Notifies the Observer that\n * the Observable has experienced an error condition.\n * @param err The `error` exception.\n */\n error(err?: any): void {\n if (this.isStopped) {\n handleStoppedNotification(errorNotification(err), this);\n } else {\n this.isStopped = true;\n this._error(err);\n }\n }\n\n /**\n * The {@link Observer} callback to receive a valueless notification of type\n * `complete` from the Observable. Notifies the Observer that the Observable\n * has finished sending push-based notifications.\n */\n complete(): void {\n if (this.isStopped) {\n handleStoppedNotification(COMPLETE_NOTIFICATION, this);\n } else {\n this.isStopped = true;\n this._complete();\n }\n }\n\n unsubscribe(): void {\n if (!this.closed) {\n this.isStopped = true;\n super.unsubscribe();\n this.destination = null!;\n }\n }\n\n protected _next(value: T): void {\n this.destination.next(value);\n }\n\n protected _error(err: any): void {\n try {\n this.destination.error(err);\n } finally {\n this.unsubscribe();\n }\n }\n\n protected _complete(): void {\n try {\n this.destination.complete();\n } finally {\n this.unsubscribe();\n }\n }\n}\n\n/**\n * This bind is captured here because we want to be able to have\n * compatibility with monoid libraries that tend to use a method named\n * `bind`. In particular, a library called Monio requires this.\n */\nconst _bind = Function.prototype.bind;\n\nfunction bind any>(fn: Fn, thisArg: any): Fn {\n return _bind.call(fn, thisArg);\n}\n\n/**\n * Internal optimization only, DO NOT EXPOSE.\n * @internal\n */\nclass ConsumerObserver implements Observer {\n constructor(private partialObserver: Partial>) {}\n\n next(value: T): void {\n const { partialObserver } = this;\n if (partialObserver.next) {\n try {\n partialObserver.next(value);\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n\n error(err: any): void {\n const { partialObserver } = this;\n if (partialObserver.error) {\n try {\n partialObserver.error(err);\n } catch (error) {\n handleUnhandledError(error);\n }\n } else {\n handleUnhandledError(err);\n }\n }\n\n complete(): void {\n const { partialObserver } = this;\n if (partialObserver.complete) {\n try {\n partialObserver.complete();\n } catch (error) {\n handleUnhandledError(error);\n }\n }\n }\n}\n\nexport class SafeSubscriber extends Subscriber {\n constructor(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((e?: any) => void) | null,\n complete?: (() => void) | null\n ) {\n super();\n\n let partialObserver: Partial>;\n if (isFunction(observerOrNext) || !observerOrNext) {\n // The first argument is a function, not an observer. The next\n // two arguments *could* be observers, or they could be empty.\n partialObserver = {\n next: (observerOrNext ?? undefined) as ((value: T) => void) | undefined,\n error: error ?? undefined,\n complete: complete ?? undefined,\n };\n } else {\n // The first argument is a partial observer.\n let context: any;\n if (this && config.useDeprecatedNextContext) {\n // This is a deprecated path that made `this.unsubscribe()` available in\n // next handler functions passed to subscribe. This only exists behind a flag\n // now, as it is *very* slow.\n context = Object.create(observerOrNext);\n context.unsubscribe = () => this.unsubscribe();\n partialObserver = {\n next: observerOrNext.next && bind(observerOrNext.next, context),\n error: observerOrNext.error && bind(observerOrNext.error, context),\n complete: observerOrNext.complete && bind(observerOrNext.complete, context),\n };\n } else {\n // The \"normal\" path. Just use the partial observer directly.\n partialObserver = observerOrNext;\n }\n }\n\n // Wrap the partial observer to ensure it's a full observer, and\n // make sure proper error handling is accounted for.\n this.destination = new ConsumerObserver(partialObserver);\n }\n}\n\nfunction handleUnhandledError(error: any) {\n if (config.useDeprecatedSynchronousErrorHandling) {\n captureError(error);\n } else {\n // Ideal path, we report this as an unhandled error,\n // which is thrown on a new call stack.\n reportUnhandledError(error);\n }\n}\n\n/**\n * An error handler used when no error handler was supplied\n * to the SafeSubscriber -- meaning no error handler was supplied\n * do the `subscribe` call on our observable.\n * @param err The error to handle\n */\nfunction defaultErrorHandler(err: any) {\n throw err;\n}\n\n/**\n * A handler for notifications that cannot be sent to a stopped subscriber.\n * @param notification The notification being sent.\n * @param subscriber The stopped subscriber.\n */\nfunction handleStoppedNotification(notification: ObservableNotification, subscriber: Subscriber) {\n const { onStoppedNotification } = config;\n onStoppedNotification && timeoutProvider.setTimeout(() => onStoppedNotification(notification, subscriber));\n}\n\n/**\n * The observer used as a stub for subscriptions where the user did not\n * pass any arguments to `subscribe`. Comes with the default error handling\n * behavior.\n */\nexport const EMPTY_OBSERVER: Readonly> & { closed: true } = {\n closed: true,\n next: noop,\n error: defaultErrorHandler,\n complete: noop,\n};\n", "/**\n * Symbol.observable or a string \"@@observable\". Used for interop\n *\n * @deprecated We will no longer be exporting this symbol in upcoming versions of RxJS.\n * Instead polyfill and use Symbol.observable directly *or* use https://www.npmjs.com/package/symbol-observable\n */\nexport const observable: string | symbol = (() => (typeof Symbol === 'function' && Symbol.observable) || '@@observable')();\n", "/**\n * This function takes one parameter and just returns it. Simply put,\n * this is like `(x: T): T => x`.\n *\n * ## Examples\n *\n * This is useful in some cases when using things like `mergeMap`\n *\n * ```ts\n * import { interval, take, map, range, mergeMap, identity } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(5));\n *\n * const result$ = source$.pipe(\n * map(i => range(i)),\n * mergeMap(identity) // same as mergeMap(x => x)\n * );\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * Or when you want to selectively apply an operator\n *\n * ```ts\n * import { interval, take, identity } from 'rxjs';\n *\n * const shouldLimit = () => Math.random() < 0.5;\n *\n * const source$ = interval(1000);\n *\n * const result$ = source$.pipe(shouldLimit() ? take(5) : identity);\n *\n * result$.subscribe({\n * next: console.log\n * });\n * ```\n *\n * @param x Any value that is returned by this function\n * @returns The value passed as the first parameter to this function\n */\nexport function identity(x: T): T {\n return x;\n}\n", "import { identity } from './identity';\nimport { UnaryFunction } from '../types';\n\nexport function pipe(): typeof identity;\nexport function pipe(fn1: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction): UnaryFunction;\nexport function pipe(fn1: UnaryFunction, fn2: UnaryFunction, fn3: UnaryFunction): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction\n): UnaryFunction;\nexport function pipe(\n fn1: UnaryFunction,\n fn2: UnaryFunction,\n fn3: UnaryFunction,\n fn4: UnaryFunction,\n fn5: UnaryFunction,\n fn6: UnaryFunction,\n fn7: UnaryFunction,\n fn8: UnaryFunction,\n fn9: UnaryFunction,\n ...fns: UnaryFunction[]\n): UnaryFunction;\n\n/**\n * pipe() can be called on one or more functions, each of which can take one argument (\"UnaryFunction\")\n * and uses it to return a value.\n * It returns a function that takes one argument, passes it to the first UnaryFunction, and then\n * passes the result to the next one, passes that result to the next one, and so on. \n */\nexport function pipe(...fns: Array>): UnaryFunction {\n return pipeFromArray(fns);\n}\n\n/** @internal */\nexport function pipeFromArray(fns: Array>): UnaryFunction {\n if (fns.length === 0) {\n return identity as UnaryFunction;\n }\n\n if (fns.length === 1) {\n return fns[0];\n }\n\n return function piped(input: T): R {\n return fns.reduce((prev: any, fn: UnaryFunction) => fn(prev), input as any);\n };\n}\n", "import { Operator } from './Operator';\nimport { SafeSubscriber, Subscriber } from './Subscriber';\nimport { isSubscription, Subscription } from './Subscription';\nimport { TeardownLogic, OperatorFunction, Subscribable, Observer } from './types';\nimport { observable as Symbol_observable } from './symbol/observable';\nimport { pipeFromArray } from './util/pipe';\nimport { config } from './config';\nimport { isFunction } from './util/isFunction';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A representation of any set of values over any amount of time. This is the most basic building block\n * of RxJS.\n */\nexport class Observable implements Subscribable {\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n source: Observable | undefined;\n\n /**\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n */\n operator: Operator | undefined;\n\n /**\n * @param subscribe The function that is called when the Observable is\n * initially subscribed to. This function is given a Subscriber, to which new values\n * can be `next`ed, or an `error` method can be called to raise an error, or\n * `complete` can be called to notify of a successful completion.\n */\n constructor(subscribe?: (this: Observable, subscriber: Subscriber) => TeardownLogic) {\n if (subscribe) {\n this._subscribe = subscribe;\n }\n }\n\n // HACK: Since TypeScript inherits static properties too, we have to\n // fight against TypeScript here so Subject can have a different static create signature\n /**\n * Creates a new Observable by calling the Observable constructor\n * @param subscribe the subscriber function to be passed to the Observable constructor\n * @return A new observable.\n * @deprecated Use `new Observable()` instead. Will be removed in v8.\n */\n static create: (...args: any[]) => any = (subscribe?: (subscriber: Subscriber) => TeardownLogic) => {\n return new Observable(subscribe);\n };\n\n /**\n * Creates a new Observable, with this Observable instance as the source, and the passed\n * operator defined as the new observable's operator.\n * @param operator the operator defining the operation to take on the observable\n * @return A new observable with the Operator applied.\n * @deprecated Internal implementation detail, do not use directly. Will be made internal in v8.\n * If you have implemented an operator using `lift`, it is recommended that you create an\n * operator by simply returning `new Observable()` directly. See \"Creating new operators from\n * scratch\" section here: https://rxjs.dev/guide/operators\n */\n lift(operator?: Operator): Observable {\n const observable = new Observable();\n observable.source = this;\n observable.operator = operator;\n return observable;\n }\n\n subscribe(observerOrNext?: Partial> | ((value: T) => void)): Subscription;\n /** @deprecated Instead of passing separate callback arguments, use an observer argument. Signatures taking separate callback arguments will be removed in v8. Details: https://rxjs.dev/deprecations/subscribe-arguments */\n subscribe(next?: ((value: T) => void) | null, error?: ((error: any) => void) | null, complete?: (() => void) | null): Subscription;\n /**\n * Invokes an execution of an Observable and registers Observer handlers for notifications it will emit.\n *\n * Use it when you have all these Observables, but still nothing is happening.\n *\n * `subscribe` is not a regular operator, but a method that calls Observable's internal `subscribe` function. It\n * might be for example a function that you passed to Observable's constructor, but most of the time it is\n * a library implementation, which defines what will be emitted by an Observable, and when it be will emitted. This means\n * that calling `subscribe` is actually the moment when Observable starts its work, not when it is created, as it is often\n * the thought.\n *\n * Apart from starting the execution of an Observable, this method allows you to listen for values\n * that an Observable emits, as well as for when it completes or errors. You can achieve this in two\n * of the following ways.\n *\n * The first way is creating an object that implements {@link Observer} interface. It should have methods\n * defined by that interface, but note that it should be just a regular JavaScript object, which you can create\n * yourself in any way you want (ES6 class, classic function constructor, object literal etc.). In particular, do\n * not attempt to use any RxJS implementation details to create Observers - you don't need them. Remember also\n * that your object does not have to implement all methods. If you find yourself creating a method that doesn't\n * do anything, you can simply omit it. Note however, if the `error` method is not provided and an error happens,\n * it will be thrown asynchronously. Errors thrown asynchronously cannot be caught using `try`/`catch`. Instead,\n * use the {@link onUnhandledError} configuration option or use a runtime handler (like `window.onerror` or\n * `process.on('error)`) to be notified of unhandled errors. Because of this, it's recommended that you provide\n * an `error` method to avoid missing thrown errors.\n *\n * The second way is to give up on Observer object altogether and simply provide callback functions in place of its methods.\n * This means you can provide three functions as arguments to `subscribe`, where the first function is equivalent\n * of a `next` method, the second of an `error` method and the third of a `complete` method. Just as in case of an Observer,\n * if you do not need to listen for something, you can omit a function by passing `undefined` or `null`,\n * since `subscribe` recognizes these functions by where they were placed in function call. When it comes\n * to the `error` function, as with an Observer, if not provided, errors emitted by an Observable will be thrown asynchronously.\n *\n * You can, however, subscribe with no parameters at all. This may be the case where you're not interested in terminal events\n * and you also handled emissions internally by using operators (e.g. using `tap`).\n *\n * Whichever style of calling `subscribe` you use, in both cases it returns a Subscription object.\n * This object allows you to call `unsubscribe` on it, which in turn will stop the work that an Observable does and will clean\n * up all resources that an Observable used. Note that cancelling a subscription will not call `complete` callback\n * provided to `subscribe` function, which is reserved for a regular completion signal that comes from an Observable.\n *\n * Remember that callbacks provided to `subscribe` are not guaranteed to be called asynchronously.\n * It is an Observable itself that decides when these functions will be called. For example {@link of}\n * by default emits all its values synchronously. Always check documentation for how given Observable\n * will behave when subscribed and if its default behavior can be modified with a `scheduler`.\n *\n * #### Examples\n *\n * Subscribe with an {@link guide/observer Observer}\n *\n * ```ts\n * import { of } from 'rxjs';\n *\n * const sumObserver = {\n * sum: 0,\n * next(value) {\n * console.log('Adding: ' + value);\n * this.sum = this.sum + value;\n * },\n * error() {\n * // We actually could just remove this method,\n * // since we do not really care about errors right now.\n * },\n * complete() {\n * console.log('Sum equals: ' + this.sum);\n * }\n * };\n *\n * of(1, 2, 3) // Synchronously emits 1, 2, 3 and then completes.\n * .subscribe(sumObserver);\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Subscribe with functions ({@link deprecations/subscribe-arguments deprecated})\n *\n * ```ts\n * import { of } from 'rxjs'\n *\n * let sum = 0;\n *\n * of(1, 2, 3).subscribe(\n * value => {\n * console.log('Adding: ' + value);\n * sum = sum + value;\n * },\n * undefined,\n * () => console.log('Sum equals: ' + sum)\n * );\n *\n * // Logs:\n * // 'Adding: 1'\n * // 'Adding: 2'\n * // 'Adding: 3'\n * // 'Sum equals: 6'\n * ```\n *\n * Cancel a subscription\n *\n * ```ts\n * import { interval } from 'rxjs';\n *\n * const subscription = interval(1000).subscribe({\n * next(num) {\n * console.log(num)\n * },\n * complete() {\n * // Will not be called, even when cancelling subscription.\n * console.log('completed!');\n * }\n * });\n *\n * setTimeout(() => {\n * subscription.unsubscribe();\n * console.log('unsubscribed!');\n * }, 2500);\n *\n * // Logs:\n * // 0 after 1s\n * // 1 after 2s\n * // 'unsubscribed!' after 2.5s\n * ```\n *\n * @param observerOrNext Either an {@link Observer} with some or all callback methods,\n * or the `next` handler that is called for each value emitted from the subscribed Observable.\n * @param error A handler for a terminal event resulting from an error. If no error handler is provided,\n * the error will be thrown asynchronously as unhandled.\n * @param complete A handler for a terminal event resulting from successful completion.\n * @return A subscription reference to the registered handlers.\n */\n subscribe(\n observerOrNext?: Partial> | ((value: T) => void) | null,\n error?: ((error: any) => void) | null,\n complete?: (() => void) | null\n ): Subscription {\n const subscriber = isSubscriber(observerOrNext) ? observerOrNext : new SafeSubscriber(observerOrNext, error, complete);\n\n errorContext(() => {\n const { operator, source } = this;\n subscriber.add(\n operator\n ? // We're dealing with a subscription in the\n // operator chain to one of our lifted operators.\n operator.call(subscriber, source)\n : source\n ? // If `source` has a value, but `operator` does not, something that\n // had intimate knowledge of our API, like our `Subject`, must have\n // set it. We're going to just call `_subscribe` directly.\n this._subscribe(subscriber)\n : // In all other cases, we're likely wrapping a user-provided initializer\n // function, so we need to catch errors and handle them appropriately.\n this._trySubscribe(subscriber)\n );\n });\n\n return subscriber;\n }\n\n /** @internal */\n protected _trySubscribe(sink: Subscriber): TeardownLogic {\n try {\n return this._subscribe(sink);\n } catch (err) {\n // We don't need to return anything in this case,\n // because it's just going to try to `add()` to a subscription\n // above.\n sink.error(err);\n }\n }\n\n /**\n * Used as a NON-CANCELLABLE means of subscribing to an observable, for use with\n * APIs that expect promises, like `async/await`. You cannot unsubscribe from this.\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * #### Example\n *\n * ```ts\n * import { interval, take } from 'rxjs';\n *\n * const source$ = interval(1000).pipe(take(4));\n *\n * async function getTotal() {\n * let total = 0;\n *\n * await source$.forEach(value => {\n * total += value;\n * console.log('observable -> ' + value);\n * });\n *\n * return total;\n * }\n *\n * getTotal().then(\n * total => console.log('Total: ' + total)\n * );\n *\n * // Expected:\n * // 'observable -> 0'\n * // 'observable -> 1'\n * // 'observable -> 2'\n * // 'observable -> 3'\n * // 'Total: 6'\n * ```\n *\n * @param next A handler for each value emitted by the observable.\n * @return A promise that either resolves on observable completion or\n * rejects with the handled error.\n */\n forEach(next: (value: T) => void): Promise;\n\n /**\n * @param next a handler for each value emitted by the observable\n * @param promiseCtor a constructor function used to instantiate the Promise\n * @return a promise that either resolves on observable completion or\n * rejects with the handled error\n * @deprecated Passing a Promise constructor will no longer be available\n * in upcoming versions of RxJS. This is because it adds weight to the library, for very\n * little benefit. If you need this functionality, it is recommended that you either\n * polyfill Promise, or you create an adapter to convert the returned native promise\n * to whatever promise implementation you wanted. Will be removed in v8.\n */\n forEach(next: (value: T) => void, promiseCtor: PromiseConstructorLike): Promise;\n\n forEach(next: (value: T) => void, promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n const subscriber = new SafeSubscriber({\n next: (value) => {\n try {\n next(value);\n } catch (err) {\n reject(err);\n subscriber.unsubscribe();\n }\n },\n error: reject,\n complete: resolve,\n });\n this.subscribe(subscriber);\n }) as Promise;\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): TeardownLogic {\n return this.source?.subscribe(subscriber);\n }\n\n /**\n * An interop point defined by the es7-observable spec https://github.com/zenparsing/es-observable\n * @return This instance of the observable.\n */\n [Symbol_observable]() {\n return this;\n }\n\n /* tslint:disable:max-line-length */\n pipe(): Observable;\n pipe(op1: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction): Observable;\n pipe(op1: OperatorFunction, op2: OperatorFunction, op3: OperatorFunction): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction\n ): Observable;\n pipe(\n op1: OperatorFunction,\n op2: OperatorFunction,\n op3: OperatorFunction,\n op4: OperatorFunction,\n op5: OperatorFunction,\n op6: OperatorFunction,\n op7: OperatorFunction,\n op8: OperatorFunction,\n op9: OperatorFunction,\n ...operations: OperatorFunction[]\n ): Observable;\n /* tslint:enable:max-line-length */\n\n /**\n * Used to stitch together functional operators into a chain.\n *\n * ## Example\n *\n * ```ts\n * import { interval, filter, map, scan } from 'rxjs';\n *\n * interval(1000)\n * .pipe(\n * filter(x => x % 2 === 0),\n * map(x => x + x),\n * scan((acc, x) => acc + x)\n * )\n * .subscribe(x => console.log(x));\n * ```\n *\n * @return The Observable result of all the operators having been called\n * in the order they were passed in.\n */\n pipe(...operations: OperatorFunction[]): Observable {\n return pipeFromArray(operations)(this);\n }\n\n /* tslint:disable:max-line-length */\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: typeof Promise): Promise;\n /** @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise */\n toPromise(PromiseCtor: PromiseConstructorLike): Promise;\n /* tslint:enable:max-line-length */\n\n /**\n * Subscribe to this Observable and get a Promise resolving on\n * `complete` with the last emission (if any).\n *\n * **WARNING**: Only use this with observables you *know* will complete. If the source\n * observable does not complete, you will end up with a promise that is hung up, and\n * potentially all of the state of an async function hanging out in memory. To avoid\n * this situation, look into adding something like {@link timeout}, {@link take},\n * {@link takeWhile}, or {@link takeUntil} amongst others.\n *\n * @param [promiseCtor] a constructor function used to instantiate\n * the Promise\n * @return A Promise that resolves with the last value emit, or\n * rejects on an error. If there were no emissions, Promise\n * resolves with undefined.\n * @deprecated Replaced with {@link firstValueFrom} and {@link lastValueFrom}. Will be removed in v8. Details: https://rxjs.dev/deprecations/to-promise\n */\n toPromise(promiseCtor?: PromiseConstructorLike): Promise {\n promiseCtor = getPromiseCtor(promiseCtor);\n\n return new promiseCtor((resolve, reject) => {\n let value: T | undefined;\n this.subscribe(\n (x: T) => (value = x),\n (err: any) => reject(err),\n () => resolve(value)\n );\n }) as Promise;\n }\n}\n\n/**\n * Decides between a passed promise constructor from consuming code,\n * A default configured promise constructor, and the native promise\n * constructor and returns it. If nothing can be found, it will throw\n * an error.\n * @param promiseCtor The optional promise constructor to passed by consuming code\n */\nfunction getPromiseCtor(promiseCtor: PromiseConstructorLike | undefined) {\n return promiseCtor ?? config.Promise ?? Promise;\n}\n\nfunction isObserver(value: any): value is Observer {\n return value && isFunction(value.next) && isFunction(value.error) && isFunction(value.complete);\n}\n\nfunction isSubscriber(value: any): value is Subscriber {\n return (value && value instanceof Subscriber) || (isObserver(value) && isSubscription(value));\n}\n", "import { Observable } from '../Observable';\nimport { Subscriber } from '../Subscriber';\nimport { OperatorFunction } from '../types';\nimport { isFunction } from './isFunction';\n\n/**\n * Used to determine if an object is an Observable with a lift function.\n */\nexport function hasLift(source: any): source is { lift: InstanceType['lift'] } {\n return isFunction(source?.lift);\n}\n\n/**\n * Creates an `OperatorFunction`. Used to define operators throughout the library in a concise way.\n * @param init The logic to connect the liftedSource to the subscriber at the moment of subscription.\n */\nexport function operate(\n init: (liftedSource: Observable, subscriber: Subscriber) => (() => void) | void\n): OperatorFunction {\n return (source: Observable) => {\n if (hasLift(source)) {\n return source.lift(function (this: Subscriber, liftedSource: Observable) {\n try {\n return init(liftedSource, this);\n } catch (err) {\n this.error(err);\n }\n });\n }\n throw new TypeError('Unable to lift unknown Observable type');\n };\n}\n", "import { Subscriber } from '../Subscriber';\n\n/**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional teardown logic here. This will only be called on teardown if the\n * subscriber itself is not already closed. This is called after all other teardown logic is executed.\n */\nexport function createOperatorSubscriber(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n onFinalize?: () => void\n): Subscriber {\n return new OperatorSubscriber(destination, onNext, onComplete, onError, onFinalize);\n}\n\n/**\n * A generic helper for allowing operators to be created with a Subscriber and\n * use closures to capture necessary state from the operator function itself.\n */\nexport class OperatorSubscriber extends Subscriber {\n /**\n * Creates an instance of an `OperatorSubscriber`.\n * @param destination The downstream subscriber.\n * @param onNext Handles next values, only called if this subscriber is not stopped or closed. Any\n * error that occurs in this function is caught and sent to the `error` method of this subscriber.\n * @param onError Handles errors from the subscription, any errors that occur in this handler are caught\n * and send to the `destination` error handler.\n * @param onComplete Handles completion notification from the subscription. Any errors that occur in\n * this handler are sent to the `destination` error handler.\n * @param onFinalize Additional finalization logic here. This will only be called on finalization if the\n * subscriber itself is not already closed. This is called after all other finalization logic is executed.\n * @param shouldUnsubscribe An optional check to see if an unsubscribe call should truly unsubscribe.\n * NOTE: This currently **ONLY** exists to support the strange behavior of {@link groupBy}, where unsubscription\n * to the resulting observable does not actually disconnect from the source if there are active subscriptions\n * to any grouped observable. (DO NOT EXPOSE OR USE EXTERNALLY!!!)\n */\n constructor(\n destination: Subscriber,\n onNext?: (value: T) => void,\n onComplete?: () => void,\n onError?: (err: any) => void,\n private onFinalize?: () => void,\n private shouldUnsubscribe?: () => boolean\n ) {\n // It's important - for performance reasons - that all of this class's\n // members are initialized and that they are always initialized in the same\n // order. This will ensure that all OperatorSubscriber instances have the\n // same hidden class in V8. This, in turn, will help keep the number of\n // hidden classes involved in property accesses within the base class as\n // low as possible. If the number of hidden classes involved exceeds four,\n // the property accesses will become megamorphic and performance penalties\n // will be incurred - i.e. inline caches won't be used.\n //\n // The reasons for ensuring all instances have the same hidden class are\n // further discussed in this blog post from Benedikt Meurer:\n // https://benediktmeurer.de/2018/03/23/impact-of-polymorphism-on-component-based-frameworks-like-react/\n super(destination);\n this._next = onNext\n ? function (this: OperatorSubscriber, value: T) {\n try {\n onNext(value);\n } catch (err) {\n destination.error(err);\n }\n }\n : super._next;\n this._error = onError\n ? function (this: OperatorSubscriber, err: any) {\n try {\n onError(err);\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._error;\n this._complete = onComplete\n ? function (this: OperatorSubscriber) {\n try {\n onComplete();\n } catch (err) {\n // Send any errors that occur down stream.\n destination.error(err);\n } finally {\n // Ensure finalization.\n this.unsubscribe();\n }\n }\n : super._complete;\n }\n\n unsubscribe() {\n if (!this.shouldUnsubscribe || this.shouldUnsubscribe()) {\n const { closed } = this;\n super.unsubscribe();\n // Execute additional teardown if we have any and we didn't already do so.\n !closed && this.onFinalize?.();\n }\n }\n}\n", "import { Subscription } from '../Subscription';\n\ninterface AnimationFrameProvider {\n schedule(callback: FrameRequestCallback): Subscription;\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n delegate:\n | {\n requestAnimationFrame: typeof requestAnimationFrame;\n cancelAnimationFrame: typeof cancelAnimationFrame;\n }\n | undefined;\n}\n\nexport const animationFrameProvider: AnimationFrameProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n schedule(callback) {\n let request = requestAnimationFrame;\n let cancel: typeof cancelAnimationFrame | undefined = cancelAnimationFrame;\n const { delegate } = animationFrameProvider;\n if (delegate) {\n request = delegate.requestAnimationFrame;\n cancel = delegate.cancelAnimationFrame;\n }\n const handle = request((timestamp) => {\n // Clear the cancel function. The request has been fulfilled, so\n // attempting to cancel the request upon unsubscription would be\n // pointless.\n cancel = undefined;\n callback(timestamp);\n });\n return new Subscription(() => cancel?.(handle));\n },\n requestAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);\n },\n cancelAnimationFrame(...args) {\n const { delegate } = animationFrameProvider;\n return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);\n },\n delegate: undefined,\n};\n", "import { createErrorClass } from './createErrorClass';\n\nexport interface ObjectUnsubscribedError extends Error {}\n\nexport interface ObjectUnsubscribedErrorCtor {\n /**\n * @deprecated Internal implementation detail. Do not construct error instances.\n * Cannot be tagged as internal: https://github.com/ReactiveX/rxjs/issues/6269\n */\n new (): ObjectUnsubscribedError;\n}\n\n/**\n * An error thrown when an action is invalid because the object has been\n * unsubscribed.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n *\n * @class ObjectUnsubscribedError\n */\nexport const ObjectUnsubscribedError: ObjectUnsubscribedErrorCtor = createErrorClass(\n (_super) =>\n function ObjectUnsubscribedErrorImpl(this: any) {\n _super(this);\n this.name = 'ObjectUnsubscribedError';\n this.message = 'object unsubscribed';\n }\n);\n", "import { Operator } from './Operator';\nimport { Observable } from './Observable';\nimport { Subscriber } from './Subscriber';\nimport { Subscription, EMPTY_SUBSCRIPTION } from './Subscription';\nimport { Observer, SubscriptionLike, TeardownLogic } from './types';\nimport { ObjectUnsubscribedError } from './util/ObjectUnsubscribedError';\nimport { arrRemove } from './util/arrRemove';\nimport { errorContext } from './util/errorContext';\n\n/**\n * A Subject is a special type of Observable that allows values to be\n * multicasted to many Observers. Subjects are like EventEmitters.\n *\n * Every Subject is an Observable and an Observer. You can subscribe to a\n * Subject, and you can call next to feed values as well as error and complete.\n */\nexport class Subject extends Observable implements SubscriptionLike {\n closed = false;\n\n private currentObservers: Observer[] | null = null;\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n observers: Observer[] = [];\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n isStopped = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n hasError = false;\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n thrownError: any = null;\n\n /**\n * Creates a \"subject\" by basically gluing an observer to an observable.\n *\n * @deprecated Recommended you do not use. Will be removed at some point in the future. Plans for replacement still under discussion.\n */\n static create: (...args: any[]) => any = (destination: Observer, source: Observable): AnonymousSubject => {\n return new AnonymousSubject(destination, source);\n };\n\n constructor() {\n // NOTE: This must be here to obscure Observable's constructor.\n super();\n }\n\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n lift(operator: Operator): Observable {\n const subject = new AnonymousSubject(this, this);\n subject.operator = operator as any;\n return subject as any;\n }\n\n /** @internal */\n protected _throwIfClosed() {\n if (this.closed) {\n throw new ObjectUnsubscribedError();\n }\n }\n\n next(value: T) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n if (!this.currentObservers) {\n this.currentObservers = Array.from(this.observers);\n }\n for (const observer of this.currentObservers) {\n observer.next(value);\n }\n }\n });\n }\n\n error(err: any) {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.hasError = this.isStopped = true;\n this.thrownError = err;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.error(err);\n }\n }\n });\n }\n\n complete() {\n errorContext(() => {\n this._throwIfClosed();\n if (!this.isStopped) {\n this.isStopped = true;\n const { observers } = this;\n while (observers.length) {\n observers.shift()!.complete();\n }\n }\n });\n }\n\n unsubscribe() {\n this.isStopped = this.closed = true;\n this.observers = this.currentObservers = null!;\n }\n\n get observed() {\n return this.observers?.length > 0;\n }\n\n /** @internal */\n protected _trySubscribe(subscriber: Subscriber): TeardownLogic {\n this._throwIfClosed();\n return super._trySubscribe(subscriber);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._checkFinalizedStatuses(subscriber);\n return this._innerSubscribe(subscriber);\n }\n\n /** @internal */\n protected _innerSubscribe(subscriber: Subscriber) {\n const { hasError, isStopped, observers } = this;\n if (hasError || isStopped) {\n return EMPTY_SUBSCRIPTION;\n }\n this.currentObservers = null;\n observers.push(subscriber);\n return new Subscription(() => {\n this.currentObservers = null;\n arrRemove(observers, subscriber);\n });\n }\n\n /** @internal */\n protected _checkFinalizedStatuses(subscriber: Subscriber) {\n const { hasError, thrownError, isStopped } = this;\n if (hasError) {\n subscriber.error(thrownError);\n } else if (isStopped) {\n subscriber.complete();\n }\n }\n\n /**\n * Creates a new Observable with this Subject as the source. You can do this\n * to create custom Observer-side logic of the Subject and conceal it from\n * code that uses the Observable.\n * @return Observable that this Subject casts to.\n */\n asObservable(): Observable {\n const observable: any = new Observable();\n observable.source = this;\n return observable;\n }\n}\n\nexport class AnonymousSubject extends Subject {\n constructor(\n /** @deprecated Internal implementation detail, do not use directly. Will be made internal in v8. */\n public destination?: Observer,\n source?: Observable\n ) {\n super();\n this.source = source;\n }\n\n next(value: T) {\n this.destination?.next?.(value);\n }\n\n error(err: any) {\n this.destination?.error?.(err);\n }\n\n complete() {\n this.destination?.complete?.();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n return this.source?.subscribe(subscriber) ?? EMPTY_SUBSCRIPTION;\n }\n}\n", "import { Subject } from './Subject';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\n\n/**\n * A variant of Subject that requires an initial value and emits its current\n * value whenever it is subscribed to.\n */\nexport class BehaviorSubject extends Subject {\n constructor(private _value: T) {\n super();\n }\n\n get value(): T {\n return this.getValue();\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n const subscription = super._subscribe(subscriber);\n !subscription.closed && subscriber.next(this._value);\n return subscription;\n }\n\n getValue(): T {\n const { hasError, thrownError, _value } = this;\n if (hasError) {\n throw thrownError;\n }\n this._throwIfClosed();\n return _value;\n }\n\n next(value: T): void {\n super.next((this._value = value));\n }\n}\n", "import { TimestampProvider } from '../types';\n\ninterface DateTimestampProvider extends TimestampProvider {\n delegate: TimestampProvider | undefined;\n}\n\nexport const dateTimestampProvider: DateTimestampProvider = {\n now() {\n // Use the variable rather than `this` so that the function can be called\n // without being bound to the provider.\n return (dateTimestampProvider.delegate || Date).now();\n },\n delegate: undefined,\n};\n", "import { Subject } from './Subject';\nimport { TimestampProvider } from './types';\nimport { Subscriber } from './Subscriber';\nimport { Subscription } from './Subscription';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * A variant of {@link Subject} that \"replays\" old values to new subscribers by emitting them when they first subscribe.\n *\n * `ReplaySubject` has an internal buffer that will store a specified number of values that it has observed. Like `Subject`,\n * `ReplaySubject` \"observes\" values by having them passed to its `next` method. When it observes a value, it will store that\n * value for a time determined by the configuration of the `ReplaySubject`, as passed to its constructor.\n *\n * When a new subscriber subscribes to the `ReplaySubject` instance, it will synchronously emit all values in its buffer in\n * a First-In-First-Out (FIFO) manner. The `ReplaySubject` will also complete, if it has observed completion; and it will\n * error if it has observed an error.\n *\n * There are two main configuration items to be concerned with:\n *\n * 1. `bufferSize` - This will determine how many items are stored in the buffer, defaults to infinite.\n * 2. `windowTime` - The amount of time to hold a value in the buffer before removing it from the buffer.\n *\n * Both configurations may exist simultaneously. So if you would like to buffer a maximum of 3 values, as long as the values\n * are less than 2 seconds old, you could do so with a `new ReplaySubject(3, 2000)`.\n *\n * ### Differences with BehaviorSubject\n *\n * `BehaviorSubject` is similar to `new ReplaySubject(1)`, with a couple of exceptions:\n *\n * 1. `BehaviorSubject` comes \"primed\" with a single value upon construction.\n * 2. `ReplaySubject` will replay values, even after observing an error, where `BehaviorSubject` will not.\n *\n * @see {@link Subject}\n * @see {@link BehaviorSubject}\n * @see {@link shareReplay}\n */\nexport class ReplaySubject extends Subject {\n private _buffer: (T | number)[] = [];\n private _infiniteTimeWindow = true;\n\n /**\n * @param _bufferSize The size of the buffer to replay on subscription\n * @param _windowTime The amount of time the buffered items will stay buffered\n * @param _timestampProvider An object with a `now()` method that provides the current timestamp. This is used to\n * calculate the amount of time something has been buffered.\n */\n constructor(\n private _bufferSize = Infinity,\n private _windowTime = Infinity,\n private _timestampProvider: TimestampProvider = dateTimestampProvider\n ) {\n super();\n this._infiniteTimeWindow = _windowTime === Infinity;\n this._bufferSize = Math.max(1, _bufferSize);\n this._windowTime = Math.max(1, _windowTime);\n }\n\n next(value: T): void {\n const { isStopped, _buffer, _infiniteTimeWindow, _timestampProvider, _windowTime } = this;\n if (!isStopped) {\n _buffer.push(value);\n !_infiniteTimeWindow && _buffer.push(_timestampProvider.now() + _windowTime);\n }\n this._trimBuffer();\n super.next(value);\n }\n\n /** @internal */\n protected _subscribe(subscriber: Subscriber): Subscription {\n this._throwIfClosed();\n this._trimBuffer();\n\n const subscription = this._innerSubscribe(subscriber);\n\n const { _infiniteTimeWindow, _buffer } = this;\n // We use a copy here, so reentrant code does not mutate our array while we're\n // emitting it to a new subscriber.\n const copy = _buffer.slice();\n for (let i = 0; i < copy.length && !subscriber.closed; i += _infiniteTimeWindow ? 1 : 2) {\n subscriber.next(copy[i] as T);\n }\n\n this._checkFinalizedStatuses(subscriber);\n\n return subscription;\n }\n\n private _trimBuffer() {\n const { _bufferSize, _timestampProvider, _buffer, _infiniteTimeWindow } = this;\n // If we don't have an infinite buffer size, and we're over the length,\n // use splice to truncate the old buffer values off. Note that we have to\n // double the size for instances where we're not using an infinite time window\n // because we're storing the values and the timestamps in the same array.\n const adjustedBufferSize = (_infiniteTimeWindow ? 1 : 2) * _bufferSize;\n _bufferSize < Infinity && adjustedBufferSize < _buffer.length && _buffer.splice(0, _buffer.length - adjustedBufferSize);\n\n // Now, if we're not in an infinite time window, remove all values where the time is\n // older than what is allowed.\n if (!_infiniteTimeWindow) {\n const now = _timestampProvider.now();\n let last = 0;\n // Search the array for the first timestamp that isn't expired and\n // truncate the buffer up to that point.\n for (let i = 1; i < _buffer.length && (_buffer[i] as number) <= now; i += 2) {\n last = i;\n }\n last && _buffer.splice(0, last + 1);\n }\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Subscription } from '../Subscription';\nimport { SchedulerAction } from '../types';\n\n/**\n * A unit of work to be executed in a `scheduler`. An action is typically\n * created from within a {@link SchedulerLike} and an RxJS user does not need to concern\n * themselves about creating and manipulating an Action.\n *\n * ```ts\n * class Action extends Subscription {\n * new (scheduler: Scheduler, work: (state?: T) => void);\n * schedule(state?: T, delay: number = 0): Subscription;\n * }\n * ```\n */\nexport class Action extends Subscription {\n constructor(scheduler: Scheduler, work: (this: SchedulerAction, state?: T) => void) {\n super();\n }\n /**\n * Schedules this action on its parent {@link SchedulerLike} for execution. May be passed\n * some context object, `state`. May happen at some point in the future,\n * according to the `delay` parameter, if specified.\n * @param state Some contextual data that the `work` function uses when called by the\n * Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is implicit\n * and defined by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(state?: T, delay: number = 0): Subscription {\n return this;\n }\n}\n", "import type { TimerHandle } from './timerHandle';\ntype SetIntervalFunction = (handler: () => void, timeout?: number, ...args: any[]) => TimerHandle;\ntype ClearIntervalFunction = (handle: TimerHandle) => void;\n\ninterface IntervalProvider {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n delegate:\n | {\n setInterval: SetIntervalFunction;\n clearInterval: ClearIntervalFunction;\n }\n | undefined;\n}\n\nexport const intervalProvider: IntervalProvider = {\n // When accessing the delegate, use the variable rather than `this` so that\n // the functions can be called without being bound to the provider.\n setInterval(handler: () => void, timeout?: number, ...args) {\n const { delegate } = intervalProvider;\n if (delegate?.setInterval) {\n return delegate.setInterval(handler, timeout, ...args);\n }\n return setInterval(handler, timeout, ...args);\n },\n clearInterval(handle) {\n const { delegate } = intervalProvider;\n return (delegate?.clearInterval || clearInterval)(handle as any);\n },\n delegate: undefined,\n};\n", "import { Action } from './Action';\nimport { SchedulerAction } from '../types';\nimport { Subscription } from '../Subscription';\nimport { AsyncScheduler } from './AsyncScheduler';\nimport { intervalProvider } from './intervalProvider';\nimport { arrRemove } from '../util/arrRemove';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncAction extends Action {\n public id: TimerHandle | undefined;\n public state?: T;\n // @ts-ignore: Property has no initializer and is not definitely assigned\n public delay: number;\n protected pending: boolean = false;\n\n constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (this.closed) {\n return this;\n }\n\n // Always replace the current state with the new state.\n this.state = state;\n\n const id = this.id;\n const scheduler = this.scheduler;\n\n //\n // Important implementation note:\n //\n // Actions only execute once by default, unless rescheduled from within the\n // scheduled callback. This allows us to implement single and repeat\n // actions via the same code path, without adding API surface area, as well\n // as mimic traditional recursion but across asynchronous boundaries.\n //\n // However, JS runtimes and timers distinguish between intervals achieved by\n // serial `setTimeout` calls vs. a single `setInterval` call. An interval of\n // serial `setTimeout` calls can be individually delayed, which delays\n // scheduling the next `setTimeout`, and so on. `setInterval` attempts to\n // guarantee the interval callback will be invoked more precisely to the\n // interval period, regardless of load.\n //\n // Therefore, we use `setInterval` to schedule single and repeat actions.\n // If the action reschedules itself with the same delay, the interval is not\n // canceled. If the action doesn't reschedule, or reschedules with a\n // different delay, the interval will be canceled after scheduled callback\n // execution.\n //\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, delay);\n }\n\n // Set the pending flag indicating that this action has been scheduled, or\n // has recursively rescheduled itself.\n this.pending = true;\n\n this.delay = delay;\n // If this action has already an async Id, don't request a new one.\n this.id = this.id ?? this.requestAsyncId(scheduler, this.id, delay);\n\n return this;\n }\n\n protected requestAsyncId(scheduler: AsyncScheduler, _id?: TimerHandle, delay: number = 0): TimerHandle {\n return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);\n }\n\n protected recycleAsyncId(_scheduler: AsyncScheduler, id?: TimerHandle, delay: number | null = 0): TimerHandle | undefined {\n // If this action is rescheduled with the same delay time, don't clear the interval id.\n if (delay != null && this.delay === delay && this.pending === false) {\n return id;\n }\n // Otherwise, if the action's delay time is different from the current delay,\n // or the action has been rescheduled before it's executed, clear the interval id\n if (id != null) {\n intervalProvider.clearInterval(id);\n }\n\n return undefined;\n }\n\n /**\n * Immediately executes this action and the `work` it contains.\n */\n public execute(state: T, delay: number): any {\n if (this.closed) {\n return new Error('executing a cancelled action');\n }\n\n this.pending = false;\n const error = this._execute(state, delay);\n if (error) {\n return error;\n } else if (this.pending === false && this.id != null) {\n // Dequeue if the action didn't reschedule itself. Don't call\n // unsubscribe(), because the action could reschedule later.\n // For example:\n // ```\n // scheduler.schedule(function doWork(counter) {\n // /* ... I'm a busy worker bee ... */\n // var originalAction = this;\n // /* wait 100ms before rescheduling the action */\n // setTimeout(function () {\n // originalAction.schedule(counter + 1);\n // }, 100);\n // }, 1000);\n // ```\n this.id = this.recycleAsyncId(this.scheduler, this.id, null);\n }\n }\n\n protected _execute(state: T, _delay: number): any {\n let errored: boolean = false;\n let errorValue: any;\n try {\n this.work(state);\n } catch (e) {\n errored = true;\n // HACK: Since code elsewhere is relying on the \"truthiness\" of the\n // return here, we can't have it return \"\" or 0 or false.\n // TODO: Clean this up when we refactor schedulers mid-version-8 or so.\n errorValue = e ? e : new Error('Scheduled action threw falsy error');\n }\n if (errored) {\n this.unsubscribe();\n return errorValue;\n }\n }\n\n unsubscribe() {\n if (!this.closed) {\n const { id, scheduler } = this;\n const { actions } = scheduler;\n\n this.work = this.state = this.scheduler = null!;\n this.pending = false;\n\n arrRemove(actions, this);\n if (id != null) {\n this.id = this.recycleAsyncId(scheduler, id, null);\n }\n\n this.delay = null!;\n super.unsubscribe();\n }\n }\n}\n", "import { Action } from './scheduler/Action';\nimport { Subscription } from './Subscription';\nimport { SchedulerLike, SchedulerAction } from './types';\nimport { dateTimestampProvider } from './scheduler/dateTimestampProvider';\n\n/**\n * An execution context and a data structure to order tasks and schedule their\n * execution. Provides a notion of (potentially virtual) time, through the\n * `now()` getter method.\n *\n * Each unit of work in a Scheduler is called an `Action`.\n *\n * ```ts\n * class Scheduler {\n * now(): number;\n * schedule(work, delay?, state?): Subscription;\n * }\n * ```\n *\n * @deprecated Scheduler is an internal implementation detail of RxJS, and\n * should not be used directly. Rather, create your own class and implement\n * {@link SchedulerLike}. Will be made internal in v8.\n */\nexport class Scheduler implements SchedulerLike {\n public static now: () => number = dateTimestampProvider.now;\n\n constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {\n this.now = now;\n }\n\n /**\n * A getter method that returns a number representing the current time\n * (at the time this function was called) according to the scheduler's own\n * internal clock.\n * @return A number that represents the current time. May or may not\n * have a relation to wall-clock time. May or may not refer to a time unit\n * (e.g. milliseconds).\n */\n public now: () => number;\n\n /**\n * Schedules a function, `work`, for execution. May happen at some point in\n * the future, according to the `delay` parameter, if specified. May be passed\n * some context object, `state`, which will be passed to the `work` function.\n *\n * The given arguments will be processed an stored as an Action object in a\n * queue of actions.\n *\n * @param work A function representing a task, or some unit of work to be\n * executed by the Scheduler.\n * @param delay Time to wait before executing the work, where the time unit is\n * implicit and defined by the Scheduler itself.\n * @param state Some contextual data that the `work` function uses when called\n * by the Scheduler.\n * @return A subscription in order to be able to unsubscribe the scheduled work.\n */\n public schedule(work: (this: SchedulerAction, state?: T) => void, delay: number = 0, state?: T): Subscription {\n return new this.schedulerActionCtor(this, work).schedule(state, delay);\n }\n}\n", "import { Scheduler } from '../Scheduler';\nimport { Action } from './Action';\nimport { AsyncAction } from './AsyncAction';\nimport { TimerHandle } from './timerHandle';\n\nexport class AsyncScheduler extends Scheduler {\n public actions: Array> = [];\n /**\n * A flag to indicate whether the Scheduler is currently executing a batch of\n * queued actions.\n * @internal\n */\n public _active: boolean = false;\n /**\n * An internal ID used to track the latest asynchronous task such as those\n * coming from `setTimeout`, `setInterval`, `requestAnimationFrame`, and\n * others.\n * @internal\n */\n public _scheduled: TimerHandle | undefined;\n\n constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {\n super(SchedulerAction, now);\n }\n\n public flush(action: AsyncAction): void {\n const { actions } = this;\n\n if (this._active) {\n actions.push(action);\n return;\n }\n\n let error: any;\n this._active = true;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions.shift()!)); // exhaust the scheduler queue\n\n this._active = false;\n\n if (error) {\n while ((action = actions.shift()!)) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\n/**\n *\n * Async Scheduler\n *\n * Schedule task as if you used setTimeout(task, duration)\n *\n * `async` scheduler schedules tasks asynchronously, by putting them on the JavaScript\n * event loop queue. It is best used to delay tasks in time or to schedule tasks repeating\n * in intervals.\n *\n * If you just want to \"defer\" task, that is to perform it right after currently\n * executing synchronous code ends (commonly achieved by `setTimeout(deferredTask, 0)`),\n * better choice will be the {@link asapScheduler} scheduler.\n *\n * ## Examples\n * Use async scheduler to delay task\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * const task = () => console.log('it works!');\n *\n * asyncScheduler.schedule(task, 2000);\n *\n * // After 2 seconds logs:\n * // \"it works!\"\n * ```\n *\n * Use async scheduler to repeat task in intervals\n * ```ts\n * import { asyncScheduler } from 'rxjs';\n *\n * function task(state) {\n * console.log(state);\n * this.schedule(state + 1, 1000); // `this` references currently executing Action,\n * // which we reschedule with new state and delay\n * }\n *\n * asyncScheduler.schedule(task, 3000, 0);\n *\n * // Logs:\n * // 0 after 3s\n * // 1 after 4s\n * // 2 after 5s\n * // 3 after 6s\n * ```\n */\n\nexport const asyncScheduler = new AsyncScheduler(AsyncAction);\n\n/**\n * @deprecated Renamed to {@link asyncScheduler}. Will be removed in v8.\n */\nexport const async = asyncScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { Subscription } from '../Subscription';\nimport { QueueScheduler } from './QueueScheduler';\nimport { SchedulerAction } from '../types';\nimport { TimerHandle } from './timerHandle';\n\nexport class QueueAction extends AsyncAction {\n constructor(protected scheduler: QueueScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n public schedule(state?: T, delay: number = 0): Subscription {\n if (delay > 0) {\n return super.schedule(state, delay);\n }\n this.delay = delay;\n this.state = state;\n this.scheduler.flush(this);\n return this;\n }\n\n public execute(state: T, delay: number): any {\n return delay > 0 || this.closed ? super.execute(state, delay) : this._execute(state, delay);\n }\n\n protected requestAsyncId(scheduler: QueueScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n\n if ((delay != null && delay > 0) || (delay == null && this.delay > 0)) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n\n // Otherwise flush the scheduler starting with this action.\n scheduler.flush(this);\n\n // HACK: In the past, this was returning `void`. However, `void` isn't a valid\n // `TimerHandle`, and generally the return value here isn't really used. So the\n // compromise is to return `0` which is both \"falsy\" and a valid `TimerHandle`,\n // as opposed to refactoring every other instanceo of `requestAsyncId`.\n return 0;\n }\n}\n", "import { AsyncScheduler } from './AsyncScheduler';\n\nexport class QueueScheduler extends AsyncScheduler {\n}\n", "import { QueueAction } from './QueueAction';\nimport { QueueScheduler } from './QueueScheduler';\n\n/**\n *\n * Queue Scheduler\n *\n * Put every next task on a queue, instead of executing it immediately\n *\n * `queue` scheduler, when used with delay, behaves the same as {@link asyncScheduler} scheduler.\n *\n * When used without delay, it schedules given task synchronously - executes it right when\n * it is scheduled. However when called recursively, that is when inside the scheduled task,\n * another task is scheduled with queue scheduler, instead of executing immediately as well,\n * that task will be put on a queue and wait for current one to finish.\n *\n * This means that when you execute task with `queue` scheduler, you are sure it will end\n * before any other task scheduled with that scheduler will start.\n *\n * ## Examples\n * Schedule recursively first, then do something\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(() => {\n * queueScheduler.schedule(() => console.log('second')); // will not happen now, but will be put on a queue\n *\n * console.log('first');\n * });\n *\n * // Logs:\n * // \"first\"\n * // \"second\"\n * ```\n *\n * Reschedule itself recursively\n * ```ts\n * import { queueScheduler } from 'rxjs';\n *\n * queueScheduler.schedule(function(state) {\n * if (state !== 0) {\n * console.log('before', state);\n * this.schedule(state - 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * console.log('after', state);\n * }\n * }, 0, 3);\n *\n * // In scheduler that runs recursively, you would expect:\n * // \"before\", 3\n * // \"before\", 2\n * // \"before\", 1\n * // \"after\", 1\n * // \"after\", 2\n * // \"after\", 3\n *\n * // But with queue it logs:\n * // \"before\", 3\n * // \"after\", 3\n * // \"before\", 2\n * // \"after\", 2\n * // \"before\", 1\n * // \"after\", 1\n * ```\n */\n\nexport const queueScheduler = new QueueScheduler(QueueAction);\n\n/**\n * @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.\n */\nexport const queue = queueScheduler;\n", "import { AsyncAction } from './AsyncAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\nimport { SchedulerAction } from '../types';\nimport { animationFrameProvider } from './animationFrameProvider';\nimport { TimerHandle } from './timerHandle';\n\nexport class AnimationFrameAction extends AsyncAction {\n constructor(protected scheduler: AnimationFrameScheduler, protected work: (this: SchedulerAction, state?: T) => void) {\n super(scheduler, work);\n }\n\n protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle {\n // If delay is greater than 0, request as an async action.\n if (delay !== null && delay > 0) {\n return super.requestAsyncId(scheduler, id, delay);\n }\n // Push the action to the end of the scheduler queue.\n scheduler.actions.push(this);\n // If an animation frame has already been requested, don't request another\n // one. If an animation frame hasn't been requested yet, request one. Return\n // the current animation frame request id.\n return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)));\n }\n\n protected recycleAsyncId(scheduler: AnimationFrameScheduler, id?: TimerHandle, delay: number = 0): TimerHandle | undefined {\n // If delay exists and is greater than 0, or if the delay is null (the\n // action wasn't rescheduled) but was originally scheduled as an async\n // action, then recycle as an async action.\n if (delay != null ? delay > 0 : this.delay > 0) {\n return super.recycleAsyncId(scheduler, id, delay);\n }\n // If the scheduler queue has no remaining actions with the same async id,\n // cancel the requested animation frame and set the scheduled flag to\n // undefined so the next AnimationFrameAction will request its own.\n const { actions } = scheduler;\n if (id != null && id === scheduler._scheduled && actions[actions.length - 1]?.id !== id) {\n animationFrameProvider.cancelAnimationFrame(id as number);\n scheduler._scheduled = undefined;\n }\n // Return undefined so the action knows to request a new async id if it's rescheduled.\n return undefined;\n }\n}\n", "import { AsyncAction } from './AsyncAction';\nimport { AsyncScheduler } from './AsyncScheduler';\n\nexport class AnimationFrameScheduler extends AsyncScheduler {\n public flush(action?: AsyncAction): void {\n this._active = true;\n // The async id that effects a call to flush is stored in _scheduled.\n // Before executing an action, it's necessary to check the action's async\n // id to determine whether it's supposed to be executed in the current\n // flush.\n // Previous implementations of this method used a count to determine this,\n // but that was unsound, as actions that are unsubscribed - i.e. cancelled -\n // are removed from the actions array and that can shift actions that are\n // scheduled to be executed in a subsequent flush into positions at which\n // they are executed within the current flush.\n let flushId;\n if (action) {\n flushId = action.id;\n } else {\n flushId = this._scheduled;\n this._scheduled = undefined;\n }\n\n const { actions } = this;\n let error: any;\n action = action || actions.shift()!;\n\n do {\n if ((error = action.execute(action.state, action.delay))) {\n break;\n }\n } while ((action = actions[0]) && action.id === flushId && actions.shift());\n\n this._active = false;\n\n if (error) {\n while ((action = actions[0]) && action.id === flushId && actions.shift()) {\n action.unsubscribe();\n }\n throw error;\n }\n }\n}\n", "import { AnimationFrameAction } from './AnimationFrameAction';\nimport { AnimationFrameScheduler } from './AnimationFrameScheduler';\n\n/**\n *\n * Animation Frame Scheduler\n *\n * Perform task when `window.requestAnimationFrame` would fire\n *\n * When `animationFrame` scheduler is used with delay, it will fall back to {@link asyncScheduler} scheduler\n * behaviour.\n *\n * Without delay, `animationFrame` scheduler can be used to create smooth browser animations.\n * It makes sure scheduled task will happen just before next browser content repaint,\n * thus performing animations as efficiently as possible.\n *\n * ## Example\n * Schedule div height animation\n * ```ts\n * // html:
    \n * import { animationFrameScheduler } from 'rxjs';\n *\n * const div = document.querySelector('div');\n *\n * animationFrameScheduler.schedule(function(height) {\n * div.style.height = height + \"px\";\n *\n * this.schedule(height + 1); // `this` references currently executing Action,\n * // which we reschedule with new state\n * }, 0, 0);\n *\n * // You will see a div element growing in height\n * ```\n */\n\nexport const animationFrameScheduler = new AnimationFrameScheduler(AnimationFrameAction);\n\n/**\n * @deprecated Renamed to {@link animationFrameScheduler}. Will be removed in v8.\n */\nexport const animationFrame = animationFrameScheduler;\n", "import { Observable } from '../Observable';\nimport { SchedulerLike } from '../types';\n\n/**\n * A simple Observable that emits no items to the Observer and immediately\n * emits a complete notification.\n *\n * Just emits 'complete', and nothing else.\n *\n * ![](empty.png)\n *\n * A simple Observable that only emits the complete notification. It can be used\n * for composing with other Observables, such as in a {@link mergeMap}.\n *\n * ## Examples\n *\n * Log complete notification\n *\n * ```ts\n * import { EMPTY } from 'rxjs';\n *\n * EMPTY.subscribe({\n * next: () => console.log('Next'),\n * complete: () => console.log('Complete!')\n * });\n *\n * // Outputs\n * // Complete!\n * ```\n *\n * Emit the number 7, then complete\n *\n * ```ts\n * import { EMPTY, startWith } from 'rxjs';\n *\n * const result = EMPTY.pipe(startWith(7));\n * result.subscribe(x => console.log(x));\n *\n * // Outputs\n * // 7\n * ```\n *\n * Map and flatten only odd numbers to the sequence `'a'`, `'b'`, `'c'`\n *\n * ```ts\n * import { interval, mergeMap, of, EMPTY } from 'rxjs';\n *\n * const interval$ = interval(1000);\n * const result = interval$.pipe(\n * mergeMap(x => x % 2 === 1 ? of('a', 'b', 'c') : EMPTY),\n * );\n * result.subscribe(x => console.log(x));\n *\n * // Results in the following to the console:\n * // x is equal to the count on the interval, e.g. (0, 1, 2, 3, ...)\n * // x will occur every 1000ms\n * // if x % 2 is equal to 1, print a, b, c (each on its own)\n * // if x % 2 is not equal to 1, nothing will be output\n * ```\n *\n * @see {@link Observable}\n * @see {@link NEVER}\n * @see {@link of}\n * @see {@link throwError}\n */\nexport const EMPTY = new Observable((subscriber) => subscriber.complete());\n\n/**\n * @param scheduler A {@link SchedulerLike} to use for scheduling\n * the emission of the complete notification.\n * @deprecated Replaced with the {@link EMPTY} constant or {@link scheduled} (e.g. `scheduled([], scheduler)`). Will be removed in v8.\n */\nexport function empty(scheduler?: SchedulerLike) {\n return scheduler ? emptyScheduled(scheduler) : EMPTY;\n}\n\nfunction emptyScheduled(scheduler: SchedulerLike) {\n return new Observable((subscriber) => scheduler.schedule(() => subscriber.complete()));\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport function isScheduler(value: any): value is SchedulerLike {\n return value && isFunction(value.schedule);\n}\n", "import { SchedulerLike } from '../types';\nimport { isFunction } from './isFunction';\nimport { isScheduler } from './isScheduler';\n\nfunction last(arr: T[]): T | undefined {\n return arr[arr.length - 1];\n}\n\nexport function popResultSelector(args: any[]): ((...args: unknown[]) => unknown) | undefined {\n return isFunction(last(args)) ? args.pop() : undefined;\n}\n\nexport function popScheduler(args: any[]): SchedulerLike | undefined {\n return isScheduler(last(args)) ? args.pop() : undefined;\n}\n\nexport function popNumber(args: any[], defaultValue: number): number {\n return typeof last(args) === 'number' ? args.pop()! : defaultValue;\n}\n", "export const isArrayLike = ((x: any): x is ArrayLike => x && typeof x.length === 'number' && typeof x !== 'function');", "import { isFunction } from \"./isFunction\";\n\n/**\n * Tests to see if the object is \"thennable\".\n * @param value the object to test\n */\nexport function isPromise(value: any): value is PromiseLike {\n return isFunction(value?.then);\n}\n", "import { InteropObservable } from '../types';\nimport { observable as Symbol_observable } from '../symbol/observable';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being Observable (but not necessary an Rx Observable) */\nexport function isInteropObservable(input: any): input is InteropObservable {\n return isFunction(input[Symbol_observable]);\n}\n", "import { isFunction } from './isFunction';\n\nexport function isAsyncIterable(obj: any): obj is AsyncIterable {\n return Symbol.asyncIterator && isFunction(obj?.[Symbol.asyncIterator]);\n}\n", "/**\n * Creates the TypeError to throw if an invalid object is passed to `from` or `scheduled`.\n * @param input The object that was passed.\n */\nexport function createInvalidObservableTypeError(input: any) {\n // TODO: We should create error codes that can be looked up, so this can be less verbose.\n return new TypeError(\n `You provided ${\n input !== null && typeof input === 'object' ? 'an invalid object' : `'${input}'`\n } where a stream was expected. You can provide an Observable, Promise, ReadableStream, Array, AsyncIterable, or Iterable.`\n );\n}\n", "export function getSymbolIterator(): symbol {\n if (typeof Symbol !== 'function' || !Symbol.iterator) {\n return '@@iterator' as any;\n }\n\n return Symbol.iterator;\n}\n\nexport const iterator = getSymbolIterator();\n", "import { iterator as Symbol_iterator } from '../symbol/iterator';\nimport { isFunction } from './isFunction';\n\n/** Identifies an input as being an Iterable */\nexport function isIterable(input: any): input is Iterable {\n return isFunction(input?.[Symbol_iterator]);\n}\n", "import { ReadableStreamLike } from '../types';\nimport { isFunction } from './isFunction';\n\nexport async function* readableStreamLikeToAsyncGenerator(readableStream: ReadableStreamLike): AsyncGenerator {\n const reader = readableStream.getReader();\n try {\n while (true) {\n const { value, done } = await reader.read();\n if (done) {\n return;\n }\n yield value!;\n }\n } finally {\n reader.releaseLock();\n }\n}\n\nexport function isReadableStreamLike(obj: any): obj is ReadableStreamLike {\n // We don't want to use instanceof checks because they would return\n // false for instances from another Realm, like an