{
 "version": 1,
 "site": {
  "title": "Eternet Docs",
  "subtitle": "platform reference"
 },
 "categories": [
  {
   "id": "start",
   "name": "Start here"
  },
  {
   "id": "services",
   "name": "Eternet services"
  },
  {
   "id": "eos",
   "name": "Custom HASS · eOS"
  },
  {
   "id": "apps",
   "name": "Apps & frontends"
  },
  {
   "id": "thirdparty",
   "name": "Third-party & forks"
  },
  {
   "id": "infra",
   "name": "Infrastructure & data"
  },
  {
   "id": "packages",
   "name": "Packages & libraries"
  },
  {
   "id": "legacy",
   "name": "Legacy & archived"
  }
 ],
 "cv": 1,
 "pages": [
  {
   "id": "catalog",
   "title": "Service glossary",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Every component of the platform on one list: what it is, what kind of thing it is, and where its code lives.",
   "blocks": [
    {
     "type": "p",
     "text": "Use the filter to find a service by name, repository or description. The **Kind** column says whether Eternet wrote it, wraps a third-party system, forked one, or runs it as-is. Open a row for goals, APIs, data and deployment."
    },
    {
     "type": "catalog"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 0,
   "v": 1
  },
  {
   "id": "architecture",
   "title": "Architecture map",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "How Eternet services, wrappers, forks, third-party systems and shared packages depend on each other.",
   "blocks": [
    {
     "type": "p",
     "text": "Each column is a menu category and each box is a page. Arrows point from a component to what it uses; dashed arrows mark an Eternet wrapper around a third-party system. Select a box to highlight its connections, or open its page from the panel."
    },
    {
     "type": "diagram"
    },
    {
     "type": "h",
     "text": "How relations are maintained"
    },
    {
     "type": "p",
     "text": "The map is drawn from the **Uses** and **Wraps** fields of each page. To change a connection, open the page, switch to edit mode and use **Page details**. Renaming a category or moving a page in the menu changes the columns here too."
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 1,
   "v": 1
  },
  {
   "id": "overview",
   "title": "Platform overview",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "An IoT and smart-building platform: devices, vehicles and Home Assistant hubs report into one cloud, and customers manage them through one portal and mobile app.",
   "blocks": [
    {
     "type": "h",
     "text": "Where to start"
    },
    {
     "type": "list",
     "items": [
      "[[catalog|Service glossary]]: one line per component.",
      "[[architecture|Architecture map]]: how components connect.",
      "[[routing|Request routing]] and [[hosts|Hosts & ports]]: what answers on which URL.",
      "[[auth|Authentication model]]: how every client signs in.",
      "[[glossary|Glossary of terms]]: words used across the codebase."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "Main data flows"
    },
    {
     "type": "cards",
     "rows": [
      [
       "Device telemetry",
       "Device → VerneMQ (MQTT) → [[device-service|device_service]] workers → topic tree, device state, InfluxDB → WebSocket `/ws/core/` → portal and app."
      ],
      [
       "Vehicle tracking",
       "GPS tracker → [[traccar|Traccar]] (`gps.eternet.net`) → `tc_positions` on TimescaleDB → [[vehicle-service|vehicle_service]] records API → maps and charts."
      ],
      [
       "eOS data",
       "Home Assistant on the hub → [[remote-recorder|Remote Recorder]] → `db.cloud.eternet.net/ingest` ([[hass-cloud|eOS Cloud]]) or the customer's own database → InfluxDB → dashboards."
      ],
      [
       "eOS remote access",
       "Hub → SSO login → `/cloud/instance/register` → ACME certificate → SniTun tunnel. A browser on `<id>.cloud.eternet.net` reaches the hub end to end."
      ],
      [
       "Workflows",
       "Portal → [[automation-service|automation_service]] → [[n8n|n8n]] (through the [[n8n-proxy|SSO proxy]]) → Eternet nodes call device_service."
      ],
      [
       "Releases",
       "Git tag → [[ha-release|eternet-ha-release]] CI → [[registry|registry]] + artifacts → [[update-service|update_service]] manifest → Supervisor on each device."
      ]
     ]
    },
    {
     "type": "h",
     "text": "Conventions you will see everywhere"
    },
    {
     "type": "list",
     "items": [
      "Django services share one template: `docker/Dockerfile`, `docker/docker-compose.yml` with Traefik labels, `.env` next to the code, Swagger at `/<service>/docs`, static files at `/static/<service>/`.",
      "All APIs share the prefix `/core/api/v1/` on `api.eternet.net`; Traefik picks the service by the next path segment.",
      "Authentication is always `eternet_auth_package.UnifiedAuthentication`: a Keycloak JWT or an Eternet API key.",
      "Services read the legacy `ETRApp_*` tables in `eternetdb` with unmanaged models (see [[postgres|PostgreSQL & TimescaleDB]]).",
      "Images build with `network: host` so pip can clone private packages from GitLab on the same host.",
      "Checkouts live under `/home/devopsadmin/eternet/` on the main server; rebuild with `git pull` then `docker compose … up -d --build`."
     ],
     "ordered": false
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 2,
   "v": 1
  },
  {
   "id": "routing",
   "title": "Request routing",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Everything under api.eternet.net goes through one Traefik instance. The path decides the service.",
   "blocks": [
    {
     "type": "h",
     "text": "api.eternet.net path map"
    },
    {
     "type": "table",
     "head": [
      "Path prefix",
      "Service",
      "Port",
      "Rewrite / note"
     ],
     "rows": [
      [
       "`/core/api/v1/eternetAuth`",
       "[[auth-service|Authorization Service]]",
       "8033",
       "—"
      ],
      [
       "`/core/api/v1/devices`",
       "[[device-service|Device Service]]",
       "8025",
       "—"
      ],
      [
       "`/ws/core/`",
       "[[device-service|Device Service]]",
       "8025",
       "WebSocket"
      ],
      [
       "`/docs`, `/redoc`",
       "[[device-service|Device Service]]",
       "8025",
       "Device API docs only"
      ],
      [
       "`/core/api/v1/vehicles`",
       "[[vehicle-service|Vehicle Service]]",
       "8026",
       "—"
      ],
      [
       "`/core/api/v1/traccar`",
       "[[vehicle-service|Vehicle Service]]",
       "8026",
       "Legacy Traccar API"
      ],
      [
       "`/core/api/v1/hub`",
       "[[hub-service|Hub Service]]",
       "8027",
       "Strips `hub/`"
      ],
      [
       "`/core/api/v1/widget`",
       "[[widget-service|Widget & Dashboard Service]]",
       "8029",
       "Strips `widget/`"
      ],
      [
       "`/core/api/v1/dashboard`",
       "[[widget-service|Widget & Dashboard Service]]",
       "8029",
       "—"
      ],
      [
       "`/core/api/v1/analytic`",
       "[[analytic-service|Analytics Service]]",
       "8030",
       "—"
      ],
      [
       "`/core/api/v1/automation`",
       "[[automation-service|Automation Service]]",
       "8031",
       "—"
      ],
      [
       "`/core/api/v1/forecast`",
       "[[forecast-service|Forecast Service]]",
       "8044",
       "—"
      ],
      [
       "`/cloud/websocket`",
       "[[hass-cloud|eOS Cloud (hass_cloud)]]",
       "8333",
       "Relay · priority 100"
      ],
      [
       "`/cloud/remote_db/insert`",
       "[[hub-stream|Hub Stream Service]]",
       "8034",
       "→ `/api/v1/ingest/batch/` · priority 50"
      ],
      [
       "`/cloud/*`",
       "[[hass-cloud|eOS Cloud (hass_cloud)]]",
       "8022",
       "Strips `/cloud` · priority 10"
      ],
      [
       "`/api/frigate`, `/core/api/v1/frigate`, `/core/api/v1/stream`, `/videoStream`, `/ws/frigate`",
       "[[backend-stream|backend-stream]]",
       "9000",
       "Priority 100"
      ],
      [
       "`/<service>/docs`",
       "Each service",
       "—",
       "Rewritten to `/docs/`"
      ],
      [
       "`/static/<service>/`",
       "Each service",
       "—",
       "Isolated static files"
      ]
     ]
    },
    {
     "type": "p",
     "text": "Routes come from Docker labels in each service's compose file, except the `/cloud`, recorder, registry, GitLab, Portainer and stream routes, which are files in [[traefik|Traefik dynamic config]]. All API routers use the `api-cors` middleware."
    },
    {
     "type": "h",
     "text": "Reading a route"
    },
    {
     "type": "code",
     "text": "traefik.http.routers.hub-api.rule=Host(`api.eternet.net`) && PathPrefix(`/core/api/v1/hub`)\ntraefik.http.routers.hub-api.middlewares=api-cors@file,hub-strip-segment\ntraefik.http.middlewares.hub-strip-segment.replacepathregex.regex=^/core/api/v1/hub/?(.*)$$\ntraefik.http.middlewares.hub-strip-segment.replacepathregex.replacement=/core/api/v1/$${1}"
    },
    {
     "type": "p",
     "text": "The hub service itself serves `/core/api/v1/hubs`; the gateway adds the `hub` segment so each service owns a distinct prefix. Widget does the same with `widget`."
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 3,
   "v": 1
  },
  {
   "id": "hosts",
   "title": "Hosts & ports",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Every public hostname and every port a service listens on, in one place.",
   "blocks": [
    {
     "type": "h",
     "text": "Public hostnames"
    },
    {
     "type": "table",
     "head": [
      "Host",
      "Served by",
      "Purpose"
     ],
     "rows": [
      [
       "`app.eternet.net`",
       "[[etrfef-next|Eternet portal (etrfef-next)]]",
       "Customer portal"
      ],
      [
       "`api.eternet.net`",
       "[[traefik|Traefik dynamic config]]",
       "All service APIs (see Request routing)"
      ],
      [
       "`docs.eternet.net`",
       "[[documents|documents]]",
       "This documentation site"
      ],
      [
       "`sso.eternet.net`",
       "[[keycloak|Keycloak]]",
       "Keycloak realm etr.org"
      ],
      [
       "`api.sso.eternet.net`",
       "[[sso-api|Eternet SSO API]]",
       "Login / refresh / verify wrapper"
      ],
      [
       "`keycloak.eternet.net:5443`",
       "[[keycloak|Keycloak]]",
       "Second Keycloak instance on the main server"
      ],
      [
       "`*.cloud.eternet.net`",
       "[[hass-cloud|eOS Cloud (hass_cloud)]]",
       "Per-hub remote UI via SniTun (TLS passthrough)"
      ],
      [
       "`db.cloud.eternet.net`",
       "[[hass-cloud|eOS Cloud (hass_cloud)]]",
       "Remote Recorder managed access point"
      ],
      [
       "`updates.eternet.net`",
       "[[update-service|Update Service]]",
       "eOS manifests and OTA artifacts"
      ],
      [
       "`registry.eternet.net`",
       "[[registry|Docker registry]]",
       "Private Docker registry"
      ],
      [
       "`gps.eternet.net`",
       "[[traccar|Traccar fork]]",
       "Stock Traccar on TimescaleDB"
      ],
      [
       "`mqtt.eternet.net`",
       "[[mqtt|VerneMQ broker]]",
       "MQTT over WebSockets"
      ],
      [
       "`mqtt-status.eternet.net`",
       "[[mqtt|VerneMQ broker]]",
       "VerneMQ status (basic auth)"
      ],
      [
       "`meet.eternet.net`",
       "[[jitsi|Jitsi Meet]]",
       "Jitsi Meet"
      ],
      [
       "`n8n.eternet.net`",
       "[[n8n|n8n fork]]",
       "Workflows (behind the SSO proxy)"
      ],
      [
       "`traefik.eternet.net`",
       "[[traefik|Traefik dynamic config]]",
       "Traefik dashboard (basic auth)"
      ],
      [
       "`gitlab.eternet.net`",
       "[[traefik|Traefik dynamic config]]",
       "GitLab CE"
      ],
      [
       "`portainer.eternet.net`",
       "[[traefik|Traefik dynamic config]]",
       "Portainer"
      ],
      [
       "`hubcloud.eternet.net`",
       "[[hubcloud-server|eternet_hubcloud_server]]",
       "Archived first remote-access server"
      ],
      [
       "`pypi.eternet.net · apt.eternet.net`",
       "[[ha-release|eternet-ha-release]]",
       "Planned package repositories for eOS"
      ],
      [
       "`account.eternet.net`",
       "[[hub-remote-access|hub_remote_access]]",
       "Account portal link shown in eOS"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Service ports"
    },
    {
     "type": "p",
     "text": "Container or host ports from the compose files. Behind Traefik most are not published on the host."
    },
    {
     "type": "table",
     "head": [
      "Port",
      "Service"
     ],
     "rows": [
      [
       "`3000`",
       "Portal"
      ],
      [
       "`1883`",
       "MQTT"
      ],
      [
       "`5433 / 5435`",
       "Postgres / TimescaleDB"
      ],
      [
       "`5443`",
       "Keycloak (main server)"
      ],
      [
       "`5551`",
       "Registry"
      ],
      [
       "`5678`",
       "n8n"
      ],
      [
       "`6377`",
       "Redis"
      ],
      [
       "`8000`",
       "SSO API"
      ],
      [
       "`8010 / 8510`",
       "eternet_auth (legacy) API / UI"
      ],
      [
       "`8022`",
       "eOS Cloud API"
      ],
      [
       "`8025`",
       "Device"
      ],
      [
       "`8026`",
       "Vehicle"
      ],
      [
       "`8027`",
       "Hub"
      ],
      [
       "`8028`",
       "Setting (stub)"
      ],
      [
       "`8029`",
       "Widget"
      ],
      [
       "`8030`",
       "Analytics"
      ],
      [
       "`8031`",
       "Automation"
      ],
      [
       "`8032`",
       "CV Face ID"
      ],
      [
       "`8033`",
       "Authorization"
      ],
      [
       "`8034`",
       "Hub Stream"
      ],
      [
       "`8039`",
       "n8n proxy"
      ],
      [
       "`8044`",
       "Forecast"
      ],
      [
       "`8045`",
       "Video"
      ],
      [
       "`8050`",
       "This docs site"
      ],
      [
       "`8082 / 8188`",
       "Traccar (stock / fork)"
      ],
      [
       "`8095`",
       "CompreFace UI"
      ],
      [
       "`8186`",
       "InfluxDB 3"
      ],
      [
       "`8333`",
       "eOS Cloud relay"
      ],
      [
       "`9000`",
       "backend-stream"
      ],
      [
       "`9009`",
       "Legacy monolith"
      ],
      [
       "`9095`",
       "Update API (localhost)"
      ],
      [
       "`10443 / 11443`",
       "SniTun peer / SNI"
      ]
     ]
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 4,
   "v": 1
  },
  {
   "id": "auth",
   "title": "Authentication model",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "One identity provider, one wrapper API, one shared library. Every component trusts the same Keycloak tokens.",
   "blocks": [
    {
     "type": "h",
     "text": "Who signs in how"
    },
    {
     "type": "table",
     "head": [
      "Client",
      "Flow",
      "Token ends up"
     ],
     "rows": [
      [
       "[[etrfef-next|Eternet portal (etrfef-next)]]",
       "Password sent to the portal server, which calls Keycloak's token endpoint (Direct Access Grants) with the server-only client secret",
       "Encrypted HttpOnly session cookie"
      ],
      [
       "OTA desktop installer",
       "Authorization Code + {{pkce|PKCE}} on a public client, redirect to `127.0.0.1:49173`",
       "Windows Credential Manager"
      ],
      [
       "[[etrapp|Eternet mobile app (etrapp)]]",
       "`api.sso.eternet.net/login/` or the Keycloak token endpoint",
       "App storage"
      ],
      [
       "[[hub-remote-access|hub_remote_access]]",
       "`KeycloakAuth` → SSO API `/login/`, `/refresh/`",
       "`.cloud` storage on the hub"
      ],
      [
       "[[remote-recorder|Remote Recorder]]",
       "Per-hub recorder token `etr_rec_…` from `/recorder/auto` or `/recorder/link`",
       "Recorder config entry"
      ],
      [
       "[[jitsi|Jitsi Meet]]",
       "Access token in `?jwt=`, checked by the [[prosody|Prosody module]]",
       "—"
      ],
      [
       "[[n8n|n8n fork]]",
       "SSO token checked by the [[n8n-proxy|n8n proxy]], user auto-provisioned",
       "n8n session"
      ],
      [
       "Machine clients",
       "{{api-key|Eternet API key}}",
       "Caller's secret store"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Inside a service"
    },
    {
     "type": "list",
     "items": [
      "`JWTAuthenticationMiddleware` decodes a bearer token on every request (JWKS cached in memory).",
      "DRF's `UnifiedAuthentication` accepts a valid Keycloak JWT, or else looks up an API key in the auth database.",
      "`IsAuthenticated` is the default permission; views add role checks with `@require_role`.",
      "Users or keys with the `administrator` role skip every role and permission check."
     ],
     "ordered": true
    },
    {
     "type": "p",
     "text": "Configuration is the same dict in every `settings.py`: `ETERNET_AUTH = {\"KEYCLOAK_BASE_URL\": \"https://sso.eternet.net\", \"AUTH_DB_URL\": …}`, with realm `etr.org` and client `etr.org-client`."
    },
    {
     "type": "h",
     "text": "Authorization data"
    },
    {
     "type": "p",
     "text": "Roles, groups and permissions are managed by the [[auth-service|Authorization Service]]. The planned model is Domain → Workspace → Role / Group / Permission → Entity. Ownership on entities is still mostly an `owner_user` column; hubs add `owner_sub` (the Keycloak user id)."
    },
    {
     "type": "h",
     "text": "Legacy"
    },
    {
     "type": "p",
     "text": "AWS Cognito was the original provider ([[etrauth|etrauth]], the monolith, the cloud backend, upstream hass-nabucasa). The [[cloudbackend|Cloud backend]] still uses Cognito."
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 5,
   "v": 1
  },
  {
   "id": "repos",
   "title": "Repository index",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Every GitLab project, its page here, its state and when it last changed.",
   "blocks": [
    {
     "type": "repos"
    },
    {
     "type": "p",
     "text": "Groups: `backenddev` (Django and FastAPI services), `customhassdev` (eOS), `eternet-platform` (new portal), `frontdev` (frontends and apps), `packages` (shared libraries), `thirdparty` (forks and infrastructure stacks), `docdev` (documents)."
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 6,
   "v": 1
  },
  {
   "id": "glossary",
   "title": "Glossary of terms",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Words and abbreviations used across the repositories, with links to where each one matters.",
   "blocks": [
    {
     "type": "terms"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 7,
   "v": 1
  },
  {
   "id": "security",
   "title": "Security & hygiene",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "An automated secret scan of the default branches found live credentials in source control. This page lists the categories and the fix; no secret values appear here.",
   "blocks": [
    {
     "type": "callout",
     "tone": "warn",
     "text": "**Treat every credential that was ever committed as exposed.** Removing a file does not remove it from Git history. Rotate first, then clean up."
    },
    {
     "type": "h",
     "text": "Findings"
    },
    {
     "type": "table",
     "head": [
      "Finding",
      "Where",
      "Fix"
     ],
     "rows": [
      [
       "**GitLab access tokens in requirements files**",
       "device, hub, vehicle, widget, forecast, video, cv_faceid, automation, auth, hub_stream services",
       "Revoke in GitLab. Use a deploy token passed as a BuildKit secret, the way analytic_service already does."
      ],
      [
       "**Committed .env files**",
       "28 repositories, including every Django service, the portal, eOS Cloud, InfluxDB, Redis and the cloud backend",
       "Rotate the secrets they contain, remove the files from Git, keep only `.env.example`, add `.env` to `.gitignore`."
      ],
      [
       "**Passwords in compose files and READMEs**",
       "Keycloak, VerneMQ, registry, Traccar, eOS Cloud client guide, archived hubcloud server",
       "Move to `.env` or Docker secrets; rotate the exposed ones."
      ],
      [
       "**Cloud and payment credentials**",
       "cloudbackend (AWS, Stripe, ERPNext, hosting APIs), legacy monolith (AWS, Firebase service account)",
       "Rotate at each provider; these give direct access to billing and infrastructure."
      ],
      [
       "**Private keys and API keys in source**",
       "Legacy monolith (Firebase private key), Angular app and mobile apps (Maps, Grafana, Firebase web keys)",
       "Rotate server keys; restrict browser keys by referrer and app id."
      ],
      [
       "**Compiled .pyc and SQLite files in Git**",
       "Most Django services",
       "Add `__pycache__/` and `db.sqlite3` to `.gitignore` and remove them; the `.pyc` files embed settings values."
      ]
     ]
    },
    {
     "type": "p",
     "text": "Scan: gitleaks 8.28 plus checks for UTF-16 files and known password patterns, on 47 repositories (default branches only; upstream fork code excluded)."
    },
    {
     "type": "h",
     "text": "Other risks worth fixing"
    },
    {
     "type": "list",
     "items": [
      "Forecast endpoints use `AllowAny`; anyone who reaches the route can run jobs.",
      "Several services set `ALLOWED_HOSTS = [\"*\"]` and default `DEBUG` to true.",
      "Keycloak and Redis use `:latest` image tags.",
      "Redis is published on all interfaces; keep it firewalled.",
      "The Prosody auth module is copied into a running container and disappears on recreate.",
      "Database hosts are hardcoded in hub, video and cv_faceid settings instead of read from `.env`."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 8,
   "v": 1
  },
  {
   "id": "changes",
   "title": "Recent changes",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Every edit made on this site, newest first. Any change can be reverted.",
   "blocks": [
    {
     "type": "changes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "start",
   "order": 9,
   "v": 1
  },
  {
   "id": "sso-api",
   "title": "Eternet SSO API",
   "role": "wrapper",
   "status": "active",
   "repo": "backenddev/eternet_sso_api",
   "updated": "2026-06-18",
   "tagline": "Thin FastAPI wrapper in front of Keycloak that every client uses to log in, refresh and verify tokens.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Gives apps a stable, Eternet-shaped login API instead of making each client speak raw OpenID Connect. It accepts the client's id and secret as HTTP Basic auth, calls the {{keycloak|Keycloak}} realm on the client's behalf, and returns the token set. Other services use its verification endpoint to check a bearer token without holding Keycloak configuration themselves."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "One login contract for the portal, mobile apps, eOS hubs and third-party tools.",
      "Keep Keycloak's admin and token endpoints off the public clients.",
      "Offer a cheap token check for systems that cannot validate JWTs natively (Jitsi Prosody, the n8n proxy)."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Python 3.13, FastAPI, python-jose, httpx"
      ],
      [
       "Port",
       "8000 in the container"
      ],
      [
       "Public host",
       "`api.sso.eternet.net`"
      ],
      [
       "Container",
       "`eternet_sso_api`"
      ],
      [
       "Runs on",
       "The separate Keycloak server, not the main VPS"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "POST",
       "/login/",
       "Password login. Basic auth = client id:secret, body has username, password, domain (realm)."
      ],
      [
       "POST",
       "/refresh/",
       "Exchange a refresh token for a new access token."
      ],
      [
       "POST",
       "/logout/",
       "Revoke a refresh token at Keycloak."
      ],
      [
       "GET",
       "/{domain}/userInfo",
       "Return the user profile for the bearer token."
      ],
      [
       "POST",
       "/registration/signup",
       "Create a user through the Keycloak admin API."
      ],
      [
       "GET",
       "/verification/verify-token",
       "Validate a bearer token against the realm JWKS. Query: domain, client_id."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "KEYCLOAK_BASE_URL",
      "KEYCLOAK_BASE_URL_DOCKER",
      "KEYCLOAK_REALM",
      "KEYCLOAK_CLIENT_ID",
      "KEYCLOAK_CLIENT_SECRET",
      "ADMIN_CLIENT_ID",
      "ADMIN_CLIENT_SECRET",
      "PUBLIC_CLIENT_ID",
      "JWKS_CACHE_TTL",
      "HTTP_TIMEOUT"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd eternet/eternetssoapi/eternet_sso_api\ngit pull\ndocker compose build\ndocker compose up -d\ndocker logs eternet_sso_api -f"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Change-password, reset-password and delete-account routers exist in the code but are commented out in `server.py`.",
      "The compose file joins the external network `traefik_proxy`, while the main VPS uses `traefik-public`. That matches it running on the separate Keycloak host."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [
    "keycloak"
   ],
   "cat": "services",
   "order": 0,
   "v": 1
  },
  {
   "id": "auth-service",
   "title": "Authorization Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/auth_service",
   "updated": "2026-09-09",
   "tagline": "Django REST service that manages Eternet users, roles, groups and permissions and mirrors user changes into Keycloak.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Holds the authorization side of identity: which roles, groups and permissions a user has inside Eternet. Authentication stays with Keycloak; this service keeps the local `users` table in step with it (create, reset password, delete) using a Keycloak service account. It is the Django rewrite of the earlier FastAPI [[eternet-auth|eternet_auth]] service."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "A single admin API for the user directory behind `admin.eternet.net`.",
      "Role- and group-based permissions that other services can rely on.",
      "Keep local user records and Keycloak accounts consistent."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 6, DRF, drf-yasg, SQLAlchemy, uvicorn (3 workers)"
      ],
      [
       "Port",
       "8033"
      ],
      [
       "Public route",
       "`api.eternet.net/core/api/v1/eternetAuth`"
      ],
      [
       "Docs",
       "`api.eternet.net/eternetAuth/docs`"
      ],
      [
       "Container",
       "`eternet-authorization-service`"
      ],
      [
       "Databases",
       "`default` (eternetdb) + `auth_db` via `AuthRouter`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "CRUD",
       "/core/api/v1/eternetAuth/users",
       "Users. Extra actions: reset_password, assign_permission."
      ],
      [
       "CRUD",
       "/core/api/v1/eternetAuth/roles",
       "Roles with permissions. Action: assign_permission."
      ],
      [
       "CRUD",
       "/core/api/v1/eternetAuth/groups",
       "Groups. Actions: assign_user, assign_permission."
      ],
      [
       "CRUD",
       "/core/api/v1/eternetAuth/permissions",
       "Permission names."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "users",
       "UUID id, profile fields, keycloak_id, M2M roles/groups/permissions"
      ],
      [
       "roles / groups / permissions",
       "Named sets; roles and groups carry permissions"
      ],
      [
       "api_access_keys",
       "Hashed API keys with roles and an is_admin flag"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "ENV",
      "DEBUG",
      "ENABLE_DOCS",
      "DB_DEFAULT_*",
      "DB_AUTH_*",
      "KEYCLOAK_BASE_URL",
      "KEYCLOAK_REALM",
      "KEYCLOAK_CLIENT_ID",
      "KEYCLOAK_CLIENT_SECRET",
      "KEYCLOAK_ADMIN_ROLE",
      "KEYCLOAK_AUDIENCE",
      "SERVICE_CLIENT_ID",
      "SERVICE_CLIENT_SECRET",
      "CORS_ORIGINS"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd ~/eternet/eternet_services/auth_service\ngit pull\ndocker compose -f docker/docker-compose.yml up -d --build\n# migrations\npython manage.py makemigrations eternet_auth\npython manage.py migrate --database=auth_db"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Ships a test suite under `eternet_auth/tests` with a fake principal for role checks.",
      "Production TLS hardening (`SECURE_SSL_REDIRECT`, HSTS) only applies when `ENV=prod`."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "keycloak",
    "auth-package",
    "postgres"
   ],
   "wraps": [],
   "cat": "services",
   "order": 1,
   "v": 1
  },
  {
   "id": "device-service",
   "title": "Device Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/device_service",
   "updated": "2026-10-10",
   "tagline": "Registry and live data plane for IoT devices: CRUD, streams, MQTT topic tree, telemetry and publish-to-device.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Owns everything about a device: its record, its data streams, the MQTT topics it publishes and subscribes to, and its recent values. Two background workers subscribe to the {{mqtt|MQTT}} broker. One mirrors every topic into a browsable {{topic-tree|topic tree}} (like MQTT Explorer); the other updates device state and pushes matched messages to WebSocket clients. Historical values are queried from InfluxDB."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Let users register devices and see their data live and historically.",
      "Map arbitrary MQTT topics to devices without device-side changes.",
      "Send commands to one device or fan out to many with wildcard topics.",
      "Serve the shared `/ws/core/` WebSocket for the whole platform."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, Channels, paho-mqtt / gmqtt, APScheduler, influxdb-client"
      ],
      [
       "Port",
       "8025"
      ],
      [
       "Public routes",
       "`/core/api/v1/devices`, `/ws/core/`, `/docs`, `/redoc`, `/device/docs`"
      ],
      [
       "Containers",
       "`eternet-device-service`, `eternet-mqtt-worker` (tree mapper), `eternet-device-subscriber`"
      ],
      [
       "Database",
       "eternetdb (`ETRApp_*` tables, mostly unmanaged)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "CRUD",
       "/devices",
       "Devices (paginated, owner-scoped)."
      ],
      [
       "CRUD",
       "/devices/{id}/streams",
       "Streams of a device (nested router)."
      ],
      [
       "CRUD",
       "/devices/groups",
       "Device groups (unique name per owner)."
      ],
      [
       "GET",
       "/devices/{uuid}/records",
       "Bucketed history from InfluxDB with automatic interval."
      ],
      [
       "GET",
       "/devices/{uuid}/records/count",
       "Row counts for a range."
      ],
      [
       "POST",
       "/devices/telemetry",
       "Ingest telemetry from a mobile app or gateway."
      ],
      [
       "POST",
       "/devices/{uuid}/publish",
       "Publish an MQTT message to the device's down topic."
      ],
      [
       "GET",
       "/devices/mqtt/tree/topics",
       "Full topic tree snapshot."
      ],
      [
       "POST",
       "/devices/mqtt/tree/publish",
       "Publish to a topic; `+`/`#` wildcards fan out to matching known topics."
      ],
      [
       "POST",
       "/devices/mqtt/tree/assignTopicToDevice",
       "Bind a topic node (and its subtree) to a device."
      ],
      [
       "POST",
       "/devices/mqtt/tree/provisionDevice",
       "Create `<domain>/<workspace>/devices/<id>/{up,down}`."
      ],
      [
       "GET",
       "/devices/mqtt/tree/topics/{id}/history",
       "Recent messages for a topic."
      ],
      [
       "POST",
       "/devices/mqtt/tree/renameTopic, moveTopic, deleteTopic, clearRetained",
       "Tree maintenance."
      ],
      [
       "GET",
       "/devices/meshcentral/loginToken",
       "MeshCentral login token for remote desktop of a device."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "ETRApp_device",
       "deviceId (UUID), keys, publish/subscribe topics, inputs, latest values, online, owner_user"
      ],
      [
       "ETRApp_stream",
       "Streams per device with value type, unit, widget id"
      ],
      [
       "ETRApp_devicedatalog",
       "Legacy per-value log"
      ],
      [
       "ETRApp_trigger / ETRApp_triggerStream",
       "Threshold triggers"
      ],
      [
       "ETRApp_mqtttopicnode / mqtttopicmessage",
       "Topic tree nodes and bounded history (managed by this service)"
      ],
      [
       "ETRApp_devicegroup",
       "Device groups"
      ],
      [
       "InfluxDB `device_data_log_seq`",
       "Time-series values"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "MQTT_HOST",
      "MQTT_PORT",
      "MQTT_CLIENT_ID",
      "MQTT_USERNAME",
      "MQTT_PASSWORD",
      "DOMAIN",
      "MQTT_TREE_*",
      "MQTT_DEFAULT_WORKSPACE",
      "MQTT_DEVICES_SEGMENT",
      "MQTT_DEVICE_TOPIC_UP/DOWN",
      "INFLUX_URL",
      "INFLUX_TOKEN",
      "INFLUX_ORG",
      "INFLUX_BUCKET",
      "REDIS_URL",
      "WS_CHANNEL_PREFIX",
      "DB_DEFAULT_*",
      "ETERNET_AUTH_DB_URL",
      "CORS_ALLOWED_ORIGINS"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd ~/eternet/eternet_services/device_service\ngit pull\ndocker compose -f docker/docker-compose.yml down\ndocker compose -f docker/docker-compose.yml up -d --build"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Each MQTT worker needs its own literal `MQTT_CLIENT_ID` (`tree-mapper`, `device-subscriber`); sharing one makes the broker disconnect the other.",
      "The bare `/docs` and `/redoc` paths on `api.eternet.net` are routed to this service, so they show the Device API only.",
      "`requirements.txt` is UTF-16 and contains plaintext GitLab tokens. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "mqtt",
    "influxdb3",
    "redis",
    "postgres",
    "auth-package",
    "websocket-core",
    "drf-exclude",
    "traefik"
   ],
   "wraps": [],
   "cat": "services",
   "order": 2,
   "v": 1
  },
  {
   "id": "hub-service",
   "title": "Hub Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/hub_service",
   "updated": "2026-10-19",
   "tagline": "CRUD for hubs: eOS/Home Assistant gateways that a user owns, with type, address and how they connect.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Keeps the `ETRApp_hub` registry. A hub is a gateway at a customer site (usually an [[eos-overview|eOS]] instance). Each record says how it is reached (`remote_address`), how it was added (`connection_type`: `hass_cloud` from the eOS Cloud panel or `app` from the portal Hub page) and who owns it (`owner_sub`, the Keycloak subject). The Remote Recorder uses these fields to decide which hubs may send data."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "One list of a user's hubs, whether added from the portal or registered by eOS itself.",
      "Ownership checks for recorder linking.",
      "Hub types for dashboards and widgets."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, gunicorn (3 workers)"
      ],
      [
       "Port",
       "8027"
      ],
      [
       "Public route",
       "`/core/api/v1/hub/*` → forwarded as `/core/api/v1/*` (Traefik strips `hub/`)"
      ],
      [
       "Container",
       "`eternet-hub-service`"
      ],
      [
       "Database",
       "eternetdb (unmanaged models)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "CRUD",
       "/hubs",
       "Hubs"
      ],
      [
       "GET",
       "/hubtypes",
       "Hub types (read-only)"
      ],
      [
       "CRUD",
       "/HubHasValue",
       "Hub values"
      ],
      [
       "GET",
       "/testWidget/",
       "Test endpoint"
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "ETRApp_hub",
       "name, ip_host, port, hubtype, status (1 = enabled), hubID (UUID), keys, owner_user, remote_address, connection_type, owner_sub"
      ],
      [
       "ETRApp_hubtype",
       "id, description, value"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "ETERNET_AUTH_DB_URL",
      "PORT"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd ~/eternet/eternet_services/hub_service\ngit pull\ndocker compose -f docker/docker-compose.yml up -d --build"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`CHANGE.md` (2026-10-20) is the runbook for adding `remote_address`, `connection_type`, `owner_sub` and the `etr_hub_writer` Postgres role used by the cloud API. The model is unmanaged, so no Django migration is run.",
      "When the Hub page creates a hub it must set `connection_type=app`, `owner_sub` and `status=1`, otherwise `/recorder/link` returns `no_hubs` or `hub_disabled`.",
      "Database host and port are hardcoded in `settings.py` instead of read from `.env`."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "postgres",
    "auth-package",
    "traefik"
   ],
   "wraps": [],
   "cat": "services",
   "order": 3,
   "v": 1
  },
  {
   "id": "vehicle-service",
   "title": "Vehicle Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/vehicle_service",
   "updated": "2026-10-27",
   "tagline": "Vehicles, their link to Traccar GPS devices, and their position history.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Manages vehicle records in `ETRApp_vehicle` and keeps each one linked to a {{traccar|Traccar}} device in `tc_devices`, in both directions. It serves the records API that charts and maps use, and proxies Traccar for routes, stops, trips, events, geofences, notifications and commands. Since October 2026 it reads positions from the stock Traccar at `gps.eternet.net` on {{timescale|TimescaleDB}}; InfluxDB 3 remains available as a legacy backend."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "One vehicle object for the portal, independent of the GPS hardware behind it.",
      "Keep `vehicle_id` ↔ `tc_devices.vehicleid` consistent automatically.",
      "Downsampled history that never returns more than `max_points` buckets."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, gunicorn, influxdb3-python, pyarrow"
      ],
      [
       "Port",
       "8026"
      ],
      [
       "Public routes",
       "`/core/api/v1/vehicles`, `/core/api/v1/traccar`, `/vehicle/docs`"
      ],
      [
       "Containers",
       "`eternet-vehicle-service`, `eternet-vehicle-sync` (sync every 300 s)"
      ],
      [
       "Databases",
       "eternetdb (`default`), Traccar DB (`traccar_db` alias via `EternetTraccarDBRouter`)"
      ],
      [
       "Records backend",
       "`VEHICLE_RECORDS_BACKEND=timescale` (default) or `influx`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Link rules"
    },
    {
     "type": "list",
     "ordered": true,
     "items": [
      "**Vehicle side wins.** A vehicle with `vehicle_id > 0` claims that device. Missing device = orphan; two vehicles on one device = conflict.",
      "**Adopt from Traccar.** An unlinked vehicle adopts the device whose `tc_devices.vehicleid` equals its UUID.",
      "**Write the Traccar side.** Claimed devices get the vehicle UUID; unclaimed ones are cleared (`--keep-stale` skips) or get a new vehicle (`--create-vehicles`)."
     ]
    },
    {
     "type": "p",
     "text": "Saves and deletes trigger `link_vehicle()`/`unlink_vehicle()` after commit. Writes go through the Traccar REST API when the device JSON has `vehicleId`, so Traccar's in-memory cache stays correct; otherwise a direct ORM update is used."
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "CRUD",
       "/vehicles",
       "Vehicles. Detail actions: routes, events, stops."
      ],
      [
       "GET",
       "/vehicles/{uuid}/records",
       "Bucketed positions. Params: dateFrom, dateTo, max_points (1500), mode (count|formula)."
      ],
      [
       "GET",
       "/vehicles/streams/latest",
       "Latest position per vehicle via `tc_devices.positionid`."
      ],
      [
       "GET",
       "/reports/eventTypes",
       "Vehicle event types."
      ],
      [
       "GET",
       "/traccar/vehicles, /traccar/vehicles/{id}/positions",
       "Legacy Traccar device views."
      ],
      [
       "ANY",
       "/traccar/geofences/…, /traccar/deviceGeofences/…",
       "Geofences and vehicle assignments."
      ],
      [
       "POST",
       "/traccar/notifications/, /traccar/commands/send/",
       "Notifications and device commands."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "ETRApp_vehicle",
       "vehicleId (UUID), vehicle_id (tc_devices.id, -1 = unlinked), status, tags, position, owner_user"
      ],
      [
       "tc_devices",
       "Traccar devices; `vehicleid` column added by `prepare_traccar_db`"
      ],
      [
       "tc_positions",
       "Traccar positions (hypertable on TimescaleDB)"
      ],
      [
       "InfluxDB 3 `vehicle_data_log_sequential`",
       "Legacy position history, tags deviceId and vehicleId"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "DB_DEFAULT_*",
      "DB_TRACCAR_*",
      "TRACCAR_URL",
      "TRACCAR_URL_SSL",
      "TRACCAR_USER",
      "TRACCAR_PASSWORD",
      "VEHICLE_RECORDS_BACKEND",
      "INFLUX_URL",
      "INFLUX_TOKEN",
      "INFLUX_DATABASE",
      "INFLUX_MEASUREMENT",
      "INFLUX_DEVICE_TAG",
      "INFLUX_VEHICLE_TAG"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd ~/eternet/eternet_services/vehicle_service\ngit pull\ndocker compose -f docker/docker-compose.yml build --no-cache\ndocker compose -f docker/docker-compose.yml up -d\n# once, on stock Traccar\ndocker compose -f docker/docker-compose.yml exec eternet-vehicle-service python manage.py prepare_traccar_db\n# dry-run the link sync first\ndocker compose -f docker/docker-compose.yml exec eternet-vehicle-service python manage.py sync_traccar_devices --dry-run"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`Vehicle` has no `vehicle_UniqueId` field; code that sets it stores nothing.",
      "Field columns are read from `information_schema` and cached 5 minutes; restart after the schema changes.",
      "`README_SYNC_VEHICLE_TRACCAR_INFLUXDB3.md` in the repo is the full runbook for backfills and diagnostics."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "traccar",
    "postgres",
    "influxdb3",
    "auth-package",
    "drf-exclude",
    "traefik"
   ],
   "wraps": [],
   "cat": "services",
   "order": 4,
   "v": 1
  },
  {
   "id": "widget-service",
   "title": "Widget & Dashboard Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/widget_service",
   "updated": "2026-10-13",
   "tagline": "Dashboards, default dashboards per entity type, and the counters and recent-activity feeds on the main dashboard.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Stores user dashboards as JSON and validates them against a schema. It generates default dashboards for devices, vehicles and hubs from their latest values, and answers the main dashboard's tiles: active device, stream, hub, vehicle and trigger counts, recent logs, last locations and chart data. It reads across eternetdb, the Traccar DB and `hassdb`."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Users get a useful dashboard without building one.",
      "One place that aggregates counts across devices, vehicles and hubs.",
      "Keep default dashboards in step when devices or streams are added (Celery tasks)."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, Celery, jsonschema, gunicorn"
      ],
      [
       "Port",
       "8029"
      ],
      [
       "Public routes",
       "`/core/api/v1/widget/*` (prefix stripped) and `/core/api/v1/dashboard*`"
      ],
      [
       "Container",
       "`eternet-widget-service`"
      ],
      [
       "Databases",
       "eternetdb, Traccar DB, `hassdb` (datacollection)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "GET",
       "/dashboard/operations",
       "Operations overview for the new portal."
      ],
      [
       "GET",
       "/dashboards/{devices|vehicles|hubs}/default/",
       "Get or generate a default dashboard."
      ],
      [
       "CRUD",
       "/dashboards/, /dashboard/create/, /dashboard/update/{pk}/, /dashboard/delete/{pk}/",
       "User dashboards."
      ],
      [
       "POST",
       "/dashboard/validate",
       "Validate dashboard JSON."
      ],
      [
       "GET",
       "/dashboard/{device|stream|hub|vehicle|trigger|state|event|tcStream}/mainDashboard/…",
       "Counters, by-date series and last-N feeds."
      ],
      [
       "GET",
       "/dashboard/charts/{chartdata|countdata|recentConnectionData|recentLocationData}",
       "Chart feeds."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "ETRApp_dashboard",
       "dashboard_value (JSON), default_of, users, privateOrPublic, active"
      ],
      [
       "ETRApp_entitylastvalues",
       "Latest values per entity used to build schemas"
      ],
      [
       "ETRApp_entitystreamuuid",
       "Entity ↔ stream UUID map"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "DB_DEFAULT_*",
      "DB_TRACCAR_*",
      "DB_DATACOLLECTION_*",
      "DASHBOARD_STALE_AFTER_SECONDS",
      "ETERNET_AUTH_DB_URL"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Calls vehicle_service internally at `http://eternet-vehicle-service:8026` (an older hardcoded container IP is still referenced).",
      "The route `dashboard/eventmainDashboard/eventLast` is missing a slash."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "postgres",
    "vehicle-service",
    "auth-package",
    "drf-exclude",
    "device-service",
    "hub-service",
    "traccar"
   ],
   "wraps": [],
   "cat": "services",
   "order": 5,
   "v": 1
  },
  {
   "id": "analytic-service",
   "title": "Analytics Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/analytic_service",
   "updated": "2026-10-10",
   "tagline": "Query builder across PostgreSQL and InfluxDB sources, with saved queries and reusable datasets.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Lets users explore data without writing SQL. Each request names one or more sources (tables in Postgres or InfluxDB), columns, joins, filters, grouping and aggregations. Each connector returns an Apache Arrow table; DuckDB runs the final join and aggregation. Results can be saved as queries or materialized as datasets, which the [[forecast-service|Forecast Service]] uses as input."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Self-service analytics over device and business data.",
      "Cross-source joins (Postgres + InfluxDB) in one query.",
      "A shared, owner-scoped data plane for Forecast."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, ADBC, PyArrow, DuckDB, SQLAlchemy (reflection), uvicorn"
      ],
      [
       "Port",
       "8030"
      ],
      [
       "Public routes",
       "`/core/api/v1/analytic`, `/analytic/docs`"
      ],
      [
       "Container",
       "`eternet-analytic-service`"
      ],
      [
       "Build secrets",
       "Package tokens passed as BuildKit secrets, not in image layers"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "POST",
       "/analytic/execute/",
       "Run a cross-source query payload."
      ],
      [
       "CRUD",
       "/analytic/queries/",
       "Saved queries (owner-scoped)."
      ],
      [
       "CRUD",
       "/analytic/datasets/",
       "Materialized datasets."
      ],
      [
       "POST",
       "/analytic/forecast-source/",
       "Resolve a dataset or query for Forecast; supports Arrow IPC response."
      ],
      [
       "GET/POST",
       "/analytic/sources/, /analytic/sources/test/",
       "List, register and test data sources."
      ],
      [
       "GET",
       "/analytic/metadata/{source}/{catalog|schemas|tables|columns}/",
       "Schema browser."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "DataSource",
       "Registered sources; secrets encrypted by `SourceRegistry`"
      ],
      [
       "SavedQuery",
       "payload, widget, owner_id, execution stats"
      ],
      [
       "AnalyticsDataset",
       "Stored columns/rows owned by a user"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "DB_DEFAULT_*",
      "ANALYTIC_MAIN_PG_URL",
      "ANALYTIC_INFLUX_URL",
      "ANALYTIC_INFLUX_TOKEN",
      "ANALYTIC_INFLUX_ORG",
      "ANALYTIC_INFLUX_BUCKET",
      "ANALYTIC_INFLUX_FLIGHT_URI",
      "DJANGO_DEBUG",
      "ETERNET_AUTH_PACKAGE_TOKEN_FILE"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`QUERY_LAYER.md` in the repo documents the connector policy and the Forecast contract.",
      "Saved queries created before the owner field existed stay visible to the whole workspace until reassigned.",
      "Uses its own unmanaged `analytics_auth.User` as `AUTH_USER_MODEL`."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "postgres",
    "influxdb3",
    "auth-package"
   ],
   "wraps": [],
   "cat": "services",
   "order": 6,
   "v": 1
  },
  {
   "id": "forecast-service",
   "title": "Forecast Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/forecast_service",
   "updated": "2026-10-10",
   "tagline": "Time-series forecasting on uploaded data or Analytics datasets, powered by statsforecast.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Takes a time series (CSV, Parquet, inline rows, or an Analytics dataset/saved query), detects which columns are time, value and series id, recommends models, runs the forecast and stores the result. Series are split by `unique_id`, so one request can forecast every device or site separately."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Forecasts without a data scientist: auto column detection and model recommendation.",
      "Save, list and export forecasts per user.",
      "Reuse Analytics data instead of re-uploading it."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, statsforecast 2.0, pyarrow, uvicorn"
      ],
      [
       "Port",
       "8044"
      ],
      [
       "Public routes",
       "`/core/api/v1/forecast`, `/forecast/docs`"
      ],
      [
       "Container",
       "`eternet-forecast-service`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "POST",
       "/forecast/detectColumns/",
       "Guess ds / y / unique_id columns."
      ],
      [
       "GET",
       "/forecast/modelMetadata/",
       "Model catalogue and parameters."
      ],
      [
       "POST",
       "/forecast/autoRecommend/",
       "Recommend models for the data and horizon."
      ],
      [
       "POST",
       "/forecast/runForecast/",
       "Run models; accepts CSV/inline input or `analytics_source`."
      ],
      [
       "POST",
       "/forecast/forecast/save/",
       "Save a forecast."
      ],
      [
       "GET/DELETE",
       "/forecast/forecast/saved/",
       "List or delete saved forecasts."
      ],
      [
       "POST",
       "/forecast/forecast/export/",
       "Export results."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "SavedForecast",
       "user, name, domain, columns/models config, horizon, freq, intervals, results, metadata"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "DB_DEFAULT_*",
      "ANALYTIC_SERVICE_BASE_URL (default http://eternet-analytic-service:8030/core/api/v1)",
      "ETERNET_AUTH_DB_URL"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "All views use `AllowAny` and identify the caller on a best-effort basis. Anyone who can reach the route can run forecasts.",
      "The doubled path segment (`/forecast/forecast/save/`) comes from the app prefix plus the URL pattern."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "analytic-service",
    "postgres",
    "auth-package"
   ],
   "wraps": [],
   "cat": "services",
   "order": 7,
   "v": 1
  },
  {
   "id": "automation-service",
   "title": "Automation Service",
   "role": "wrapper",
   "status": "active",
   "repo": "backenddev/automation_service",
   "updated": "2026-08-10",
   "tagline": "REST facade over n8n so the portal can list, create, activate and run a user's workflows.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Wraps the n8n public API so the portal and apps manage workflows through the normal Eternet auth instead of talking to n8n directly. It filters workflows to the signed-in user."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Workflows as a first-class Eternet feature.",
      "Per-user workflow lists."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, uvicorn"
      ],
      [
       "Port",
       "8031"
      ],
      [
       "Public routes",
       "`/core/api/v1/automation`, `/automation/docs`"
      ],
      [
       "Container",
       "`eternet-automation-service`"
      ],
      [
       "Database",
       "SQLite (committed `db.sqlite3`)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "GET/POST",
       "/automation/workflows/",
       "List or create workflows."
      ],
      [
       "GET/PUT/DELETE",
       "/automation/workflows/{id}/",
       "One workflow."
      ],
      [
       "POST",
       "/automation/workflows/{id}/activate/, /deactivate/",
       "Toggle a workflow."
      ],
      [
       "GET",
       "/automation/workflows/{id}/status/",
       "Status and executions."
      ],
      [
       "POST",
       "/automation/workflows/{id}/execute/",
       "Run now."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "N8N_API_BASE_URL (hardcoded http://172.17.0.1:5678)",
      "ETERNET_AUTH_DB_URL"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Two URL modules register the same `workflows/` prefix; the first match wins.",
      "The README's `docker run -p 803:8031` is a typo for 8031."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "auth-package"
   ],
   "wraps": [
    "n8n"
   ],
   "cat": "services",
   "order": 8,
   "v": 1
  },
  {
   "id": "video-service",
   "title": "Video Service",
   "role": "wrapper",
   "status": "active",
   "repo": "backenddev/video_service",
   "updated": "2026-05-18",
   "tagline": "Django API in front of ZLMediaKit: stream proxies, recordings, snapshots and media-server webhooks.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Manages live camera streams through the {{zlmediakit|ZLMediaKit}} media server. It adds and removes RTSP/RTMP stream proxies, starts and stops recording, returns snapshots and media info, and receives ZLMediaKit's hook callbacks (publish, play, record finished, stream changed) to keep its tables current."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Cameras as manageable entities in the portal.",
      "Recording and playback of MP4 segments.",
      "Hooks to react to stream events."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, uvicorn"
      ],
      [
       "Port",
       "8045 (published directly, no Traefik labels)"
      ],
      [
       "API prefix",
       "`/core/api/v1/videoStream/index/api/*` and `/index/hook/*`"
      ],
      [
       "Database",
       "eternetdb (unmanaged)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "POST/DELETE",
       "/index/api/addStreamProxy, /delStreamProxy",
       "Pull a camera stream into ZLMediaKit."
      ],
      [
       "GET",
       "/index/api/getMediaList, getMediaInfo, isMediaOnline, getSnap",
       "Inspect streams."
      ],
      [
       "POST",
       "/index/api/startRecord, stopRecord, getMp4RecordFile",
       "Recording."
      ],
      [
       "POST",
       "/index/hook/on_publish, on_play, on_record_mp4, on_stream_changed, …",
       "16 ZLMediaKit webhooks."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "streamproxy",
       "Proxied streams"
      ],
      [
       "recordfile",
       "Recorded files"
      ],
      [
       "viewerhistory",
       "Playback sessions"
      ],
      [
       "VideoStream_computervision / computervisionHistory",
       "Vision jobs attached to streams"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "ZLMediaKit hosts and ports are hardcoded (raw IP) in `settings.py`.",
      "Newer stream work (Frigate, analytics, face identity) lives in [[backend-stream|backend-stream]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "postgres",
    "auth-package",
    "drf-exclude"
   ],
   "wraps": [
    "zlmediakit"
   ],
   "cat": "services",
   "order": 9,
   "v": 1
  },
  {
   "id": "cv-faceid",
   "title": "CV Face ID Service",
   "role": "wrapper",
   "status": "active",
   "repo": "backenddev/cv_faceid_service",
   "updated": "2026-06-10",
   "tagline": "Face recognition API wrapping CompreFace, plus live RTSP/HLS stream processing with Celery.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Exposes {{compreface|CompreFace}} recognition, detection and verification through an Eternet-authenticated API. Users manage subjects and example faces, then start a stream session: a Celery worker grabs frames at a set interval, sends them to CompreFace and pushes results over a WebSocket."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Face recognition on uploaded images and live camera streams.",
      "Subjects and face examples managed per deployment."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, drf-spectacular, Channels, Celery, OpenCV, PyAV, compreface-sdk"
      ],
      [
       "Port",
       "8032"
      ],
      [
       "Bundled",
       "CompreFace 1.2.0 (core, admin, api, fe, postgres) in the same compose; UI on host port 8095"
      ],
      [
       "WebSocket",
       "`ws/streams/<session_id>/`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "ANY",
       "/api/v1/recognition/subjects/, /faces/, /recognize/",
       "Subjects, face examples, recognize."
      ],
      [
       "POST",
       "/api/v1/detection/detect/, /verification/verify/",
       "Detect and verify."
      ],
      [
       "ANY",
       "/api/v1/sdk/…",
       "Same operations through the official SDK."
      ],
      [
       "ANY",
       "/api/v1/streams/sessions/…",
       "Create, start, stop sessions and read results."
      ],
      [
       "POST",
       "/api/v1/streams/snapshot/",
       "One-off frame analysis."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "StreamSession",
       "stream_url, mode, backend, status, interval, counters"
      ],
      [
       "StreamResult",
       "Per-frame JSON result and face count"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "COMPREFACE_URL",
      "COMPREFACE_TIMEOUT",
      "REDIS_URL",
      "STREAM_FRAME_INTERVAL",
      "STREAM_MAX_WORKERS",
      "STREAM_JPEG_QUALITY",
      "CF_POSTGRES_*",
      "SECRET_KEY",
      "DEBUG"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The README says port 8031 (same as automation_service); compose actually uses 8032.",
      "Two Django projects exist in the repo (`core/` inside `cv_faceid` and `cv_faceid_service/`); the second is the one deployed.",
      "`DEBUG` defaults to True."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "redis",
    "auth-package"
   ],
   "wraps": [
    "compreface"
   ],
   "cat": "services",
   "order": 10,
   "v": 1
  },
  {
   "id": "backend-stream",
   "title": "backend-stream",
   "role": "wrapper",
   "status": "active",
   "repo": "frontdev/full-fathi-dev → backend-stream/",
   "updated": "2026-10-25",
   "tagline": "Frigate-based video analytics API: tenant-scoped counting, occupancy, action recognition and face identity.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The stream API behind the portal's Streams pages. It ingests {{frigate|Frigate}} events and builds deterministic analytics on top: line crossings, zone sequences and occupancy, plus optional action recognition and subject identity workers. Results are written to an append-only Postgres ledger and projected to InfluxDB, MQTT or WebSocket through a transactional outbox. Lives in the `full-fathi-dev` workspace; its README points to a planned `eternet-platform/backend-stream` repo."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Count people and objects reliably per camera and portal, with auditable corrections.",
      "Keep biometric identifiers out of time-series tags and global feeds.",
      "Separate experimental models (actions, patient monitoring) from production counting."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django, DRF, Redis, PostgreSQL, Frigate, MMAction2 (optional GPU worker)"
      ],
      [
       "Port",
       "9000 (`backend_stream-api-1`)"
      ],
      [
       "Public routes",
       "`/api/frigate`, `/core/api/v1/frigate`, `/core/api/v1/stream`, `/videoStream`, `/ws/frigate` on api.eternet.net"
      ],
      [
       "Workers",
       "frigate-ingestor, analytics, identity, subject, outbox, action (GPU)"
      ],
      [
       "Deployments",
       "`compose.single-node.yml`, `stack.swarm.yml`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "ANY",
       "/api/frigate/analytics/v1/profiles/…",
       "Analytics profiles, regions, portals, validation (optimistic `revision`)."
      ],
      [
       "GET",
       "/api/frigate/analytics/v1/capabilities/",
       "What the camera setup supports."
      ],
      [
       "GET",
       "/flow-events/, /occupancy/current/, /action-events/",
       "Product analytics queries (≤ 31 days, tenant headers)."
      ],
      [
       "GET",
       "/api/health/",
       "Health"
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "X-Tenant-Id / X-Site-Id / X-Camera-Id headers (scope)"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`docs/` in the folder holds 21 design and runbook documents (counting, face identity, security & privacy, swarm HA).",
      "Patient monitoring is explicitly experimental; predictions are not diagnoses."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "frigate-web",
    "redis",
    "postgres",
    "influxdb3"
   ],
   "wraps": [
    "frigate"
   ],
   "cat": "services",
   "order": 11,
   "v": 1
  },
  {
   "id": "hub-stream",
   "title": "Hub Stream Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/hub_stream_service",
   "updated": "2026-10-27",
   "tagline": "Idempotent ingest for Home Assistant state, event, statistic and log streams, written to InfluxDB 2, InfluxDB 3 and TimescaleDB.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Receives batches from the eOS [[remote-recorder|Remote Recorder]] and stores them in every configured {{sink|sink}}. Each batch carries a client UUID and sequence number, so retries after outages or crashes never create duplicates. It also keeps the last N records per key in Postgres ring tables for fast 'current value' reads."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "At-least-once delivery with exactly-once storage.",
      "Survive hub outages of days (the hub buffers to SQLite).",
      "Pluggable storage: enable any mix of InfluxDB v2, v3 and TimescaleDB from `.env`."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.1, DRF, psycopg 3, influxdb-client, gunicorn"
      ],
      [
       "Port",
       "8034"
      ],
      [
       "Public route",
       "`api.eternet.net/cloud/remote_db/insert` → `/api/v1/ingest/batch/` (Traefik rewrite, priority 50)"
      ],
      [
       "Containers",
       "`hub-stream-web`, `hub-stream-trimmer` (trims ring tables every 60 s)"
      ],
      [
       "Databases",
       "eternetdb (read-only), `hubstreamdb` (owned, migrated on start)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "POST",
       "/api/v1/ingest/batch/",
       "201 stored · 200 duplicate · 400 invalid (don't retry) · 503 sink failed (retry)."
      ],
      [
       "GET",
       "/api/v1/ingest/cursor/",
       "Last batch and client_seq seen; the recorder prunes its queue from this."
      ],
      [
       "GET",
       "/api/v1/ingest/stats/",
       "Totals per instance."
      ],
      [
       "GET",
       "/api/v1/ingest/ping/",
       "Sink health."
      ],
      [
       "GET",
       "/api/v1/ingest/recent/{states|events|statistics|logs}/",
       "Last N (≤100) records per key."
      ],
      [
       "GET",
       "/health",
       "Liveness (no auth in URLconf, but the auth middleware still applies)."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "tenants_instance",
       "(username, instance_id) with counters"
      ],
      [
       "ingest_ingestedbatch",
       "Idempotency log, unique batch_uuid"
      ],
      [
       "ingest_droplog",
       "Rejected batches for review"
      ],
      [
       "ingest_recent*",
       "Ring tables, unique (instance, key, time)"
      ],
      [
       "Sinks",
       "`ha_state`, `ha_event`, `ha_statistic`, `ha_log` in every backend"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "POSTGRES_*",
      "HUB_STREAM_POSTGRES_*",
      "SINK_FAILURE_POLICY (all|any)",
      "SINK_WRITE_TIMEOUT_MS",
      "INFLUXV2_*",
      "INFLUXV3_*",
      "TIMESCALE_DSN / TIMESCALE_*",
      "KEYCLOAK_ENABLED",
      "ETERNET_AUTH_DB_URL",
      "DJANGO_SECRET_KEY"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cp .env.example .env   # fill POSTGRES_*, sinks\ndocker compose up -d --build\ndocker compose logs -f web\n# tests\npytest"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Every request needs `X-Instance-Id`; the instance is created on first use.",
      "Uses `network: host` at build time to dodge the VPS hairpin-NAT problem when pip clones from GitLab.",
      "Remote Recorder 3.0 posts to the cloud API's `/recorder/ingest` instead; this endpoint keeps the 2.x path working."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "influxdb3",
    "postgres",
    "auth-package",
    "sso-api",
    "keycloak"
   ],
   "wraps": [],
   "cat": "services",
   "order": 12,
   "v": 1
  },
  {
   "id": "update-service",
   "title": "Update Service",
   "role": "eternet",
   "status": "active",
   "repo": "backenddev/update_service",
   "updated": "2026-10-07",
   "tagline": "Serves the eOS update manifest (stable/beta/dev), AppArmor profiles and OTA artifacts at updates.eternet.net.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Replaces `version.home-assistant.io` for Eternet devices. The Supervisor on each device reads `/{channel}.json` to learn which Supervisor, Core and OS versions exist, then downloads signed `.raucb` bundles from `/artifacts/`. The release pipeline publishes a new manifest row with `POST /publish`; the newest row per channel wins."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Devices update from Eternet servers, not Nabu Casa's.",
      "Channels so releases go dev → beta → stable.",
      "Admin endpoints never exposed publicly."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "FastAPI, asyncpg, uvicorn; nginx for artifacts"
      ],
      [
       "Port",
       "9095 (bound to 127.0.0.1 for admin calls)"
      ],
      [
       "Containers",
       "`updates-api`, `updates-artifacts` (nginx serving `/srv/eternet-artifacts`)"
      ],
      [
       "Public",
       "`/stable.json`, `/beta.json`, `/dev.json`, `/apparmor_{channel}.txt`, `/artifacts/*`"
      ],
      [
       "Database",
       "Postgres `updatesdb` (external)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "GET",
       "/{stable|beta|dev}.json",
       "Version manifest (Supervisor `URL_HASSIO_VERSION`)."
      ],
      [
       "GET",
       "/apparmor_{channel}.txt",
       "AppArmor profile."
      ],
      [
       "POST",
       "/publish",
       "Insert a manifest (bearer `UPDATES_ADMIN_TOKEN`, localhost only)."
      ],
      [
       "PUT",
       "/apparmor/{channel}",
       "Store an AppArmor profile (localhost only)."
      ],
      [
       "GET",
       "/healthz",
       "DB-checked health (localhost only)."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "POSTGRES_*",
      "ARTIFACT_BASE",
      "ARTIFACTS_DIR",
      "DOCKER_REGISTRY",
      "UPDATES_ADMIN_TOKEN"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cp .env.example .env\npsql \"$DB_URL\" -f schema.sql   # first time\ndocker compose up -d --build\ncurl -s https://updates.eternet.net/dev.json"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The `hassos` version in a manifest must differ from what a device runs, or no update triggers.",
      "`fleet_report` in `schema.sql` is not used yet.",
      "Traefik routes are defined by Docker labels; delete `/opt/traefik/dynamic/dynamic-updates.yml` if present to avoid duplicates."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "registry",
    "traefik"
   ],
   "wraps": [],
   "cat": "services",
   "order": 13,
   "v": 1
  },
  {
   "id": "cloudbackend",
   "title": "Cloud backend (nodeshop)",
   "role": "eternet",
   "status": "active",
   "repo": "frontdev/cloudbackend",
   "updated": "2026-07-16",
   "tagline": "Express/TypeScript backend for cloud resources, billing, ERP, chat and shop features.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Backs the portal's Cloud and billing areas. It provisions VPS, Kubernetes, load balancers, networks and storage at Vultr, Contabo, RunPod and others (OpenTofu modules under `tofu/`), talks to ERPNext for invoices and payments, takes card payments (Stripe, Mamo, Ziina), and runs chat with Socket.IO and WebRTC signalling."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Sell and manage cloud resources from the Eternet portal.",
      "Invoicing through ERPNext."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Node.js, Express, TypeScript, Socket.IO, MQTT, MongoDB, Redis, OpenTofu"
      ],
      [
       "Auth",
       "AWS Cognito and JWT (pre-Keycloak)"
      ],
      [
       "Docs",
       "`/api-docs` (swagger-ui-express)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "MONGO_PATH",
      "REDIS_URL",
      "JWT_SECRET",
      "COGNITO_*",
      "ERPNEXT_URL",
      "ERPNEXT_API_KEY",
      "ERPNEXT_SECRET_KEY",
      "RUNPOD_APIKEY",
      "CONTABO_*",
      "MAMO_API_KEY",
      "ZIINA_API_KEY"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Still authenticates with Cognito; not yet on Keycloak.",
      "`.env` with cloud and payment credentials is committed. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "mqtt"
   ],
   "wraps": [],
   "cat": "services",
   "order": 14,
   "v": 1
  },
  {
   "id": "n8n-proxy",
   "title": "n8n proxy & deployment",
   "role": "wrapper",
   "status": "active",
   "repo": "thirdparty/eternet_n8n_proxy",
   "updated": "2026-10-25",
   "tagline": "FastAPI reverse proxy that checks the Eternet SSO token before passing requests and WebSockets to n8n.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Sits in front of n8n. It reads the SSO access token from the request (header, cookie or query), verifies it with the SSO API, and forwards the call with the identity headers n8n's SSO patch expects. It also proxies the `/n8n/rest/push` WebSocket. `composer.yml` and `install.sh` deploy both containers."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "n8n reachable only by signed-in Eternet users.",
      "Allow embedding n8n in the portal (iframe) with the user's token."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "FastAPI, httpx, websockets"
      ],
      [
       "Ports",
       "8039 (proxy), 5678 (n8n), both bound to 127.0.0.1"
      ],
      [
       "Containers",
       "`n8n-2.22.4`, `n8n_proxy`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "ANY",
       "/n8n/{path}",
       "Authenticated pass-through to n8n."
      ],
      [
       "WS",
       "/n8n/rest/push",
       "n8n push channel."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "N8N_HOST",
      "N8N_PATH",
      "N8N_EDITOR_BASE_URL",
      "N8N_ENCRYPTION_KEY",
      "SSO_PROXY_CLIENT_ID",
      "SSO_PROXY_CLIENT_SECRET",
      "DB_POSTGRESDB_*",
      "FASTAPI_PORT"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cp .env.example .env\n./install.sh   # generates SSO proxy secrets and the n8n encryption key"
    }
   ],
   "depends": [
    "sso-api"
   ],
   "wraps": [
    "n8n"
   ],
   "cat": "services",
   "order": 15,
   "v": 1
  },
  {
   "id": "prosody",
   "title": "Prosody auth module",
   "role": "wrapper",
   "status": "active",
   "repo": "backenddev/eternet_prosody_module",
   "updated": "2025-10-04",
   "tagline": "Lua module that lets Jitsi Meet (meet.eternet.net) accept Eternet SSO tokens.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Users join a meeting at `https://meet.eternet.net/<room>?jwt=<access_token>`. Prosody passes the token to this module, which calls the SSO API's verify endpoint; a valid Keycloak token admits the user."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Meetings restricted to signed-in Eternet users without a separate Jitsi account."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Language",
       "Lua"
      ],
      [
       "Target",
       "Jitsi Prosody container `docker-jitsi-meet-prosody-1`"
      ],
      [
       "Verifies via",
       "`GET https://api.sso.eternet.net/verification/verify-token?domain=etr.org&client_id=etr.org-client`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "# current minimal approach: replace the token util in the running container\ndocker cp util.lib.lua docker-jitsi-meet-prosody-1:/prosody-plugins/token/util.lib.lua"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Copying a file into a running container is lost when the container is recreated. Mount it as a volume instead.",
      "The Jitsi stack itself is in `frontdev/full-fathi-dev/BackEnd-jitsi`."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "sso-api"
   ],
   "wraps": [
    "jitsi"
   ],
   "cat": "services",
   "order": 16,
   "v": 1
  },
  {
   "id": "setting-service",
   "title": "Setting Service",
   "role": "eternet",
   "status": "stub",
   "repo": "backenddev/setting_service",
   "updated": "2026-10-10",
   "tagline": "Placeholder for API-key and user-settings endpoints. No routes are wired yet.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Intended to host settings and API-key management moved out of the legacy monolith. The `apikeymanagement` app contains the old Cognito-era code, fully commented out, and `urls.py` is empty."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Future home for API key issue/revoke and IP allowlists."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, gunicorn"
      ],
      [
       "Port",
       "8028"
      ],
      [
       "Database",
       "SQLite"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`ALLOWED_HOSTS = []`, so it would reject requests even if routes existed."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "auth-package"
   ],
   "wraps": [],
   "cat": "services",
   "order": 17,
   "v": 1
  },
  {
   "id": "eos-overview",
   "title": "eOS at a glance",
   "role": "guide",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "How the Eternet Home Assistant distribution is built, shipped, connected and monitored.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "eOS (branded eOS Pro) is Eternet's own build of {{ha|Home Assistant}}: the core, frontend and OS are forked, Nabu Casa's cloud library is replaced with `hub_remote_access`, and devices log in with Eternet SSO. Six repositories cooperate."
    },
    {
     "type": "h",
     "text": "The pieces"
    },
    {
     "type": "table",
     "head": [
      "Concern",
      "Repository"
     ],
     "rows": [
      [
       "Build & release orchestration",
       "[[ha-release|eternet-ha-release]]"
      ],
      [
       "Home Assistant core (rebrand + remote_recorder default)",
       "[[ha-core-fork|core fork]]"
      ],
      [
       "Frontend (rebrand + Remote Recorder card)",
       "[[ha-frontend-fork|frontend fork]]"
      ],
      [
       "Operating system (Buildroot)",
       "[[ha-os-fork|OS fork]]"
      ],
      [
       "Cloud client library (Keycloak instead of Cognito)",
       "[[hub-remote-access|hub_remote_access]]"
      ],
      [
       "Data streaming integration",
       "[[remote-recorder|remote_recorder]]"
      ],
      [
       "Cloud server (remote access + recorder API)",
       "[[hass-cloud|eternet_hass_cloud]]"
      ],
      [
       "Updates",
       "[[update-service|update_service]] + [[registry|registry]]"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Remote access, step by step"
    },
    {
     "type": "list",
     "ordered": true,
     "items": [
      "The hub logs in at `api.sso.eternet.net/login/` and gets Keycloak tokens.",
      "`POST api.eternet.net/cloud/instance/register` assigns a subdomain under `cloud.eternet.net`.",
      "The hub requests a Let's Encrypt certificate with a DNS-01 challenge that the cloud API writes to Cloudflare.",
      "`/instance/snitun_token` returns a Fernet token; the hub opens a SniTun tunnel to the peer port (10443).",
      "A browser opens `https://<id>.cloud.eternet.net`; Traefik passes the TLS bytes through to SniTun on port 11443, which splices them into the hub's tunnel. TLS stays end to end; the cloud never decrypts it."
     ]
    },
    {
     "type": "h",
     "text": "Releasing a version"
    },
    {
     "type": "p",
     "text": "Tag `2026.6.1-eternet.1` (→ beta), `…-stable` or `…-dev`. CI fetches the forks, assembles one tarball, builds Docker (amd64, aarch64, armv7), a wheel, a .deb and HAOS images, then promotes the channel. Always publish to `dev` first."
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Open gap (from the HAOS guide): the Supervisor inside the image still talks to `version.home-assistant.io` and `ghcr.io`. Until it is patched and rebuilt, devices do not use the Eternet update server.",
      "Five editions are planned (Core, Home, Pro, Enterprise, Industrial), plus ODROID and Raspberry Pi boards next to generic x86-64."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "eos",
   "order": 0,
   "v": 1
  },
  {
   "id": "hass-cloud",
   "title": "eOS Cloud (hass_cloud)",
   "role": "eternet",
   "status": "active",
   "repo": "thirdparty/eternet_hass_cloud",
   "updated": "2026-10-19",
   "tagline": "Self-hosted replacement for Nabu Casa Cloud: SniTun remote access, ACME via Cloudflare, and the Remote Recorder API.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The server side of eOS cloud features. The FastAPI `api` verifies Keycloak tokens, assigns each instance a subdomain, issues {{fernet|Fernet}} tokens for {{snitun|SniTun}}, writes DNS-01 challenge records to Cloudflare, and runs the Remote Recorder provisioning and ingest endpoints. `snitun` routes browser traffic by SNI; `relay` is the WebSocket relayer stub the client expects."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Remote UI for every eOS at `https://<id>.cloud.eternet.net` with end-to-end TLS.",
      "Recorder tokens scoped to one hub, and a managed sink for customers who don't run their own DB.",
      "Fix the known bugs of the first implementation (12 listed in the README)."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "FastAPI, SQLAlchemy async, Alembic, python-jose, httpx, snitun"
      ],
      [
       "Containers",
       "`postgres` (hasscloud), `api` (8022), `relay` (8333), `snitun` (peer 10443, SNI 11443)"
      ],
      [
       "Public",
       "`api.eternet.net/cloud/*` (prefix stripped), `api.eternet.net/cloud/websocket` → relay, `db.cloud.eternet.net` → `/recorder`, `*.cloud.eternet.net` → SniTun (TCP passthrough)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "POST",
       "/instance/register",
       "Allocate or return the instance subdomain; registers the hub in eternetdb when enabled."
      ],
      [
       "POST",
       "/instance/snitun_token",
       "Fernet token for the SniTun peer."
      ],
      [
       "POST",
       "/instance/dns_challenge_txt, /dns_challenge_cleanup",
       "ACME DNS-01 via Cloudflare."
      ],
      [
       "POST",
       "/recorder/auto",
       "Zero-input recorder provisioning for hass_cloud hubs (Keycloak auth)."
      ],
      [
       "POST",
       "/recorder/link",
       "Link an app hub with username/password/tenant (never stored)."
      ],
      [
       "GET/PUT",
       "/recorder/config, /recorder/sink",
       "Recorder config and sink (managed | postgres)."
      ],
      [
       "POST",
       "/recorder/sink/test, /recorder/rotate, /recorder/ingest",
       "Test a Postgres URL, rotate token, ingest a batch."
      ],
      [
       "DELETE",
       "/recorder/credential",
       "Revoke the recorder token."
      ],
      [
       "GET",
       "/payments/subscription_info",
       "Subscription stub."
      ],
      [
       "POST",
       "/access_token",
       "Alexa stub."
      ],
      [
       "GET",
       "/admin/users, /admin/peers/recent, /admin/hub/{hostname}",
       "Admin views (admin role)."
      ],
      [
       "GET",
       "/get_dns, /verify, /health",
       "Debug and health."
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "HASSCLOUD_BASEDOMAIN (cloud.eternet.net)",
      "SNITUN_SERVER",
      "SNITUN_FERNET_KEYS",
      "SNITUN_SNI_PORT",
      "SNITUN_PEER_PORT",
      "SNITUN_THROTTLING",
      "KEYCLOAK_*",
      "CLOUDFLARE_API_TOKEN",
      "CLOUDFLARE_ZONE_ID",
      "DB_*",
      "ETR_HUB_ENABLED",
      "ETR_WRITER_DATABASE_URL",
      "ETR_HUB_TYPE_ID",
      "REMOTE_DB_*",
      "RELAY_*"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "make env && make keys   # generate Fernet keys etc.\nnano .env\nmake up\nmake health"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`SNITUN_FERNET_KEYS` must be identical in `api` and `snitun`, otherwise tokens cannot be decrypted.",
      "`snitun` and `*` DNS records must be DNS-only (grey cloud) in Cloudflare; the proxy breaks SniTun.",
      "The managed recorder sink writes line protocol straight to InfluxDB (v2 or v3).",
      "The client README contains a real Keycloak client secret in plain text. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "sso-api",
    "keycloak",
    "hub-service",
    "influxdb3",
    "traefik",
    "cloudflare"
   ],
   "wraps": [],
   "cat": "eos",
   "order": 1,
   "v": 1
  },
  {
   "id": "remote-recorder",
   "title": "Remote Recorder",
   "role": "eternet",
   "status": "active",
   "repo": "customhassdev/remote_recorder",
   "updated": "2026-10-21",
   "tagline": "Home Assistant integration (v3.0) that streams entity states and events from each eOS to Eternet or the user's own database.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Captures state changes and selected events, queues them in memory, and sends them in batches (100 records or every 5 s) to an {{access-point|access point}}. A hub registered through eOS Cloud is linked automatically; a hub added from the portal links with username, password and tenant domain. Configured from Settings → eOS Cloud → Remote Recorder."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Every eOS reports its data to the cloud with no YAML.",
      "Let customers keep data in their own Postgres or InfluxDB.",
      "Never block the Home Assistant event loop."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Python, Home Assistant integration (config flow, single entry)"
      ],
      [
       "Default API",
       "`https://api.eternet.net/cloud`"
      ],
      [
       "Default access point",
       "`https://db.cloud.eternet.net`"
      ],
      [
       "Access point types",
       "`managed`, `postgres`, `influxdb`, `http`"
      ],
      [
       "Bundled as",
       "`homeassistant/components/remote_recorder`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Options"
    },
    {
     "type": "table",
     "head": [
      "Option",
      "Default"
     ],
     "rows": [
      [
       "Include domains",
       "sensor, binary_sensor, switch, light, climate, lock, cover, fan, media_player"
      ],
      [
       "Exclude",
       "uptime, last_boot, date, time sensors"
      ],
      [
       "Record events",
       "none (`*` for all)"
      ],
      [
       "Batch",
       "100 records / 5 s"
      ],
      [
       "Offline buffer",
       "5000 records"
      ],
      [
       "Verify TLS",
       "on"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "2.x posted to `/remote_db/insert` and trusted the username in the body; 3.0 uses a per-hub `etr_rec_…` token.",
      "Statistics recording was removed in 3.0."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "hass-cloud",
    "hub-service",
    "hub-stream"
   ],
   "wraps": [],
   "cat": "eos",
   "order": 2,
   "v": 1
  },
  {
   "id": "hub-remote-access",
   "title": "hub_remote_access",
   "role": "fork",
   "status": "active",
   "repo": "customhassdev/hub_remote_access",
   "updated": "2026-09-05",
   "tagline": "Eternet's rebrand of the hass-nabucasa cloud library: Keycloak login, Eternet endpoints, same public API.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The library behind Home Assistant's `cloud` component. Upstream speaks AWS Cognito's SRP protocol, which cannot be pointed elsewhere, so `auth/keycloak.py` replaces it with plain HTTP calls to the SSO API (`/login/`, `/refresh/`, `/logout/`). All server URLs in `const.py` point at `api.eternet.net/cloud`. Everything else (ACME, SniTun remote UI, cloudhooks) is upstream code."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Remote access and cloud features for eOS without Nabu Casa.",
      "Keep the diff small so upstream updates stay cheap."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Python ≥ 3.14, aiohttp"
      ],
      [
       "Branches",
       "`main` (upstream), `eternet/main` (Eternet)"
      ],
      [
       "Package",
       "`hub_remote_access` (was `hass_nabucasa`)"
      ],
      [
       "Endpoints used",
       "`api.eternet.net/cloud` (api, relayer, remotestate, account_link), `api.sso.eternet.net/{login,refresh,logout}/`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Register, resend confirmation, forgot password and TOTP raise `NotImplementedError`; the SSO API has no matching endpoints.",
      "`username` falls back through `cognito:username` → `preferred_username` → `email` → `sub`; subscription expiry defaults to 2099-01-01 unless a Keycloak mapper supplies `custom:sub-exp`.",
      "`STATUS.md` on `eternet/main` lists four `TODO(eternet)` checks before production.",
      "The default branch in GitLab is upstream `main`; the Eternet work is on `eternet/main`."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "sso-api",
    "hass-cloud"
   ],
   "wraps": [],
   "cat": "eos",
   "order": 3,
   "v": 1
  },
  {
   "id": "ha-release",
   "title": "eternet-ha-release",
   "role": "eternet",
   "status": "active",
   "repo": "customhassdev/eternet-ha-release",
   "updated": "2026-10-10",
   "tagline": "Single-source release pipeline that turns the forks into Docker images, a wheel, a .deb and signed HAOS images.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Guarantees every artifact contains bit-identical Python code. `assemble.sh` builds one tarball from the forks; four packagers consume it. Scripts also mirror upstream Supervisor images into `registry.eternet.net`, upload artifacts and publish manifests to the update service. `rebrand.sh` keeps the Nabu Casa → Eternet renames applied after each upstream rebase."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "One command (`release.sh`) or one git tag produces a full release.",
      "Follow upstream Home Assistant monthly with 1–3 hours of rebase work.",
      "Sign OTA bundles with the Eternet OTA CA so devices trust only our updates."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Bash, GitLab CI, Docker buildx, Buildroot, skopeo, RAUC"
      ],
      [
       "Build host",
       "VPS `vmi2623664`, user `builder` (Buildroot refuses root)"
      ],
      [
       "Outputs",
       "`registry.eternet.net/homeassistant`, `pypi.eternet.net`, `apt.eternet.net`, `updates.eternet.net/artifacts`"
      ],
      [
       "Signing keys",
       "`/etc/eternet/ota-{ca,cert,key}.pem`; public CA baked into `packagers/haos/overlay/etc/rauc/keyring.pem`"
      ],
      [
       "Boards",
       "generic-x86-64, rpi4-64, rpi5-64, odroid-n2, odroid-m1, tinker-2-64"
      ]
     ]
    },
    {
     "type": "h",
     "text": "CI stages"
    },
    {
     "type": "table",
     "head": [
      "Stage",
      "Job",
      "Does"
     ],
     "rows": [
      [
       "fetch",
       "fetch:sources",
       "Clone the forks at `eternet/<HA_VERSION>`"
      ],
      [
       "assemble",
       "assemble",
       "Build frontend and wheels, produce `eternet-ha-<ver>.tar.gz`"
      ],
      [
       "package",
       "docker:{amd64,aarch64,armv7}, manifest, wheel, debian, haos",
       "Build and push artifacts (HAOS on a self-hosted `haos-builder` runner)"
      ],
      [
       "promote",
       "promote",
       "Tell the update service which versions the channel has"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "HA_VERSION",
      "ETERNET_VERSION",
      "CHANNEL",
      "REGISTRY",
      "REGISTRY_USER/PASSWORD",
      "GITLAB_TOKEN",
      "UPDATES_ADMIN_TOKEN",
      "PYPI_TOKEN",
      "BOARD",
      "ARCHES"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "sudo -iu builder\ncd ~/eternet-ha-release && git pull\n./scripts/bootstrap_sources.sh\ntmux new -s haos\nHA_VERSION=2026.6.1 ETERNET_VERSION=1 ./packagers/haos/build-generic.sh 2>&1 | tee build-generic.log\n./mirror_images.sh\n./upload_artifacts.sh artifacts\nCHANNEL=dev ./upload_artifacts.sh publish"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Never recreate the OTA CA once devices are installed; they would reject every later update.",
      "`ETERNET_HAOS_README.md` in the repo is the beginner-friendly, end-to-end runbook with troubleshooting."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "ha-core-fork",
    "ha-frontend-fork",
    "ha-os-fork",
    "hub-remote-access",
    "remote-recorder",
    "registry",
    "update-service"
   ],
   "wraps": [],
   "cat": "eos",
   "order": 4,
   "v": 1
  },
  {
   "id": "ha-core-fork",
   "title": "Home Assistant core fork",
   "role": "fork",
   "status": "active",
   "repo": "customhassdev/eternet_custom_hass_core_fork",
   "updated": "2026-10-21",
   "tagline": "Fork of home-assistant/core, pinned to 2026.6.1, rebranded to eOS Pro.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Upstream core with two Eternet commits on `eternet/2026.6.1`: the rebrand (the `cloud` component imports `hub_remote_access` instead of `hass_nabucasa`, branding strings changed, trusted-proxy handling adjusted) and `default_config` loading `remote_recorder` on every eOS."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Minimal, rebase-friendly patch set on top of upstream."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Branches",
       "`dev` (upstream mirror), `eternet/2026.6.1` (release), `eternet/main`"
      ],
      [
       "Upstream",
       "github.com/home-assistant/core (pull mirror)"
      ],
      [
       "Changed areas",
       "`components/cloud/*`, `components/default_config`, `components/http/forwarded.py`, `helpers/network.py`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [
    "hub-remote-access",
    "remote-recorder"
   ],
   "wraps": [],
   "cat": "eos",
   "order": 5,
   "v": 1
  },
  {
   "id": "ha-frontend-fork",
   "title": "Home Assistant frontend fork",
   "role": "fork",
   "status": "active",
   "repo": "customhassdev/eternet_custom_hass_frontend_fork",
   "updated": "2026-10-21",
   "tagline": "Fork of home-assistant/frontend with eOS branding and the Remote Recorder settings card.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Four Eternet commits on `eternet/main`: rename Home Assistant to eOS Pro, replace nabucasa.com links with eternet.com, add the `remote_recorder` panel, and add the Remote Recorder card under Settings → Cloud."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Users configure cloud remote access and data recording from one place."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Branches",
       "`dev` (upstream mirror), `eternet/main`"
      ],
      [
       "Upstream",
       "github.com/home-assistant/frontend"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "eos",
   "order": 6,
   "v": 1
  },
  {
   "id": "ha-os-fork",
   "title": "Home Assistant OS fork",
   "role": "fork",
   "status": "active",
   "repo": "customhassdev/eternet_custom_hass_operating_system_fork",
   "updated": "2026-09-12",
   "tagline": "Fork of home-assistant/operating-system at release 18.2, built by eternet-ha-release.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Currently a clean mirror of upstream HAOS 18.2. Eternet changes (RAUC keyring, branding, update URLs) are applied at build time from `packagers/haos/overlay` and `buildroot-patches` in the release repo, not committed here."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Track upstream HAOS while the overlay carries the Eternet changes."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Branch",
       "`eternet/main`"
      ],
      [
       "Build tool",
       "Buildroot (hours for a first build; ≥ 60 GB disk)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "eos",
   "order": 7,
   "v": 1
  },
  {
   "id": "etrfef-next",
   "title": "Eternet portal (etrfef-next)",
   "role": "app",
   "status": "active",
   "repo": "eternet-platform/etrfef-next",
   "updated": "2026-10-27",
   "tagline": "The new web portal at app.eternet.net: Next.js App Router replacement for the Angular app.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The main product UI for customers and operators. It covers devices, hubs, vehicles and dispatch, dashboards and widgets, analytics and forecast, automations and workflows, streams and vision, meetings (Jitsi), cloud resources, OTA updates and settings. Every backend call goes through a server-side API layer, so tokens stay in an encrypted HttpOnly session cookie and never reach browser JavaScript."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Replace the Angular/Fuse app feature by feature without copying its architecture.",
      "Secure-by-default auth: Keycloak password grant on the server, PKCE for the desktop OTA installer.",
      "Typed, explicit API clients per backend (core, cloud, sso, stream)."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Next.js 16, React 19, TypeScript strict, Tailwind, shadcn/ui, ECharts, Recharts, Leaflet, react-grid-layout"
      ],
      [
       "Port",
       "3000 (container `etrfef-frontend`)"
      ],
      [
       "Public host",
       "`app.eternet.net`"
      ],
      [
       "Tests",
       "Vitest, Playwright"
      ],
      [
       "Docs in repo",
       "`docs/migration/*` (15 audits and plans), `docs/widget-stack`, product presentations"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Feature areas"
    },
    {
     "type": "p",
     "text": "analytics · automations · cloud (cluster, compute, devops, load balancer, network, storage, VPS) · dashboard · dashboards · devices · forecast · geofences · hubs · meetings · OTA updates · settings (access, activity logs, appearance, billing, dashboards, profile, services) · streams · vehicles and dispatch · widgets · workflows"
    },
    {
     "type": "h",
     "text": "Auth flow"
    },
    {
     "type": "list",
     "ordered": true,
     "items": [
      "`/sign-in` posts credentials to `/api/auth/password-login`.",
      "The server calls Keycloak's token endpoint (Direct Access Grants) with the server-only client secret.",
      "Tokens are stored AES-256-GCM encrypted in an HttpOnly cookie; `/api/auth/session` refreshes near expiry.",
      "`src/lib/api/client.ts` attaches the access token to backend calls and normalizes errors."
     ]
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "APP_ORIGIN",
      "AUTH_REQUIRED",
      "AUTH_SESSION_SECRET",
      "ETERNET_API_BASE_URL",
      "ETERNET_CLOUD_API_BASE_URL",
      "STREAM_API_BASE_URL",
      "KEYCLOAK_ISSUER",
      "KEYCLOAK_CLIENT_ID",
      "KEYCLOAK_CLIENT_SECRET",
      "KEYCLOAK_DESKTOP_CLIENT_ID",
      "JITSI_BASE_URL",
      "NEXT_PUBLIC_*"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "docker compose -f docker-compose.production.yml up -d --build"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`AGENTS.md` sets the working rules: Server Components by default, no generic engines or global interceptors, update `migration-manifest.json` per migrated feature.",
      "`.env` and `.env.local` are committed. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "keycloak",
    "device-service",
    "hub-service",
    "vehicle-service",
    "widget-service",
    "analytic-service",
    "forecast-service",
    "automation-service",
    "backend-stream",
    "cloudbackend",
    "prosody",
    "video-service",
    "n8n",
    "jitsi"
   ],
   "wraps": [],
   "cat": "apps",
   "order": 0,
   "v": 1
  },
  {
   "id": "etrapp",
   "title": "Eternet mobile app (etrapp)",
   "role": "app",
   "status": "active",
   "repo": "frontdev/etrapp",
   "updated": "2026-09-29",
   "tagline": "React Native app for Android and iOS: devices, live maps, telemetry, workflows and video calls.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The current mobile client. Users sign in with Eternet SSO, browse and control devices, see live device maps and charts, run workflows and join Jitsi meetings. It can also act as a device: phone telemetry is sent to `devices/mqtt/tree/ingest`."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Field access to the platform from a phone.",
      "Use the phone itself as a sensor."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "React Native, TypeScript, Firebase (messaging, analytics), Jitsi RN SDK, Google Maps/Navigation, JSON Forms"
      ],
      [
       "Backends",
       "`api.eternet.net/core/api/v1`, `api.sso.eternet.net/login/`, Keycloak token endpoint, `meet.eternet.net`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [
    "sso-api",
    "device-service",
    "vehicle-service",
    "prosody",
    "jitsi"
   ],
   "wraps": [],
   "cat": "apps",
   "order": 1,
   "v": 1
  },
  {
   "id": "full-fathi-dev",
   "title": "full-fathi-dev workspace",
   "role": "app",
   "status": "active",
   "repo": "frontdev/full-fathi-dev",
   "updated": "2026-10-25",
   "tagline": "Developer workspace bundling the portal, the Angular app, backend-stream, Jitsi, the OTA installer and an OTA lab.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "A monorepo-style working copy used for cross-cutting development. It contains projects that have no repository of their own yet."
    },
    {
     "type": "h",
     "text": "Contents"
    },
    {
     "type": "table",
     "head": [
      "Folder",
      "What it is"
     ],
     "rows": [
      [
       "`etrfef-next`",
       "Working copy of the portal"
      ],
      [
       "`etrfef`",
       "Working copy of the Angular app"
      ],
      [
       "`backend-stream`",
       "[[backend-stream|Video analytics API]]"
      ],
      [
       "`BackEnd-jitsi`",
       "Jitsi Meet on Docker for `meet.eternet.net`"
      ],
      [
       "`ota-installer`",
       "Tauri desktop app (Windows) that builds a signed Debian ISO locally with Podman and enrolls devices; signs in with Keycloak PKCE"
      ],
      [
       "`ota-lab`",
       "Local hawkBit + SWUpdate lab on Podman (loopback only)"
      ],
      [
       "`models`",
       "MMAction2 / TSM action-recognition models"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Several committed `.env` files and a test token file. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "apps",
   "order": 2,
   "v": 1
  },
  {
   "id": "etr-landing",
   "title": "Landing site",
   "role": "app",
   "status": "legacy",
   "repo": "frontdev/etr-landing",
   "updated": "2025-09-10",
   "tagline": "Marketing landing page on Next.js 14.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Static marketing site."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Next.js 14, React 18, Tailwind"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "apps",
   "order": 3,
   "v": 1
  },
  {
   "id": "keycloak",
   "title": "Keycloak",
   "role": "third-party",
   "status": "active",
   "repo": "thirdparty/eternet_keycloak",
   "updated": "2025-08-21",
   "tagline": "The identity provider behind all Eternet logins: realm etr.org, client etr.org-client.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Stores user accounts and issues the OpenID Connect tokens every other component trusts. The production realm is served at `sso.eternet.net`; this repo also documents a second instance on the main VPS at `keycloak.eternet.net:5443`."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Single sign-on across portal, mobile, eOS, Jitsi and n8n.",
      "Standard OIDC tokens with roles that services can check offline via JWKS."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Image",
       "`quay.io/keycloak/keycloak:latest`"
      ],
      [
       "Realm",
       "`etr.org`"
      ],
      [
       "Main client",
       "`etr.org-client`"
      ],
      [
       "Hosts",
       "`sso.eternet.net` (main), `keycloak.eternet.net:5443` (VPS instance)"
      ],
      [
       "Database",
       "Postgres `keycloakdb`"
      ],
      [
       "TLS",
       "certbot standalone certificates mounted into the container"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd /home/devopsadmin/eternet/keycloak\ndocker compose up -d\nsudo ufw allow 5443"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The image tag is `latest`; pin a version so restarts do not upgrade Keycloak unexpectedly.",
      "Admin and database passwords are written in plain text in `docker-compose.yml` and the README. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "thirdparty",
   "order": 0,
   "v": 1
  },
  {
   "id": "traccar",
   "title": "Traccar fork",
   "role": "fork",
   "status": "active",
   "repo": "thirdparty/eternet_traccar",
   "updated": "2026-09-22",
   "tagline": "Traccar GPS server 6.15.3 with Eternet changes: InfluxDB writes, backend webhooks and a vehicleId on devices.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Receives positions from 200+ GPS protocols. The Eternet build also writes every position and event to InfluxDB (with a backfill service for history), posts latest values to the Eternet backend, and adds `vehicleId` to `Device`. Changes are marked `start/end Eternet change` in 9 files. The platform is moving to a stock Traccar at `gps.eternet.net` on TimescaleDB, which drops the InfluxDB path."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Vehicle tracking for the portal and mobile apps.",
      "Upgrade path: merge Eternet changes file by file onto new upstream versions."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Java 21, Gradle, Netty, Jetty"
      ],
      [
       "Ports",
       "8188 (web/API in this build), 5000–5150 (device protocols)"
      ],
      [
       "New Eternet classes",
       "`influx/InfluxService`, `influx/InfluxBackfillService`, `eternet/EternetService`"
      ],
      [
       "Modified",
       "`Keys`, `NotificationManager`, `DatabaseHandler`, `Device`, `Main`, `StopsReportProvider`, `TripsReportProvider`, `ReportUtils`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "influx.url",
      "influx.token",
      "influx.org",
      "influx.vehicle.bucket",
      "influx.backfill.{enable,positions,events,startDate,windowDays,lookbackDays,markerFile}",
      "eternet.latestvalues.api.url"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "docker compose build\ndocker compose up -d\ndocker logs -f traccar"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The Dockerfile pulls the official 6.15.3 web UI from GitHub releases at build time.",
      "Passwords appear in `conf/traccar.xml` and the README. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "influxdb3",
    "postgres"
   ],
   "wraps": [],
   "cat": "thirdparty",
   "order": 1,
   "v": 1
  },
  {
   "id": "n8n",
   "title": "n8n fork",
   "role": "fork",
   "status": "active",
   "repo": "thirdparty/eternet_n8n",
   "updated": "2026-08-02",
   "tagline": "n8n 2.22.4 with Eternet SSO login, automatic user provisioning and Eternet custom nodes.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The workflow engine behind Automations. The branch `eternet-n8n-2.22.4` adds SSO proxy authentication (users arriving through the n8n proxy are signed in and provisioned automatically), an `EternetSSOApi` credential injected for them, and an `EternetDevice` node so workflows can read and control Eternet devices."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Users build workflows without a separate n8n login.",
      "Native nodes for Eternet entities."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "TypeScript, Vue, Node.js (pnpm monorepo)"
      ],
      [
       "Branch",
       "`eternet-n8n-2.22.4` (the default `main` is upstream)"
      ],
      [
       "Host",
       "`n8n.eternet.net`, path `/n8n/`"
      ],
      [
       "Database",
       "Postgres `n8ndb`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Fixes on the branch: encryption-key mismatch on startup, `/signin` redirect loop, codemirror grammar build error."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "n8n-proxy",
    "sso-api",
    "device-service"
   ],
   "wraps": [],
   "cat": "thirdparty",
   "order": 2,
   "v": 1
  },
  {
   "id": "frigate",
   "title": "Frigate NVR",
   "role": "third-party",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Open-source network video recorder with real-time object detection.",
   "blocks": [
    {
     "type": "h",
     "text": "What it is"
    },
    {
     "type": "p",
     "text": "Detects people and objects in camera streams and emits events. [[backend-stream|backend-stream]] ingests those events for counting and occupancy; [[frigate-web|Frigate Web]] is Eternet's customized UI for it."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Image",
       "`ghcr.io/blakeblackshear/frigate:stable` (custom build `eternet/frigate:custom`)"
      ],
      [
       "Port",
       "6060 → 5000"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "thirdparty",
   "order": 3,
   "v": 1
  },
  {
   "id": "frigate-web",
   "title": "Frigate Web (Eternet)",
   "role": "fork",
   "status": "active",
   "repo": "thirdparty/eternet_frigateweb",
   "updated": "2025-08-17",
   "tagline": "Customized Frigate NVR web UI, built into a custom Frigate image.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The Frigate frontend (React, Vite) with Eternet changes. It is built and copied into `ghcr.io/blakeblackshear/frigate:stable` as `eternet/frigate:custom`, or served standalone on port 8182."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Eternet-branded camera review UI over the Frigate backend."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "React, TypeScript, Vite, Tailwind"
      ],
      [
       "Ports",
       "6060 → Frigate 5000 (custom image), 8182 (standalone UI)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "npm install && npm run build\ndocker build -t eternet/frigate:custom .\ndocker compose up -d"
    }
   ],
   "depends": [
    "frigate"
   ],
   "wraps": [],
   "cat": "thirdparty",
   "order": 4,
   "v": 1
  },
  {
   "id": "jitsi",
   "title": "Jitsi Meet",
   "role": "third-party",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Open-source video conferencing behind meet.eternet.net.",
   "blocks": [
    {
     "type": "h",
     "text": "What it is"
    },
    {
     "type": "p",
     "text": "Hosts the platform's meetings. The Docker stack is kept in `frontdev/full-fathi-dev/BackEnd-jitsi`; Eternet's [[prosody|Prosody module]] makes it accept Eternet SSO tokens, and the portal embeds it through `JITSI_BASE_URL`."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Host",
       "`meet.eternet.net`"
      ],
      [
       "Stack",
       "jitsi/docker-jitsi-meet (web, prosody, jicofo, jvb; optional jibri, jigasi)"
      ],
      [
       "Source",
       "`full-fathi-dev/BackEnd-jitsi`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "thirdparty",
   "order": 5,
   "v": 1
  },
  {
   "id": "zlmediakit",
   "title": "ZLMediaKit",
   "role": "third-party",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Streaming media server for camera streams: RTSP/RTMP in, HLS/WebRTC/MP4 out.",
   "blocks": [
    {
     "type": "h",
     "text": "What it is"
    },
    {
     "type": "p",
     "text": "Pulls camera streams, re-publishes them for browsers and records MP4 segments. Eternet controls it through the [[video-service|Video Service]], which also receives its webhooks."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Protocols",
       "RTSP, RTMP, HLS, HTTP-FLV, WebRTC"
      ],
      [
       "API",
       "`/index/api/*` (HTTP), hooks to `/index/hook/*`"
      ],
      [
       "Ports in use",
       "8066 / 4466 (API), 8085 / 8444 (media)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "thirdparty",
   "order": 6,
   "v": 1
  },
  {
   "id": "compreface",
   "title": "CompreFace",
   "role": "third-party",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Self-hosted face recognition server (Exadel), version 1.2.0.",
   "blocks": [
    {
     "type": "h",
     "text": "What it is"
    },
    {
     "type": "p",
     "text": "Provides face detection, recognition and verification as a REST service with its own admin UI. It runs in the same compose stack as the [[cv-faceid|CV Face ID Service]], which wraps it with Eternet auth and stream processing."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Images",
       "`exadel/compreface-{core,admin,api,fe}:1.2.0` + postgres:16"
      ],
      [
       "UI",
       "Host port 8095"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "thirdparty",
   "order": 7,
   "v": 1
  },
  {
   "id": "cloudflare",
   "title": "Cloudflare DNS",
   "role": "third-party",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "DNS for eternet.net, plus the API used for ACME DNS-01 challenges.",
   "blocks": [
    {
     "type": "h",
     "text": "What it is"
    },
    {
     "type": "p",
     "text": "Holds the eternet.net zone. Most records are proxied; `snitun`, `registry` and the `*.cloud` wildcard must be DNS-only because their traffic is not plain HTTP. [[hass-cloud|eOS Cloud]] writes TXT records through the Cloudflare API so each hub can get its own Let's Encrypt certificate."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Zone",
       "`eternet.net`"
      ],
      [
       "API use",
       "DNS:Edit token scoped to the zone"
      ],
      [
       "Must be DNS-only",
       "`snitun`, `*.cloud`, `registry`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "thirdparty",
   "order": 8,
   "v": 1
  },
  {
   "id": "traefik",
   "title": "Traefik dynamic config",
   "role": "infra",
   "status": "active",
   "repo": "thirdparty/traefik_dynamics",
   "updated": "2026-10-19",
   "tagline": "File-provider routes for everything that isn't routed by Docker labels.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Traefik v3 terminates TLS on port 443 for all Eternet hosts and replaced Nginx Proxy Manager. Most services declare their routes with Docker labels on the `traefik-public` network; this repo holds the files Traefik loads from `/opt/traefik/dynamic/` for the rest."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "One gateway with automatic Let's Encrypt certificates.",
      "Define each host in exactly one place: a file here or labels, never both."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Entrypoint",
       "`websecure` (443), plus a `redis` TCP entrypoint"
      ],
      [
       "Cert resolver",
       "`letsencrypt`"
      ],
      [
       "Network",
       "`traefik-public`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Files"
    },
    {
     "type": "table",
     "head": [
      "File",
      "Routes"
     ],
     "rows": [
      [
       "`cors.yml`",
       "Middleware `api-cors` (allows `localhost:4200`)"
      ],
      [
       "`dashboard.yml`",
       "`traefik.eternet.net` + `dashboard-auth` basic auth"
      ],
      [
       "`dynamic-cloud.yml`",
       "`/cloud/websocket` → relay (prio 100), `/cloud/` → cloud API with prefix strip (prio 10), TCP passthrough `*.cloud.eternet.net` → SniTun"
      ],
      [
       "`dynamic-cloud-registry.yml`",
       "`registry.eternet.net`"
      ],
      [
       "`dynamic-recorder.yml`",
       "`db.cloud.eternet.net` → cloud API `/recorder`, 4 MiB body limit"
      ],
      [
       "`dynamic-updates.yml`",
       "Old file route for updates (now labels; delete on the server)"
      ],
      [
       "`gitlab.yml`, `portainer.yml`",
       "`gitlab.eternet.net`, `portainer.eternet.net`"
      ],
      [
       "`stream.yml`",
       "Frigate and stream paths → backend-stream"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Some file routes still target the host's public IP; a container on the same host cannot always reach that (hairpin NAT). Prefer container names on a shared network."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 0,
   "v": 1
  },
  {
   "id": "postgres",
   "title": "PostgreSQL & TimescaleDB",
   "role": "infra",
   "status": "active",
   "repo": "",
   "updated": "",
   "tagline": "Where the relational data lives, and which service owns which database.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "PostgreSQL runs in Docker on the main VPS (host port 5433). Most services share `eternetdb` and read its `ETRApp_*` tables with unmanaged Django models, a legacy of the [[etrbetemp|monolith]] that created them. A separate TimescaleDB stack (host port 5435) hosts the stock Traccar database."
    },
    {
     "type": "h",
     "text": "Databases"
    },
    {
     "type": "table",
     "head": [
      "Database",
      "Owner",
      "Readers"
     ],
     "rows": [
      [
       "`eternetdb`",
       "Legacy monolith schema (`ETRApp_*`)",
       "device, hub, vehicle, widget, analytic, forecast, video, hass_cloud (writer role `etr_hub_writer`)"
      ],
      [
       "`eternet_auth` / `eternetauthdb`",
       "auth_service",
       "eternet_auth_package (API keys)"
      ],
      [
       "`hubstreamdb`",
       "hub_stream_service",
       "—"
      ],
      [
       "`traccar` (TimescaleDB)",
       "Stock Traccar `traccar-ts`",
       "vehicle, widget"
      ],
      [
       "`traccardb`",
       "Old Traccar fork",
       "legacy"
      ],
      [
       "`hassdb`",
       "Old datacollection recorder",
       "widget (states, events)"
      ],
      [
       "`hasscloud`",
       "eOS Cloud API (own container)",
       "—"
      ],
      [
       "`updatesdb`",
       "update_service",
       "—"
      ],
      [
       "`keycloakdb`, `n8ndb`",
       "Keycloak, n8n",
       "—"
      ]
     ]
    },
    {
     "type": "p",
     "text": "Other stores: InfluxDB 3 (time series), Redis (channels, queues), MongoDB (cloudbackend), SQLite (automation, setting, cv_faceid sessions)."
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Because the tables are unmanaged, schema changes on `eternetdb` are applied by hand (see hub_service `CHANGE.md` for the pattern)."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 1,
   "v": 1
  },
  {
   "id": "influxdb3",
   "title": "InfluxDB 3 Core",
   "role": "infra",
   "status": "active",
   "repo": "thirdparty/eternet_influxdb3_docker",
   "updated": "2026-08-12",
   "tagline": "Self-contained InfluxDB 3 Core + Explorer UI with a setup script that handles ports, UIDs and token minting.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "The platform's time-series store for vehicle positions, Home Assistant streams and device values. `setup.sh` creates `.env`, checks memory and ports, mints the one-time admin token and creates the default database."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Repeatable InfluxDB setup without the usual v3 gotchas."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Ports",
       "8186 host → 8181 container; Explorer on 127.0.0.1:8989"
      ],
      [
       "Container",
       "`influxdb3` on network `influxdb3_setup_default`"
      ],
      [
       "Databases",
       "`ha_states`, `hub_stream_service_db`, `vehicle_data_log_sequential`"
      ],
      [
       "Query",
       "SQL (DataFusion) or InfluxQL; no Flux"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "./setup.sh\n./query.sh \"SELECT COUNT(*) FROM ha_state\"\n./backup.sh"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The admin token (`apiv3_…`) is shown once; losing it means wiping `data/`.",
      "Port 8181 collides with GitLab's bundled nginx on the VPS, hence 8186."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 2,
   "v": 1
  },
  {
   "id": "mqtt",
   "title": "VerneMQ broker",
   "role": "infra",
   "status": "active",
   "repo": "thirdparty/eternet_mqtt",
   "updated": "2026-08-15",
   "tagline": "MQTT broker for devices: raw TCP on 1883 and MQTT over WebSockets at mqtt.eternet.net.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Devices and gateways publish here; device_service's workers subscribe to everything under the domain to build the topic tree and update device state."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "One broker for all device traffic, reachable from browsers over WSS."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Image",
       "`vernemq/vernemq`"
      ],
      [
       "Container",
       "`eternet-vernemq`"
      ],
      [
       "Ports",
       "1883 (TCP), `wss://mqtt.eternet.net` (8080 via Traefik)"
      ],
      [
       "Status UI",
       "`mqtt-status.eternet.net` behind `dashboard-auth`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The MQTT user password is written into the compose file."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 3,
   "v": 1
  },
  {
   "id": "redis",
   "title": "Redis",
   "role": "infra",
   "status": "active",
   "repo": "thirdparty/eternet_redis",
   "updated": "2026-08-16",
   "tagline": "Shared Redis for Channels layers, Celery and caches.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Backs Django Channels group messaging (WebSocket fan-out), Celery queues and caches."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Image",
       "`redis:latest`"
      ],
      [
       "Port",
       "6377 host, plus Traefik TCP entrypoint `redis`"
      ],
      [
       "Auth",
       "`requirepass ${REDIS_PASSWORD}`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Bound to 0.0.0.0 and published; keep the firewall closed for 6377 and use a strong password."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 4,
   "v": 1
  },
  {
   "id": "registry",
   "title": "Docker registry",
   "role": "infra",
   "status": "active",
   "repo": "packages/eternet_registry",
   "updated": "2026-09-08",
   "tagline": "Private registry:2 at registry.eternet.net for eOS images and mirrored Supervisor images.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Holds the rebranded Home Assistant images and the upstream Supervisor, CLI, DNS, audio, observer and multicast images mirrored by `mirror_images.sh`. Basic auth with bcrypt htpasswd users (`eternet-ci`, `builder`)."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Devices and CI pull images from Eternet, not ghcr.io."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Image",
       "`registry:2`"
      ],
      [
       "Port",
       "5551 (host)"
      ],
      [
       "Route",
       "`dynamic-cloud-registry.yml`"
      ],
      [
       "Storage",
       "`./data` (back this up)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The README shows a real registry password in a curl example. See [[security|Security & hygiene]]."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "traefik"
   ],
   "wraps": [],
   "cat": "infra",
   "order": 5,
   "v": 1
  },
  {
   "id": "ota-readmes",
   "title": "OTA research notes",
   "role": "guide",
   "status": "lab",
   "repo": "thirdparty/eternet_ota_readmes",
   "updated": "2026-08-01",
   "tagline": "Step-by-step guides for OTA with SWUpdate + hawkBit on Debian 13 and Yocto.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Lab notes from evaluating {{hawkbit|hawkBit}} and SWUpdate (Suricatta) as an OTA path for non-HAOS devices: building SWUpdate with Suricatta, signing `.swu` packages, and configuring hawkBit. Every problem hit is documented with its fix."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Evaluate an OTA path for Debian/Yocto devices alongside RAUC for HAOS."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Folders",
       "`debian/`, `yocto/`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "The portal's OTA Updates area and the Tauri installer in full-fathi-dev build on this research."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 6,
   "v": 1
  },
  {
   "id": "documents",
   "title": "documents",
   "role": "guide",
   "status": "empty",
   "repo": "docdev/documents",
   "updated": "2025-08-17",
   "tagline": "Placeholder repository; a natural home for this documentation site's source and its content backups.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "No content yet. A natural home for this documentation site's source."
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "infra",
   "order": 7,
   "v": 1
  },
  {
   "id": "auth-package",
   "title": "eternet_auth_package",
   "role": "package",
   "status": "active",
   "repo": "packages/eternet_auth_package",
   "updated": "2026-08-09",
   "tagline": "The shared Django/DRF library every Eternet microservice uses to authenticate requests.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Puts authentication in one place so services do not each re-implement it. `UnifiedAuthentication` first tries a Keycloak JWT (verified against cached JWKS), then falls back to an Eternet API key looked up in the auth database. Users or keys with the `administrator` role bypass every role and permission check."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Identical auth behaviour across all services.",
      "Support both human users (Keycloak) and machine clients (API keys).",
      "Fast checks: JWKS cached in memory."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django, DRF, SQLAlchemy"
      ],
      [
       "Install",
       "`git+https://gitlab.eternet.net/packages/eternet_auth_package.git`"
      ],
      [
       "Main classes",
       "`UnifiedAuthentication`, `JWTAuthenticationMiddleware`, `@require_role`, `AnonymousGetActionPermission`"
      ],
      [
       "Used as",
       "`AUTH_USER_MODEL = 'eternet_auth_package.User'`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "ETRApp_user",
       "Shared user table (unmanaged)"
      ],
      [
       "ETRApp_eternetapikey",
       "API keys (prefix + hash, expiry, revoked)"
      ],
      [
       "ETRApp_apiipwhitelist / ETRApp_apientitiespermission",
       "Per-key IP allowlist and entity access flags"
      ],
      [
       "ETRBaseModel",
       "Abstract base with created/modified at/by"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "ETERNET_AUTH = {REALM, KEYCLOAK_BASE_URL, CLIENT_ID, ADMIN_ROLE, AUTH_DB_URL}",
      "ETERNET_AUTH_DB_URL",
      "DOMAIN",
      "CLIENT_ID"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`JWTAuthenticationMiddleware` applies to every path with no exclusions, so unauthenticated health checks return 401 (hub_stream_service removed its healthcheck for this reason).",
      "Admin bypass applies when the JWT has the admin role, an `is_admin: true` claim, or the API key is marked admin."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "keycloak"
   ],
   "wraps": [],
   "cat": "packages",
   "order": 0,
   "v": 1
  },
  {
   "id": "websocket-core",
   "title": "eternet_websocket_core",
   "role": "package",
   "status": "active",
   "repo": "packages/eternet_websocket_core",
   "updated": "2026-08-19",
   "tagline": "Reusable Django Channels consumer with guaranteed delivery, used for live updates to browsers and apps.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Gives each connected client a channel group at `ws/core/<client_id>/`. Outbound messages are stored in a table first and marked delivered on acknowledgement, so a client that reconnects gets what it missed. A retry worker resends unacknowledged messages."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Live push without losing messages across reconnects.",
      "One WebSocket implementation shared by all services."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django, Channels, channels-redis"
      ],
      [
       "Route",
       "`ws/core/<client_id>/`"
      ],
      [
       "Public route",
       "`api.eternet.net/ws/core/` (served by device_service)"
      ],
      [
       "Worker",
       "`manage.py ws_retry_worker`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Data"
    },
    {
     "type": "table",
     "head": [
      "Table / store",
      "Holds"
     ],
     "rows": [
      [
       "OutboundMessage",
       "client_id, message_id, payload, priority, delivered, attempts, expires_at"
      ],
      [
       "IncomingMessage",
       "Messages received from clients"
      ]
     ],
     "style": "data"
    },
    {
     "type": "h",
     "text": "Configuration"
    },
    {
     "type": "p",
     "text": "Environment variables and settings the service reads. Real values live in the server-side `.env`."
    },
    {
     "type": "chips",
     "items": [
      "CHANNEL_LAYERS",
      "WS_CHANNEL_PREFIX (default ws_client)",
      "WS_REDIS_URL / REDIS_URL"
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`setup.py` names the package `ethernet_websocket_core` (typo) at version 1.0.0 while `pyproject.toml` says `eternet_websocket_core` 0.1.0."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "redis"
   ],
   "wraps": [],
   "cat": "packages",
   "order": 1,
   "v": 1
  },
  {
   "id": "drf-exclude",
   "title": "etrdrf-exclude-docs",
   "role": "package",
   "status": "active",
   "repo": "packages/etrdrf_exclude_docs",
   "updated": "2025-12-26",
   "tagline": "Tiny helper that hides selected views and methods from the drf-yasg Swagger output.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Lets services keep internal or legacy endpoints out of the public API docs. Views decorated with `exclude_func_from_docs` do not appear in `/docs/` or `/redoc/`."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Public Swagger pages only show supported endpoints."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Python, drf-yasg"
      ],
      [
       "Decorator",
       "`exclude_func_from_docs`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "packages",
   "order": 2,
   "v": 1
  },
  {
   "id": "n8n-nodes",
   "title": "eternet_n8n_nodes",
   "role": "package",
   "status": "empty",
   "repo": "thirdparty/eternet_n8n_nodes",
   "updated": "2025-11-01",
   "tagline": "Empty repository. Eternet nodes currently live inside the n8n fork.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Reserved for packaging Eternet n8n nodes separately. No branches or commits yet."
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "packages",
   "order": 3,
   "v": 1
  },
  {
   "id": "etrbetemp",
   "title": "ETR backend monolith (etrbetemp)",
   "role": "eternet",
   "status": "legacy",
   "repo": "backenddev/etrbetemp",
   "updated": "2026-10-10",
   "tagline": "The original Django monolith that created the ETRApp_* schema; the microservices were carved out of it.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "One Django project (`ETRProject`) that served everything at `core/api/v1/`: devices, hubs, vehicles and Traccar, triggers and automations, dashboards, API keys, jobs, Node-RED containers, Docker control, file management, meetings, push notifications, the recorder, video streams and auth via Cognito, Firebase and Keycloak. It ran with gunicorn/uvicorn on port 9009 under systemd."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Reference for behaviour and schema; also still the origin of many shared tables."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django, DRF, Channels/Daphne, APScheduler"
      ],
      [
       "Port",
       "9009 (systemd `eternetbe.service`)"
      ],
      [
       "Apps",
       "ETRApp, ETRCognito, ETRFileManagement, ETRFirebaseAuth, ETRKeycloak, ETRMeet, ETROAuth2Provider, ETRPushWebNotifications, ETRRecorder, ETRScheduler, VideoStream, datacollection, socialauth, traccar, uploadstorage, wsock"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Where its features went"
    },
    {
     "type": "table",
     "head": [
      "Monolith area",
      "Now"
     ],
     "rows": [
      [
       "Devices, streams, MQTT",
       "[[device-service|Device Service]]"
      ],
      [
       "Hubs",
       "[[hub-service|Hub Service]]"
      ],
      [
       "Vehicles, Traccar",
       "[[vehicle-service|Vehicle Service]]"
      ],
      [
       "Main dashboard, dashboards",
       "[[widget-service|Widget Service]]"
      ],
      [
       "VideoStream",
       "[[video-service|Video Service]], [[backend-stream|backend-stream]]"
      ],
      [
       "Automation, Node-RED",
       "[[automation-service|Automation Service]] + [[n8n|n8n]]"
      ],
      [
       "Users, API keys, Cognito/Keycloak",
       "[[auth-service|Authorization Service]], [[auth-package|eternet_auth_package]]"
      ],
      [
       "datacollection / ETRRecorder",
       "[[hub-stream|Hub Stream Service]], [[remote-recorder|Remote Recorder]]"
      ],
      [
       "ETRMeet",
       "Jitsi + [[prosody|Prosody module]]"
      ],
      [
       "Settings / API keys UI",
       "[[setting-service|Setting Service]] (stub)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Contains a committed Firebase service-account key, private keys and AWS credentials. Rotate them even if the service is retired."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [
    "drf-exclude"
   ],
   "wraps": [],
   "cat": "legacy",
   "order": 0,
   "v": 1
  },
  {
   "id": "hubcloud-server",
   "title": "eternet_hubcloud_server",
   "role": "eternet",
   "status": "archived",
   "repo": "backenddev/eternet_hubcloud_server",
   "updated": "2026-06-24",
   "tagline": "First SniTun remote-access server for Home Assistant; archived 2026-06-24 and replaced by eternet_hass_cloud.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "A DIY rebuild of Nabu Casa remote control (SniTun + websocket + server, originally on AWS Cognito and Cloudflare) deployed as systemd units and later Docker behind `hubcloud.eternet.net`. Its bugs (mismatched Fernet keys, SQL injection, hardcoded secrets) are listed and fixed in [[hass-cloud|eOS Cloud]]."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Replaced by",
       "[[hass-cloud|thirdparty/eternet_hass_cloud]]"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "Archived but still contains a Cloudflare token, database passwords and a log with JWTs."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "legacy",
   "order": 1,
   "v": 1
  },
  {
   "id": "eternet-auth",
   "title": "eternet_auth (FastAPI)",
   "role": "eternet",
   "status": "legacy",
   "repo": "backenddev/eternet_auth",
   "updated": "2025-11-16",
   "tagline": "First-generation user/role/group service on FastAPI with a Streamlit admin UI.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Managed users, roles, groups, permissions and API access keys with SQLAlchemy and Alembic, and synced users to Keycloak. A Streamlit UI on port 8510 gave admins a quick console. The Django [[auth-service|Authorization Service]] replaces it with the same model."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Historical reference; new work goes to auth_service."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "FastAPI, SQLAlchemy, Alembic, Streamlit"
      ],
      [
       "Ports",
       "8010 (API), 8510 (UI)"
      ],
      [
       "Container",
       "`eternet_auth_service`"
      ]
     ]
    },
    {
     "type": "h",
     "text": "API surface"
    },
    {
     "type": "table",
     "head": [
      "Method",
      "Path",
      "What it does"
     ],
     "rows": [
      [
       "CRUD",
       "/users, /roles, /groups, /permissions",
       "Directory management"
      ],
      [
       "POST",
       "/users/change-password, /users/reset-password",
       "Password flows via Keycloak"
      ],
      [
       "POST",
       "/signup",
       "Registration"
      ]
     ],
     "style": "api"
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [
    "keycloak"
   ],
   "wraps": [],
   "cat": "legacy",
   "order": 2,
   "v": 1
  },
  {
   "id": "etrauth",
   "title": "etrauth",
   "role": "package",
   "status": "legacy",
   "repo": "packages/etrauth",
   "updated": "2025-07-27",
   "tagline": "Original auth package built on AWS Cognito, replaced by eternet_auth_package.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Provided `CognitoAuthentication`, Eternet permission classes and the shared user/API-key models when the platform authenticated against AWS Cognito. Same models as eternet_auth_package; the Keycloak migration superseded it."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Historical reference for the Cognito-era permission model (`DevicePermission`, `DefaultEternetPermission`)."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Django 5.2, DRF, PyJWT, djangorestframework-api-key"
      ],
      [
       "Replaced by",
       "[[auth-package|eternet_auth_package]]"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "`requirements.txt` is UTF-16 encoded."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "legacy",
   "order": 3,
   "v": 1
  },
  {
   "id": "eternetfront",
   "title": "eternetfront (Angular)",
   "role": "app",
   "status": "legacy",
   "repo": "frontdev/eternetfront",
   "updated": "2026-08-12",
   "tagline": "The original Eternet web app on Angular 14 and the Fuse admin template.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Delivered the first generation of devices, hubs, vehicles (Traccar), triggers and n8n workflows, video streams, widgets and Grafana dashboards. It is the behavioral and API reference for the Next.js migration."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Reference only; new features go to etrfef-next."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Angular 14, Fuse, Mapbox GL, Leaflet, ApexCharts, ECharts, Transloco"
      ],
      [
       "Run",
       "PM2 (`eternet-front`)"
      ],
      [
       "Branches",
       "`main` and `master` (1000+ commits apart)"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    },
    {
     "type": "h",
     "text": "Run & deploy"
    },
    {
     "type": "code",
     "text": "cd /home/keycloak/eternet/eternetfront\ngit pull\nnpm run build\npm2 restart eternet-front"
    },
    {
     "type": "h",
     "text": "Observations"
    },
    {
     "type": "list",
     "items": [
      "API keys for maps, Grafana and Firebase are in source and environment files."
     ],
     "ordered": false,
     "style": "notes"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "legacy",
   "order": 4,
   "v": 1
  },
  {
   "id": "application-rn",
   "title": "application-rn",
   "role": "app",
   "status": "legacy",
   "repo": "frontdev/application-rn",
   "updated": "2026-06-25",
   "tagline": "Previous copy of the React Native app, superseded by etrapp.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Same app structure as etrapp without the telemetry service; last updated June 2026."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "React Native"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "legacy",
   "order": 5,
   "v": 1
  },
  {
   "id": "etrfef-next-dev",
   "title": "etrfef-next-dev",
   "role": "app",
   "status": "legacy",
   "repo": "frontdev/etrfef-next-dev",
   "updated": "2026-09-06",
   "tagline": "Earlier snapshot of the Next.js portal, kept as a development copy.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Same project (`etrfef-next` 0.1.0) at an earlier stage with fewer routes. Active development moved to `eternet-platform/etrfef-next`."
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Stack",
       "Next.js 16, React 19"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [],
   "wraps": [],
   "cat": "legacy",
   "order": 6,
   "v": 1
  },
  {
   "id": "custom-hass",
   "title": "Eternet Custom Home Assistant",
   "role": "fork",
   "status": "legacy",
   "repo": "thirdparty/eternet_custom_hass",
   "updated": "2026-06-24",
   "tagline": "Pre-eOS approach: stock Home Assistant 2024.6.4 with a patched hass-nabucasa mounted over the bundled one.",
   "blocks": [
    {
     "type": "h",
     "text": "Purpose"
    },
    {
     "type": "p",
     "text": "Before the forks existed, Eternet ran the official container and overlaid four patched files (`const.py`, `auth.py`, `__init__.py`, `remote.py`) plus the 2.x `remote_recorder` custom component. Still useful for quick testing against the cloud."
    },
    {
     "type": "h",
     "text": "Goals"
    },
    {
     "type": "list",
     "items": [
      "Run an Eternet-connected Home Assistant without building anything."
     ],
     "ordered": false
    },
    {
     "type": "h",
     "text": "At a glance"
    },
    {
     "type": "facts",
     "rows": [
      [
       "Image",
       "homeassistant/home-assistant:2024.6.4"
      ],
      [
       "Mount",
       "`./hass-nabucasa → /usr/local/lib/python3.12/site-packages/hass_nabucasa`"
      ],
      [
       "Extra",
       "`gai.conf` forcing IPv4 so Let's Encrypt works on broken-IPv6 hosts"
      ]
     ]
    },
    {
     "type": "h",
     "text": "Connections"
    },
    {
     "type": "relations"
    }
   ],
   "depends": [
    "hass-cloud",
    "sso-api"
   ],
   "wraps": [],
   "cat": "legacy",
   "order": 7,
   "v": 1
  }
 ],
 "glossary": [
  {
   "id": "access-point",
   "term": "Access point (recorder)",
   "def": "Where a Remote Recorder sends its batches. `managed` = Eternet's cloud (db.cloud.eternet.net), `postgres` = the customer's PostgreSQL, `influxdb` = the customer's InfluxDB written directly by eOS, `http` = a custom endpoint.",
   "related": [
    "remote-recorder",
    "hass-cloud"
   ]
  },
  {
   "id": "acme",
   "term": "ACME DNS-01",
   "def": "The Let's Encrypt challenge in which the requester proves domain control by publishing a TXT record. eOS Cloud writes the record to Cloudflare for each hub so the hub can get its own certificate.",
   "related": [
    "hass-cloud"
   ]
  },
  {
   "id": "admin-bypass",
   "term": "Administrator bypass",
   "def": "Rule in eternet_auth_package: a user or API key with the `administrator` role (or an `is_admin` claim/flag) passes every role and permission check.",
   "related": [
    "auth-package"
   ]
  },
  {
   "id": "arrow",
   "term": "Apache Arrow / ADBC / DuckDB",
   "def": "The Analytics data plane: connectors (ADBC drivers) return Arrow tables, DuckDB joins and aggregates them in memory, and Forecast can receive the result as an Arrow IPC stream.",
   "related": [
    "analytic-service"
   ]
  },
  {
   "id": "api-key",
   "term": "API key",
   "def": "Machine credential for Eternet APIs. Stored as prefix + hash with expiry and revoked flag; optional IP allowlist and per-entity permissions. Checked by UnifiedAuthentication when no JWT is present.",
   "related": [
    "auth-package",
    "auth-service"
   ]
  },
  {
   "id": "batch-uuid",
   "term": "batch_uuid / client_seq",
   "def": "Identity of a recorder batch. The server keeps a unique log of batch UUIDs, so a retried batch returns `duplicate` instead of being stored twice; client_seq lets the hub prune its queue after a crash.",
   "related": [
    "hub-stream"
   ]
  },
  {
   "id": "buildroot",
   "term": "Buildroot",
   "def": "The tool that compiles the HAOS Linux image from source. Slow on first run, refuses to run as root, so builds use the `builder` user.",
   "related": [
    "ha-release",
    "ha-os-fork"
   ]
  },
  {
   "id": "channel",
   "term": "Channel",
   "def": "Release track a device follows: `dev`, `beta` or `stable`. Always publish to dev first.",
   "related": [
    "update-service",
    "ha-release"
   ]
  },
  {
   "id": "compreface",
   "term": "CompreFace",
   "def": "Open-source face recognition server (Exadel). cv_faceid_service wraps its recognition, detection and verification APIs.",
   "related": [
    "cv-faceid"
   ]
  },
  {
   "id": "connection-type",
   "term": "connection_type",
   "def": "How a hub entered the registry: `hass_cloud` (registered by eOS from its Cloud panel) or `app` (added on the portal Hub page). Decides how the Remote Recorder links it.",
   "related": [
    "hub-service",
    "remote-recorder"
   ]
  },
  {
   "id": "contabo",
   "term": "Contabo VPS (vmi2623664)",
   "def": "The main server that runs the Eternet stack: Traefik, GitLab, Postgres, the Django services, eOS Cloud, the registry, update service and build user.",
   "related": [
    "traefik"
   ]
  },
  {
   "id": "device",
   "term": "Device",
   "def": "An IoT thing registered in Eternet (`ETRApp_device`) with a UUID, keys, MQTT topics, inputs and latest values.",
   "related": [
    "device-service"
   ]
  },
  {
   "id": "channels",
   "term": "Django Channels",
   "def": "WebSocket and async layer for Django. Eternet uses it with Redis for live device updates (`ws/core/<client_id>/`).",
   "related": [
    "websocket-core",
    "device-service"
   ]
  },
  {
   "id": "domain",
   "term": "Domain / realm",
   "def": "The Keycloak realm, `etr.org` in production. Many APIs take it as `domain`. In MQTT it is also the first topic level for a tenant.",
   "related": [
    "keycloak",
    "device-service"
   ]
  },
  {
   "id": "drf",
   "term": "DRF / drf-yasg",
   "def": "Django REST Framework, used by every Django service; drf-yasg generates the Swagger (`/docs`) and ReDoc (`/redoc`) pages.",
   "related": [
    "drf-exclude"
   ]
  },
  {
   "id": "entity",
   "term": "Entity",
   "def": "Generic word for anything with data: a device, vehicle or hub (and in Home Assistant, an `entity_id` such as `sensor.temperature`).",
   "related": [
    "widget-service"
   ]
  },
  {
   "id": "eos",
   "term": "eOS / eOS Pro",
   "def": "Eternet's distribution of Home Assistant, rebranded and connected to Eternet Cloud. Planned editions: Core, Home, Pro, Enterprise, Industrial.",
   "related": [
    "eos-overview"
   ]
  },
  {
   "id": "etrapp-tables",
   "term": "ETRApp_* tables",
   "def": "The shared schema in eternetdb created by the legacy monolith's ETRApp app. Microservices read them with `managed = False` models, so Django never migrates them.",
   "related": [
    "postgres",
    "etrbetemp"
   ]
  },
  {
   "id": "fanout",
   "term": "Fan-out publish",
   "def": "Publishing to an MQTT filter with `+` or `#`: device_service expands it to every matching known topic and publishes to each (limit `MQTT_TREE_FANOUT_LIMIT`).",
   "related": [
    "device-service"
   ]
  },
  {
   "id": "fernet",
   "term": "Fernet token",
   "def": "Symmetric encrypted token. eOS Cloud issues one per hub so SniTun can verify the hub; the same `SNITUN_FERNET_KEYS` must be configured in the API and in SniTun.",
   "related": [
    "hass-cloud"
   ]
  },
  {
   "id": "frigate",
   "term": "Frigate",
   "def": "Open-source NVR with object detection. backend-stream ingests its events; frigate-web is Eternet's customized UI.",
   "related": [
    "backend-stream",
    "frigate-web"
   ]
  },
  {
   "id": "hairpin",
   "term": "Hairpin NAT",
   "def": "A container on the VPS cannot reach the VPS's own public IP. Fixes used in the repos: build with `network: host`, or reach services by container name on a shared Docker network.",
   "related": [
    "hub-stream",
    "traefik"
   ]
  },
  {
   "id": "haos",
   "term": "HAOS",
   "def": "Home Assistant Operating System: a minimal Linux that runs the Supervisor and Home Assistant containers.",
   "related": [
    "ha-os-fork",
    "ha-release"
   ]
  },
  {
   "id": "hawkbit",
   "term": "hawkBit / SWUpdate / Suricatta",
   "def": "Eclipse hawkBit is an OTA rollout server; SWUpdate is an embedded Linux updater whose Suricatta mode polls hawkBit. Evaluated for non-HAOS devices.",
   "related": [
    "ota-readmes"
   ]
  },
  {
   "id": "ha",
   "term": "Home Assistant",
   "def": "Open-source home automation platform. Eternet forks its core, frontend and OS to build eOS.",
   "related": [
    "eos-overview"
   ]
  },
  {
   "id": "hub",
   "term": "Hub",
   "def": "A gateway at a customer site, usually an eOS instance, registered in `ETRApp_hub` with a `hubID`, type, address and owner.",
   "related": [
    "hub-service"
   ]
  },
  {
   "id": "hypertable",
   "term": "Hypertable",
   "def": "A TimescaleDB table partitioned by time. Traccar's `tc_positions` becomes one on the TimescaleDB stack.",
   "related": [
    "vehicle-service"
   ]
  },
  {
   "id": "influx",
   "term": "InfluxDB 3 / line protocol",
   "def": "Time-series database. Version 3 queries with SQL (no Flux) and accepts writes in line protocol at `/api/v3/write_lp`.",
   "related": [
    "influxdb3"
   ]
  },
  {
   "id": "jwks",
   "term": "JWKS",
   "def": "The JSON Web Key Set a Keycloak realm publishes. Services verify JWT signatures against it offline and cache it (default 1 h).",
   "related": [
    "auth-package",
    "sso-api"
   ]
  },
  {
   "id": "keycloak",
   "term": "Keycloak",
   "def": "Identity provider for all Eternet logins. Realm `etr.org`, client `etr.org-client`, at sso.eternet.net.",
   "related": [
    "keycloak",
    "sso-api"
   ]
  },
  {
   "id": "managed-false",
   "term": "managed = False",
   "def": "Django model option meaning the table exists outside this service. Django will not create or migrate it; schema changes are run by hand.",
   "related": [
    "postgres"
   ]
  },
  {
   "id": "manifest",
   "term": "Manifest",
   "def": "The JSON (`dev.json` etc.) that tells eOS Supervisors which versions exist and where to download them.",
   "related": [
    "update-service"
   ]
  },
  {
   "id": "max-points",
   "term": "max_points / mode",
   "def": "Records API parameters. The service picks the finest time bucket that keeps the result at or below max_points (`mode=count`), or divides the range evenly (`mode=formula`).",
   "related": [
    "vehicle-service",
    "device-service"
   ]
  },
  {
   "id": "meshcentral",
   "term": "MeshCentral",
   "def": "Remote device management/desktop tool. Device service issues login tokens so the portal can open a device in it.",
   "related": [
    "device-service"
   ]
  },
  {
   "id": "mqtt",
   "term": "MQTT",
   "def": "Lightweight publish/subscribe protocol used by devices. Eternet runs VerneMQ; device topics follow `<domain>/<workspace>/devices/<deviceId>/{up,down}`.",
   "related": [
    "mqtt",
    "device-service"
   ]
  },
  {
   "id": "topic-tree",
   "term": "MQTT topic tree",
   "def": "device_service's persistent, browsable copy of every topic seen on the broker, with last payload, retained flag, history and device bindings (inherited down the tree).",
   "related": [
    "device-service"
   ]
  },
  {
   "id": "n8n",
   "term": "n8n",
   "def": "Open-source workflow automation tool. Eternet forks it for SSO login and Eternet nodes.",
   "related": [
    "n8n",
    "automation-service"
   ]
  },
  {
   "id": "ota",
   "term": "OTA",
   "def": "Over-the-air update of a device's OS or software.",
   "related": [
    "update-service",
    "ota-readmes"
   ]
  },
  {
   "id": "owner-sub",
   "term": "owner_sub",
   "def": "The Keycloak `sub` (user id) of a hub's owner. `/recorder/link` uses it to check that the person linking a hub owns it.",
   "related": [
    "hub-service",
    "hass-cloud"
   ]
  },
  {
   "id": "pkce",
   "term": "PKCE",
   "def": "OAuth Authorization Code flow with a proof key, used by public clients that cannot keep a secret. The desktop OTA installer uses it.",
   "related": [
    "etrfef-next"
   ]
  },
  {
   "id": "prosody",
   "term": "Prosody",
   "def": "The XMPP server inside Jitsi Meet; Eternet's Lua module makes it accept SSO tokens.",
   "related": [
    "prosody"
   ]
  },
  {
   "id": "rauc",
   "term": "RAUC / .raucb",
   "def": "The A/B update system in HAOS. A `.raucb` is a signed OS update bundle; devices accept only bundles signed under the Eternet OTA CA.",
   "related": [
    "ha-release",
    "update-service"
   ]
  },
  {
   "id": "records-api",
   "term": "Records API",
   "def": "`/devices/{id}/records` and `/vehicles/{id}/records`: downsampled history with the last value per field in each time bucket.",
   "related": [
    "vehicle-service",
    "device-service"
   ]
  },
  {
   "id": "registry",
   "term": "registry.eternet.net",
   "def": "Eternet's private Docker registry for eOS and mirrored Supervisor images.",
   "related": [
    "registry"
   ]
  },
  {
   "id": "sink",
   "term": "Sink",
   "def": "A storage backend that hub_stream_service writes every batch to: InfluxDB 2, InfluxDB 3 or TimescaleDB. `SINK_FAILURE_POLICY` decides whether one failure fails the batch.",
   "related": [
    "hub-stream"
   ]
  },
  {
   "id": "snitun",
   "term": "SniTun",
   "def": "Nabu Casa's tunnel protocol. A hub keeps an outbound tunnel open; incoming TLS is routed to the right hub by its SNI hostname and never decrypted by the server.",
   "related": [
    "hass-cloud",
    "eos-overview"
   ]
  },
  {
   "id": "sso-wrapper",
   "term": "SSO wrapper",
   "def": "Eternet SSO API at api.sso.eternet.net: `/login/`, `/refresh/`, `/logout/`, `/verification/verify-token`.",
   "related": [
    "sso-api"
   ]
  },
  {
   "id": "stream",
   "term": "Stream",
   "def": "One data channel of a device (`ETRApp_stream`), such as temperature, with a value type and unit. Also used for video streams in the vision services.",
   "related": [
    "device-service",
    "video-service"
   ]
  },
  {
   "id": "supervisor",
   "term": "Supervisor",
   "def": "The HAOS program that runs containers and checks for updates. Eternet still needs to patch it to use updates.eternet.net.",
   "related": [
    "eos-overview",
    "update-service"
   ]
  },
  {
   "id": "tc-devices",
   "term": "tc_devices / tc_positions",
   "def": "Traccar's device and position tables. Eternet links `tc_devices.vehicleid` to `ETRApp_vehicle.vehicleId`.",
   "related": [
    "vehicle-service",
    "traccar"
   ]
  },
  {
   "id": "timescale",
   "term": "TimescaleDB",
   "def": "PostgreSQL extension for time series. Hosts the stock Traccar database and is one hub_stream sink.",
   "related": [
    "postgres",
    "vehicle-service"
   ]
  },
  {
   "id": "traccar",
   "term": "Traccar",
   "def": "Open-source GPS tracking server with 200+ device protocols.",
   "related": [
    "traccar",
    "vehicle-service"
   ]
  },
  {
   "id": "traefik",
   "term": "Traefik router / middleware",
   "def": "Traefik terms: a router matches requests (`Host()`, `PathPrefix()`), middlewares change them (strip prefix, CORS, basic auth), a service is the backend. `websecure` is the 443 entrypoint.",
   "related": [
    "traefik"
   ]
  },
  {
   "id": "traefik-public",
   "term": "traefik-public",
   "def": "The external Docker network Traefik watches. A container must join it for label-based routing to work.",
   "related": [
    "traefik"
   ]
  },
  {
   "id": "unified-auth",
   "term": "UnifiedAuthentication",
   "def": "DRF authentication class from eternet_auth_package: tries a Keycloak JWT, then an API key.",
   "related": [
    "auth-package"
   ]
  },
  {
   "id": "vehicle-link",
   "term": "vehicle_id ↔ vehicleid",
   "def": "Two-way link between a vehicle and its Traccar device. `ETRApp_vehicle.vehicle_id` holds the tc_devices id (-1 = not linked); `tc_devices.vehicleid` holds the vehicle UUID.",
   "related": [
    "vehicle-service"
   ]
  },
  {
   "id": "workspace",
   "term": "Workspace",
   "def": "Grouping level inside a tenant's MQTT namespace and planned authorization model (Domain → Workspace → Role/Group/Permission → Entity).",
   "related": [
    "device-service",
    "auth-service"
   ]
  },
  {
   "id": "zlmediakit",
   "term": "ZLMediaKit",
   "def": "Streaming media server (RTSP, RTMP, HLS, WebRTC). video_service controls it and receives its hooks.",
   "related": [
    "video-service"
   ]
  }
 ],
 "gv": 1
}