Merge pull request 'ansible: edge-host role — bare-metal Arch N100 replaces k8s-edge VM' (#95) from edge-host-arch into master
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
ci/woodpecker/push/deploy-infrastructure Pipeline was successful
Reviewed-on: gitea_admin/homelab#95
This commit was merged in pull request #95.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
# Secret indirection — real values live in vault.yml (ansible-vault) next
|
||||
# to this file; see roles/edge-host/README.md for how to populate it from
|
||||
# the cluster secrets.
|
||||
edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
|
||||
edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
|
||||
edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
|
||||
@@ -39,3 +39,6 @@ woodpecker.srv.renner.uno ansible_user=root
|
||||
[woodpecker_agents]
|
||||
wp-agent-1.srv.renner.uno ansible_user=root
|
||||
wp-agent-2.srv.renner.uno ansible_user=root
|
||||
|
||||
[edge_host]
|
||||
edge-1.iot.renner.uno ansible_user=root
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
# Bare-metal Arch Linux edge host (N100) — replaces the k8s-edge Talos VM
|
||||
# (opentofu vm_153) as the home for hardware-bound services:
|
||||
# - zwave-js-ui (ZWA-2 Z-Wave USB radio, docker compose)
|
||||
# - sml2mqtt (FTDI power-meter reader, docker compose)
|
||||
# - open3e (Innomaker USB2CAN -> Vitocal/VX3, native venv + systemd)
|
||||
# Base install procedure and cutover checklist: ansible/roles/edge-host/README.md
|
||||
- name: Install Arch edge host
|
||||
hosts: edge_host
|
||||
roles:
|
||||
- docker-install
|
||||
- edge-host
|
||||
@@ -0,0 +1,107 @@
|
||||
# edge-host — bare-metal Arch N100 (replaces the k8s-edge Talos VM)
|
||||
|
||||
Runs the hardware-bound services that used to be pinned to the `k8s-edge`
|
||||
Talos node (opentofu `vm_153_k8s_edge.tf`), plus the new CAN bridge:
|
||||
|
||||
| Service | How it runs | Hardware | Talks to |
|
||||
|---|---|---|---|
|
||||
| zwave-js-ui | docker compose | ZWA-2 Z-Wave USB radio | HA `zwave_js` via ws://:3000, UI :8091 |
|
||||
| sml2mqtt | docker compose | FTDI FT230X meter reader | Mosquitto (`sml2mqtt` user) |
|
||||
| open3e | venv + systemd | Innomaker USB2CAN (gs_usb) | Mosquitto (`open3e` user) |
|
||||
|
||||
Everything else that once lived on k8s-edge (HA core, matter-server, OTBR,
|
||||
music-assistant) already moved to the general node pool and stays in the
|
||||
cluster — they need VLAN L2 presence, not USB, so a single LAN NIC on the
|
||||
N100 is sufficient.
|
||||
|
||||
Rationale for leaving Kubernetes for the CAN part: the stock Talos kernel
|
||||
ships with `CONFIG_CAN` unset (siderolabs/talos#7686), so SocketCAN cannot
|
||||
work in any pod without a custom kernel image per Talos release.
|
||||
|
||||
## Base install (once, from the edge-host installer ISO)
|
||||
|
||||
Build and boot the dedicated installer image — see
|
||||
`images/edge-host-iso/README.md`. Short version:
|
||||
|
||||
1. `sudo pacman -S archiso && sudo images/edge-host-iso/build.sh`
|
||||
2. Write ISO to a USB stick, boot the N100 from it, run `edge-install`
|
||||
(partitions the NVMe, installs the ansible-ready base system with SSH
|
||||
keys, DHCP, docker — asks for confirmation before wiping).
|
||||
3. Add the DHCP reservation/DNS entry for `edge-1.srv.renner.uno` on the
|
||||
UDM, confirm `ssh root@edge-1.srv.renner.uno` works.
|
||||
4. From the workstation:
|
||||
```
|
||||
ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host
|
||||
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
|
||||
```
|
||||
|
||||
## Secrets (before first play run)
|
||||
|
||||
Create `inventory/group_vars/edge_host/vault.yml` with ansible-vault; the
|
||||
current live values come straight out of the cluster:
|
||||
|
||||
```
|
||||
# sml2mqtt broker password
|
||||
kubectl -n home-assistant get secret mosquitto-sml2mqtt-credentials -o jsonpath='{.data.password}' | base64 -d
|
||||
# open3e broker password
|
||||
kubectl -n home-assistant get secret mosquitto-open3e-credentials -o jsonpath='{.data.password}' | base64 -d
|
||||
# zwave-js session secret
|
||||
kubectl -n home-assistant get secret zwave-js-session -o jsonpath='{.data.session-secret}' | base64 -d
|
||||
```
|
||||
|
||||
```yaml
|
||||
# vault.yml
|
||||
vault_edge_sml2mqtt_mqtt_password: "..."
|
||||
vault_edge_open3e_mqtt_password: "..."
|
||||
vault_edge_zwavejs_session_secret: "..."
|
||||
```
|
||||
|
||||
The Z-Wave network keys do NOT need separate handling — they live in
|
||||
`settings.json` inside the store that gets migrated below.
|
||||
|
||||
## Cutover checklist (service downtime: minutes, do per-service)
|
||||
|
||||
Preparation:
|
||||
- [ ] `vault.yml` populated (above), play runs green against the N100
|
||||
- [ ] CAN: Innomaker adapter connected to the VX3 external CAN port,
|
||||
`ip -details link show can0` shows `state ERROR-ACTIVE bitrate 250000`,
|
||||
`candump can0 -n 5` shows frames
|
||||
|
||||
zwave-js (order matters — never two servers on one radio):
|
||||
- [ ] Copy the store out of the PVC while the pod still runs:
|
||||
`kubectl -n home-assistant exec deploy/zwave-js -- tar cf - -C /usr/src/app store | tar xf - -C /opt/edge-host/zwave-js/`
|
||||
- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/zwave-js --replicas 0`
|
||||
- [ ] Move the ZWA-2 stick to the N100, `docker compose up -d zwave-js`
|
||||
- [ ] Point HA `zwave_js` integration at `ws://edge-1.srv.renner.uno:3000`
|
||||
- [ ] Nodes alive in the UI on :8091 → remove the flux kustomization
|
||||
`home-assistant-zwavejs` + PVC in a follow-up PR
|
||||
|
||||
sml2mqtt:
|
||||
- [ ] Scale down in-cluster: `kubectl -n home-assistant scale deploy/sml2mqtt --replicas 0`
|
||||
- [ ] Move the FTDI stick, `docker compose up -d sml2mqtt`
|
||||
- [ ] HA meter entities update again (same entity_ids — config is a 1:1
|
||||
port, Wh stays Wh) → remove sml2mqtt from the home-assistant
|
||||
kustomization in the follow-up PR
|
||||
|
||||
open3e:
|
||||
- [ ] Stop any workstation listener (ONE instance per bus, hard rule)
|
||||
- [ ] Move the USB2CAN adapter, `systemctl start open3e`
|
||||
- [ ] Topics under `open3e/#` update on the broker
|
||||
|
||||
Afterwards:
|
||||
- [ ] Retire VM 153: remove `vm_153_k8s_edge.tf`, `talos/patches/k8s-edge.yaml`,
|
||||
node from the cluster (`kubectl delete node k8s-edge`, tofu apply)
|
||||
- [ ] Update wiki (k8s-edge-node-pattern note is then historical)
|
||||
|
||||
## Day-2 notes
|
||||
|
||||
- Image bumps: edit `edge_zwavejs_image` / `edge_sml2mqtt_image` in
|
||||
defaults and re-run the play (no flux image-automation out here —
|
||||
deliberate for sml2mqtt, acceptable for zwave-js).
|
||||
- open3e updates: re-run the play; the pip task tracks the git default
|
||||
branch (pin `edge_open3e_pkg` to a tag if that ever bites).
|
||||
- Ad-hoc CAN scans: `systemctl stop open3e` first (single-instance rule),
|
||||
restart afterwards.
|
||||
- can-utils (`candump`) is AUR-only on Arch — install manually via
|
||||
`pamac build can-utils` if needed for debugging; the role deliberately
|
||||
doesn't manage AUR packages.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
edge_base_dir: /opt/edge-host
|
||||
edge_tz: Europe/Berlin
|
||||
|
||||
# --- MQTT: all services publish to the central in-cluster Mosquitto via
|
||||
# its LoadBalancer hostname (the edge host is outside the cluster).
|
||||
edge_mqtt_host: mqtt.renner.uno
|
||||
edge_mqtt_port: 1883
|
||||
|
||||
# --- USB devices (stable by-id symlinks). After physically moving the
|
||||
# sticks to the N100, verify with `ls -l /dev/serial/by-id/` — the by-id
|
||||
# names travel with the device, not the host, so these should not change.
|
||||
edge_zwave_device: /dev/serial/by-id/usb-Nabu_Casa_ZWA-2_80B54EE15720-if00
|
||||
edge_stromzaehler_device: /dev/serial/by-id/usb-FTDI_FT230X_Basic_UART_DK0E1GP5-if00-port0
|
||||
|
||||
# --- Images. Versions mirror the k8s manifests at migration time.
|
||||
# sml2mqtt stays hand-pinned (critical meter bridge — was deliberately
|
||||
# excluded from image-automation in k8s, keep that property here).
|
||||
edge_zwavejs_image: ghcr.io/zwave-js/zwave-js-ui:11.21.1
|
||||
edge_sml2mqtt_image: spacemanspiff2007/sml2mqtt:3.6.0
|
||||
|
||||
# --- CAN / open3e (Viessmann E3 external bus) ---
|
||||
edge_can_interface: can0
|
||||
edge_can_bitrate: 250000
|
||||
edge_open3e_pkg: git+https://github.com/open3e/open3e.git
|
||||
edge_open3e_mqtt_user: open3e
|
||||
edge_open3e_mqtt_topic: open3e
|
||||
edge_open3e_mqtt_format: "{device}_{didNumber:04d}_{didName}"
|
||||
edge_open3e_poll_seconds: 30
|
||||
# Read-only datapoint set (Phase 1 extended set, established 2026-07-02 on
|
||||
# the commissioning workstation; see vican repo CLAUDE.md for the rationale).
|
||||
# NO -w / write DIDs here — live heating + battery, writes are Phase 3.
|
||||
edge_open3e_datapoints: >-
|
||||
vitocal.268,vitocal.269,vitocal.271,vitocal.274,vitocal.318,vitocal.321,vitocal.324,vitocal.548,vitocal.565,vitocal.1043,vitocal.1190,vitocal.1211,vitocal.1391,vitocal.2320,vitocal.2346,vitocal.2351,vitocal.2352,vitocal.2369,vitocal.2370,vitocal.2622,vitocal.2624,vitocal.2625,vitocal.2735,vitocal.3016,vx3.378,vx3.379,vx3.380,vx3.535,vx3.1603,vx3.1664,vx3.1690,vx3.1801,vx3.1802,vx3.1810,vx3.1828,vx3.1830,vx3.1834,vx3.1836,vx3.2240,vcmu.322,vcmu.325,vcmu.389,vcmu.391,vcmu.1772,vcmu.1773,vcmu.1775,vcmu.1776,vcmu.2256,vcmu.2259,vcmu.2333,vcmu.2334,vcmu.2569,vcmu.2571
|
||||
# ECU addresses; -dev selects codec set, tx selects the UDS address.
|
||||
# vcmu (refrigerant-circuit controller) has no dedicated codec set —
|
||||
# omitting dpList makes open3e fall back to the generic datapoint list,
|
||||
# which decodes all polled VCMU DIDs.
|
||||
edge_open3e_devices:
|
||||
vitocal: { tx: "0x680", dpList: "vcal" }
|
||||
vx3: { tx: "0x6a1", dpList: "vx3" }
|
||||
vcmu: { tx: "0x68c" }
|
||||
|
||||
# --- Secrets: define these in inventory/group_vars/edge_host/vault.yml
|
||||
# (ansible-vault). See README.md for the kubectl one-liners that read the
|
||||
# current values out of the cluster during migration.
|
||||
# edge_sml2mqtt_mqtt_password: "{{ vault_edge_sml2mqtt_mqtt_password }}"
|
||||
# edge_open3e_mqtt_password: "{{ vault_edge_open3e_mqtt_password }}"
|
||||
# edge_zwavejs_session_secret: "{{ vault_edge_zwavejs_session_secret }}"
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
# reload alone only re-reads config files; existing links keep their old
|
||||
# state until reconfigured.
|
||||
- name: Reload networkd
|
||||
ansible.builtin.shell:
|
||||
cmd: networkctl reload && networkctl reconfigure {{ edge_can_interface }}
|
||||
changed_when: true
|
||||
|
||||
- name: Restart edge compose services
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d --force-recreate
|
||||
chdir: "{{ edge_base_dir }}"
|
||||
changed_when: true
|
||||
|
||||
- name: Restart open3e
|
||||
ansible.builtin.systemd:
|
||||
name: open3e
|
||||
state: restarted
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
# Arch edge host: zwave-js-ui + sml2mqtt as docker compose services,
|
||||
# open3e natively (venv + systemd) because it needs a SocketCAN interface,
|
||||
# which is host network state, not something to hide in a container.
|
||||
# Docker itself comes from the docker-install role (Arch-aware).
|
||||
|
||||
- name: Install base packages
|
||||
ansible.builtin.package:
|
||||
name:
|
||||
- python
|
||||
- python-packaging # ansible's pip module imports it on the target
|
||||
- git
|
||||
state: present
|
||||
|
||||
# --- SocketCAN: systemd-networkd owns can0. gs_usb is an in-tree module
|
||||
# and autoloads when the Innomaker adapter enumerates; networkd then
|
||||
# matches the interface and applies bitrate + up. RestartSec recovers the
|
||||
# link after bus-off without manual intervention.
|
||||
- name: Configure can0 via systemd-networkd
|
||||
ansible.builtin.template:
|
||||
src: 80-can0.network.j2
|
||||
dest: /etc/systemd/network/80-can0.network
|
||||
mode: "0644"
|
||||
notify: Reload networkd
|
||||
|
||||
- name: Enable systemd-networkd
|
||||
ansible.builtin.service:
|
||||
name: systemd-networkd
|
||||
state: started
|
||||
enabled: true
|
||||
|
||||
# --- Directory layout ---
|
||||
- name: Create service directories
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ edge_base_dir }}"
|
||||
- "{{ edge_base_dir }}/zwave-js/store"
|
||||
- "{{ edge_base_dir }}/sml2mqtt"
|
||||
|
||||
# --- Compose services: zwave-js-ui + sml2mqtt ---
|
||||
- name: Deploy docker-compose.yml
|
||||
ansible.builtin.template:
|
||||
src: docker-compose.yml.j2
|
||||
dest: "{{ edge_base_dir }}/docker-compose.yml"
|
||||
mode: "0644"
|
||||
notify: Restart edge compose services
|
||||
|
||||
- name: Deploy zwave-js env (session secret)
|
||||
ansible.builtin.template:
|
||||
src: zwavejs.env.j2
|
||||
dest: "{{ edge_base_dir }}/zwavejs.env"
|
||||
mode: "0600"
|
||||
notify: Restart edge compose services
|
||||
|
||||
# sml2mqtt reads only a config file and has no env override for the
|
||||
# password, so the rendered config carries it — 0600, root-owned, same
|
||||
# trust boundary as the k8s init-container approach it replaces.
|
||||
- name: Deploy sml2mqtt config
|
||||
ansible.builtin.template:
|
||||
src: sml2mqtt-config.yml.j2
|
||||
dest: "{{ edge_base_dir }}/sml2mqtt/config.yml"
|
||||
mode: "0600"
|
||||
notify: Restart edge compose services
|
||||
|
||||
- name: Pull compose images
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose pull
|
||||
chdir: "{{ edge_base_dir }}"
|
||||
changed_when: false
|
||||
|
||||
# Tolerates missing USB devices: before the physical cutover the ZWA-2 /
|
||||
# FTDI sticks are still on the old k8s-edge VM, and a container whose
|
||||
# device is absent must not block the rest of the role (open3e/CAN moves
|
||||
# first). Re-run the play — or `docker compose up -d` — after each stick
|
||||
# arrives.
|
||||
- name: Start compose services
|
||||
ansible.builtin.command:
|
||||
cmd: docker compose up -d
|
||||
chdir: "{{ edge_base_dir }}"
|
||||
register: compose_up
|
||||
failed_when: >-
|
||||
compose_up.rc != 0
|
||||
and 'error gathering device information' not in compose_up.stderr
|
||||
changed_when: >-
|
||||
'Started' in compose_up.stderr or 'Created' in compose_up.stderr
|
||||
or 'Started' in compose_up.stdout or 'Creating' in compose_up.stdout
|
||||
|
||||
- name: Warn about containers waiting for their USB device
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
docker compose could not start every service (USB device not present
|
||||
yet — expected before cutover): {{ compose_up.stderr_lines | select('search', 'device') | list }}
|
||||
when: compose_up.rc != 0
|
||||
|
||||
# --- open3e: native install. CAN_RAW sockets need no capabilities, so
|
||||
# the service runs as an unprivileged system user.
|
||||
- name: Create open3e system user
|
||||
ansible.builtin.user:
|
||||
name: open3e
|
||||
system: true
|
||||
create_home: false
|
||||
shell: /usr/bin/nologin
|
||||
|
||||
# Owned by the service user: udsoncan writes its log file into the
|
||||
# working directory.
|
||||
- name: Create open3e directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ edge_base_dir }}/open3e"
|
||||
state: directory
|
||||
owner: open3e
|
||||
group: open3e
|
||||
mode: "0755"
|
||||
|
||||
- name: Install open3e into a venv
|
||||
ansible.builtin.pip:
|
||||
name: "{{ edge_open3e_pkg }}"
|
||||
virtualenv: "{{ edge_base_dir }}/open3e/venv"
|
||||
virtualenv_command: python -m venv
|
||||
notify: Restart open3e
|
||||
|
||||
- name: Deploy open3e devices.json
|
||||
ansible.builtin.copy:
|
||||
content: "{{ edge_open3e_devices | to_nice_json }}\n"
|
||||
dest: "{{ edge_base_dir }}/open3e/devices.json"
|
||||
mode: "0644"
|
||||
notify: Restart open3e
|
||||
|
||||
- name: Deploy open3e environment (broker credential)
|
||||
ansible.builtin.template:
|
||||
src: open3e.env.j2
|
||||
dest: "{{ edge_base_dir }}/open3e/open3e.env"
|
||||
owner: open3e
|
||||
mode: "0600"
|
||||
notify: Restart open3e
|
||||
|
||||
- name: Deploy open3e systemd unit
|
||||
ansible.builtin.template:
|
||||
src: open3e.service.j2
|
||||
dest: /etc/systemd/system/open3e.service
|
||||
mode: "0644"
|
||||
notify: Restart open3e
|
||||
|
||||
- name: Enable open3e
|
||||
ansible.builtin.systemd:
|
||||
name: open3e
|
||||
enabled: true
|
||||
state: started
|
||||
daemon_reload: true
|
||||
@@ -0,0 +1,15 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# Viessmann E3 external CAN bus via Innomaker USB2CAN (gs_usb).
|
||||
# The adapter is a stub tap on an already-terminated bus (120R jumper OFF).
|
||||
[Match]
|
||||
Name={{ edge_can_interface }}
|
||||
|
||||
[Link]
|
||||
# networkd applies [CAN] settings but does not raise a CAN link on its
|
||||
# own (observed on edge-1); force it up, and back up after any down.
|
||||
ActivationPolicy=always-up
|
||||
|
||||
[CAN]
|
||||
BitRate={{ edge_can_bitrate }}
|
||||
# Auto-recover the link if the controller ever goes bus-off.
|
||||
RestartSec=1s
|
||||
@@ -0,0 +1,42 @@
|
||||
# Managed by ansible (roles/edge-host). Mirrors the former k8s manifests:
|
||||
# kubernetes/apps/home-assistant-zwavejs/deployment.yaml and
|
||||
# kubernetes/apps/home-assistant/sml2mqtt-deployment.yaml.
|
||||
#
|
||||
# No `privileged` needed here: docker's --device handles the device-cgroup
|
||||
# grant that hostPath in k8s could not (that was the only reason the pods
|
||||
# ran privileged).
|
||||
services:
|
||||
zwave-js:
|
||||
image: {{ edge_zwavejs_image }}
|
||||
container_name: zwave-js
|
||||
restart: unless-stopped
|
||||
stop_signal: SIGINT
|
||||
tty: true
|
||||
environment:
|
||||
- TZ={{ edge_tz }}
|
||||
env_file:
|
||||
- zwavejs.env
|
||||
devices:
|
||||
- "{{ edge_zwave_device }}:/dev/zwave"
|
||||
volumes:
|
||||
# Persistent store: settings.json, device DB, network keys. Seeded
|
||||
# from the k8s PVC `zwavejs-store` at cutover (see README). Since
|
||||
# this host is stateful there is no init-container merge step —
|
||||
# settings.json is edited via the UI and persists here; zwave.port
|
||||
# stays /dev/zwave.
|
||||
- ./zwave-js/store:/usr/src/app/store
|
||||
ports:
|
||||
- "3000:3000" # Z-Wave JS websocket — HA zwave_js integration
|
||||
- "8091:8091" # web UI
|
||||
|
||||
sml2mqtt:
|
||||
image: {{ edge_sml2mqtt_image }}
|
||||
container_name: sml2mqtt
|
||||
restart: unless-stopped
|
||||
# Serial exclusivity: this is the ONLY reader of the meter port —
|
||||
# HA's edl21 integration stays disabled (see sml2mqtt-config comment
|
||||
# history in the k8s repo).
|
||||
devices:
|
||||
- "{{ edge_stromzaehler_device }}:/dev/stromzaehler"
|
||||
volumes:
|
||||
- ./sml2mqtt/config.yml:/sml2mqtt/config.yml:ro
|
||||
@@ -0,0 +1,4 @@
|
||||
# Managed by ansible (roles/edge-host). Read by open3e.service.
|
||||
# Full user:password pair as one variable so the unit can pass it as a
|
||||
# single -muser argument without exposing it in the unit file.
|
||||
OPEN3E_MUSER={{ edge_open3e_mqtt_user }}:{{ edge_open3e_mqtt_password }}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Managed by ansible (roles/edge-host).
|
||||
# READ-ONLY listener — no -w, no service 0x77. Writes are Phase 3 and get
|
||||
# their own review; this unit must never grow write flags casually.
|
||||
# -j is load-bearing: without it open3e flattens JSON payloads into
|
||||
# per-key subtopics and the HA package (homeassistant repo,
|
||||
# packages/open3e.yaml) receives nothing.
|
||||
# Exactly ONE open3e instance may be on the bus at a time (CAN collisions
|
||||
# stop open3e) — stop this service before running ad-hoc scans.
|
||||
[Unit]
|
||||
Description=open3e Viessmann E3 CAN-to-MQTT bridge (read-only)
|
||||
# Tie the service to the CAN device: stops on unplug, starts on plug via
|
||||
# the device unit rather than polling.
|
||||
BindsTo=sys-subsystem-net-devices-{{ edge_can_interface }}.device
|
||||
After=sys-subsystem-net-devices-{{ edge_can_interface }}.device network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=open3e
|
||||
EnvironmentFile={{ edge_base_dir }}/open3e/open3e.env
|
||||
WorkingDirectory={{ edge_base_dir }}/open3e
|
||||
ExecStart={{ edge_base_dir }}/open3e/venv/bin/open3e \
|
||||
-c {{ edge_can_interface }} \
|
||||
-cnfg {{ edge_base_dir }}/open3e/devices.json \
|
||||
-m {{ edge_mqtt_host }}:{{ edge_mqtt_port }}:{{ edge_open3e_mqtt_topic }} \
|
||||
-muser ${OPEN3E_MUSER} \
|
||||
-mcid open3e-edge \
|
||||
-mfstr "{{ edge_open3e_mqtt_format }}" \
|
||||
-r {{ edge_open3e_datapoints }} \
|
||||
-t {{ edge_open3e_poll_seconds }} \
|
||||
-j
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,32 @@
|
||||
# Managed by ansible (roles/edge-host). Ported 1:1 from the k8s ConfigMap
|
||||
# sml2mqtt-config; only host and password delivery differ (broker is now
|
||||
# reached via its LoadBalancer hostname, password rendered by ansible
|
||||
# instead of an init container).
|
||||
logging:
|
||||
level: INFO
|
||||
file: stdout # 'stdout' -> docker logs (NOT '' — that opens the dir)
|
||||
|
||||
mqtt:
|
||||
connection:
|
||||
identifier: sml2mqtt-meter
|
||||
host: {{ edge_mqtt_host }}
|
||||
port: {{ edge_mqtt_port }}
|
||||
user: sml2mqtt
|
||||
password: {{ edge_sml2mqtt_mqtt_password }}
|
||||
topic prefix: sml2mqtt
|
||||
defaults:
|
||||
qos: 0
|
||||
retain: false
|
||||
last will:
|
||||
topic: status
|
||||
|
||||
general:
|
||||
Wh in kWh: false # keep raw Wh to match edl21 history — HA long-term
|
||||
# stats continue with zero unit discontinuity
|
||||
republish after: 120 # re-emit unchanged values every 120 s
|
||||
|
||||
inputs:
|
||||
- type: serial
|
||||
url: /dev/stromzaehler # FTDI FT230X, mapped by docker compose
|
||||
timeout: 3
|
||||
# 9600 8N1 are sml2mqtt defaults for SML/EDL21 — no override needed.
|
||||
@@ -0,0 +1 @@
|
||||
SESSION_SECRET={{ edge_zwavejs_session_secret }}
|
||||
@@ -0,0 +1 @@
|
||||
out/
|
||||
@@ -0,0 +1,47 @@
|
||||
# edge-host installer ISO
|
||||
|
||||
Bootable Arch installer image for `edge-1.srv.renner.uno` (the bare-metal
|
||||
N100 that replaces the k8s-edge VM — see `ansible/roles/edge-host`).
|
||||
|
||||
The image is the official archiso **releng** profile plus:
|
||||
- `edge-install` in the live environment — partitions the NVMe
|
||||
(ESP + ext4 root, systemd-boot), pacstraps the base system incl.
|
||||
docker/python, configures DHCP via systemd-networkd, enables sshd and
|
||||
drops the common homelab SSH keys for root (key-only, no root password)
|
||||
- the extra disk tools the installer needs
|
||||
|
||||
Division of labour: the ISO produces an **ansible-ready base system**;
|
||||
all service configuration (zwave-js-ui, sml2mqtt, open3e/CAN) stays in
|
||||
`ansible/roles/edge-host` as the single source of truth. Rebuilding the
|
||||
box is: boot ISO → `edge-install` → run the play.
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
sudo pacman -S archiso
|
||||
cd images/edge-host-iso
|
||||
sudo ./build.sh # ISO lands in ./out/
|
||||
```
|
||||
|
||||
The profile is assembled at build time from `/usr/share/archiso/configs/releng`
|
||||
plus the overlay in `airootfs/` — the repo carries only the delta, so
|
||||
archiso updates flow in automatically.
|
||||
|
||||
## Install
|
||||
|
||||
1. Write the ISO to a USB stick: `cp out/edge-host-*.iso /dev/sdX`
|
||||
2. Boot the target from it (UEFI, Secure Boot off; one-time F12/setup
|
||||
visit if the box doesn't already boot USB first)
|
||||
3. Either on the console (auto-login as root) or headless —
|
||||
`ssh root@<dhcp-ip>` works out of the box, the live env carries the
|
||||
homelab keys — run `edge-install`. It auto-detects the internal disk
|
||||
(NVMe, else eMMC — covers the Wyse 3040 — else first non-USB disk),
|
||||
shows the target and requires typing the hostname before wiping
|
||||
anything. Override with `edge-install /dev/sdY`.
|
||||
4. Reboot, then from the workstation:
|
||||
```
|
||||
cd ansible
|
||||
ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host
|
||||
ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml
|
||||
```
|
||||
5. Continue with the cutover checklist in `ansible/roles/edge-host/README.md`.
|
||||
@@ -0,0 +1,5 @@
|
||||
# The Wyse 3040's eMMC card is detected by sdhci_acpi, but the live env
|
||||
# does not reliably autoload mmc_block — without it no /dev/mmcblk*
|
||||
# appears and edge-install finds no target. Load explicitly.
|
||||
mmc_block
|
||||
sdhci_acpi
|
||||
@@ -0,0 +1,4 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310
|
||||
@@ -0,0 +1,4 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPzFNBP3bq2m/6JqDqod9w+FP/uxXxHMolqhq3Ojflr1 krenner@fortress
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGfe3HJqFR+jIGrboweseAO7XjIVE24sje/b1HybgU9C ansible-runner@ci-new
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyNnX1I4W9jyDvCJ1DJauHSmbo1ZXSyBxVhSAwMWgen woodpecker-ci@homelab
|
||||
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBB1OFjh6Q5xeioI6Cwfuv8Xka27veomYy9tKBbpMo8mcrsB4yIN4FX8HovBLtogetBhwBUefBar8nhA9GbRh6HQ= krenner@krenner-XPS-13-9310
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installer for edge-1 (bare-metal edge host, replaces the k8s-edge VM).
|
||||
# Runs from the edge-host ISO live environment. WIPES the target disk.
|
||||
# Usage: edge-install [disk]
|
||||
# Default: the internal disk — first NVMe, else eMMC (Wyse 3040), else
|
||||
# the first non-USB, non-optical disk. The live USB stick itself is
|
||||
# excluded via the ISO9660 label mount.
|
||||
set -euo pipefail
|
||||
|
||||
HOST=edge-1
|
||||
|
||||
detect_disk() {
|
||||
# eMMC needs mmc_block before any /dev/mmcblk* exists (see
|
||||
# modules-load.d/edge-mmc.conf; belt-and-braces here for older sticks).
|
||||
modprobe -q mmc_block sdhci_acpi 2>/dev/null || true
|
||||
udevadm settle 2>/dev/null || true
|
||||
[ -b /dev/nvme0n1 ] && { echo /dev/nvme0n1; return; }
|
||||
for d in /dev/mmcblk0 /dev/mmcblk1 /dev/mmcblk2; do
|
||||
[ -b "$d" ] && { echo "$d"; return; }
|
||||
done
|
||||
# NAME,TYPE first — TRAN is often empty (eMMC, virtio) and would shift
|
||||
# whitespace-split fields if it sat in the middle.
|
||||
lsblk -dno NAME,TYPE,TRAN | awk '$2=="disk" && $3!="usb" {print "/dev/"$1; exit}'
|
||||
}
|
||||
|
||||
DISK="${1:-$(detect_disk)}"
|
||||
[ -n "$DISK" ] || { echo "no internal disk found; pass one explicitly"; exit 1; }
|
||||
[ -b "$DISK" ] || { echo "no such block device: $DISK"; exit 1; }
|
||||
|
||||
echo "Target disk: $DISK"
|
||||
lsblk "$DISK"
|
||||
echo
|
||||
read -rp "This WIPES ${DISK} and installs ${HOST}. Type '${HOST}' to continue: " ans
|
||||
[ "$ans" = "$HOST" ] || { echo "aborted"; exit 1; }
|
||||
|
||||
part() {
|
||||
case "$DISK" in
|
||||
*nvme*|*mmcblk*) echo "${DISK}p$1" ;;
|
||||
*) echo "${DISK}$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
sgdisk --zap-all "$DISK"
|
||||
sgdisk -n1:0:+512M -t1:ef00 -c1:ESP -N2 -t2:8304 -c2:root "$DISK"
|
||||
partprobe "$DISK"
|
||||
mkfs.fat -F32 "$(part 1)"
|
||||
mkfs.ext4 -F "$(part 2)"
|
||||
|
||||
mount "$(part 2)" /mnt
|
||||
mount --mkdir "$(part 1)" /mnt/boot
|
||||
|
||||
# Everything the edge-host ansible role expects to find; the role itself
|
||||
# is applied over SSH after first boot (see ansible/roles/edge-host).
|
||||
pacstrap -K /mnt base linux linux-firmware intel-ucode openssh python git \
|
||||
docker docker-compose
|
||||
|
||||
genfstab -U /mnt >> /mnt/etc/fstab
|
||||
|
||||
# eMMC boot: autodetect does not reliably include the mmc stack, and a
|
||||
# root on /dev/mmcblk0p2 without mmc_block in the initramfs is unbootable
|
||||
# (observed on the Wyse 3040). Pin the modules and regenerate.
|
||||
sed -i 's/^MODULES=.*/MODULES=(sdhci_acpi sdhci_pci mmc_block)/' /mnt/etc/mkinitcpio.conf
|
||||
arch-chroot /mnt mkinitcpio -P
|
||||
echo "$HOST" > /mnt/etc/hostname
|
||||
ln -sf /usr/share/zoneinfo/Europe/Berlin /mnt/etc/localtime
|
||||
echo 'en_US.UTF-8 UTF-8' > /mnt/etc/locale.gen
|
||||
echo 'LANG=en_US.UTF-8' > /mnt/etc/locale.conf
|
||||
arch-chroot /mnt locale-gen
|
||||
|
||||
cat > /mnt/etc/systemd/network/20-wired.network <<'EOF'
|
||||
[Match]
|
||||
Type=ether
|
||||
|
||||
[Network]
|
||||
DHCP=yes
|
||||
EOF
|
||||
ln -sf /run/systemd/resolve/stub-resolv.conf /mnt/etc/resolv.conf
|
||||
|
||||
# Key-only root SSH (no root password is set — console login stays locked,
|
||||
# which is fine for a headless box; recovery goes through this ISO).
|
||||
install -d -m700 /mnt/root/.ssh
|
||||
install -m600 /root/edge-authorized_keys /mnt/root/.ssh/authorized_keys
|
||||
|
||||
arch-chroot /mnt systemctl enable \
|
||||
systemd-networkd systemd-resolved systemd-timesyncd sshd docker
|
||||
|
||||
arch-chroot /mnt bootctl install
|
||||
cat > /mnt/boot/loader/loader.conf <<'EOF'
|
||||
default edge.conf
|
||||
timeout 3
|
||||
EOF
|
||||
cat > /mnt/boot/loader/entries/edge.conf <<EOF
|
||||
title Arch Linux (${HOST})
|
||||
linux /vmlinuz-linux
|
||||
initrd /intel-ucode.img
|
||||
initrd /initramfs-linux.img
|
||||
options root=UUID=$(blkid -s UUID -o value "$(part 2)") rw
|
||||
EOF
|
||||
|
||||
umount -R /mnt
|
||||
echo
|
||||
echo "${HOST} installed. Remove the USB stick, reboot, then from the"
|
||||
echo "workstation: cd ansible &&"
|
||||
echo " ansible-playbook -i inventory/pve_hosts.yml plays/common.yml -l edge_host"
|
||||
echo " ansible-playbook -i inventory/pve_hosts.yml plays/install-edge-host.yml"
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs INSIDE the archlinux container (invoked by build.sh).
|
||||
# Assembles the profile from the container's releng + the repo overlay,
|
||||
# then builds the ISO to /out.
|
||||
set -euo pipefail
|
||||
|
||||
pacman -Syu --noconfirm archiso
|
||||
|
||||
profile=/tmp/profile
|
||||
cp -r /usr/share/archiso/configs/releng/. "$profile"
|
||||
cp -r /src/airootfs/. "$profile/airootfs/"
|
||||
|
||||
# Tools the installer script needs in the live environment (idempotent —
|
||||
# skip entries releng already ships).
|
||||
for pkg in gptfdisk dosfstools e2fsprogs; do
|
||||
grep -qx "$pkg" "$profile/packages.x86_64" || echo "$pkg" >> "$profile/packages.x86_64"
|
||||
done
|
||||
|
||||
# Brand the image and make the installer executable inside the ISO.
|
||||
sed -i 's/^iso_name=.*/iso_name="edge-host"/' "$profile/profiledef.sh"
|
||||
sed -i '/^file_permissions=(/a\ ["/usr/local/bin/edge-install"]="0:0:755"' "$profile/profiledef.sh"
|
||||
# Headless install path: releng enables sshd; with the homelab keys on the
|
||||
# live root you can `ssh root@<dhcp-ip>` and run edge-install monitor-free.
|
||||
sed -i '/^file_permissions=(/a\ ["/root/.ssh"]="0:0:700"' "$profile/profiledef.sh"
|
||||
sed -i '/^file_permissions=(/a\ ["/root/.ssh/authorized_keys"]="0:0:600"' "$profile/profiledef.sh"
|
||||
|
||||
mkarchiso -v -w /tmp/work -o /out "$profile"
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the edge-host installer ISO inside an Arch container.
|
||||
# Building must happen against real Arch repos — a Manjaro (or any
|
||||
# non-Arch) host can't feed mkarchiso, so the container is the default
|
||||
# and only path. Needs docker; --privileged because pacstrap bind-mounts
|
||||
# into the chroot.
|
||||
# ./build.sh [output-dir]
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
out="${1:-$here/out}"
|
||||
mkdir -p "$out"
|
||||
|
||||
docker run --rm --privileged \
|
||||
-v "$here:/src:ro" \
|
||||
-v "$out:/out" \
|
||||
archlinux:latest /src/build-inner.sh
|
||||
|
||||
echo
|
||||
echo "ISO in $out — write it with:"
|
||||
echo " sudo sh -c 'cp $out/edge-host-*.iso /dev/sdX && sync' # USB stick, NOT the NVMe"
|
||||
@@ -13,8 +13,11 @@
|
||||
# Pinned to neptune: VM 103 (legacy HAOS) currently holds the ZWA-2 +
|
||||
# stromzaehler USB mappings on neptune; at HA cutover the USBs rebind
|
||||
# from VM 103 to this VM in-place on the same host (no physical move).
|
||||
# k8s-edge stays a Proxmox VM (decision 2026-05-05) — bare-metal N100
|
||||
# trajectory is shelved.
|
||||
# DEPRECATION PATH (decision 2026-07-02, reverses 2026-05-05): this VM is
|
||||
# being replaced by a bare-metal Arch N100 (`edge-1`, see
|
||||
# ansible/roles/edge-host) because the new CAN/open3e service needs a
|
||||
# kernel with CONFIG_CAN, which stock Talos does not ship. Remove this
|
||||
# file after the cutover checklist in roles/edge-host/README.md is done.
|
||||
module "k8s_edge" {
|
||||
source = "./modules/proxmox_vm"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user