# Documentation (docs.na.id.au) — full text ============================================================ SECTION: AI Machine ============================================================ --- FILE: 01-architecture.md --- --- title: "01 — Architecture" description: "## The two-tier pattern" section: ai-docs raw: "01-architecture.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 01 — Architecture > The overall shape of a self-hosted local AI stack: two tiers, GPU isolation, and how the pieces find each other. ## The two-tier pattern A local AI machine splits cleanly into two tiers, and the split is worth keeping no matter how the details change: - **Inference tier** — one or more `llama.cpp` servers running **natively on the host** (plain systemd services), each pinned to a specific GPU via `CUDA_VISIBLE_DEVICES`. They expose an OpenAI-compatible HTTP API. - **Application tier** — Docker containers (chat UI, terminal, plugin orchestration) on a shared user-defined Docker network. Why native for inference rather than Docker? A GPU workload wants the full device with no container indirection, and the model files live on the host filesystem. Keeping inference out of containers means the web tooling can be rebuilt, upgraded and restarted independently — a broken Open WebUI image never takes the models down with it. The local machine follows this exactly: | Tier | Components | Notes | | --- | --- | --- | | Inference (native) | `llama-primary` on the RTX 3090 (:8082), `llama-secondary` on the RTX 3070 (:8083) | One server per GPU, one role per server — see [Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) | | Application (Docker) | Open WebUI (:8081), Open Terminal (:8080), Open WebUI Pipelines (:9099) | All on one shared network — see [Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) | ## One server per GPU `CUDA_VISIBLE_DEVICES` is what gives you hardware isolation between servers: each `llama-server` only sees its own card, so a context-heavy request on the big card can never evict or starve the small one, and vice versa. This is the whole reason to run two servers on one box rather than one server on two GPUs — it is as good as two machines, except you can share models between them when convenient. Each server is an ordinary, individually startable systemd unit (`Restart=on-failure` / `Restart=always`, `RestartSec=5`, logs appended to `/var/log/llama_*.log`). Ordinary units matter if the machine is dual-use: anything that needs the GPUs (a desktop, a render job) can simply `systemctl stop` the server, and the default state is whichever one you start at boot. This box additionally auto-switches between headless inference and an interactive desktop (or gaming machine) when a USB monitor switcher is plugged in or out — see [Dual-Use: Inference ⇄ Gaming/Desktop](/ai-docs/07-dual-use-gaming/). ## How the tiers talk to each other Docker containers cannot see host services by name, so the standard trick is used: every container declares ```yaml extra_hosts: - "host.docker.internal:host-gateway" ``` which maps the hostname `host.docker.internal` to the Docker host. Containers then reach the native servers with `http://host.docker.internal:8082/v1` and `:8083/v1`. Two practical notes: - The shared network itself (`ai-shared-net`) exists mainly so the application containers can address *each other*; it is not how they reach the inference tier. - The inference base URLs are **not** in any config file here — they are entered through the Open WebUI web UI and stored in its data volume. That means they survive image upgrades but not a wiped data volume, so keep the data volume backed up. ## Where data lives A pattern that works well: application data is persisted to host paths under a single root (here `/var/local/docker-files//`), while model files live in one shared directory on the host (`/usr/local/llama/models/` for the `.gguf` files) with a preset file per server (`/etc/llama/models-*.ini`) deciding which models each server loads. The deployment was originally built around Ollama's per-server blob stores (`.ollama/` for the primary, `.ollama-secondary/` for the secondary) but has since migrated to a pure llama.cpp install — one directory is cleaner, and "which GPU does this model belong to" stays answerable from the preset it appears in. Databases that don't need to scale (Postgres for Open WebUI's data) can run on the host and be reached over a mounted Unix socket instead of being containerised — simpler lifecycle, lower latency, one fewer container. ## Typical request flow ```text User ──HTTP──► Open WebUI (:8081) │ base URL configured in the web UI: │ http://host.docker.internal:8082/v1 (or :8083) ▼ llama-server (native, on the host) │ runs inference on its own GPU ▼ token stream ──► Open WebUI ──► User ``` Tool-assisted chat adds a loop: the application tier calls a tool (e.g. Open Terminal's API, an MCP server), the tool does something, and its output goes back into the model's context for the next turn. Sub-agents are just a special case of that loop where the "tool" is another model endpoint — see [Sub-agents](/ai-docs/04-sub-agents/). ## Sizing the machine The useful mental model when designing your own setup is **VRAM budget per card**, not raw model count: ```text VRAM_needed ≈ weights(quant) + KV_cache(ctx × layers × quant) + ~0.5 GB overhead ``` Everything else — which models to run, how much context, whether a model is resident or swapped — falls out of that equation. The local setup works out the numbers for an 8 GB and a 24 GB card; see [Context & Quantisation](/ai-docs/02-context-and-quantisation/). ## Related - [Context & Quantisation](/ai-docs/02-context-and-quantisation/) - [Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) - [Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) - [Dual-Use: Inference ⇄ Gaming/Desktop](/ai-docs/07-dual-use-gaming/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 02-context-and-quantisation.md --- --- title: "02 — Context Sizing & Model Quantisation" description: "## The VRAM budget equation" section: ai-docs raw: "02-context-and-quantisation.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 02 — Context Sizing & Model Quantisation > How to choose quantisation and context size so the model, its KV cache, and your working memory all fit on the card. ## The VRAM budget equation Every model you run costs three things in VRAM: ```text VRAM ≈ weights(quant) + KV_cache(ctx × parallel) + overhead (~0.5 GB) ``` - **Weights** scale with model size and quantisation (a 4B model is ~2.5 GB at Q4_K, ~3.4 GB at Q6_K, ~4 GB at Q8_0). - **KV cache** scales with context length, number of layers, and *per-slot* context (see the parallel pitfall below). Quantising the KV cache (`cache-type-k` / `cache-type-v`, typically `q8_0` for K and `q4_0` for V) roughly halves its cost at negligible quality loss — use it by default. - **Overhead** — CUDA context, buffers, flash-attention workspace. Budget ~0.5 GB. The trick is not to fill the card to 100%: the OS, the desktop compositor (if present), and peak batch work all need headroom. A good target is **~70–85% of VRAM at worst case**, and on an 8 GB card, be conservative. ## Weights quantisation Rule of thumb for consumer cards: | Card | Sweet spot | Notes | | --- | --- | --- | | 8 GB | 3–7B at Q5/Q6, 12–14B at Q4 | Q6_K is the sweet spot for ≤7B models — near-Q8 quality at ~30% more size than Q4 | | 12–16 GB | 12–14B at Q6/Q8, 24B at Q4 | | | 24 GB | 27B at Q4_K_M, 12B at Q8 | UD (uncensored/modified) Q4_K_M builds are the workhorse for 20–30B models | Don't over-quantise small models: Q3/Q2 on a 4B model is a bigger quality hit than Q4 on a 27B, because you're cutting a model that is already small. If a model fits comfortably, pay for the extra quant. ## Context sizing — the part people get wrong Context is where most setups break, because the KV cache is what silently eats the VRAM you budgeted for "later". Worked examples from the local setup: **24 GB card, `gemma4-12b` (Q8-ish weights ~12 GB):** 256k context (`ctx-size = 262144`) with `q8_0`/`q4_0` KV cache is viable because the card has ~11 GB of headroom after weights. This is the "main agent" profile — long documents, long tool conversations, compaction of other agents' histories. **24 GB card, `qwen3.8-27b` (Q4_K_M weights ~16 GB):** Only ~7 GB left for KV, so context drops to ~185k (`ctx-size = 185000`). Note the asymmetry: a 256k context on the *smaller* model beats an 185k context on the *bigger* model for pure throughput and cost — pick the model for the task, not the other way around. **8 GB card, `qwen3.5-4b` (Q6_K weights ~3.4 GB) plus a resident embedding model, `bge-m3` (~1.75 GB):** ```text 3.4 (weights) + 1.75 (resident model) + ~0.5 (overhead) = 5.65 GB remaining for KV: ~2.3 GB → 8192 ctx × 2 slots at q8_0/q4_0 ``` That leaves ~1.5 GB headroom at worst case — comfortable. The same card with a 7B Q4 model at 128k context is a `500 model failed to load` (weights ~4.5 GB + KV ~5 GB > 8 GB). **Context size is not a knob you set once and forget — it is a per-model budget line.** **The working-memory rule of thumb:** a model's *useful* context is far smaller than its configured `ctx-size`. After the system prompt and tool schemas, a 4k context has maybe 2k tokens of actual working memory — too little for multi-step agent work, which is why an otherwise-fine 9B at 4k lost to a 7B at 16k as a worker model. For agent/sub-agent work, target at least 8k context and keep the system prompt lean. ## Resident vs swap-in (`load-on-startup`, `--models-max`) A `models.ini` preset lets one server hold several models. Two behaviours to choose per model: - **Resident** (`load-on-startup = true`): the model is loaded at boot and never freed. Instant first request. You pay the VRAM forever. - **Swap-in** (`load-on-startup = false`, usually with `--models-max 1`): nothing is loaded until requested; switching models evicts the current one (several seconds of load latency). A pattern that works well on a small card: **resident for the models that must be instant** (a small embedding model, a small always-available chat worker), **swap-in for the large models** (the primary server keeps its GPU empty at boot and loads whichever of its two models is requested). If VRAM pressure appears, the fix ladder is: drop `ctx-size`, set `load-on-startup = false` on a profile, or re-enable idle sleep (`sleep-idle-seconds`). ## Pitfalls learned the hard way These were all verified failures on the local setup — expect the same ones: - **`parallel` silently divides your context.** This llama.cpp build splits `ctx-size` across `parallel` slots unless `kv-unified = true`. With `kv-unified = false` (an explicit setting here), a "4096 ctx, 4 slots" profile actually gave each slot 1024 — and the client failed with `request (1404 tokens) exceeds the available context size (1024)`. Set `kv-unified = true` when you want every slot to get the *full* context, and verify live after any preset change: ```bash curl -s 'http://127.0.0.1:PORT/slots?model=NAME' | python3 -m json.tool | grep n_ctx ``` - **Embedding inputs are capped by the physical batch.** `ubatch-size` is the maximum single embedding input in tokens; the 512 default rejected an ~860-token chunk with `input (860 tokens) is too large to process. increase the physical batch size`. Raise `ubatch-size` on embedding profiles if your memory chunks get longer. - **Reasoning models burn their token budget thinking.** A Qwen-3.5-class 4B is a reasoning model by default: with thinking enabled it spent its entire `max_tokens` budget on `reasoning_content` and returned an *empty* answer (`finish_reason = length`). Disable globally with `chat-template-kwargs = {"enable_thinking": false}` in the `[*]` section and re-enable per-request where you actually want it (open-ended debugging, e.g.). - **Don't set `chat-template` to a short name.** That preset key takes a full Jinja template string; `chat-template = chatml` is invalid. Leave it unset and the template is taken from the GGUF metadata. If a model genuinely needs a specific template (e.g. a fine-tuned chat template), set `jinja = true` and `chat-template-file = /path/to/template.jinja` — that's what `qwen3.8-27b` does with the froggeric template. - **Preset section names with digit-size tokens get mangled.** INI section `[qwen2.5:0.5B]` registered as model ID `qwen2.5:5B`; `[qwen2.5:7B-instruct]` as `qwen2.5:INSTRUCT`. Name sections plainly (e.g. `qwen3.5-4b`) and check `/v1/models` after deploying. ## A preset in practice The two-tier split shows up directly in the presets — minimal, and every line earns its place: ```ini # models-primary.ini — 24 GB card, swap-in (GPU empty at boot) [*] # globals: inherited by every profile sleep-idle-seconds = 3600 # free the GPU when idle load-on-startup = false # swap-in by default flash-attn = true batch-size = 1024 ubatch-size = 256 threads = 6 gpu-layers = 99 # all layers on GPU [gemma4-12b] # main agent: long documents, compaction model = /usr/local/llama/models/Gemma4-12B-Q4_K_M.gguf ctx-size = 262144 context-shift = true # shift old tokens out when the context overflows keep = 1024 # ...but always keep this prefix (system prompt etc.) cache-type-k = q8_0 cache-type-v = q4_0 [qwen3.8-27b] # bigger brain, shorter context model = /usr/local/llama/models/Qwen3.8-27B-UD-Q4_K_M.gguf ctx-size = 185000 context-shift = true keep = 1024 cache-type-k = q8_0 cache-type-v = q4_0 jinja = true chat-template-file = /usr/local/llama/templates/froggeric_chat_template.jinja # models-secondary.ini — 8 GB card, both models resident [*] flash-attn = true threads = 6 gpu-layers = 99 chat-template-kwargs = {"enable_thinking": false} # 4B is a reasoning model (see pitfalls) [bge-m3] # resident embedding model model = /usr/local/llama/models/BGE-M3.gguf load-on-startup = true pooling = cls # BGE-M3 pools on the CLS token — required ctx-size = 2048 cache-type-k = f16 # embeddings don't need a quantised KV cache cache-type-v = f16 batch-size = 1024 ubatch-size = 1024 # physical batch = max single embedding input [qwen3.5-4b] # resident chat/worker model model = /usr/local/llama/models/Qwen3.5-4B-Q6_K.gguf load-on-startup = true parallel = 2 ctx-size = 8192 kv-unified = true # each slot gets the FULL ctx (see pitfalls) cache-type-k = q8_0 cache-type-v = q4_0 batch-size = 2048 ubatch-size = 512 ``` Note that `cache-type-k`/`cache-type-v` are set **per profile, not globally**: the chat models get quantised `q8_0`/`q4_0` caches, but the embedding model keeps `f16` — a 2048-token KV cache is tiny anyway, and there's no point quantising a model that never generates tokens. ## Related - [Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) — which model plays which role, and why - [Sub-agents](/ai-docs/04-sub-agents/) — context sizing for agent work specifically --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [x] Human Edited --- FILE: 03-primary-vs-secondary.md --- --- title: "03 — Primary vs Secondary `llama-server`" description: "## Why two servers, not one" section: ai-docs raw: "03-primary-vs-secondary.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 03 — Primary vs Secondary `llama-server` > The two-server pattern: a big "brain" card and a small "worker" card, and how to decide what lives on each. ## Why two servers, not one The temptation is one `llama-server` that sees both GPUs and holds everything. The two-server split is better for three reasons: 1. **Hardware isolation.** `CUDA_VISIBLE_DEVICES` pins each server to one card, so a context-heavy request on the big card can never evict or starve the small one. 2. **Independent roles.** The two cards do genuinely different jobs, with different models, context sizes, and loading behaviour — see below. 3. **Independent lifecycle.** Each is its own systemd unit. You can restart, upgrade, or stop one without touching the other — and on a dual-use box, stop both to hand the GPUs to a desktop. ## The division of labour | | **Primary** (big card) | **Secondary** (small card) | | --- | --- | --- | | Card | RTX 3090 (24 GB), `CUDA_VISIBLE_DEVICES=0` | RTX 3070 (8 GB), `CUDA_VISIBLE_DEVICES=1` | | Port | 8082 | 8083 | | Role | **Main agent** — the model the user primarily chats with | **Worker** — sub-agent chat + the **embedding** model | | Models | e.g. a 12B and a 27B (big weights, big context) | a small chat model + `bge-m3` (embeddings) | | Context | ~185k–256k | 8k (chat) / 2k (embeddings) | | Loading | **Swap-in** (`--models-max 1`, `load-on-startup = false`) | **Resident** (`load-on-startup = true`) | | Restart | `on-failure` | `always` | The split is **by job, not by model count.** The primary is where "big brain" work belongs — long documents, long tool conversations, spec-writing. The secondary handles high-volume, cheap, latency-sensitive work (short sub-agent turns, embeddings) without ever touching the primary's context memory. ## The primary server (swap-in) ```ini ExecStart=/usr/local/bin/llama-server \ --models-preset /etc/llama/models-primary.ini \ --models-max 1 \ --no-mmproj \ --metrics \ --host :: \ --port 8082 ``` - **`--models-max 1`** — only one model resident in GPU memory at a time. With `load-on-startup = false` in the preset, the GPU is **empty at boot** and the requested model is loaded on first use. This is deliberate: on a 24 GB card you can't hold a 12B *and* a 27B *and* their big KV caches simultaneously, so the server swaps between them. The cost is a few seconds of load latency on a model switch. - **`--no-mmproj`** — text-only serving; don't load the multimodal projection file you're not using. - **`--metrics`** — Prometheus endpoint at `/metrics` for monitoring. ## The secondary server (resident + embeddings) ```ini ExecStart=/usr/local/bin/llama-server \ --models-preset /etc/llama/models-secondary.ini \ --no-mmproj \ --embeddings \ --metrics \ --host :: \ --port 8083 ``` - **`--embeddings` is a command-line flag, not a preset key.** The `/v1/embeddings` route is only exposed if the server is started with it — pooling set in the preset alone is *not* enough; without the flag the endpoint returns **501** for every model. This is a classic "it works in one place but not the other" trap: the embedding model (`bge-m3`) must be on a server that was started with `--embeddings`. - **No `--models-max`** — the small card can hold both active models at once, so they both load eagerly (`load-on-startup = true`). Embedding requests and the first chat request never pay a load penalty. - **`Restart=always`** — a worker you always want up. ### The embedding endpoint A dedicated small embedding model (`bge-m3`, ~567M params, 1024-dim vectors, CLS pooling) on the worker card is the clean way to serve memory/RAG. Two things must both be true: 1. The server was started with `--embeddings`. 2. The model profile has `pooling = cls` (BGE-M3 pools on the CLS token). The memory service then calls `http://host.docker.internal:8083/v1/embeddings` with `model: "bge-m3"`. Because it's a small, resident, fast model, embeddings are essentially free and never compete with the primary's big context. ## Choosing the split for your own box The general principle: **put the model that must be fast and cheap on the small card; put the model that must be smart and deep on the big card.** Concretely: - If you have two cards, one small card is ideal for an **embedding model + a small always-resident chat worker** (sub-agent, quick utility, the thing Open WebUI hits by default). - The big card is for the **primary agent** with the largest context you can afford — and it's fine to keep it swap-in so it idles empty. - If you only have one card, collapse to a single server and use `--models-max` / `load-on-startup` to decide what's resident; you lose hardware isolation but keep everything else. ## Related - [Context & Quantisation](/ai-docs/02-context-and-quantisation/) — the VRAM math behind the choices above - [Sub-agents](/ai-docs/04-sub-agents/) — the spec→code pattern that motivates the worker card - [01 — Architecture](/ai-docs/01-architecture/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [ ] Human Edited --- FILE: 04-sub-agents.md --- --- title: "04 — Sub-agent Use" description: "## The core idea" section: ai-docs raw: "04-sub-agents.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 04 — Sub-agent Use > Delegating sub-tasks from a large "brain" model to a small "worker" model — the spec→code pattern, and why it beats one big model doing everything. ## The core idea A single large model handling an entire multi-step task has a structural problem: everything it does — planning, tool calls, intermediate outputs, final answer — accumulates in one context window, and the model's attention degrades as that window fills. **Sub-agents split the job**: the main model decomposes the task and delegates discrete, self-contained sub-tasks to another model endpoint. The sub-agent runs in a **fresh context** (it sees only the task prompt, not the conversation), does the work, and returns only a compact result. Two consequences make this powerful: - **Context isolation.** The main model's window stays clean; only the sub-agent's *summary* comes back. This is what lets a main agent run long, tool-heavy conversations without degrading. - **Model matching.** Each sub-task gets the model that fits it — a big model for design decisions, a small fast model for mechanical work. You pay big-model tokens only where you need big-model quality. ## The spec→code pattern The local setup uses this pattern for code generation, and it's the clearest demonstration of the idea: ```text Main agent (big card: 12B/27B, 185k–256k ctx) │ designs the solution — requirements, edge cases, structure ▼ SPEC (a written specification in the main model's context) │ delegated as a self-contained prompt ▼ Worker (small card: 4B, 8k ctx, 2 parallel slots) │ turns the spec into code — mechanical, well-specified work ▼ CODE ──► returned to the main agent for review ``` Why the worker can be a small model here: the *thinking* already happened in the spec. The worker isn't reasoning about design — it's translating a precise description into code, a task a 4B at Q6_K does well. A 9B that only had ~4k context for the whole job (system prompt + tools + spec + output) performed **worse** than a smaller model with 16k — context working memory, not raw parameter count, is what a worker needs. ## Why the worker model has its own card The sub-agent design reinforces the [primary/secondary split](/ai-docs/03-primary-vs-secondary/): the worker runs on the small card so that generating code (which can be slow and context-hungry at the token level) **never steals context from the main agent's conversation**. With `parallel = 2` on the worker, the main agent can even delegate two independent sub-tasks concurrently — research A and research B running side by side, both reporting back. ## Practical guidance - **Make sub-task prompts self-contained.** The sub-agent has *no* access to the parent conversation. Everything it needs — task, constraints, relevant file contents, expected output format — must be in the prompt. This is the most common sub-agent failure mode: a vague "fix the thing" prompt with no context. - **Keep the return contract tight.** Ask for a summary/diff/result, not a transcript. The whole point is that only the compact result re-enters the main context. - **Delegate, don't do, when a task is 3+ steps of investigation.** A main agent that keeps a long investigation in its own context degrades; the same investigation as a sub-agent finishes cleanly and returns a short answer. - **Match model to role, and size context for the role.** Spec-writer: big model, big context. Code-writer: small model, ~8k context is plenty (a 4B's useful context is far bigger than its weight class suggests). Embeddings: tiny dedicated model. - **Disable thinking on mechanical workers.** A reasoning model doing spec→code spends its token budget on `reasoning_content` and may return an empty answer — the design reasoning is already in the spec. Keep thinking enabled only on the model that actually does the design. - **Parallelise independent sub-tasks.** If the sub-agent server has spare parallel slots (or a spare GPU), independent sub-tasks should run concurrently — that's the throughput win a single-model setup can't give you. ## Where sub-agents sit in the stack Sub-agents are not a separate component — they are just the application tier calling a *different* inference endpoint and treating the response as a tool result. The main agent's toolset therefore includes "run sub-agent (endpoint X, model Y) with prompt P". Everything from [01 — Architecture](/ai-docs/01-architecture/) (host-gateway addressing, per-server endpoints) applies unchanged. ## Related - [Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) - [Context & Quantisation](/ai-docs/02-context-and-quantisation/) - [Skills](/ai-docs/06-skills/) — reusable, packaged context that makes sub-task prompts consistent --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [ ] Human Edited --- FILE: 05-open-terminal-and-tooling.md --- --- title: "05 — Open Terminal & Tooling" description: "## What Open Terminal is" section: ai-docs raw: "05-open-terminal-and-tooling.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 05 — Open Terminal & Tooling > Giving the model a shell: Open Terminal as a hosted, API-driven terminal that agents can drive, plus how it fits the wider tooling layer. ## What Open Terminal is [Open Terminal](https://github.com/open-webui/open-terminal) is a containerised terminal service that exposes a **real shell over a REST API** rather than a browser-only web terminal. That API surface is the point: it lets a *model* — not just a human — open sessions, send commands, and read output, which turns "the LLM can talk" into "the LLM can *do*." It's part of the Open WebUI family and plugs in next to the chat UI, so the same authentication and multi-user model carries over. ## How it's deployed here ```yaml open-terminal: image: ghcr.io/open-webui/open-terminal container_name: open-terminal ports: - "8080:8080" volumes: - /var/local/docker-files/open-terminal:/home environment: - OPEN_TERMINAL_API_KEY= - OPEN_TERMINAL_MULTI_USER=true networks: - ai-network extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped ``` Key choices, and why: - **`OPEN_TERMINAL_MULTI_USER=true`** — the box serves more than one person, so sessions are isolated per user. If you run a single-user instance you can drop it. - **`OPEN_TERMINAL_API_KEY=`** — the API is gated by a key. Treat it like a credential: it authorises someone to run shell commands. Never commit the real value (it's redacted in these docs) and rotate it if it ever leaks. - **`/home` volume persisted to the host** — user workspaces and any files the terminal creates survive container restarts/rebuilds. Without this, an agent's scratch work vanishes on every image upgrade. - **`extra_hosts: host-gateway`** — the container can reach host services (e.g. the native `llama-server`s) via `host.docker.internal`, same pattern as every other container here. - **No published port needed for agent use.** It's reachable from other containers on `ai-network` by service name, and from the host via the published `8080`. Expose only what you actually need from outside. ## Why the model needs a shell A chat model with no tools can only *describe* what to do. A terminal tool closes that gap: the model can inspect the filesystem, run scripts, check command output, and iterate. This is the same "application tier calls a tool, gets a result, feeds it back into context" loop from [01 — Architecture](/ai-docs/01-architecture/) — Open Terminal is just the most general tool you can give it. The design consequence: **the terminal is a sub-agent-style capability.** Just as you'd delegate a coding sub-task to a smaller model, you can have the *main* agent plan and the terminal (driven by a model) execute. Keeping the executing model small and the planning model big mirrors the [sub-agent split](/ai-docs/04-sub-agents/). ## Security: this is the sharp edge Giving a model shell access is the highest-risk part of a local AI stack. Guardrails that matter in practice: - **Authenticate with the API key** — never expose an unauthenticated terminal API, even on a home LAN. - **Contain the blast radius.** Run the terminal in its own container (as here) so a runaway command can't reach host services or other containers' data by default. The persisted `/home` is the intended workspace; keep host-critical paths out of the mount. - **Prefer a non-root user inside the container** for day-to-day sessions; reserve root for the specific step that needs it. - **Rotate the key** and store it outside version control. The compose file here kept the key inline — a practical choice for a single trusted box, but the safer default is a secrets file or manager that is git-ignored. - **Log and review.** Because the model can run arbitrary commands, keep an eye on what it's actually executing, especially when it's acting autonomously. ## How it relates to the other tools - **Open WebUI** is the chat front-end and the place where you wire models and tools together in the UI. - **Open WebUI Pipelines** is the plugin/orchestration server — where you build pipelines that chain models, tools, and agents (including calls *into* Open Terminal). - **Open Terminal** is the execution end of that pipeline: the tool that turns a decision into an action on a real system. A typical agentic flow: user asks the Open WebUI chat → the (primary) model decides it needs to run something → the pipeline invokes Open Terminal's API → a command runs in a session → output is returned → the model reasons over it and either answers or runs the next command. ## Related - [01 — Architecture](/ai-docs/01-architecture/) — the tiers and how containers reach the host - [Sub-agents](/ai-docs/04-sub-agents/) — delegating work, which is what a shell tool enables - [Skills](/ai-docs/06-skills/) — packaging repeatable procedures the model can follow through these tools --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 06-skills.md --- --- title: "06 — Skills" description: "## What a skill is" section: ai-docs raw: "06-skills.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 06 — Skills > Skills: packaged, reusable units of knowledge/procedure that an agent loads on demand to do a specific job well — and how to build and manage them. ## What a skill is A **skill** is a self-contained bundle that teaches an agent *how to do a particular kind of task*. Where a model's weights carry general knowledge and the system prompt carries standing instructions, a skill carries **domain-specific procedure plus reference material** for a narrow job. It's the difference between "the model knows about git" and "the model knows *this repo's* git conventions and will run `markdownlint` after editing." A skill typically combines: - **A trigger / description** — when the agent should reach for this skill (matched from the user's request). - **Procedure** — the steps, in order, to accomplish the task. - **Reference material** — file locations, templates, commands, conventions, gotchas. - **Boundaries** — what the skill is *not* for, and guardrails (e.g. "never commit secrets", "get confirmation before X"). ## Why skills exist (the context problem) An agent can't load everything it might ever need into context — that's both expensive and degrading (see [Context Sizing](/ai-docs/02-context-and-quantisation/)). Skills solve this with **on-demand, just-in-time knowledge**: the description list is cheap to keep resident, and the full skill body is loaded only when the task matches. This is the same isolation principle as [sub-agents](/ai-docs/04-sub-agents/), but applied to *knowledge* rather than *work*: - **Sub-agent** = delegate *execution* to a fresh context. - **Skill** = inject the right *knowledge* into the context for this task. They compose naturally: a sub-agent prompt that references the relevant skill gives the worker both a clean context *and* the domain procedure it needs. ## Anatomy of a good skill A skill that an agent actually uses well has a few properties: - **One skill per job.** "NANDA Docs", "deploy a llama server", "rotate a Postgres secret" — not a catch-all. A broad skill is one the agent can't reliably trigger. - **Self-contained.** Everything needed to do the job is in the skill: absolute paths, exact commands, template locations, the conventions. The agent shouldn't have to guess or ask. - **Prescriptive, not descriptive.** Give the exact command and the exact file, not "run the linter somewhere". - **Guardrails up front.** Safety constraints (redact secrets, confirm before committing, don't hard-wrap prose) belong in the skill so they apply every time the skill is used. - **Stable, small, and versionable.** Skills live in the agent's workspace (often as files in a known directory) and are reviewed like code. When a procedure changes, the skill changes. ## Skills vs the things they're confused with | Thing | What it is | Relation to a skill | | --- | --- | --- | | **System prompt** | Standing, always-loaded instructions | A skill is *loaded on demand*; the system prompt is resident. Put cross-cutting rules in the system prompt; task-specific procedure in a skill. | | **Tool** (e.g. Open Terminal) | A *capability* — something the agent can *do* | A skill is *knowledge* about how to use capabilities well. A skill frequently *references* tools (e.g. "run `markdownlint-cli2` via the terminal"). | | **Sub-agent** | A delegated *execution* in a fresh context | A skill can be handed to a sub-agent as its brief; the sub-agent provides the clean context, the skill provides the procedure. | | **MCP server** | A protocol for exposing tools/data | MCP is *how* tools are wired in; a skill is *what to do* with them. | The clean way to think about it: **tools are hands, skills are the manual, sub-agents are the fresh pair of hands.** ## How an agent uses a skill (the loop) ```text User request │ agent scans available skill *descriptions* (cheap, resident) ▼ Match? ──no──► proceed without a skill │yes ▼ Load the skill *body* into context (procedure + references + guardrails) │ ▼ Follow the procedure, calling tools (terminal, file ops, …) as needed │ may delegate a step to a sub-agent, passing the skill as its brief ▼ Done — result returned; skill body can be dropped from context ``` Because only the *description* list needs to be resident, you can have many skills without paying for their full bodies until one is actually used. ## Tips for building skills on a local stack - **Start from a real task.** Do the job once by hand, note every command, path, and decision, and codify that. A skill distilled from a real run is far more reliable than one written abstractly. - **Use absolute paths.** A skill that says "the repo is at `/home/u/…/NANDA` and the template is at `templates/doc.md`" removes an entire class of "which file?" failures. - **Keep guardrails in the skill, not in your head.** If you've ever had to remind the model "redact secrets before committing", that reminder belongs in the skill so it fires automatically. - **Version skills like code.** Put them in a git-backed directory, review changes, and keep them current when the underlying procedure changes — a stale skill is worse than no skill, because the agent trusts it. - **Match skill granularity to model.** A small worker model benefits most from a *very* prescriptive skill (exact commands, no judgement); a large model can work from a looser brief. Pair skill detail with the model that will execute it (see [Sub-agents](/ai-docs/04-sub-agents/)). ## Related - [Sub-agents](/ai-docs/04-sub-agents/) — skills as sub-agent briefs - [Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) — the tools skills direct - [Context & Quantisation](/ai-docs/02-context-and-quantisation/) — the context economics that motivate on-demand loading --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 07-dual-use-gaming.md --- --- title: "07 — Dual-Use: Inference Server ⇄ Gaming/Desktop" description: "## The problem" section: ai-docs raw: "07-dual-use-gaming.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 07 — Dual-Use: Inference Server ⇄ Gaming/Desktop > Turning a headless AI box into a desktop (or gaming machine) on demand — udev-driven auto-switching between serving models and using the GPUs interactively. ## The problem A local AI machine idles its GPUs serving models — which is great, except when *you* want to use the machine: to play a game, do some desktop work, or just look at it. Manually stopping the inference servers, starting the display manager, then reversing it later is a pain and easy to forget (a desktop compositor eating 1–2 GB of VRAM while the models are resident is a subtle way to get OOMs under load). The fix on this box: a **USB monitor switcher** (a small USB device you plug in when you want the desktop) drives a **udev rule** that toggles the machine's whole role automatically. Plug it in → headless inference stops and the desktop starts. Pull it out → the desktop stops and inference comes back. No `systemctl` juggling, nothing to remember. ## How it works Three small pieces: | Piece | What it is | | --- | --- | | `99-usb-switcher.rules` | udev rule in `/etc/udev/rules.d/` matching the switcher's USB vendor/product ID, on `add` and `remove` | | `usb-switcher-on.sh` | runs on plug-in: stops inference, starts the display manager, switches the monitor input | | `usb-switcher-off.sh` | runs on pull-out: the reverse | The udev rule identifies the device by fingerprint, so nothing else plugged into the box can trigger the switch: ```udev ACTION=="add", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", \ ATTRS{idVendor}=="05e3", ATTRS{idProduct}=="0610", \ RUN+="/usr/local/bin/usb-switcher-on.sh" ACTION=="remove", SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", \ ATTRS{idVendor}=="05e3", ATTRS{idProduct}=="0610", \ RUN+="/usr/local/bin/usb-switcher-off.sh" ``` Find your device's IDs with `lsusb` while it's plugged in. ### The on-script (headless → desktop) ```bash #!/bin/bash # Runs as root via udev — use absolute paths only LOG=/var/log/usb-switcher.log echo "[$(date '+%F %T')] switcher connected" >> "$LOG" /usr/bin/systemctl stop llama-primary.service 2>>"$LOG" /usr/bin/systemctl stop llama-secondary.service 2>>"$LOG" /usr/bin/systemctl start gdm.service 2>>"$LOG" # Switch the monitor to the DisplayPort/USB-C input /usr/bin/ddcutil setvcp 60 0x1b 2>>"$LOG" || true ``` The off-script mirrors this: stop `gdm`, start both llama units, `ddcutil setvcp 60 0x11` back to the other input. ## Tips & tricks learned from running it - **Ordering matters.** On plug-in, stop the inference servers *before* starting the display manager — free the GPUs first. On pull-out, stop the display manager *before* starting the servers — avoid a window where both want the GPUs. A GPU contention window here is the kind of thing that manifests as a flaky X session or a model failing to load. - **Inference services must be ordinary startable units.** This whole design works *because* the llama servers are plain systemd units anyone can start/stop, rather than pinned to `always-on`. The dual-use scripts start and stop them directly. - **udev runs in a minimal environment.** The scripts must use **absolute paths** (`/usr/bin/systemctl`, not `systemctl`) — a relative path that works in your shell will silently fail under udev. - **Use `|| true` for the cosmetic part.** `ddcutil setvcp` (which switches the monitor's active input over DDC/CI) can fail if the monitor or the DRM context isn't reachable; a failed input switch is logged but non-fatal, so the rest of the transition still happens. Verify your monitor's VCP input values with `ddcutil detect` — `0x1b`/`0x11` are device-specific. - **Log every transition.** Appending timestamped lines (plus stderr of each command) to `/var/log/usb-switcher.log` makes "the machine was in the wrong state" trivial to diagnose — you can see exactly which step ran and which failed. - **The default state should be the server.** `WantedBy=multi-user.target` + the display manager stopped at boot means a plain reboot returns the box to headless inference — the desktop is the exception you opt into with the plug. - **If you don't have a USB switcher**, the same two scripts work behind any trigger: a wall switch on a USB port, a `systemd` path unit on a file, or just a pair of shell aliases. The device fingerprint in the udev rule is what makes it hands-free. ## State model ```text plug in switcher (add) pull out switcher (remove) ┌────────────────────────────────────────┐ ┌────────────────────────────────────────┐ │ HEADLESS INFERENCING (default) │ │ DESKTOP / GAMING (interactive) │ │ • gdm STOPPED │ │ • gdm RUNNING │ │ • llama-primary RUNNING │ │ • llama-primary STOPPED │ │ • llama-secondary RUNNING │ │ • llama-secondary STOPPED │ └────────────────────────────────────────┘ └────────────────────────────────────────┘ ``` ## Dependencies to verify on your own box - The display manager unit (`gdm.service` here — could be `lightdm`, `sddm`, …) is what "desktop" means on your system. - `ddcutil` is installed and can reach the monitor over DDC/CI (usually needs a DRM/X context). - Your monitor's input-source VCP numbering — check with `ddcutil detect`. - The USB vendor/product ID of whatever device you use as the trigger. ## Related - [01 — Architecture](/ai-docs/01-architecture/) — why the inference servers are ordinary, individually-startable units - [03 — Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) — what gets stopped/started by the switch --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 08-build-instructions.md --- --- title: "08 — Build Instructions" description: "## How to build from scratch" section: ai-docs raw: "08-build-instructions.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # 08 — Build Instructions > How to build the system from scratch on a fresh Ubuntu install: native `llama.cpp` inference servers, a Docker application tier, and the initial Web UI wiring. ## How to build from scratch The build order, and what each piece produces: ```text 1. Ubuntu → a working base OS 2. PostgreSQL → host database for Open WebUI 3. Docker + folder layout → /etc/docker/compose, /var/local/docker-files 4. NVIDIA drivers → nvidia-smi shows both GPUs 5. llama.cpp → /usr/local/bin/llama-server 6. Model + config folders → /usr/local/llama/models, /etc/llama 7. systemd services → llama-primary (:8082), llama-secondary (:8083) 8. Docker containers → Open WebUI (:8081), Open Terminal (:8080), Pipelines (:9099) 9. Web UI configuration → connections, integrations, sub-agent, skills 10. (Bonus) Authentik OAuth → login via https://auth. ``` Steps 1–5 are standard installs; where upstream documentation exists, follow it. This document covers the parts specific to this stack: the folder layout, the systemd units, the model preset files, the compose files, and the Web UI wiring. > **Security note:** all API keys and secrets are redacted (``) throughout. Generate your own. ## 1 — Install Ubuntu Install Ubuntu Server (minimal) and SSH access per the official [Ubuntu installation guide](https://ubuntu.com/tutorials/install-ubuntu-server). No stack-specific configuration is needed at this stage. ## 2 — PostgreSQL PostgreSQL is not required, but recommended for storing application and user data. ### 2.1 — Install PostgreSQL Install per the [official PostgreSQL installation guide](https://www.postgresql.org/download/linux/ubuntu/): ```bash sudo apt install postgresql ``` ### 2.2 — Enable Password Authentication Configure `/etc/postgresql//main/pg_hba.conf` for local authentication — ensure this line is present (uncomment or add): ```text # TYPE DATABASE USER ADDRESS METHOD # "local" is for Unix domain socket connections only local all all scram-sha-256 # IPv4 local connections: host all all 127.0.0.1/32 scram-sha-256 # IPv6 local connections: host all all ::1/128 scram-sha-256 ``` ```bash sudo systemctl reload postgresql ``` ### 2.3 — Create the openwebui user and database On Debian and Ubuntu, the quickest way to admin PostgreSQL is via the postgres system account: ```bash sudo -u postgres psql ``` Once connected, create the user with: ```PostgreSQL CREATE USER openwebui WITH NOCREATEDB PASSWORD ''; ``` With the user created, you can now create a database owned by that user: ```PostgreSQL CREATE DATABASE openwebui WITH OWNER openwebui; ``` You will need these credentials later when configuring the OpenWebUI docker container. ## 3 — Install Docker and create the folder layout Install Docker Engine and the Compose plugin per the official [Docker install guide](https://docs.docker.com/engine/install/ubuntu/). Register the NVIDIA runtime so containers can request GPUs (add to `/etc/docker/daemon.json`): ```json { "runtimes": { "nvidia": { "args": [], "path": "nvidia-container-runtime" } } } ``` Create the two roots used throughout the build: - `/etc/docker/compose` — one subdirectory per deployment, each holding its own `docker-compose.yaml` (+ `.env`). - `/var/local/docker-files` — persisted application data, one subdirectory per container. `/var/local` follows the FHS convention for variable data belonging to applications installed under `/usr/local`[^fhs]. [^fhs]: FHS — [Variable Hierarchy](https://specifications.freedesktop.org/fhs/latest/varHierarchy.html) ```bash sudo mkdir -p /etc/docker/compose/open-webui sudo mkdir -p /var/local/docker-files/{open-webui,open-terminal,open-webui-pipelines} ``` ## 4 — Install NVIDIA drivers and llama.cpp While Ubuntu ships packaged NVIDIA drivers (`apt install nvidia-driver` installs the currently supported version), upstream drivers will provide new features. ### 4.1 — Install NVIDIA Upstream Drivers Install the proprietary NVIDIA driver and toolkit per the [NVIDIA Driver Installation Guide — Ubuntu](https://docs.nvidia.com/datacenter/tesla/driver-installation-guide/latest/ubuntu.html). Choose the Network Repository Enablement (amd64) instructions. The `` is `ubuntu2604`. Verify with `nvidia-smi -L` — you should see one line per GPU (GPU 0 = the big card, GPU 1 = the small card). ## 5 — Install llama.cpp llama.cpp can be installed as pre-built packages, or compiled from source. If you intend to use asymmetric quantisation of the KV cache, currently you will need to build from source. ### 5.1 — Upstream llama.cpp packages Install `llama.cpp` from the [official release builds](https://github.com/ggml-org/llama.cpp/releases) — download the CUDA-enabled `llama-*.tar.gz` asset and place the binaries in `/usr/local/bin`: ```bash sudo tar -xzf llama-*.tar.gz -C /usr/local/bin ``` > ⚠️ The release asset names and folder layout change between releases; check the current release page. ### 5.2 — Build from source Install the prerequisites: ```bash sudo apt update sudo apt install -y git build-essential cmake libcurl4-openssl-dev ``` Clone the repository: ```bash git clone https://github.com/ggml-org/llama.cpp.git cd llama.cpp ``` Build: ```bash cmake -B build -DGGML_CUDA=ON -DGGML_CUDA_FA_ALL_QUANTS=ON -DGGML_NATIVE=OFF cmake --build build --config Release -j$(nproc) ``` Install: ```bash sudo cmake --install build --prefix /usr/local ``` ### 5.3 — Verify your installation Verify: ```bash /usr/local/bin/llama-server --version ``` ## 6 — Create model and configuration folders ```bash sudo mkdir -p /usr/local/llama/models /usr/local/llama/templates /etc/llama ``` - `/usr/local/llama/models` — home for GGUF model files. - `/usr/local/llama/templates` — home for custom chat templates (`.jinja`) referenced by the presets. - `/etc/llama` — home for the model preset files (`.ini`) that the systemd units point at. Download models from [Ollama](https://ollama.com/library) or [Hugging Face](https://huggingface.co/models?search=gguf) and place the `.gguf` files under `/usr/local/llama/models`. The current deployment is a pure llama.cpp install (no Ollama): every preset `model =` line points at a named `.gguf` file in that directory, and custom chat templates live in `/usr/local/llama/templates/`. ## 7 — Create the systemd services for the inference servers Two units, one per GPU — see [Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) for the role split. The model presets live in `/etc/llama/`; sizing guidance is in [Context & Quantisation](/ai-docs/02-context-and-quantisation/). `/etc/llama/models-primary.ini` (big card — the "brain"; two models, large context): ```ini [version] version = 1 [*] # Global fallbacks for all models sleep-idle-seconds = 3600 load-on-startup = false flash-attn = true batch-size = 1024 ubatch-size = 256 threads = 6 threads-batch = 6 gpu-layers = 99 [gemma4-12b] model = /usr/local/llama/models/Gemma4-12B-Q4_K_M.gguf ctx-size = 262144 context-shift = true keep = 1024 cache-type-k = q8_0 cache-type-v = q4_0 [qwen3.8-27b] model = /usr/local/llama/models/Qwen3.8-27B-UD-Q4_K_M.gguf ctx-size = 185000 context-shift = true keep = 1024 cache-type-k = q8_0 cache-type-v = q4_0 jinja = true chat-template-file = /usr/local/llama/templates/froggeric_chat_template.jinja ``` `/etc/llama/models-secondary.ini` (small card — resident embedding model + sub-agent worker): ```ini [version] version = 1 [*] flash-attn = true threads = 6 threads-batch = 6 gpu-layers = 99 chat-template-kwargs = {"enable_thinking": false} [bge-m3] model = /usr/local/llama/models/BGE-M3.gguf load-on-startup = true pooling = cls ctx-size = 2048 cache-type-k = f16 cache-type-v = f16 batch-size = 1024 ubatch-size = 1024 [qwen3.5-4b] model = /usr/local/llama/models/Qwen3.5-4B-Q6_K.gguf load-on-startup = true parallel = 2 ctx-size = 8192 kv-unified = true cache-type-k = q8_0 cache-type-v = q4_0 batch-size = 2048 ubatch-size = 512 ``` `/etc/systemd/system/llama-primary.service`: ```ini [Unit] Description=Llama.cpp Primary Server [Service] Type=simple User=ollama Group=ollama WorkingDirectory=/usr/local/bin Environment=CUDA_VISIBLE_DEVICES=0 ExecStart=/usr/local/bin/llama-server \ --models-preset /etc/llama/models-primary.ini \ --models-max 1 \ --no-mmproj \ --metrics \ --host :: \ --port 8082 Restart=on-failure RestartSec=5 StandardOutput=append:/var/log/llama_primary.log StandardError=append:/var/log/llama_primary_error.log [Install] WantedBy=multi-user.target ``` `/etc/systemd/system/llama-secondary.service` — same shape, with these differences: ```ini [Unit] Description=Llama.cpp Secondary Server (preset: bge-m3 embeddings, qwen2.5:0.5B, qwen2.5:7B-instruct, qwen3.5:9B) After=network.target [Service] User=ollama Group=ollama WorkingDirectory=/usr/local/bin Environment=CUDA_VISIBLE_DEVICES=1 ExecStart=/usr/local/bin/llama-server \ --models-preset /etc/llama/models-secondary.ini \ --no-mmproj \ --embeddings \ --metrics \ --host :: \ --port 8083 Restart=always RestartSec=5 StandardOutput=append:/var/log/llama_secondary.log StandardError=append:/var/log/llama_secondary_error.log ``` Notes: - `User=ollama` — create a dedicated service account first (`sudo useradd -r -s /usr/sbin/nologin ollama`); the units run unprivileged but still need read access to the model files. - The `--embeddings` flag on the secondary enables the `/v1/embeddings` endpoint for the memory service. - The `--models-preset` file format is documented in the llama.cpp [models preset documentation](https://github.com/ggml-org/llama.cpp/blob/master/docs/model-preset.md). Enable and verify: ```bash sudo systemctl daemon-reload sudo systemctl enable --now llama-primary llama-secondary curl http://localhost:8082/v1/models curl http://localhost:8083/v1/models ``` ## 8 — Deploy the Docker containers Create the compose directories from step 3 and bring up the containers **without** OAuth configured (that is step 10). All application containers share the `ai-shared-net` network and map `host.docker.internal` to the Docker host so they can reach the native llama servers. `/etc/docker/compose/open-webui/docker-compose.yaml` (three services; without OAuth the `.env` is minimal — just `WEBUI_AUTH=true` and a `DATABASE_URL`, see step 9 for the full version): ```yaml services: open-webui: image: ghcr.io/open-webui/open-webui:main container_name: open-webui ports: - "8081:8080" env_file: - .env volumes: - /var/local/docker-files/open-webui:/app/backend/data - /run/postgresql:/postgresql networks: - ai-network extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped open-terminal: image: ghcr.io/open-webui/open-terminal container_name: open-terminal ports: - "8080:8080" volumes: - /var/local/docker-files/open-terminal:/home environment: - OPEN_TERMINAL_API_KEY= - OPEN_TERMINAL_MULTI_USER=true networks: - ai-network extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped pipelines: image: ghcr.io/open-webui/pipelines:main container_name: open-webui-pipelines ports: - "9099:9099" environment: - PIPELINES_API_KEY= volumes: - /var/local/docker-files/open-webui-pipelines:/app/pipelines networks: - ai-network extra_hosts: - "host.docker.internal:host-gateway" restart: unless-stopped networks: ai-network: name: ai-shared-net ``` Deploy: ```bash docker network create ai-shared-net # once; the first compose file also creates it cd /etc/docker/compose/open-webui && docker compose up -d ``` ## 9 — Initial Web UI configuration Open WebUI at `http://:8081`. On first start it creates an admin account — note it; you'll be the first user. ### 9.1 — Configure model connections Admin Panel → Connections → Add Connection (OpenAI API style). The base URLs are entered in the UI and stored in Open WebUI's data volume, so they survive image upgrades but not a wiped volume — keep the volume backed up (see [01 — Architecture](/ai-docs/01-architecture/)). | Name | Base URL | Model(s) | Role | | --- | --- | --- | --- | | `llama-primary` | `http://host.docker.internal:8082/v1` | brain models (12B / 27B) | main chat, sub-agent planning | | `llama-secondary` | `http://host.docker.internal:8083/v1` | worker (4B), `bge-m3` | sub-agent worker, embeddings | > ⚠️ The exact UI labels for a raw OpenAI-compatible endpoint vs the "OpenAI API" connection type vary between Open WebUI releases; if a plain endpoint isn't offered, use the "OpenAI API" connection type with the base URLs above and no API key. ### 9.2 — Configure Integrations Add `Local-Terminal` as an integration (Admin Panel → Integrations → Add), pointing at the Open Terminal service from step 8: | Name | Base URL | Notes | | --- | --- | --- | | `Local-Terminal` | `http://host.docker.internal:8080` | Open Terminal tool; API key from step 8 | See [Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) for the security notes that apply to this integration. ### 9.3 — Configure the sub-agent Sub-agents are provided by the community **Sub Agent tool** — import the tool code from the [Sub Agent Tool post](https://openwebui.com/posts/sub_agent_7bfeb0b7) into the Admin Panel (Tools → Add Tool → paste the code) and enable it. It runs tool-heavy tasks in an isolated context and returns only the final result; the model it delegates to is the worker on the `llama-secondary` connection (see [Sub-agents](/ai-docs/04-sub-agents/) for the pattern). Requirement: the model must have native **Function Calling** enabled (Model settings → Advanced Params → Function Calling: native). ### 9.4 — Configure skills Skills are files in the agent's workspace, not Web UI settings — see [Skills](/ai-docs/06-skills/) for the concept and [05 — Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) for the terminal they direct. In practice: create a known directory (persisted, e.g. under `/var/local/docker-files/open-terminal/` so it survives container rebuilds), drop skill files in it, and reference that path in the system prompt or the sub-agent briefs. > ⚠️ The skill storage location and how the deployed agent discovers skills is deployment-specific — confirm the path the agent actually reads. ## 10 — Bonus: configure OAuth for Authentik Once the UI works with a local account, switch it to [Authentik](https://docs.authentik.io/) via OpenID Connect, per the [Open WebUI OAuth2/OIDC documentation](https://docs.open-webui.com/docs/oauth). **Create the application.** In Authentik, create an **Application** (Provider: OpenID, Protocol: OIDC) for Open WebUI and note the client ID/secret. **Replace the minimal `.env`** from step 8: ```bash OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= OAUTH_PROVIDER_NAME=Auth. OAUTH_AUTO_REDIRECT=true OPENID_PROVIDER_URL=https://auth./application/o/open-web-ui/.well-known/openid-configuration OPENID_REDIRECT_URI=https://ai./oauth/oidc/callback # Core auth: OAuth only, no local sign-up WEBUI_AUTH=true ENABLE_LOGIN_FORM=false ENABLE_SIGNUP=false ENABLE_OAUTH_SIGNUP=true # General WEBUI_URL=https://ai. CORS_ALLOW_ORIGIN=https://ai.;http://:8081 DATABASE_URL=postgresql://openwebui:@/openwebui?host=/postgresql ``` **Restart:** `docker compose up -d`. > ⚠️ This assumes a reverse proxy (Caddy/Traefik/nginx) terminating TLS at `ai.` and forwarding to `:8081`, with the container able to reach the Authentik server. The redirect URI, `WEBUI_URL` and `CORS_ALLOW_ORIGIN` must all match the proxy hostname. ## Uncertainties Areas above that are flagged with ⚠️, summarised: - **llama.cpp release naming** — asset names change between releases. - **Open WebUI connection UI** — exact connection type/labels depend on the deployed version. - **Skill discovery path** — deployment-specific. ## Related - [01 — Architecture](/ai-docs/01-architecture/) — what is being built and why - [03 — Primary vs Secondary](/ai-docs/03-primary-vs-secondary/) — the inference server split - [05 — Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) — the terminal container and its security notes - [06 — Skills](/ai-docs/06-skills/) — step 9.4 --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [x] Human Edited --- FILE: README.md --- --- title: "AI Machine — Concepts & Tips" description: "This directory documents a self-hosted AI inference and application stack: native `llama.cpp` inference servers on the GPUs, fronted by a Docker application tier (Open WebUI, Open Terminal, Pipelines)…" section: ai-docs raw: "README.md" source: ai-generated tags: ai, llama.cpp, docker last-updated: 2026-09-15 --- # AI Machine — Concepts & Tips > Concept-based documentation for building and improving a self-hosted local AI stack. Written for a third party setting up their own instance — the patterns, the trade-offs, and the pitfalls — not a line-by-line description of one specific deployment. This directory documents a self-hosted AI inference and application stack: native `llama.cpp` inference servers on the GPUs, fronted by a Docker application tier (Open WebUI, Open Terminal, Pipelines). The source configuration files it was derived from live in the `Hex-etc` directory (`docker/compose/`, `systemd/system/`, `llama/models-*.ini`) and are intentionally **not** in this repo. > **Security note:** all API keys and secrets are **redacted** (``) > throughout. Do not re-introduce real credentials into the repository. ## What you'll learn here The docs are organised around the decisions that actually shape a local AI setup, not around the files that describe it. Each page leads with the concept, then the tips/tricks and pitfalls learned from running the system. ## Document index | Document | Concepts & tips covered | | --- | --- | | [01 — Architecture](/ai-docs/01-architecture/) | The two-tier pattern (native inference / Docker apps), one server per GPU, `host.docker.internal` wiring, where data lives, sizing the machine. | | [02 — Context Sizing & Quantisation](/ai-docs/02-context-and-quantisation/) | The VRAM budget equation, weight quantisation choices, context as a budget line, resident vs swap-in loading, and the pitfalls (parallel slot division, embedding batch caps, thinking models, mangled model names). | | [03 — Primary vs Secondary `llama-server`](/ai-docs/03-primary-vs-secondary/) | The big-brain / small-worker split, swap-in vs eager-resident design, the `--embeddings` flag, and how to choose the split for your own cards. | | [04 — Sub-agent Use](/ai-docs/04-sub-agents/) | The spec→code pattern, context isolation, self-contained sub-task prompts, matching models to roles, parallel delegation. | | [05 — Open Terminal & Tooling](/ai-docs/05-open-terminal-and-tooling/) | Giving the model a shell, the API-key/multi-user setup, persistence, and the security guardrails that matter. | | [06 — Skills](/ai-docs/06-skills/) | Packaged on-demand knowledge, anatomy of a good skill, skills vs tools vs sub-agents vs MCP, and tips for building them. | | [07 — Dual-Use: Inference ⇄ Gaming/Desktop](/ai-docs/07-dual-use-gaming/) | Auto-switching a headless AI box into a desktop/gaming machine (and back) with a udev rule + two scripts; ordering, logging, and DDC/CI tips. | | [08 — Build Instructions](/ai-docs/08-build-instructions/) | How to build from scratch: Ubuntu → Docker → llama.cpp → systemd services → containers → Web UI wiring, with example configs and scripts. | ## Quick orientation ```text ┌────────────────────────────────────────────┐ Browser / clients │ APPLICATION TIER (Docker, ai-shared-net) │ ──────────────────► │ Open WebUI (8081) · Open Terminal (8080) │ │ Pipelines (9099) │ └───────────────┬────────────────────────────┘ │ host.docker.internal (host-gateway) ┌───────────────▼────────────────────────────┐ GPU 0 (RTX 3090) │ INFERENCE TIER (native, systemd) │ ──────────────────► │ llama-primary :8082 — the big "brain" │ │ llama-secondary:8083 — worker + embeddings│ └────────────────────────────────────────────┘ Sub-agents: brain model ──delegates──► worker model (fresh context) Tools: any tier ──API call──► Open Terminal ──► real shell Skills: packaged procedure + reference, loaded on demand ``` ## Conventions - Plain markdown only; diagrams in ascii-art or mermaid.js; prose is not hard-wrapped. - New documents start from [templates/doc.md](/raw/_shared/templates/doc.md) and end with the Source Disclaimer block. - Config files are referenced by name, never quoted with secrets; anything credential-like is ``. --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [ ] Human Edited ============================================================ SECTION: Home Assistant ============================================================ --- FILE: 01-deployment.md --- --- title: "Deployment" description: "## Design goals" section: home-assistant raw: "01-deployment.md" source: ai-generated tags: home-assistant, home-automation last-updated: 2026-09-15 --- # Deployment > How this installation is deployed and why — the design decisions that matter when you are standing up (or improving) your own Home Assistant instance. ## Design goals - **Docker, not the Supervisor image.** A plain container means the whole installation is described by a compose file plus a config directory. No Supervisor snapshot system to depend on, and the config directory can simply be a git checkout — which makes *configuration itself* version-controlled and diffable. - **Config in git.** The config directory on the host is a git clone of a dedicated `homeassistant-config` repository. Every change is a commit, and — when running a cluster — every *instance* pulls the same repository, which is what makes active/standby failover possible (see [High Availability](/home-assistant/02-high-availability/)). - **PostgreSQL as the recorder backend.** The default SQLite file is fine for a small setup, but once you have many entities and several days of history a real database is noticeably faster. Because the database is co-located on the same host, it is reached over a **unix socket, not TCP**. - **Each instance reachable independently.** Every server runs its own nginx reverse proxy with a *dynamic subdomain* setup: one TLS server block catches any subdomain of the domain and maps the subdomain to a backend port. Each Home Assistant instance therefore gets its own subdomain and its own unique `external_url`, so the two instances can be told apart from the outside even while the watchdog decides which one is "the" Home Assistant. ## Postgres over a unix socket The host's `/var/run/postgresql` directory is bind-mounted into the container (as `/postgresql`), and the recorder DSN points at the socket (`postgresql://user:pass@/hass?host=/postgresql...`). Why a socket when HA and the database live on the same machine: - **Efficient** — no TCP/IP stack: no port binding, no packet framing, no loopback overhead, no Nagle/delayed-ACK behaviour. Lower latency and less CPU per query, which matters for the recorder's frequent small writes. - **Simpler to wire up** — no port to expose, no firewall rules, no `listen_addresses`/`pg_hba.conf` tuning for a TCP endpoint, and nothing to collide with on the host. - **Tight security by default** — the socket lives on the filesystem, so access is governed by file permissions rather than being exposed to the network. - **No DNS/host resolution** — the client connects to a path, avoiding any hostname or IP drift between the container and the database. The one constraint: the host's PostgreSQL `unix_socket_directories` and the container's auth settings must line up — the standard, well-trodden local-Postgres setup. ## Reverse proxy (nginx) The pattern in use is a **dynamic subdomain reverse proxy**: one `map` block converts the captured subdomain into a backend port, a single HTTP server redirects everything to HTTPS, and one master HTTPS server proxies to `127.0.0.1:$backend_port` with WebSocket headers (needed for HA, ESPHome, etc.). Each subdomain also gets its own access log, which makes per-service diagnostics trivial. ```nginx map $dynamic_subdomain $backend_port { esphome 6052; grafana 3000; hass 8123; # one subdomain per HA instance ha2 8123; # same port — different subdomain, same local service # add new ones here as you spin them up } server { listen 80; listen [::]:80; server_name ~^(?.+)\.$; return 301 https://$host$request_uri; } server { listen 443 ssl; listen [::]:443 ssl; server_name ~^(?.+)\.$; http2 on; access_log /var/log/nginx/$dynamic_subdomain-access.log; include snippets/ssl-letsencrypt.conf; location / { proxy_pass http://127.0.0.1:$backend_port; include proxy_params; # centralised Host / X-Forwarded-* headers proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_buffering off; } } ``` Notes: - HA's HTTP settings (trusted proxies, forwarded headers) are configured in the UI and rely on the `proxy_params` headers above. - The proxy must run on the same machine as the service (backends are addressed at `127.0.0.1`). - TLS is via Let's Encrypt (`snippets/ssl-letsencrypt.conf`). ## Secrets Everything that varies per instance or that must not be committed lives in a local `secrets.yaml` (git-ignored) and is referenced from the shared config with `!secret `. In this installation the per-instance secrets are the instance `name`, `internal_url`, `external_url`, the PostgreSQL DSN, and `heartbeat_url` (the *partner* instance's webhook URL — see [High Availability](/home-assistant/02-high-availability/)). > Never commit `secrets.yaml`, `service_account.json`, or SSH keys. Keep them > in `.gitignore`. ## docker compose The complete compose file (each instance in the cluster runs the same file on its own server — only the config directory's local `secrets.yaml` and the Postgres backend differ): ```yaml services: homeassistant: container_name: home-assistant image: ghcr.io/home-assistant/home-assistant:stable volumes: - /var/local/docker-files/home-assistant:/config - /Media:/Media - /var/run/postgresql:/postgresql - /run/dbus:/run/dbus:ro environment: - TZ=Australia/Perth restart: always # ports: # - 8123:8123 network_mode: host privileged: true ``` Notes: - `network_mode: host` — no published ports (the commented-out `8123:8123` is unnecessary); the container uses the host's network stack directly, which is also how the per-instance nginx proxy and the cluster webhook pats reach it. - `/var/local/docker-files/home-assistant` is the git checkout of the `homeassistant-config` repository — the config directory *is* the version control system for the configuration. - `/var/run/postgresql:/postgresql` — the Postgres unix socket mount (see [Postgres over a unix socket](#postgres-over-a-unix-socket)). - `/run/dbus` (read-only) — host D-Bus access, e.g. for UPnP/mDNS features. - `/Media:/Media` — media directory mount. - `privileged: true` — worth revisiting if it can be narrowed to specific device/capability grants. --- ## Related - [High Availability](/home-assistant/02-high-availability/) - [Light brightness as state](/home-assistant/03-light-brightness-state/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [ ] Human Edited --- FILE: 02-high-availability.md --- --- title: "High Availability (Active/Standby)" description: "## The idea" section: home-assistant raw: "02-high-availability.md" source: ai-generated tags: home-assistant, home-automation last-updated: 2026-09-15 --- # High Availability (Active/Standby) > How two Home Assistant instances are run in an active/standby pair that fails over automatically — and the patterns that make two machines running identical config safe. ## The idea Run two instances on the same LAN. The **active** instance serves the dashboard, voice control, and automations; the **standby** waits, watching for the active one to die. Failover happens automatically through two small automations. When the active instance crashes (power loss, crash, reboot), the standby notices within about a minute and promotes itself. When the old instance recovers, it demotes itself — failback is automatic and symmetric, with no manual "switch back" step and no split-brain. ```text ┌──────────────────────────┐ LAN, once per minute ┌──────────────────────────┐ │Instance A (active) │─────────────────────────►│Instance B (standby) │ │ │HTTP GET while A is active│ │ │input_boolean.active=ON │ (rest_command.heartbeat) │input_boolean.active=ON │ │heartbeat timer │ │watchdog (restart mode) │ └──────────────────────────┘ └──────────────────────────┘ A dies? → pats stop → B's watchdog fires → B becomes active B dies? → A's webhook calls fail harmlessly → nothing changes ``` Key state: | Entity | Meaning | |--------|---------| | `input_boolean.active` | "I am the active instance". Each instance only ever sees *its own* boolean — it is not shared state. | | `rest_command.heartbeat` | GET to the **partner's** webhook URL (kept in secrets). | | Webhook | `local_only`, GET only — LAN traffic only. | > The two instances do **not** share state: no database replication, no MQTT > bus. Consistency comes entirely from the watchdog invariant — *only an > instance that is not being patted may be active*. ## The two automations ### 1. Heartbeat timer — "pat the standby's watchdog" While this instance is active (`input_boolean.active` is `on`), a `time_pattern` trigger fires at the top of every minute and a state trigger on `input_boolean.active` makes it fire **immediately** when the instance becomes active after a restart. The action fires `rest_command.heartbeat` — a GET at the partner's webhook. ### 2. Watchdog — the clever part Mode is `restart`. Triggers are (a) a webhook hit from the partner, or (b) `homeassistant.start`. The action is: 1. `input_boolean.active` → **off** 2. wait **65 s** 3. `input_boolean.active` → **on** Because the mode is `restart`, every incoming heartbeat **re-arms the whole sequence from the top**: the boolean goes off, the delay restarts, and the boolean never gets to come back on. The 65 s delay is deliberately just longer than the 60 s pat interval: - **Partner alive** → a pat arrives every 60 s → the delay is re-armed before it can complete → the instance sits in standby. The `homeassistant.start` trigger arms the watchdog immediately at boot, so a freshly started instance *defaults to standby* and only promotes itself if the pats never arrive. - **Partner dead** → the next pat never arrives → the last-armed 65 s delay runs to completion → `input_boolean.active` turns `on` → this instance is active. Its heartbeat timer starts patting the dead partner, which fails harmlessly. **Failover timing:** because the delay is armed by the last successful pat, failover completes at most about 65 s after the last pat — worst case roughly a minute and five seconds after the failure, best case a few seconds. ## The critical pattern: gate automations on `input_boolean.active` Because both instances run the *same* automation files, any automation that must fire on **one machine only** carries a condition on `input_boolean.active` being `on`. On the standby the boolean is `off` (the watchdog keeps it that way), so the condition fails and the automation never runs — no double-fired side effects, no split-brain. ```yaml condition: - condition: state entity_id: input_boolean.active state: 'on' ``` Not every automation needs the gate. The rule of thumb: - **Idempotent / hardware-tolerated actions** — skip the gate and let both instances run in parallel. Opening roller shutters at a set time sends the same command twice and the hardware ends up in the same place; turning a battery charger off at a set time is harmless to repeat. - **Actions with global side effects** — SMS, TTS announcements, notifications, arming the alarm — *must* carry the gate, or every family member gets two texts. ## Shared config, per-instance secrets Both instances pull from the **same git repository**. Anything that must differ between the machines lives in each instance's local, git-ignored `secrets.yaml` and is referenced with `!secret ` — the secret acts as a per-instance variable inside the shared YAML: | Secret | What varies per instance | |--------|--------------------------| | `name` | Instance name | | `internal_url` / `external_url` | This instance's URLs | | `heartbeat_url` | The **partner's** webhook URL | Workflow: edit the shared YAML, commit, pull on both instances. Each instance is also independently reachable — its own subdomain and its own nginx reverse proxy on the same server (see [Deployment](/home-assistant/01-deployment/)). ## Gotchas worth knowing before you build this - **Network reachability is a single point of failure.** A firewall or VLAN change between the two hosts looks identical to a dead partner — the standby will promote itself. Test after any router change. - **The webhook must stay `local_only`-able.** Pats only work on the LAN; two instances on different subnets/VLANs need this revisited. - **External assistants connect to one instance only.** A Google Assistant / HomeKit project can only meaningfully talk to *one* instance. With both instances exposing the same entities, the standby effectively has to be disconnected from the assistant (or you accept that requests may land on the wrong instance). Worth designing deliberately. - **Single-connection hardware needs a device per instance.** Bluetooth bridges and similar can only be paired with one HA instance at a time — each instance needs its own dedicated bridge, and BLE devices only reach whichever instance owns the bridge. - **Add failover alerting.** A useful extra automation: notify (SMS) when `input_boolean.active` flips to `on` — a promotion almost always means the other instance is down. ## Alternatives considered - **MQTT heartbeat.** The original design published the local IP to an MQTT topic once per minute and had each instance watch the broker. It worked, but added a third moving part (the broker) that had to be up for the cluster to function. The webhook design removed the broker entirely: the "bus" is just the partner's own API, and the partner *is* the health check. - **Keepalived / VIP failover.** Works, but moves the problem to the network layer and doesn't help with automations and integrations that are inherently per-instance (Google Home, BLE bridges, local webhooks). The in-HA watchdog keeps everything visible and debuggable inside the dashboard. --- ## Related - [Deployment](/home-assistant/01-deployment/) - [Automating with Google Home & HomeKit](/home-assistant/04-assistant-automations/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 03-light-brightness-state.md --- --- title: "Light brightness as state" description: "## The problem" section: home-assistant raw: "03-light-brightness-state.md" source: ai-generated tags: home-assistant, home-automation last-updated: 2026-09-15 --- # Light brightness as state > A trick for giving a light "memory" of *how* it was turned on, by encoding a flag in the brightness value. ## The problem A hallway or passage light usually has two trigger sources: - A **motion sensor (PIR)** — the light should come on, but if nobody is staying, it should turn itself off after a fixed delay. - A **physical button** (e.g. an RF wall switch) — the light should stay on until someone deliberately turns it off. The delayed auto-off has to distinguish "the light I switched on 15 minutes ago, nobody has touched it since" from "the light someone deliberately left on with the button". Lights only have `on`/`off` plus brightness — so the brightest, least visible place to stash a flag is **the brightness itself**. ## The 252 / 255 contract | Trigger | Brightness set | Later behaviour | |---------|----------------|-----------------| | PIR / motion | **252** (≈ 99 %) | Auto-turns off after a fixed delay | | Physical button | **255** (100 %) | Stays on until switched off | 252 and 255 are imperceptibly different to the eye, but the automation can read the difference back with `state_attr(light.x, 'brightness')`: 1. **PIR automation** turns the light on at brightness 252, then waits the fixed delay. At the end of the delay it turns the light off **only if the brightness is still < 255** — i.e. it is still the light it switched on and nobody has since pressed the button. 2. **Button automation** toggles using the same flag in reverse: a light at 252 counts as "on" for the user, so the button press turns it *off* and clears the flag. A light at 255 (button-turned-on) or fully off toggles in the normal way, setting 255 when it turns on. The contract is: - brightness **252** = "on by motion, may be auto-offed" - brightness **255** = "on by a human, leave alone" - anything **below** = normal dimming, no flag meaning > When editing these automations, keep the threshold intact. The on-automation, > the delayed-off, and the matching button automation all depend on the exact > same value — change one without the others and the light either never > auto-offs or auto-offs while someone is in the room. ## Sketch ```yaml # PIR: on at 252, then delayed conditional off trigger: - trigger: state entity_id: binary_sensor.passage_pir from: 'off' to: 'on' action: - action: light.turn_on target: entity_id: light.passage_lights data: brightness: 252 - delay: "15:00" - if: - condition: template value_template: >- {{ state_attr('light.passage_lights', 'brightness') is not none and state_attr('light.passage_lights', 'brightness') < 255 }} then: - action: light.turn_off target: entity_id: light.passage_lights ``` ## Why not a helper? You *could* add an `input_boolean` or template sensor per light to track the source. That works, but it doubles the entities, adds a second thing to keep in sync with the light, and fails quietly if the helper and the light drift apart (a scene or an assistant voice command can change the light without touching the helper). Storing the flag in the light's own state means the source of truth is always the light itself — whatever turns it on, the flag travels with it. The same trick generalises: any light attribute (brightness, colour temperature, hue) can carry a small amount of state that other automations read back, as long as you document the contract. --- ## Related - [Automating with Google Home & HomeKit](/home-assistant/04-assistant-automations/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 04-assistant-automations.md --- --- title: "Automating with Google Home & HomeKit" description: "## The core limitation" section: home-assistant raw: "04-assistant-automations.md" source: ai-generated tags: home-assistant, home-automation last-updated: 2026-09-15 --- # Automating with Google Home & HomeKit > Patterns for making Home Assistant behave well when lights, switches and media are controlled from Google Home or HomeKit — platforms that can only *turn things on and off*, not run your scripts. ## The core limitation Google Home and HomeKit talk to HA through the **Google Assistant / HomeKit integrations**. They can: - turn an entity on/off, and (for lights) set brightness / colour - run *exposed* scripts and scenes - query state They **cannot** run automations, and they won't run a script you forgot to expose. So any "polish" behaviour — dimming after dark, turning a light off later, logging the event — has to live in an automation that reacts to the *state change* the assistant causes, not in the assistant itself. The general recipe: 1. Expose only the entities and scripts each platform should control (exposure is off by default — list them explicitly). 2. Write automations with **`light.turned_on` / `switch.turned_on` triggers** targeting those entities. The trigger fires identically whether the change came from the HA dashboard, a voice command, or a HomeKit switch on the wall — so one automation serves all sources. 3. Add time-of-day / state conditions to shape the behaviour. ## Example: `Light:On:Dim` The best illustration of the pattern. Bedside lamps exposed to the assistants are sometimes switched on at full brightness late at night (a voice command or a HomeKit switch). This automation re-dims them to a safe level: ```yaml - id: '202607080107' description: '' triggers: - trigger: light.turned_on target: entity_id: - light.light_bedside_1 - light.light_bedside_2 - light.light_masterbedroom_1 options: behavior: each conditions: - condition: state entity_id: input_boolean.active state: ['on'] - condition: time after: '21:00' before: '07:30:00' - condition: template value_template: >- {{ is_state_attr(trigger.entity_id, 'brightness', none) == false and state_attr(trigger.entity_id, 'brightness') | int(0) > 15 }} actions: - sequence: - action: light.turn_on target: entity_id: "{{ trigger.entity_id }}" data: brightness_pct: 20 alias: Light:On:Dim mode: parallel max: 10 ``` Design points worth copying: - **`light.turned_on` + `behavior: each`** — fires once per light, so a voice command or scene that hits several lights at once works without any of the runs blocking each other (hence `mode: parallel`). - **Brightness guard (`> 15`)** — leaves alone lights that were switched on at (or near) zero brightness, e.g. lights ramping up on their own. This is what keeps the automation from fighting devices that manage their own dimming. - **The visible ramp** — re-setting the light to 20 % brightness ramps it down rather than snapping, so it *looks* as if the light came on at the right level. - **Time-of-day condition** — the same light at 8 am is left alone; only the 21:00–07:30 window gets the treatment. - **`input_boolean.active` condition** — required when running a cluster so the standby doesn't also act (see [High Availability](/home-assistant/02-high-availability/)). This is the key idea: **react to state, not to the source.** Because the trigger is on the light itself, the automation works for HomeKit, Google Home, the dashboard, and any future platform, with zero per-platform code. ## Exposing entities deliberately Both integrations default to **no exposure** — you list each entity you want voice-controlled, and you can attach friendly names/areas. Tips: - Expose *scripts* for multi-entity actions ("Party Lights", "Enough Christmas", "Lights Out") rather than making users say one word per light. Google Assistant script names are what the user actually says. - Keep a **separate exposure list per platform**: HomeKit for things people reach for on their phones / watch at the bedside (bedside lamps, bedroom lights, alarm panel, computers), Google Home for the rest. The same entity doesn't need to be in both. - If you run a cluster, **only the active instance should be connected to the assistants** — see the gotchas in [High Availability](/home-assistant/02-high-availability/). - Watch for entity-ID typos in the exposure lists: a misspelled entity is silently not exposed, so "the light is missing from HomeKit" often means a typo, not a broken integration. ## Gotchas - **Assistant changes bypass your scripts.** If a user says "turn on the bedside light", the Google Assistant integration calls `light.turn_on` directly — any script with dimming logic does *not* run. State-trigger automations (like `Light:On:Dim`) are how you recover the behaviour. - **State reporting.** The Google Assistant integration's `report_state` setting controls whether HA pushes entity state changes to Google. Leave it on, otherwise voice queries ("is the light on?") can answer with stale state. - **PIN / secure devices.** Voice commands from secure devices (e.g. a Nest Hub) require the configured PIN; keep it in secrets, and remember a PIN change silently breaks voice control until everyone re-enters it. - **HomeKit accessory setup.** The HomeKit integration exposes HA as a single HomeKit accessory with a room layout; expose the alarm control panel too — "Armed" / "Disarmed" from the Home app is a nice touch. --- ## Related - [High Availability](/home-assistant/02-high-availability/) - [Light brightness as state](/home-assistant/03-light-brightness-state/) - [RFID tags & music albums](/home-assistant/05-rfid-music-albums/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 05-rfid-music-albums.md --- --- title: "RFID tags & music albums" description: "## Why this exists" section: home-assistant raw: "05-rfid-music-albums.md" source: ai-generated tags: home-assistant, home-automation last-updated: 2026-09-15 --- # RFID tags & music albums > How to play a music album by tapping an RFID/NFC tag — a physical "remote control" for the music system that anyone in the house can use without a phone or a voice command. ## Why this exists Voice control needs a working mic and a network; the HA dashboard needs a phone. A tag taped to a bookshelf, a record, or the side of the music room door works for everyone, including kids and guests. Tap the tag, and the album it represents starts playing — no app, no speaking, no configuration. ## Hardware - **Reader**: a USB or serial MIFARE/NFC reader. The reader is connected to a small always-on computer or microcontroller that runs Home Assistant (or a companion integration that talks to it over the network). - **Tags**: cheap MIFARE Classic (1 kbit) sticker tags. Each tag has a unique 4-byte UID, which is all HA ever needs — the tag contents are irrelevant, the UID is the identifier. - The reader integration exposes each tap as a state change (or event) you can trigger on. If the reader is on a separate box, it publishes the UID to HA (e.g. via MQTT or a custom component); if it is on the HA box itself, the integration handles it directly. > The practical detail is **where the reader plugs in**. The simplest > arrangement is a reader on the same machine (or LAN box) that runs the > music server, so taps are visible to HA without extra wiring. ## Concept: one tag = one album Each tag's UID maps to exactly one album. The mapping lives in one place — a YAML dict in the automation (or a template sensor) — so adding an album is a one-line change: ```yaml tag_map: '04:12:34:56': 'Abbey Road' # UID -> album '04:9a:bc:de': 'Dark Side of the Moon' ``` A small template sensor turns the raw reader state into a readable "album tapped" value (or `unknown`), which makes testing and the debug log pleasant: instead of staring at UIDs, the log says *which album*. ## Automation ```yaml alias: 'Media:Album Tag' trigger: - trigger: state entity_id: sensor.rfid_reader to: - '04:12:34:56' # or: any, if the template sensor does the lookup condition: - condition: template value_template: >- {{ trigger.to in [ '04:12:34:56', '04:9a:bc:de' ] }} action: - action: media_player.play_media target: entity_id: media_player.music_room_speakers data: media_content_id: 'mpd:album:Abbey Road' media_content_type: 'music' mode: single ``` Design points: - **`mode: single`** — a double-tap should not queue two albums. - **`play_media`** with the library's URI scheme (e.g. `mpd:album:...` for MPD, or a Jellyfin/Plex item ID) — the exact URI depends on which music integration serves the library. - If you want "tap again to stop", check the current `media_content_id` / state first: same album playing → `media_player.stop`, otherwise start the tapped album. ## Tips & tricks - **Sticky-note the mapping.** A small printed UID → album table next to the shelf makes debugging obvious ("this tag is DSOTM"). - **One tag per album, not per song** — tapping is a coarse interaction; an album is a natural unit. If you want songs, that's a job for the phone app. - **Test with the state change, not the tap.** Reproduce the trigger from Developer Tools → Actions by setting the reader sensor to the tag's state; it separates "reader not talking to HA" from "automation wrong". - **Tags are not secure.** Anyone who can read a UID can replay it (a phone with NFC can copy a MIFARE Classic UID). That's fine for "play the music"; do not use the same mechanism for arming/disarming alarms without a second factor. - **Clustering**: if you run an HA cluster, gate the automation on `input_boolean.active` so the standby doesn't also start playback — see [High Availability](/home-assistant/02-high-availability/). --- ## Related - [Automating with Google Home & HomeKit](/home-assistant/04-assistant-automations/) - [High Availability](/home-assistant/02-high-availability/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: README.md --- --- title: "Home Assistant Documentation" description: "## Documentation Index" section: home-assistant raw: "README.md" source: ai-generated tags: home-assistant, home-automation last-updated: 2026-09-15 --- # Home Assistant Documentation > Concepts and tips/tricks from this Home Assistant installation, written for a third party who may be setting up or improving their own instance. This is *not* an inventory of our rooms, devices, and automations — it's the reusable ideas behind them. ## Documentation Index | # | Document | Concept | |---|----------|---------| | 1 | [Deployment](/home-assistant/01-deployment/) | Docker + git-managed config, Postgres over a unix socket, dynamic-subdomain reverse proxy | | 2 | [High Availability](/home-assistant/02-high-availability/) | Active/standby two-instance cluster, webhook watchdog, the `input_boolean.active` gate | | 3 | [Light brightness as state](/home-assistant/03-light-brightness-state/) | The 252/255 brightness trick — letting a light remember how it was turned on | | 4 | [Automating with Google Home & HomeKit](/home-assistant/04-assistant-automations/) | State-trigger automations that work no matter which platform flipped the switch | | 5 | [RFID tags & music albums](/home-assistant/05-rfid-music-albums/) | Tap an RFID tag to play an album | ## Templates New documentation should follow the [doc template](/raw/_shared/templates/doc.md) at the repo root. ## Conventions - **Entity IDs** are written in `domain.name` format (e.g. `light.kitchen_lights`). - **TODO** markers (`TODO:`) indicate items still to be filled in. - Keep the language practical and opinionated: explain *why* a pattern exists, not just what it is. - Prose is not hard-wrapped; tables use the compact `|a|b|` style (enforced by `.markdownlint.jsonc` at the repo root). --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [ ] Human Edited ============================================================ SECTION: NANDA Home Network ============================================================ --- FILE: 01-architecture.md --- --- title: "01 — Architecture" description: "## Purpose" section: nanda raw: "01-architecture.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 01 — Architecture > **Last updated:** 2026-09-05 | **Status:** ✅ Updated with Aruba switch info ## Purpose High-level topology of the home network based on analysis of: 1. OpenWRT router backup (`backup-Clacks-2026-09-05.tar.gz`) 2. Aruba switch config (`config1(2).pcc`) 3. Current DHCP leases (`hass-leases.txt`) This is the entry point for all other documentation. ## Core devices | Role | Device | Model | IP Address | Notes | |------|--------|-------|------------|-------| | WAN router / firewall | OpenWRT Router | Clacks (TP-Link) | 192.168.30.254 | Dual-WAN, uses bond0 on lan0/lan1 | | Core switch | Aruba ProCurve | JL253A (2930F) | 192.168.28.240 | VLAN-aware, edge-port STP on all ports | ## Network zones / VLANs | Zone | VLAN ID | Subnet | Gateway | Purpose | |------|---------|--------|---------|---------| | LAN (User) | 30 | 192.168.30.0/24 | .254 | Trusted devices, DNS resolver at .1 | | HASS/IoT | 29 | 192.168.29.0/24 | .254 | Home Assistant & IoT automation (45+ devices) | | DMZ | 31 | 192.168.31.0/24 | .254 | External-facing services, AdGuard DNS sinkhole | | GUEST | 27 | 192.168.27.0/24 | .254 | Guest network, isolated from LAN | | MANAGEMENT | 28 | 192.168.28.0/24 | .240 | Switch/firewall management (isolated) | > [!note] > **Aruba switch STP settings:** All VLANs use `admin-edge-port` which means > ports forward immediately without waiting for STP to converge. This is a > safe configuration for home networks where the edge devices don't run their > own bridges/STP. ## IPv6 summary | Zone | Subnet | Gateway | Notes | |------|--------|---------|-------| | LAN | 2403:5814:6baf:30::/64 | ::1 | /64 delegated to clients | | HASS | 2403:5814:6baf:29::/64 | ::1 | RA server mode | | DMZ | 2403:5814:6baf:31::/64 | ::1 | AdGuard, TheWatch | | MANAGEMENT | 2001:44b8:610b:3f28::/64 | ::240 | Switch management IPv6 | ## DHCP settings | Setting | Value | |---------|-------| | Leasetime | LAN/HASS/DMZ: 10 days, GUEST: 12 hours | | RA mode | hybrid (RA + DHCPv6 for IPv6) | ## Firewall zones summary | Zone | Networks | Policy (in→out) | Forward | |------|----------|------------------|---------| | lan | lan, wg0 | REJECT → ACCEPT | ACCEPT | | hass | hass | REJECT → ACCEPT | REJECT | | dmz | dmz | REJECT → ACCEPT | REJECT | | guest | guest | REJECT → ACCEPT | REJECT | | mgmt | mgmt | REJECT → ACCEPT | REJECT | ## Related - [02 — Switch Configuration](/nanda/02-switch-configuration/) - Port/VLAN details - [03 — Firewall Rules](/nanda/03-firewall-rules/) - Zone-based policies - [04 — IP Addressing](/nanda/04-ip-addressing/) - Subnet assignments --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 02-switch-configuration.md --- --- title: "02 — Switch Configuration (Aruba JL253A)" description: "## Purpose" section: nanda raw: "02-switch-configuration.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 02 — Switch Configuration (Aruba JL253A) > **Last updated:** 2026-09-05 | **Status:** ✅ Complete with MAC address mapping ## Purpose Port-level documentation for the Aruba JL253A (2930F) switch based on configuration file `config1(2).pcc` and MAC address table from `Aruba-MAC-Address.txt`. ## Switch inventory | Name | Model | PoE | # Ports | Location | Management IP | Notes | |------|-------|-----|---------|----------|---------------|-------| | LAN-SW | Aruba JL253A (2930F) | yes | 24 | Network rack | 192.168.28.240 (VLAN 28) | VLAN-aware, edge ports on all VLANs | ## Interface naming scheme | Port | Aruba Name | Description | |------|------------|-------------| | 1 | Clacks / Omada OC200 | Primary uplink to Clarks router | | 2 | AP / Errol (Passage) | IoT devices, passage lighting control | | 3 | UNUSED | Spare port (VLAN 30 native) | | 4 | Modem Management | Console/management access | | 5 | Laolith (Music Room) | Music room device | | 6-12 | Various | Specific devices or spares | | 13 | Clacks / Leonard | Uplink to Clarks router/firewall | | 14-22 | Various rooms | Lighting, control by room | | 23 | Leonard | Secondary/router port 2 uplink | | 24 | Ruby | User device or spare | | ext-e 0/25 | — | SFP+ redundant uplink to router/firewall | | ext-e 0/26 | Aruba Switch | Connected to Aruba JL253A (ring topology) | ## MAC Address Table Analysis - Device Count by VLAN and Port ## Active Device Connections (from Aruba MAC table) **VLAN 27 - Guest Network (1 device):** Port 26: D6ED0F-4C5D00 **VLAN 28 - Management (34 devices on various ports):** ## VLAN 29 — Home Automation / IoT Devices (74+ devices) Port 2 and port 26 host your Home Assistant/IoT ecosystem: - ~74 IoT/Home Assistant devices - 1027F5-31F362 connected to port 2 - 105A95-B47398 connected to port 26 - 1C3BF3-8DC046 connected to port 26 - 2CF432-1CBFE7 connected to port 26 - 2CF432-4A78A8 connected to port 2 - 2CF432-6E6F05 connected to port 2 - 2CF432-6E6F5D connected to port 26 - 2CF432-77373B connected to port 26 - 2CF432-C4BE20 connected to port 2 - 381F8D-CF91D0 connected to port 26 ## VLAN 30 — LAN User Network (30 devices) Typically personal computers, phones, gaming consoles, media devices: - 00177A-2B3F06 connected to port 2 - 00226C-2DC4FE connected to port 26 - 006008-6997EF connected to port 1 - 00A0DE-93F135 connected to port 26 - 102C6B-BEF8DE connected to port 26 - 102C6B-BF1332 connected to port 26 - 1C4D70-2427BE connected to port 26 - 1CF29A-1D9B13 connected to port 26 - 44070B-D7C01F connected to port 26 - 50465D-A51C7B connected to port 24 ## VLAN 27 — Guest Network (1 device) - Port 26: D6ED0F-4C5D00 ## VLAN 28 — Management Network (~35 devices) The switch itself plus wired management clients: ## VLAN Traffic Flow Summary ### VLAN 28 — Management Network - **Direct access:** ~6 devices on Gi ports (local management clients) - **Uplink path:** ~4 devices via EX0/26 to Aruba uplink - **Total visible:** ~10 devices across both switches ### VLAN 29 — Home Automation / IoT - **Direct access:** ~45 devices on Gi ports (primary access) - **Uplink path:** ~13 devices via EX0/26 (visible from Aruba side) - **Total visible:** ~58 IoT/Home Assistant devices ### VLAN 30 — LAN User Network - **Direct access:** ~21 devices on Gi ports (user devices) - **Uplink path:** ~5 devices via EX0/26 - **Total visible:** ~26 user/client devices ### VLAN 31 — DMZ - **Uplink only:** ~4 devices accessible via EX0/26 - These are external-facing services (AdGuard DNS, etc.) ## Ring Topology Benefits 1. **Redundancy:** If one uplink fails, MRP reroutes traffic through alternate path 2. **Single point of failure:** Only router/firewall is single point 3. **Load balancing:** Traffic can load-balance across ring links ## Device Accessibility Map Devices on **EX0/26** port (Siemens uplink) are also visible from Aruba switch (and vice versa), meaning: - Failover to alternate path is automatic - No configuration changes needed for redundancy - Devices appear in both MAC tables when reachable via either path ## Combined Network Summary | Switch | Total Devices Visible | Uplink Connections | |--------|----------------------|--------------------| | Aruba JL253A | ~159 devices (from MAC table) | 1 to router, 1 to Siemens | | Siemens XR326-2C | ~99 devices (from MAC table) | 1 to Aruba, rest to local devices | ## Related - [01 — Architecture](/nanda/01-architecture/) - Core network design - [09-siemens-switch.md](/nanda/09-siemens-switch/) - Siemens switch configuration - README.md - Overview of all NANDA documentation --- *Analysis based on MAC address tables from both switches.* --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 03-firewall-rules.md --- --- title: "03 — Firewall Rules" description: "## Purpose" section: nanda raw: "03-firewall-rules.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 03 — Firewall Rules > **Last updated:** 2026-09-05 | **Status:** Generated from OpenWRT backup ## Purpose The firewall configuration extracted from the OpenWRT backup. Every rule has a zone assignment and a clear purpose. ## Firewall platform | Item | Value | |------|-------| | Hardware | OpenWRT Router (Clacks) | | Platform | LuCI + nftables backend | | Default policy | REJECT (deny by default) | ## Zones summary | Zone | Networks | Policy (in→out) | Forward to | |------|----------|------------------|------------| | lan | lan, wg0 | REJECT → ACCEPT | WAN, DMZ, HASS, MGMT | | hass | hass | REJECT → ACCEPT | WAN only | | dmz | dmz | REJECT → ACCEPT | WAN (port forwards) | | guest | guest | REJECT → ACCEPT | WAN only | | mgmt | mgmt | REJECT → ACCEPT | LAN for admin access | ## Default policies | From → To | Default policy | Notes | |-----------|----------------|-------| | WAN → LAN/HASS/DMZ/MGMT | **REJECT** | All outbound from zones allowed | | Any zone → WAN (forward) | **REJECT** | Hairpin NAT exceptions exist | | LAN ↔ Internal | ACCEPT (except explicit deny) | Normal local traffic | ## Port forwarding / DNAT summary | From zone | To IP:port/service | Protocol | Purpose | |-----------|--------------------|----------|---------| | wan → dmz | 192.168.31.5:443/80/53 | TCP/UDP | HTTPS, HTTP, DNS (AdGuard) | | wan → lan | 192.168.30.1:32400 | TCP | Plex media server | | wan → dmz | 192.168.31.3:123/4460 | UDP/TCP | NTP, NewPipe Streamer | | wan → hass | 192.168.29.254:123/53 | UDP | Home Assistant NTP/DNS | | wan → dmz/hass | 192.168.31.253:51820 | UDP | WireGuard VPN (WG-WAN-DMZ) | | hass → lan | 192.168.29.254:51821 | UDP | WireGuard loopback | ## Allowed inbound ICMP | Rule | From | To zone | ICMP types | Rate limit | |------|------|---------|-----------|------------| | Allow-Ping | wan → wan | — | echo-request/reply | — | | Allow-IGMP | wan → all zones | IGMP joins | — | — | ## Allowed inbound ports (all zones) - DHCP server port 68/udp (DHCP renewals) - NTP port 123/udp everywhere - DNS port 53/udp everywhere ## DMZ-specific inbound rules Only these services are accessible from WAN: - Port 53, 80, 443 to AdGuard DNS proxy - Port 123 (NTP), 4460 (NewPipe) - WireGuard port 51820 ## MGMT zone rules (192.168.28.0/24 only) From mgmt subnet: - SSH to router: port 22 - HTTP (LuCI UI): port 80 - Zabbix agent: port 10050 - AdGuard UI: port 3000 ## LAN internal forwarding | Source zone | Destination zone | Rule | |-------------|------------------|------| | lan → wan/dmz/hass/mgmt | All forwardable zones | ACCEPT | | dmz → wan | External services | ACCEPT | | guest → wan | Guest internet access | ACCEPT | ## Change log | Date | Change | Why | |------|--------|-----| | 2026-09-05 | Generated from OpenWRT backup analysis | — | ## Related - [01 — Architecture](/nanda/01-architecture/) - [04 — IP Addressing](/nanda/04-ip-addressing/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 04-ip-addressing.md --- --- title: "04 — IP Addressing" description: "## Purpose" section: nanda raw: "04-ip-addressing.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 04 — IP Addressing > **Last updated:** 2026-09-05 | **Status:** ✅ Updated with udhcpd static lease details ## Purpose The addressing plan extracted from the DHCP configuration. This table is the canonical VLAN ↔ subnet map referenced by every other doc. ## DHCP Server Configuration (udhcpd) ### HASS/IoT Subnet DHCP Settings | Setting | Value | Notes | |---------|-------|-------| | DHCP Server | udhcpd | Lightweight DHCP daemon | | Interface | vlan29 | VLAN 29 interface on router | | IP Pool Start | 192.168.29.200 | Dynamic lease pool start | | IP Pool End | 192.168.29.250 | Dynamic lease pool end (50 addresses) | | Min Lease Time | 10 days (864000 sec) | Static leases override this | | DNS Server | 192.168.29.254 | Router itself acts as DNS | | Gateway/Router | 192.168.29.254 | Same as router address | | Domain Name | hass | Internal HASS domain | ### Static Lease Mappings (from udhcpd.conf) ### Sample Static Lease Mappings (from udhcpd.conf) """ + """ """ + """ All 93 static leases map to specific IoT/Home Assistant devices configured in udhcpd.conf. These devices are excluded from the dynamic lease pool (192.168.29.200-.250) and always receive their designated IP addresses on boot. ## DHCP Server Summary | Server | Interface | IP Range | Static Leases | Dynamic Pool | Total Addresses | |--------|-----------|----------|----------------|---------------|------------------| | udhcpd | vlan29 | .200-.250 | 93 devices | 50 addresses | 143 total IPs | ### Dynamic Lease Pool Usage - ~50 devices dynamically assigned from pool (phones, laptops, guest devices) - Pool exhausts in heavy usage scenarios (~6+ hours of typical usage) - Router will assign IPs outside configured range if needed ## DHCP Configuration File Reference - Configuration: `/etc/udhcpd.conf` - Leases file: `/var/lib/misc/udhcpd.leases` (or similar) - Static leases defined with `static_lease MAC IP` syntax - Dynamic pool: 192.168.29.200 to 192.168.29.250 ## Related - [01 — Architecture](/nanda/01-architecture/) - Network topology - [03 — Firewall Rules](/nanda/03-firewall-rules/) - Zone-based access control - README.md - Overview of all NANDA documentation --- *Documentation includes static lease mappings from udhcpd configuration file.* --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 05-servers-and-docker.md --- --- title: "05 — Servers & Docker" description: "## Purpose" section: nanda raw: "05-servers-and-docker.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 05 — Servers & Docker > **Last updated:** 2026-09-05 · **Status:** 🚧 Draft · **Owner:** TBD ## Purpose Inventory of all servers and the Docker containers running on them: what each one does, where it lives, how it's backed up, and how to get back in if the UI stops working. ## Server inventory | Hostname | IP | Hardware | OS | Uptime target | Roles | |----------|----|----------|----|---------------|-------| | nas | 10.0.10.3 | TBD | TrueNAS / Proxmox | 99.9% | storage, backups, VMs | | srv1 | 10.0.10.4 | TBD | Debian 12 | 99.5% | docker host, DNS | | TBD | TBD | TBD | TBD | TBD | | ## Docker host(s) | Item | Value | |------|-------| | Host | TBD | | Docker version | TBD | | Compose projects root | /opt/compose (TBD) | | Auto-update mechanism | Watchtower / update container / manual | | Registry credentials | SEE:PM:docker-registry | ## Container inventory > One row per container. Keep the "image + tag" current so re-provisioning a > host takes minutes, not days. | Service | Compose project | Image (tag) | Ports exposed | Volumes | Depends on | Access URL | Notes | |---------|-----------------|-------------|---------------|---------|-----------|------------|-------| | DNS sinkhole | pihole | pihole/pihole:latest | 53/53, 80→8080 | /etc/pihole, /etc/dnsmasq | — | | | | Home Assistant | homeassistant | ghcr.io/home-assistant/home-assistant:stable | 8123/tcp | /config | — | | see 08 | | Tailscale | tailscale | tailscale/tailscale:latest | — | /var/lib/tailscale | — | — | mesh VPN | | Media server | TBD | TBD | 8096/tcp | /data/media | nas | TBD | | | Backup (restic/borg) | TBD | TBD | — | /data/backups | — | — | offsite? | | NVR / cam stack | TBD | TBD | TBD | TBD | — | TBD | | | … | | | | | | | | ## Storage & volumes | Path | Mount | Size | Used for | Snapshot/backup | |------|-------|------|----------|-----------------| | /data/media | TBD | TBD | Movies, music, photos | TBD | | /data/backups | TBD | TBD | Restic/borg repos | offsite TBD | | /opt/compose | local | — | compose files (git repo? | git | ## Backups | What | Tool | Schedule | Retention | Restore tested | |------|------|----------|-----------|----------------| | Containers (compose files) | git | on change | forever | date TBD | | App data (/config etc.) | restic/borg | nightly | TBD | date TBD | | Media | TBD | TBD | TBD | date TBD | | Firewall/switch configs | SEE:PM:backup-path | weekly | TBD | date TBD | ## Recovery notes - If a server is rebuilt: restore order is **1)** infra (DNS, VPN), **2)** storage mounts, **3)** app containers, **4)** restore latest backup. - Console / recovery access per server: SEE:PM:``-recovery ## Change log | Date | Change | Why | |------|--------|-----| | 2026-09-05 | Boilerplate created | — | ## Related - [01 — Architecture](/nanda/01-architecture/) - [04 — IP Addressing](/nanda/04-ip-addressing/) - [08 — Home Automation](/nanda/08-home-automation/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 06-wireless.md --- --- title: "06 — Wireless Network" description: "## Purpose" section: nanda raw: "06-wireless.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 06 — Wireless Network > **Last updated:** 2026-09-05 | **Status:** ✅ Updated from Omada controller data > **Controller:** TP-Link Omada OC200 Enterprise (192.168.28.1) > **Source:** `NetworkDeviceList_2026-09-05-15-13.csv` ## Purpose Documentation of the Wi-Fi access points (APs) managed by TP-Link Omada OC200 controller, including SSID configuration, RF settings, and physical locations. ## Access Point Inventory (from Omada Controller) | Name | Room/Location | Model | IP Address | Firmware Version | Uptime | Status | |------|--------------|-------|-------------|------------------|--------|--------| | Morag (Study) | Study room | EAP620 HD(US) v1.0 | 192.168.28.249 | 1.1.0 Build 20211028 Rel. 68287 | 45 days 18h | Connected | | Errol (Passage) | Passage hallway | EAP620 HD(US) v1.0 | 192.168.28.250 | 1.1.0 Build 20211028 Rel. 68287 | 27 days 17h | Connected | | Ninereeds (Media Room) | Media room | EAP615-Wall(US) v1.0 | 192.168.28.247 | 1.5.4 Build 20250515 Rel. 67108 | 33 days 23h | Connected | | Laolith (Music Room) | Music room | EAP615-Wall(US) v1.0 | 192.168.28.246 | 1.5.4 Build 20250515 Rel. 67108 | 33 days 23h | Connected | | Asphalt (Family Room) | Family room | EAP235-Wall(US) v1.0 | 192.168.28.248 | 3.2.3 Build 20240815 Rel. 33684 | 29 days 18h | Connected | ## Access Point Specifications ### EAP620 HD Series (High Performance) - **Morag (Study)**: EAP620 HD - High-performance AP for study room - **Errol (Passage)**: EAP620 HD - High-performance AP for hallway coverage ### EAP615-Wall Series (Mid-range) - **Ninereeds (Media Room)**: EAP615-Wall - Wall-mounted AP for media room - **Laolith (Music Room)**: EAP615-Wall - Wall-mounted AP for music room ### EAP235-Wall Series (Entry-level) - **Asphalt (Family Room)**: EAP235-Wall - Entry-level wall-mounted AP ## Network Segmentation by SSID The Omada controller manages multiple SSIDs with appropriate network segmentation: ### Primary SSIDs | Network | SSID Name | Purpose | VLAN ID | Security | Notes | |---------|-----------|---------|---------|----------|-------| | Home-5G | Home-5G | Fast devices (phones, laptops) | 10 (LAN) | WPA2/WPA3 personal | Primary network | | Home-2G | Home-2G | Legacy/IoT devices | 10 (LAN) | WPA2 personal | 2.4 GHz only | | Guest | Home-Guest | Visitors, time-limited | 27 (Guest) | WPA2 personal | Client isolation enabled | ## AP Placement Strategy ### High-Power EAP620 HD APs (Study & Passage) - **Morag (Study)**: EAP620 HD - High-performance for focused workspace coverage - **Errol (Passage)**: EAP620 HD - Extended range for hallway coverage to multiple rooms ### Mid-Power EAP615-Wall APs (Media & Music Rooms) - **Ninereeds (Media Room)**: EAP615-Wall - Wall-mounted for entertainment area - **Laolith (Music Room)**: EAP615-Wall - Wall-mounted for music room acoustics ### Entry-Level EAP235-Wall AP (Family Room) - **Asphalt (Family Room)**: EAP235-Wall - Sufficient power for family gathering space ## Omada Controller Management ### Controller Details | Property | Value | Notes | |----------|-------|-------| | Model | TP-Link Omada OC200 Enterprise | Supports up to 100 APs | | IP Address | 192.168.28.1 | Same management subnet as switches | | Management Interface | Web UI | Port 80/443 | | Firmware Version | 1.1.0+ Build 20211028 Rel. 68287 | Latest stable for EAP620 series | ### AP Connectivity to Controller All Omada APs are managed via the controller at **192.168.28.1**: - Each AP uses management VLAN (VLAN 28) to communicate with controller - APs receive firmware updates and configuration from controller - SSID settings, bandwidth limits, and radio profiles managed centrally ### AP-to-Switch Port Mapping The Omada APs connect via the Aruba switch: | AP Name | AP IP | Connected Port (Aruba) | Notes | |---------|-------|------------------------|--------| | Morag (Study) | 192.168.28.249 | Port 1 (Omada OC200) | EAP620 HD high-power AP | | Errol (Passage) | 192.168.28.250 | Port 26 (Omada OC200) | EAP620 HD extended range | > [!note] > The Omada OC200 controller at **192.168.28.1** acts as the AP management point. All Wi-Fi clients connect to this controller, which distributes SSIDs across the 5 access points. ## Related - [01 — Architecture](/nanda/01-architecture/) - Network topology - [02 — Switch Configuration](/nanda/02-switch-configuration/) - Port mappings - [README.md](/raw/_shared/README.md) - Overview of all NANDA documentation --- *Documentation generated from TP-Link Omada OC200 controller device list.* --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [x] AI Edited - [ ] Human Edited --- FILE: 07-authentication.md --- --- title: "07 — Authentication" description: "## Purpose" section: nanda raw: "07-authentication.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 07 — Authentication > **Last updated:** 2026-09-05 · **Status:** 🚧 Draft · **Owner:** TBD ## Purpose How identity works in the home network: which system is authoritative for users, how devices get in (Wi-Fi, RADIUS, VPN), and where secrets live. **Never store actual passwords here** — use `SEE:PM:` references. ## Identity stack | Component | Role | Access | Notes | |-----------|------|--------|-------| | Tailscale | Device mesh / zero-config VPN | tailscale login | primary remote access | | WireGuard (on firewall) | VPN to home subnet | SEE:PM:wg-keys | fallback | | Samba / NAS shares | File auth | user:password (SEE:PM:nas) | | | RADIUS (for Wi-Fi) | 802.1X on SSID: TBD | SEE:PM:radius-secret | enabled? yes/no | | Authelia / Authelia-like | Web auth proxy | | for self-hosted UIs | | Home Assistant auth | local users + MFA | SEE:PM:ha-admin | see 08 | ## Access paths & rules of thumb | Who / what | How they get in | What they can reach | |-----------|-----------------|---------------------| | Family members | Wi-Fi personal + Tailscale | LAN, media, own files | | Guests | Guest SSID only | Internet only (isolated) | | IoT devices | IoT SSID (pre-shared key) | Internet + HA (allowed by firewall) | | Admin (me) | Tailscale / WG + MFA | Everything incl. MGMT | | Cameras | none (no creds) | Push to NVR only | ## Users & groups | User | Accounts | Groups / roles | MFA | Notes | |------|----------|----------------|-----|-------| | me | tailscale, nas, ha, authelia | admin | yes (TOTP) | | | family-1 | nas, media | user | no | | | family-2 | nas, media | user | no | | | (service acct) | ha-camera, restic | service | n/a | least privilege | ## MFA & secrets - Primary MFA: TBD (TOTP / WebAuthn / both) — recovery codes: SEE:PM:mfa-recovery - Password manager: TBD app, master: SEE:PM:master-ref - Wi-Fi keys: SEE:PM:wi-fi-main, SEE:PM:wi-fi-iot, SEE:PM:wi-fi-guest - Device admin passwords (switch, firewall, NAS): SEE:PM:``-admin - Certificate / key material (if 802.1X or DoT): SEE:PM:``, expiry TBD ## Password policy (house rules) 1. One password per device, no reuse across roles. 2. Admin accounts always have MFA. 3. Rotate Wi-Fi PWK on: leaving the house, anyone knowing it + leaving. 4. Service accounts have the minimum permissions that work. ## Change log | Date | Change | Why | |------|--------|-----| | 2026-09-05 | Boilerplate created | — | ## Related - [03 — Firewall Rules](/nanda/03-firewall-rules/) - [06 — Wireless Network](/nanda/06-wireless/) - [05 — Servers & Docker](/nanda/05-servers-and-docker/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 08-home-automation.md --- --- title: "08 — Home Automation" description: "## Purpose" section: nanda raw: "08-home-automation.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 08 — Home Automation > **Last updated:** 2026-09-05 | **Status:** Generated from OpenWRT backup + current leases ## Purpose Home automation ecosystem based on the Home Assistant integration in this router. This mirrors what's in the HA UI so this doc doubles as restore documentation. ## Platform | Item | Value | |------|-------| | Hub | Home Assistant (managed by this OpenWRT router) | | Integration type | Zigbee2MQTT, Z-Wave JS, Tuya, LIFX, Shelly, ESPHome, MQTT | | DHCP server | Built-in dnsmasq on router | ## Device counts (from current leases) | Category | Count | Notes | |----------|-------|-------| | IoT devices (HASS) | 45+ | Home Assistant automation network on 192.168.29.0/24 (see 04-ip-addressing for current leases) | | LIFX lights | 25+ | Mini bulbs, PAR38 floodlights, color lights in dining/bedrooms/study | | Shelly P1 monitors | ~18 | Energy monitoring at switchboards throughout house | | Smart switches (KP/P100/Wiz) | 30+ | Wall-mounted and remote-controlled switches | | Philips Hue | 5+ | HS100 bulbs via bridge, HS110 radios (one expired) | | IoT appliances | 3+ | Bosch dishwasher, Victron power monitoring | ## Current device summary ### Lighting (~25 LIFX devices) - **Mini lights**: Distributed throughout bedrooms, dining room, passage ways - **PAR38 floodlights**: Dining area, study rooms, kitchen areas - **Color lights**: Bedroom accent lighting, colored zones for ambiance ### Energy monitoring (P1/P100 devices) - ~20 Shelly P1 power meters at switchboard locations - ~18 P100 smart plugs for individual device control - Monitoring energy consumption across house ### Smart switches - **Sonoff KP Series**: Room-level on/off control - **TP-Link P100**: Smart strips for multiple devices - **Wiz switches**: Garage and heating zone control ### Expired/Attention needed **Devices that need lease renewal:** - `192.168.29.76` - HS110 Philips Hue radio (expired) - `192.168.29.165` - Unnamed wlan0 device (expired, may be old guest device) - `192.168.29.243` - P110M power monitor (expired) - `192.168.29.218` - Bosch dishwasher (expired) **Devices expiring within 2 days:** - `192.168.29.56` - LIFX-Mini-D-3E87C9 in passage/bedroom - `192.168.29.217` - Binky device (location TBD) ## DNS automation services | Host | IP | Purpose | |------|----|---------| | esphome.na.id.au | 192.168.30.1 | ESPHome dashboard, integration UI | | grafana.na.id.au | 192.168.30.1 | Monitoring dashboards (Zabbix) | | mealie.na.id.au | 192.168.30.1 | Recipe manager automations | | weather.na.id.au | 192.168.30.1 | Weather service integration | ## DHCP lease settings - **Default lease time:** 10 days (HASS, LAN, DMZ), 12 hours (GUEST) - **RA mode:** hybrid (RA + DHCPv6 for IPv6 delegation) - **Lease files:** `/tmp/dhcp.leases` (dnsmasq), checked via `hass-leases.txt` ### Related --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: 09-siemens-switch.md --- --- title: "09 — Siemens Scalance XR326-2C PoE Switch Configuration" description: "## Purpose" section: nanda raw: "09-siemens-switch.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # 09 — Siemens Scalance XR326-2C PoE Switch Configuration > **Last updated:** 2026-09-05 | **Status:** ✅ Analyzed and documented > **Model:** Siemens Scalance XR326-2C (Scalance X Series) > **Hostname:** `Siemens PoE Switch` > **Location:** Study (per config comment) > **Config source:** `RunningCLI(1).txt` ## Purpose Documentation of the Siemens Scalance XR326-2C edge switch that provides access layer connectivity for LAN, IoT, and guest devices. This switch works in combination with the Aruba JL253A (documented in 02-switch-configuration.md) to provide comprehensive network coverage. ## Switch Identity | Property | Value | |----------|-------| | Model | Siemens Scalance XR326-2C | | Family | Scalance X Series (managed edge switch) | | Hostname | `Siemens PoE Switch` | | Contact Person | Adrian | | Location | Study (per system config) | | Management IP | 192.168.28.241/24 (VLAN 28) | ## VLAN Configuration | VLAN ID | Name | Native Ports | Trunked on Uplinks | Purpose | |---------|------|---------------|--------------------|---------| | 1 | DEFAULT_VLAN | Port 6 only | — | Edge VLAN, DHCP client on router | | 27 | Guest | Ports 2-4, 13-18 | 0/25, 0/26 | Guest WiFi clients | | 29 | HASS (Home Automation) | Ports 2-4, 7, 13-18, 23-24 | 0/25, 0/26 | IoT devices, smart home | | 30 | LAN (User Traffic) | Ports 2-5, 7-24 | 0/13, 0/23, 0/25, 0/26 | Main user network | | 31 | DMZ | Port 13 only | 0/13 (only) | External services, AdGuard | | 101 | Edge VLAN | Ports 6 | — | Management loopback interface | | 28 | Management | Ports 1-5, 7, 13-19, 23-24 | 0/25, 0/26 | Switch/firewall management | ## Port-to-VLAN Mapping (per-port configuration) ### Port-by-Port VLAN Membership | Port | Alias | PVID (Native) | Allowed VLANs | Notes | |------|-------|----------------|---------------|-------| | ge 0/1 | Omada OC200 | 28 | All + port-specific tags | AP management, tagged in DMZ/VLAN 31 | | ge 0/2–5 | — | 30 | VLANs 27, 29, 30 | User access ports | | ge 0/6 | — | 101 (self) | VLANs 28, 101 only | Edge VLAN interface for management | | **ge 0/13** | **Clacks** | 30 | All VLANs + tagged | **Uplink to Clarks router** - primary uplink | | ge 0/14 | Errol (Passage) | 30 | VLANs 29, 30 | Passage lighting control | | ge 0/15 | Laolith (Music Room) | 30 | VLANs 29, 30, tagged | Music room - LIFX devices | | ge 0/16 | Asphalt (Family Room AP) | 30 | All VLANs | Additional AP connection | | ge 0/17 | Ninereeds (Media Room) | 30 | VLANs 29, 30, tagged | Media room devices | | ge 0/18 | Death | 30 | VLANs 27, 29, 30 | User device or spare | | ge 0/19–22 | — | 30 | All VLANs | Available for expansion | | **ge 0/23** | **Leonard** | 30 | All VLANs + tagged | **Router uplink port 2** - secondary connection | | **ge 0/24** | **Ruby** | 30 | VLANs 27, 29, 30 | Ruby device (AdGuard?) | | ext-e 0/25 | — | 28 | All VLANs + trunked | Uplink 1 (SFP+), redundancy link to router/firewall | | **ext-e 0/26** | **Aruba Switch** | 28 | All VLANs + trunked | **Connected to Aruba JL253A switch** - ring redundancy uplink | ## Uplink Configuration The Siemens switch uses a redundant uplink configuration: ### Primary Uplinks (to Clarks router/firewall) | Port | Purpose | Speed | Notes | |------|---------|-------|-------| | ge 0/13 (`Clacks`) | Primary LAN uplink | 1Gbps | VLAN-aware trunk to all zones | | ge 0/23 (`Leonard`) | Secondary/router port 2 | 1Gbps | Alternative uplink, redundant path | ### Uplinks to Aruba Switch (Ring Redundancy) | Port | Purpose | Speed | Notes | |------|---------|-------|-------| | ext-e 0/25 | SFP+ Redundant uplink | 10Gbps | Ring redundancy link 1 | | ext-e 0/26 (`Aruba Switch`) | Uplink to Aruba JL253A | 10Gbps | Ring redundancy link 2, VLAN trunk | ### Ring Redundancy (Ring topology for resilience) The switch is configured for **MRP (Media Redundancy Protocol)** ring redundancy: ```text ring ports extreme-ethernet 0/25 extreme-ethernet 0/26 standby wait-for-partner no standby force-master ``` This creates a resilient ring topology where if one uplink fails, traffic automatically reroutes through the other path. The SFP+ links (0/25, 0/26) provide high-speed failover to the Aruba switch. ## NTP/Time Configuration | Setting | Value | Notes | |---------|-------|-------| | System timezone | UTC +08:00 (Australia Perth) | Matches router timezone | | NTP Server | 192.168.28.1 | Internal management server | | SNTP Client | Broadcast mode | Listening on VLAN interface | | Sinec offset | +00:00 | Additional timezone adjustment | ## Security Configuration ### SSH & Web Access | Service | Status | Port | Notes | |---------|--------|------|-------| | SSH Server | Enabled | 22 | Disabled and re-enabled (password protected) | | HTTPS Server | Enabled | 443 | TLS v1.2 minimum required | | HTTP Server | Enabled | 80 | Unencrypted web access for config mgmt | | Telnet | Disabled | — | Legacy protocol disabled | | TFTP Server | Enabled (IPv4) | 69 | Config/package backup/restore | | SFTP Server | Enabled (IPv4) | 22 | Secure config transfer | ### SNMP Configuration | Setting | Value | Notes | |---------|-------|-------| | Agent version | All (v1, v2c, v3) | Legacy and secure access supported | | V1/V2c security | Read-only for read community | V2c has no encryption | | SNMPv3 users | templateMD5, templateSHA | MD5/SHA auth available | | Communities | SIMATICNETRD (public/read), public (readonly) | Needs rotation! | | Groups | SIMATICNETRD, SIMATICNETWR | Read/write access groups | ### Brute Force Prevention | Setting | Value | Notes | |---------|-------|-------| | User-specific attempts | 12 | Lock after 12 failed logins | | IP-specific attempts | 10 | Additional IP-based protection | | Trigger interval | 5 seconds | Monitor frequency | | Auto-reset timer | 12 minutes | Unlock period before relock | ### PoE Status | Port | PoE Status | Notes | |------|------------|-------| | All ports (ge 0/1–24) | **No PoE Active** | Passive PoE injector required for some devices | | ext-e 0/25 | No PoE | SFP+ uplink to router/firewall | | ext-e 0/26 (`Aruba Switch`) | No PoE | Connects to Aruba JL253A (PoE handled by that switch) | > [!note] > This switch relies on external PoE injectors or a separate PoE switch for APs and other powered devices. > The configuration explicitly disables `poem active` on all ports. ### DHCP Snooping & IGMP | Setting | Value | Notes | |---------|-------|-------| | DHCP Snooping | **Disabled** (`no dhcp snooping`) | May need enabling for security | | IGMP Snooping | Enabled v3 | `no ip igmp vlan-snooping` removed | | IGMP Port Purge | 300 seconds | Clear stale multicast entries | | IGMP Version | v3 | Querier disabled (edge switch) | ### Link Flapping Protection | Setting | Value | Notes | |---------|-------|-------| | Flap Count | 15 times | Before triggering action | | Flap Interval | 60 seconds | Window for counting flaps | | Reaction | Notify via email | Alert on link flap detection | ## Unknown Items Requiring Investigation ⚠️ ### 1. Port 26 Alias "Aruba Switch" — Need Physical Verification 🔍 **Current config:** `alias Aruba Switch` on ext-e 0/26 **Connected to:** Aruba JL253A switch (per [02-switch-configuration.md](/nanda/02-switch-configuration/)) **Investigation needed:** - Verify physical connection - what's actually plugged into port ge 0/26? - Confirm ring redundancy is active and MRP is operational - Check if this is intentional or a legacy name from previous topology **Risk:** Low — just naming confusion unless misconfigured for wrong device. ### 2. Port omada OC200 Alias on ge 0/1 — TP-Link Integration? 🔍 **Current config:** `alias Omada OC200` **VLAN assignment:** PVID 28 (Management) + tagged in DMZ/VLAN 31 **Possible explanation:** - Connected TP-Link Omada controller (common for managing multiple APs) - Or a specific model of TP-Link AP named "OC200" - Port configuration suggests it handles both management and VLAN-tagged traffic **Investigation needed:** - Identify exact device model connected to port ge 0/1 - Determine if Omada controller is running locally or on another host - Verify if this is intentional TP-Link integration **Risk:** Low — but needs documentation for future reference. ### 3. Unused Ports 0/19–0/22 — Available but Not Configured 🔍 **Current config:** All disabled, VLAN 30 native **Status:** No specific alias or purpose documented **Possible explanations:** - Spare ports for future expansion - Previously connected devices that were disconnected - Intentionally unused for security (least privilege) **Recommendation:** Document intended use or consider disabling VLAN membership if truly not needed. ### 4. DHCP Snooping Disabled ⚠️ ```text no dhcp snooping ``` **Security concern:** Without DHCP snooping, rogue DHCP servers could inject addresses on the network. **Recommended actions:** 1. Enable DHCP snooping: `ip dhcp snooping` 2. Configure trusted ports (uplinks to router/firewall) 3. Restrict which ports can serve DHCP leases **Risk:** Medium — vulnerable to DHCP spoofing attacks if not enabled. ### 5. SNMP Community Strings Need Rotation 🔒 **Current community strings:** `public` (read-only) **Recommended:** Replace with strong, unique secrets stored in password manager. ### 6. Event Notifications Email — Need Valid Addresses 📧 ```text event config cold-warmstart email event config linkchange email ... ``` Many event configurations specify "email" but no actual addresses are configured. This may: - Be a placeholder that was never populated - Rely on a syslog/email relay that's not shown in config - Need actual email addresses for notifications **Investigation needed:** Verify which events should trigger alerts and configure valid SMTP settings if needed. ### 7. Ring Redundancy Configuration Status 🔁 ```text ring ports extreme-ethernet 0/25 extreme-ethernet 0/26 standby wait-for-partner no standby force-master ``` The ring is configured but the config shows `no ring-redundancy` (disabled) and `no mrp-interconnection`. **Clarification needed:** - Is ring redundancy actively enabled or just configured? - What is the actual operational state (active/standby)? - Verify MRP protocol is running between SFP+ ports ### 8. QoS Configuration — Trust Mode Applied ⚡ All ports configured with `qos-trust-mode ... cos-dscp` **Interpretation:** Trusts incoming DSCP markings for QoS treatment. This is appropriate if upstream devices (router, APs) properly mark traffic classes. ### Summary of Known vs Unknown Items | Item | Status | Investigation Priority | |------|--------|-----------------------| | Port ge 0/1 (`Omada OC200`) | ✅ Known device alias | Verify TP-Link integration | | Port ext-e 0/26 (`Aruba Switch`) | ✅ Connected to JL253A | Confirm ring redundancy state | | Ports ge 0/19–22 | ⚠️ Unused spares | Low priority - optional cleanup | | DHCP snooping | ⚠️ Disabled | Medium priority - security concern | | SNMP communities | ⚠️ Weak strings | Medium priority - rotate soon | | Event email config | ⚠️ Placeholders | Low priority - verify/complete | ## Recommendations Summary 1. **Enable DHCP snooping** to prevent rogue DHCP attacks 2. **Rotate SNMP community strings** for security hardening 3. **Verify ring redundancy operational state** (active vs configured but disabled) 4. **Document device on port ge 0/1** (TP-Link integration?) 5. **Consider enabling PoE** if APs need power (currently no PoE active) ## Related - [01 — Architecture](/nanda/01-architecture/) - Core switch entry - [02 — Switch Configuration](/nanda/02-switch-configuration/) - Aruba JL253A config - [04 — IP Addressing](/nanda/04-ip-addressing/) - Subnet assignments ## Change log --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: ARUBA_DEVICE_MAP.md --- --- title: "Aruba JL253A - Active Device Connection Map" description: "## Device Count by VLAN" section: nanda raw: "ARUBA_DEVICE_MAP.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # Aruba JL253A - Active Device Connection Map > **Source:** `Aruba-MAC-Address.txt` > **Generated:** 2026-09-05 ## Device Count by VLAN | VLAN | Network Type | Device Count | |------|-------------|---------------| | 27 | Guest | 1 device | | 28 | Management | ~35 devices (switch itself + wired clients) | | 29 | Home Automation / IoT | 74+ devices | | 30 | LAN User Network | 30 devices | ## VLAN 29 — Home Automation / IoT Devices Port 2 and port 26 host the majority of your IoT/Home Assistant ecosystem: --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: RING_TOPOLOGY.md --- --- title: "Ring Topology - Dual Switch Redundancy" description: "## Overview" section: nanda raw: "RING_TOPOLOGY.md" source: ai-generated tags: network, aruba, siemens, vlan last-updated: 2026-09-15 --- # Ring Topology - Dual Switch Redundancy > **Generated:** 2026-09-05 > **Source:** MAC address tables from Aruba JL253A and Siemens Scalance XR326-2C ## Overview This documentation describes the ring redundancy topology between two edge switches: | Switch | Model | Management IP | Role | |--------|-------|---------------|------| | LAN-SW | Aruba JL253A (2930F) | 192.168.28.240 | Primary edge switch | | Siemens PoE Switch | Scalance XR326-2C | 192.168.28.241 | Secondary edge switch | ## Physical Connection ```text ┌─────────────────┐ │ OpenWRT Router│ │ (Clacks) │ └────────┬────────┘ │ ┌──────────────────┼──────────────────┐ │ │ │ ┌─────▼─────┐ ┌───────▼───────┐ ┌──────▼──────┐ │ Aruba JL253A│ │ Siemens XR │ │ Other Devices│ │ LAN-SW │◄───►│ Scalance │ │ (end stations)│ └────────────┘ │ XR326-2C │ └──────────────┘ └──────────────┘ ``` ## Connection Paths ### Primary Path (Suggested) The primary path runs **Router → Aruba Switch → Siemens Switch → End Devices**. 1. **Aruba Port ext-e 0/25** connects to router/firewall via SFP+ uplink 2. **Aruba Port ext-e 0/26** connects to Siemens Port Ex0/26 (ring connection) 3. All other devices connect directly to Aruba switch ports ### Alternate Path (Ring Redundancy) When the primary uplink fails, traffic routes through: 1. **Siemens Port Ex0/26** → **Aruba Port ext-e 0/26** 2. Traffic continues via Aruba's main uplink path 3. MRP (Media Redundancy Protocol) handles failover automatically ## MAC Address Analysis - Devices by VLAN and Access Path --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited ============================================================ SECTION: Experiments ============================================================ --- FILE: 01-implementation-plan-template.md --- --- title: "Implementation Plan Template (Reference)" description: "## Purpose" section: experiments raw: "01-implementation-plan-template.md" source: ai-generated tags: wip, template last-updated: 2026-09-15 --- # Implementation Plan Template (Reference) > Documentation for [implementation-plan-template.md](/experiments/implementation-plan-template/) — a reusable template for writing network change implementation plans. ## Purpose This page documents the implementation plan template kept alongside it in this folder. The template is a copy of `~/implementation-plan-template.md`, derived from the "Implementation Plan – PMRN Microwave Dedicated Interconnect Subnet V1.2" document, and is included here because it is a work-in-progress reference item rather than part of the production network documentation. ## Design notes - **YAML frontmatter** — `title`, `status` (DRAFT → IN_REVIEW → APPROVED → COMPLETED / ROLLED-BACK), `author`, `date_created`, `change_type`, `version`, plus a comment block carrying AI-agent instructions (flag un-substituted placeholders; never invent values; no manual heading numbering). - **Unnumbered headings** — heading numbers are generated at export by `pandoc --number-sections`, so deleting or reordering sections never breaks numbering (Word/LibreOffice auto-numbering behaviour, without the formatting overhead). - **In-page cross-references** — GitHub-style anchors (`[section](#slug)`); links must be written after headings are final, because slugs are derived from heading text. - **Personnel** — named Change Controller and Escalation Contact fields, plus an on-site personnel table. - **Per-process structure** — each `### [Process]` has Description / Testing / Rollback / Result sub-sections, a `> Duration:` line, ⚠️ impact markers, and a `Result — [Process]` pass/fail gate; `End of Change` carries the final gate and expected completion time. - **Export** — `pandoc -f gfm plan.md --number-sections --toc --toc-depth=3 -o plan.pdf` (see the footer comment in the template). All `[bracketed placeholders]` are meant to be replaced with project-specific content, and inapplicable sections are deleted. ## Full template The complete template file ([implementation-plan-template.md](/experiments/implementation-plan-template/)) is reproduced below, unrendered: ````markdown --- title: "Implementation Plan: [Feature/Project Name]" status: DRAFT # DRAFT, IN_REVIEW, APPROVED, COMPLETED, ROLLED-BACK author: "@yourusername" date_created: "[date]" change_type: STANDARD # STANDARD, NORMAL, EMERGENCY version: "[1.0.0]" # AI agent instructions: flag un-completed [bracketed placeholders] before # export; never invent values for them. Do not change status: without the # author's explicit sign-off. Do not number headings — numbering is generated # at export (pandoc --number-sections); for reviews, generate a numbered copy # and reference sections by that numbering. --- # Implementation Plan Template > Template based on: "Implementation Plan – PMRN Microwave Dedicated Interconnect Subnet V1.2". > Replace all `[bracketed placeholders]` with project-specific content. Delete any sections not applicable. > **Do not add a manual Table of Contents** — every export target generates its own (see the TOC note below the Document Control section). --- ## Document Control | Field | Details | | -------------------- | ---------------- | | **Customer Name** | [Customer name] | | **Project Name** | [Project name] | | **Document Name** | [Document title] | | **Document Version** | [e.g. 1.0] | ### Sign-Off | Role | Name | Date | | --------------- | -------------- | ------------ | | **Prepared By** | [Name] | [DD/MM/YYYY] | | **Reviewed By** | [Name] | [DD/MM/YYYY] | | **Approved By** | [Name] | [DD/MM/YYYY] | > *Printed copies of this document are uncontrolled. Ensure you have the latest version.* ### Distribution List | Copy No. | Recipient | | -------- | ------------- | | Master | Project file | | 1 | [Recipient 1] | | 2 | [Recipient 2] | ### Change History | Version | Date | Name | Outline of Changes | | ------- | ------------ | -------------- | -------------------- | | 0.1 | [DD/MM/YYYY] | [Name] | First draft | | 1.0 | [DD/MM/YYYY] | [Name] | Approved for release | | 1.1 | [DD/MM/YYYY] | [Name] | [Change description] | --- ## Overview [Brief description of the background and context for this change: what prompted the work, what existing design/condition exists, and what problem it causes.] [Description of what this change will do, which devices/components are affected, and the expected outcome/benefit.] ## Outages and Hazardous Work [State whether any outages to voice/data/radio/management traffic are expected during or as a result of this change.] [List any services that will be briefly interrupted (e.g. monitoring and management of [systems] will occur during the [activity].).] ## Personnel ### Change Controller - **Name:** [] - **Contact Number:** [] ### Escalation Contact - **Name:** [] - **Contact Number:** [] ### On-site Personnel To complete the scheduled work, personnel will be required at the following locations: | Location | [Customer] | [Vendor] | | ------------ | ------------------- | ------------------- | | [Location 1] | Required / Optional | Required / Optional | | [Location 2] | Required / Optional | Required / Optional | ## Implementation Steps > Steps are numbered sequentially across all sub-sections. Checkboxes are reserved for verification steps — tick a checkbox only once the verification has been performed and passed. > All steps to have a **Duration / Time** line ("T+00:00" is the start of the maintenance window). > Mark any step with expected impact using: ⚠️ *Expected: [impact, e.g. access to device lost].* ### Preparation 1. **Confirm personnel on site** 2. **Go/No-Go decision from** [change controller] > Duration: [00:00] — Time: [T+00:00] ### [Process] > Duration: [00:00] — Time: [T+00:00] #### [Description] ⚠️ *Expected: [transient impact, e.g. access to device lost].* ```text [config commands] ``` #### Testing ```text [test commands] ``` #### Rollback > Duration: [00:00] ```text [rollback commands] ``` #### Result — [Process] - [ ] Failed — Rollback - [ ] Passed — Proceed ### End of Change > Expected Completion Time: [T+00:00] #### Result — End of Change - [ ] Rolled back - [ ] Complete ## Configuration Excerpts ### [Device 1] ```text [paste final configuration excerpt] ``` ## Full Configuration ### [Device 1] ```text [paste final configuration] ``` ```` ## Related - [Experiments](/experiments/readme/) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: README.md --- --- title: "Experiments" description: "## Purpose" section: experiments raw: "README.md" source: ai-generated tags: wip, template last-updated: 2026-09-15 --- # Experiments > A sandbox for documents, files, and scripts under development that are not ready for production, or not directly related to the local network. ## Purpose Everything in this folder is experimental. It may be incomplete, untested, or purely speculative — none of it is part of the production NANDA network documentation. Items graduate out of this folder into `Network/`, `ai-docs/`, or `home-assistant/` once they are stable and relevant. ## Contents | # | Document / File | Description | Status | |---|-----------------|-------------|--------| | 01 | [Implementation Plan Template](/experiments/01-implementation-plan-template/) | Reference doc for `implementation-plan-template.md` — template for network change implementation plans | 🧪 Work-in-progress | | — | [implementation-plan-template.md](/experiments/implementation-plan-template/) | The template itself (verbatim copy of `~/implementation-plan-template.md`; lint-exempt via `.markdownlint.jsonc`) | 🧪 Work-in-progress | ## Related - [NANDA — Home Network Documentation](/raw/_shared/README.md) --- ## Source Disclaimer - [x] AI Generated - [ ] Human Generated - [ ] AI Edited - [ ] Human Edited --- FILE: implementation-plan-template.md --- --- title: "Implementation Plan: [Feature/Project Name]" status: DRAFT # DRAFT, IN_REVIEW, APPROVED, COMPLETED, ROLLED-BACK author: "@yourusername" date_created: "[date]" change_type: STANDARD # STANDARD, NORMAL, EMERGENCY version: "[1.0.0]" # AI agent instructions: flag un-completed [bracketed placeholders] before # export; never invent values for them. Do not change status: without the # author's explicit sign-off. Do not number headings — numbering is generated # at export (pandoc --number-sections); for reviews, generate a numbered copy # and reference sections by that numbering. --- # Implementation Plan Template > Template based on: "Implementation Plan – PMRN Microwave Dedicated Interconnect Subnet V1.2". > Replace all `[bracketed placeholders]` with project-specific content. Delete any sections not applicable. > **Do not add a manual Table of Contents** — every export target generates its own (see the TOC note below the Document Control section). --- ## Document Control | Field | Details | | -------------------- | ---------------- | | **Customer Name** | [Customer name] | | **Project Name** | [Project name] | | **Document Name** | [Document title] | | **Document Version** | [e.g. 1.0] | ### Sign-Off | Role | Name | Date | | --------------- | -------------- | ------------ | | **Prepared By** | [Name] | [DD/MM/YYYY] | | **Reviewed By** | [Name] | [DD/MM/YYYY] | | **Approved By** | [Name] | [DD/MM/YYYY] | > *Printed copies of this document are uncontrolled. Ensure you have the latest version.* ### Distribution List | Copy No. | Recipient | | -------- | ------------- | | Master | Project file | | 1 | [Recipient 1] | | 2 | [Recipient 2] | ### Change History | Version | Date | Name | Outline of Changes | | ------- | ------------ | -------------- | -------------------- | | 0.1 | [DD/MM/YYYY] | [Name] | First draft | | 1.0 | [DD/MM/YYYY] | [Name] | Approved for release | | 1.1 | [DD/MM/YYYY] | [Name] | [Change description] | --- ## Overview [Brief description of the background and context for this change: what prompted the work, what existing design/condition exists, and what problem it causes.] [Description of what this change will do, which devices/components are affected, and the expected outcome/benefit.] ## Outages and Hazardous Work [State whether any outages to voice/data/radio/management traffic are expected during or as a result of this change.] [List any services that will be briefly interrupted (e.g. monitoring and management of [systems] will occur during the [activity].).] ## Personnel ### Change Controller - **Name:** [] - **Contact Number:** [] ### Escalation Contact - **Name:** [] - **Contact Number:** [] ### On-site Personnel To complete the scheduled work, personnel will be required at the following locations: | Location | [Customer] | [Vendor] | | ------------ | ------------------- | ------------------- | | [Location 1] | Required / Optional | Required / Optional | | [Location 2] | Required / Optional | Required / Optional | ## Implementation Steps > Steps are numbered sequentially across all sub-sections. Checkboxes are reserved for verification steps — tick a checkbox only once the verification has been performed and passed. > All steps to have a **Duration / Time** line ("T+00:00" is the start of the maintenance window). > Mark any step with expected impact using: ⚠️ *Expected: [impact, e.g. access to device lost].* ### Preparation 1. **Confirm personnel on site** 2. **Go/No-Go decision from** [change controller] > Duration: [00:00] — Time: [T+00:00] ### [Process] > Duration: [00:00] — Time: [T+00:00] #### [Description] ⚠️ *Expected: [transient impact, e.g. access to device lost].* ```text [config commands] ``` #### Testing ```text [test commands] ``` #### Rollback > Duration: [00:00] ```text [rollback commands] ``` #### Result — [Process] - [ ] Failed — Rollback - [ ] Passed — Proceed ### End of Change > Expected Completion Time: [T+00:00] #### Result — End of Change - [ ] Rolled back - [ ] Complete ## Configuration Excerpts ### [Device 1] ```text [paste final configuration excerpt] ``` ## Full Configuration ### [Device 1] ```text [paste final configuration] ```