Compare commits

..

21 Commits

Author SHA1 Message Date
a56402c2cc update litellm docs
All checks were successful
Podman DDNS Image / build-and-push-ddns (push) Successful in 1m22s
2026-05-11 18:22:14 -04:00
f2015e2c71 checkpoint commit
All checks were successful
Podman DDNS Image / build-and-push-ddns (push) Successful in 1m3s
2026-05-05 06:26:40 -04:00
e43c534ceb update instruct and think containers 2026-04-16 16:05:52 -04:00
66f9304cc6 add longhorn demo
All checks were successful
Podman DDNS Image / build-and-push-ddns (push) Successful in 1m28s
2026-04-16 13:11:17 -04:00
65b9c8e70e add kubernetes_traefik 2026-04-16 12:37:35 -04:00
8865a11d67 finalize for kube class
All checks were successful
Podman DDNS Image / build-and-push-ddns (push) Successful in 1m14s
2026-04-14 10:16:03 -04:00
8b256bda98 add escaping stuck ssh terminals 2026-04-06 11:48:23 -04:00
8136740105 add presentations 2026-04-06 11:48:12 -04:00
171cfed7e3 add scratch dir to gitignore 2026-04-06 11:47:20 -04:00
56257e85d6 add kube hosts to ansible inventory 2026-04-06 11:47:05 -04:00
f359a64218 init agentic server monitor scripts
All checks were successful
Podman DDNS Image / build-and-push-ddns (push) Successful in 1m4s
2026-04-06 11:46:51 -04:00
3bc92c5889 last updates to wyoming before deprecation 2026-04-06 11:46:21 -04:00
430be75fab updates to fedora 43 image builder 2026-04-06 11:45:59 -04:00
25e812ab55 clean up k3s for new deployment 2026-04-06 11:45:24 -04:00
67b644005a add firewalld log following 2026-04-06 11:45:06 -04:00
9eb79d34f1 add vllm notes 2026-04-06 11:44:53 -04:00
9776f8ed9f update metallb to work for kube 1.35+ 2026-04-06 11:44:45 -04:00
57ec92fc5d add kubectl config set context namespace note 2026-04-06 11:44:28 -04:00
10786dead3 add roku list tv apps 2026-04-06 11:43:52 -04:00
a2be3dc1ea add launch.json 2026-04-06 11:43:40 -04:00
b78c205c9a update driveripper vm docs to fedora 43 2026-04-06 11:43:29 -04:00
102 changed files with 5327 additions and 484 deletions

3
.gitignore vendored
View File

@@ -11,4 +11,5 @@ TODO.md
eicar.com
*.pp
*.mod
*.log
*.log
scratch/

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
}
]
}

View File

@@ -41,6 +41,7 @@ or give me access to your servers.
- [tmux](#tmux)
- [bash](#bash)
- [Bulk File/Folder Renaming](#bulk-filefolder-renaming)
- [Escaping a Stuck SSH Terminal](#escaping-a-stuck-ssh-terminal)
- [SSH Setup](#ssh-setup)
- [Git GPG Commit Signing](#git-gpg-commit-signing)
- [Important Dates and Times](#important-dates-and-times)
@@ -128,6 +129,10 @@ for change_dir in $(ls | grep 'podman_*'); do
done
```
### Escaping a Stuck SSH Terminal
Press the following keys: enter + ~ + .
## SSH Setup
Generate a key (password protect it!)

View File

@@ -4,6 +4,7 @@
- [Reeseapps vs Reeselink](#reeseapps-vs-reeselink)
- [Reeselink Addresses](#reeselink-addresses)
- [Reeseapps Addresses](#reeseapps-addresses)
- [Converting Unifi Records to AWS Records](#converting-unifi-records-to-aws-records)
## Reeseapps vs Reeselink
@@ -28,3 +29,14 @@ aws route53 change-resource-record-sets --hosted-zone-id $(cat active/aws_route5
```bash
aws route53 change-resource-record-sets --hosted-zone-id $(cat active/aws_route53/secrets/reeseapps-zoneid) --change-batch file://active/aws_route53/secrets/reeseapps.json
```
## Converting Unifi Records to AWS Records
The script `unifi_to_aws.py` will create a file at
`secrets/unifi_reeselink_records.json` which contains all `reeselink.com`
domains in the unifi server converted to AWS route53 batch format. Simply run
the script and then use that file to update reeselink.com records.
```python
python active/aws_route53/unifi_to_aws.py
```

View File

@@ -0,0 +1,76 @@
#!/bin/bash
# --- Configuration ---
PYTHON_SCRIPT="active/aws_route53/unifi_to_aws.py"
ZONE_ID_FILE="active/aws_route53/secrets/reeselink-zoneid"
RECORDS_FILE="active/aws_route53/secrets/unifi_reeselink_records.json"
# --- Colors for logging ---
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# --- Logging Function ---
log() {
echo -e "[$(date +'%Y-%m-%d %H:%M:%S')] $1"
}
error_exit() {
echo -e "[$(date +'%Y-%m-%d %H:%M:%S')] ${RED}ERROR: $1${NC}" >&2
exit 1
}
# --- 1. Pre-flight Checks ---
log "${YELLOW}Starting Route53 update process...${NC}"
if [[ ! -f "$PYTHON_SCRIPT" ]]; then
error_exit "Python script not found at $PYTHON_SCRIPT"
fi
if [[ ! -f "$ZONE_ID_FILE" ]]; then
error_exit "Zone ID file not found at $ZONE_ID_FILE"
fi
# --- 2. Run Python Script ---
log "Running $PYTHON_SCRIPT to generate JSON records..."
# Execute the python script
python "$PYTHON_SCRIPT"
# Check the exit code of the python script
if [[ $? -eq 0 ]]; then
log "${GREEN}Python script executed successfully.${NC}"
else
error_exit "Python script failed. Aborting AWS update to prevent corrupting DNS."
fi
# Verify the output file actually exists after the python run
if [[ ! -f "$RECORDS_FILE" ]]; then
error_exit "Python script reported success, but $RECORDS_FILE was not found."
fi
# --- 3. Update Route53 ---
# Read the Zone ID from the secret file
ZONE_ID=$(cat "$ZONE_ID_FILE" | tr -d '\n\r ')
if [[ -z "$ZONE_ID" ]]; then
error_exit "Zone ID file is empty or could not be read."
fi
log "Updating Route53 records for Zone ID: $ZONE_ID..."
# Run the AWS CLI command
# Using file:// prefix as required by AWS CLI for local files
aws route53 change-resource-record-sets \
--hosted-zone-id "$ZONE_ID" \
--change-batch "file://$RECORDS_FILE"
# Check the exit code of the AWS command
if [[ $? -eq 0 ]]; then
log "${GREEN}Route53 records updated successfully!${NC}"
else
error_exit "AWS CLI command failed. Check your AWS credentials and JSON formatting."
fi
log "${GREEN}Process complete.${NC}"

View File

@@ -0,0 +1,113 @@
import json
import os
import sys
from typing import Any, Dict, List
import requests
# Configuration
API_KEY = os.environ.get("API_KEY")
URL_DEVICES = "https://10.1.0.1/proxy/network/v2/api/site/default/static-dns/devices"
URL_POLICIES = "https://10.1.0.1/proxy/network/integration/v1/sites/88f7af54-98f8-306a-a1c7-c9349722b1f6/dns/policies"
OUTPUT_FILE = "active/aws_route53/secrets/unifi_reeselink_records.json"
ALLOWED_DOMAIN = "reeselink.com"
FIXED_TTL = 60
# Headers
headers = {"Accept": "application/json", "X-API-Key": API_KEY}
def fetch_json(url: str) -> Any:
"""Helper to perform the GET request and return parsed JSON."""
response = requests.get(
url,
headers=headers,
verify=False, # -k: Don't verify SSL certificate
allow_redirects=True, # -L: Follow redirects
)
if response.status_code != 200:
print(f"Error: Received status code {response.status_code} from {url}")
print(f"Response: {response.text}")
sys.exit(1)
return response.json()
def main():
all_changes: List[Dict[str, Any]] = []
# 1. Process Devices API
devices_data = fetch_json(URL_DEVICES)
devices_count = 0
# devices_data is expected to be a list: [{hostname: ..., ip_address: ...}, ...]
for device in devices_data:
hostname = device.get("hostname", "")
ip = device.get("ip_address", "")
if hostname.endswith(ALLOWED_DOMAIN):
all_changes.append(
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": hostname,
"Type": "A",
"TTL": FIXED_TTL,
"ResourceRecords": [{"Value": ip}],
},
}
)
devices_count += 1
# 2. Process Policies API
policies_response = fetch_json(URL_POLICIES)
policies_count = 0
# policies_response is expected to be a dict: {"data": [{domain: ..., ipv4Address: ...}, ...]}
policies_list = policies_response.get("data", [])
for policy in policies_list:
domain = policy.get("domain", "")
ip = policy.get("ipv4Address", "")
if domain.endswith(ALLOWED_DOMAIN):
all_changes.append(
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": domain,
"Type": "A",
"TTL": FIXED_TTL,
"ResourceRecords": [{"Value": ip}],
},
}
)
policies_count += 1
# Construct Final AWS Payload
final_payload = {
"Comment": "Combined records from Unifi devices and policies",
"Changes": all_changes,
}
# Write to file
try:
# Ensure directory exists
os.makedirs(os.path.dirname(OUTPUT_FILE), exist_ok=True)
with open(OUTPUT_FILE, "w") as f:
json.dump(final_payload, f, indent=4)
except Exception as e:
print(f"Error writing to file: {e}")
sys.exit(1)
# Print Summary
print(f"Successfully processed records:")
print(f" - devices: {devices_count}")
print(f" - policies: {policies_count}")
print(f"Total records in file: {len(all_changes)}")
print(f"Saved to {OUTPUT_FILE}")
if __name__ == "__main__":
# Suppress InsecureRequestWarning for verify=False
requests.packages.urllib3.disable_warnings() # type: ignore
main()

View File

@@ -154,7 +154,7 @@ curl -L -X POST 'https://aipi.reeseapps.com/v1/chat/completions' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer sk-1234' \
-d '{
"model": "gpt-4o-mini", # 👈 REPLACE with 'public model name' for any db-model
"model": "driveripper/think",
"messages": [
{
"content": "Hey, how's it going",

View File

@@ -70,11 +70,6 @@ active/container_caddy/install_caddy_proxy.yaml
ansible-playbook \
-i ansible/inventory.yaml \
active/container_caddy/install_caddy_deskwork.yaml
# Toybox (AI) Proxy
ansible-playbook \
-i ansible/inventory.yaml \
active/container_caddy/install_caddy_toybox.yaml
```
See ansible playbook [install_caddy.yaml](/active/container_caddy/install_caddy.yaml)

View File

@@ -58,7 +58,7 @@ Now you can install the DDNS service with something like:
```bash
ansible-playbook \
-i ansible/inventory.yaml \
-l proxy \
-l proxy-root \
active/container_ddns/install_ddns.yaml
```

View File

@@ -1,3 +0,0 @@
# Compose
Put your compose.yaml here.

View File

@@ -1,27 +1,35 @@
services:
litellm:
image: docker.litellm.ai/berriai/litellm:main-latest
image: docker.litellm.ai/berriai/litellm:main-stable
#########################################
## Uncomment these lines to start proxy with a config.yaml file ##
# volumes:
# - ./config.yaml:/app/config.yaml
# command:
# - "--config=/app/config.yaml"
##############################################
ports:
- 4000:4000
env_file: /home/ai/litellm.env
- "4000:4000" # Map the container port to the host, change the host port if necessary
environment:
DATABASE_URL: "postgresql://llmproxy:dbpassword9090@host.containers.internal:5432/litellm"
STORE_MODEL_IN_DB: "True"
restart: unless-stopped
DATABASE_URL: "postgresql://llmproxy:dbpassword9090@db:5432/litellm"
STORE_MODEL_IN_DB: "True" # allows adding models to proxy via UI
env_file:
- ../secrets/litellm.env # Load local .env file
depends_on:
- litellm-db # Indicates that this service depends on the 'litellm-db' service, ensuring 'litellm-db' starts first
healthcheck: # Defines the health check configuration for the container
- db # Indicates that this service depends on the 'db' service, ensuring 'db' starts first
healthcheck: # Defines the health check configuration for the container
test:
- CMD-SHELL
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
interval: 30s # Perform health check every 30 seconds
timeout: 10s # Health check command times out after 10 seconds
retries: 3 # Retry up to 3 times if health check fails
start_period: 40s # Wait 40 seconds after container start before beginning health checks
- python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')" # Command to execute for health check
interval: 30s # Perform health check every 30 seconds
timeout: 10s # Health check command times out after 10 seconds
retries: 3 # Retry up to 3 times if health check fails
start_period: 40s # Wait 40 seconds after container start before beginning health checks
litellm-db:
db:
image: docker.io/postgres:16
restart: always
container_name: litellm_db
environment:
POSTGRES_DB: litellm
POSTGRES_USER: llmproxy
@@ -29,9 +37,26 @@ services:
ports:
- "5432:5432"
volumes:
- litellm_postgres_data:/var/lib/postgresql/data:z
- postgres_data:/var/lib/postgresql/data # Persists Postgres data across container restarts
healthcheck:
test: ["CMD-SHELL", "pg_isready -d litellm -U llmproxy"]
interval: 1s
timeout: 5s
retries: 10
prometheus:
image: docker.io/prom/prometheus
volumes:
- prometheus_data:/prometheus
- ../seccrets/prometheus.yml:/etc/prometheus/prometheus.yml
ports:
- "9090:9090"
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=15d"
restart: always
volumes:
prometheus_data:
postgres_data:

View File

@@ -1,67 +0,0 @@
# General settings
general_settings:
request_timeout: 600
# Models
model_list:
# Qwen3.5-35B variants
- model_name: qwen3.5-35b-think-general
litellm_params:
model: openai/qwen3.5-35b-a3b
api_base: https://llama-cpp.reeselink.com
api_key: none
temperature: 1.0
top_p: 0.95
presence_penalty: 1.5
extra_body:
top_k: 20
min_p: 0.0
repetition_penalty: 1.0
chat_template_kwargs:
enable_thinking: true
- model_name: qwen3.5-35b-think-code
litellm_params:
model: openai/qwen3.5-35b-a3b
api_base: https://llama-cpp.reeselink.com
api_key: none
temperature: 0.6
top_p: 0.95
presence_penalty: 0.0
extra_body:
top_k: 20
min_p: 0.0
repetition_penalty: 1.0
chat_template_kwargs:
enable_thinking: true
- model_name: qwen3.5-35b-instruct-general
litellm_params:
model: openai/qwen3.5-35b-a3b
api_base: https://llama-cpp.reeselink.com
api_key: none
temperature: 0.7
top_p: 0.8
presence_penalty: 1.5
extra_body:
top_k: 20
min_p: 0.0
repetition_penalty: 1.0
chat_template_kwargs:
enable_thinking: false
- model_name: qwen3.5-35b-instruct-reasoning
litellm_params:
model: openai/qwen3.5-35b-a3b
api_base: https://llama-cpp.reeselink.com
api_key: none
temperature: 1.0
top_p: 0.95
presence_penalty: 1.5
extra_body:
top_k: 20
min_p: 0.0
repetition_penalty: 1.0
chat_template_kwargs:
enable_thinking: false

View File

@@ -9,9 +9,8 @@
- [Convert litellm compose spec to quadlets](#convert-litellm-compose-spec-to-quadlets)
- [Create the litellm.env file](#create-the-litellmenv-file)
- [Start and enable your systemd quadlet](#start-and-enable-your-systemd-quadlet)
- [Install via Ansible](#install-via-ansible)
- [Expose litellm](#expose-litellm)
- [Using LiteLLM](#using-litellm)
- [Adding Models](#adding-models)
- [Testing Models](#testing-models)
- [Backup litellm](#backup-litellm)
- [Upgrade litellm](#upgrade-litellm)
@@ -110,63 +109,28 @@ journalctl --user -u litellm -f
systemctl --user enable --now podman-auto-update.timer
```
### Install via Ansible
Preview changes with a dry run:
```bash
ansible-playbook -i ansible/inventory.yaml active/container_litellm/playbook.yml --check --diff
```
Run the playbook from the Homelab root:
```bash
ansible-playbook -i ansible/inventory.yaml active/container_litellm/playbook.yml
```
This copies the quadlets, config, reloads the systemd user daemon, and starts both `litellm-db` and `litellm` services as the `ai` user.
### Expose litellm
1. If you need a domain, follow the [DDNS instructions](/active/container_ddns/ddns.md#install-a-new-ddns-service)
2. For a web service, follow the [Caddy instructions](/active/container_caddy/caddy.md#adding-a-new-caddy-record)
3. Finally, follow your OS's guide for opening ports via its firewall service.
## Using LiteLLM
### Adding Models
```json
// qwen3.5-35b-a3b-thinking
{
"temperature": 1,
"top_p": 0.95,
"presence_penalty": 1.5,
"extra_body": {
"top_k": 20,
"min_p": 0,
"repetition_penalty": 1,
"chat_template_kwargs": {
"enable_thinking": true
}
}
}
// qwen3.5-35b-a3b-coding
{
"temperature": 0.6,
"top_p": 0.95,
"presence_penalty": 0,
"extra_body": {
"top_k": 20,
"min_p": 0,
"repetition_penalty": 1,
"chat_template_kwargs": {
"enable_thinking": true
}
}
}
// qwen3.5-35b-a3b-instruct
{
"temperature": 0.7,
"top_p": 0.8,
"presence_penalty": 1.5,
"extra_body": {
"top_k": 20,
"min_p": 0,
"repetition_penalty": 1,
"chat_template_kwargs": {
"enable_thinking": false
}
}
}
```
### Testing Models
```bash
@@ -191,7 +155,7 @@ curl -L -X POST 'https://aipi.reeseapps.com/v1/chat/completions' \
## Backup litellm
Follow the [Borg Backup instructions](/active/systemd_borg/borg.md#set-up-a-client-for-backup)
Follow the [Borg Backup instructions](/active/software_borg/borg.md#set-up-a-client-for-backup)
## Upgrade litellm

View File

@@ -0,0 +1,86 @@
---
- name: Install and start LiteLLM quadlets for ai user
hosts: ai-ai
remote_user: ai
vars:
ai_user: ai
quadlets_dir: "/home/{{ ai_user }}/.config/containers/systemd"
tasks:
- name: Ensure ai user home directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0755"
loop:
- "{{ quadlets_dir }}"
- name: Copy litellm container pod
ansible.builtin.copy:
src: quadlets/litellm.pod
dest: "{{ quadlets_dir }}/litellm.pod"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0644"
- name: Copy litellm container quadlet
ansible.builtin.copy:
src: quadlets/litellm-web.container
dest: "{{ quadlets_dir }}/litellm-web.container"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0644"
- name: Copy litellm-db container quadlet
ansible.builtin.copy:
src: quadlets/litellm-db.container
dest: "{{ quadlets_dir }}/litellm-db.container"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0644"
- name: Copy prometheus container quadlet
ansible.builtin.copy:
src: quadlets/litellm-prometheus.container
dest: "{{ quadlets_dir }}/litellm-prometheus.container"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0644"
- name: Copy prometheus config
ansible.builtin.copy:
src: secrets/litellm-prometheus.yaml
dest: "/home/{{ ai_user }}/litellm-prometheus.yaml"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0644"
- name: Copy litellm.env file
ansible.builtin.copy:
src: secrets/litellm.env
dest: "/home/{{ ai_user }}/litellm.env"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0600"
- name: Copy litellm-config.yaml
ansible.builtin.copy:
src: secrets/litellm-config.yaml
dest: "/home/{{ ai_user }}/litellm-config.yaml"
owner: "{{ ai_user }}"
group: "{{ ai_user }}"
mode: "0644"
- name: Reload systemd user daemon
ansible.builtin.systemd:
daemon_reload: true
scope: user
- name: Restart litellm pod
ansible.builtin.systemd:
name: litellm-pod
state: restarted
scope: user

View File

@@ -1,12 +1,13 @@
[Container]
Pod=litellm.pod
ContainerName=litellm-db
Environment=POSTGRES_DB=litellm POSTGRES_USER=llmproxy POSTGRES_PASSWORD=dbpassword9090
HealthCmd='pg_isready -d litellm -U llmproxy'
HealthInterval=1s
HealthRetries=10
HealthTimeout=5s
Image=docker.io/postgres:16
PublishPort=5432:5432
Volume=litellm_postgres_data:/var/lib/postgresql/data:z
Volume=litellm_postgres_data:/var/lib/postgresql/data
[Service]
Restart=always

View File

@@ -0,0 +1,13 @@
[Container]
Pod=litellm.pod
ContainerName=litellm-prom
Exec='--config.file=/etc/prometheus/prometheus.yml' '--storage.tsdb.path=/prometheus' '--storage.tsdb.retention.time=15d'
Image=docker.io/prom/prometheus
Volume=litellm_prometheus_data:/prometheus
Volume=/home/ai/litellm-prometheus.yaml:/etc/prometheus/prometheus.yml:z
[Service]
Restart=always
[Install]
WantedBy=default.target

View File

@@ -2,18 +2,18 @@
Requires=litellm-db.service
[Container]
Environment=DATABASE_URL=postgresql://llmproxy:dbpassword9090@host.containers.internal:5432/litellm STORE_MODEL_IN_DB=True
Pod=litellm.pod
ContainerName=litellm-web
Environment=DATABASE_URL=postgresql://llmproxy:dbpassword9090@localhost:5432/litellm STORE_MODEL_IN_DB=True
EnvironmentFile=/home/ai/litellm.env
HealthCmd="python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:4000/health/liveliness')\""
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=40s
HealthTimeout=10s
Image=docker.litellm.ai/berriai/litellm:main-latest
PublishPort=4000:4000
[Service]
Restart=always
Image=ghcr.io/berriai/litellm-database:v1.83.14-stable.patch.3
Volume=/home/ai/litellm-config.yaml:/app/config.yaml:z
Exec=--config=/app/config.yaml
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,3 @@
[Pod]
# litellm web interface
PublishPort=4000:4000/tcp

View File

@@ -3,6 +3,7 @@
- [Driveripper](#driveripper)
- [General Principles](#general-principles)
- [Important Locations](#important-locations)
- [Backups](#backups)
- [Monitoring Scripts](#monitoring-scripts)
- [Quick Ansible Commands](#quick-ansible-commands)
- [Quickstart VM](#quickstart-vm)
@@ -12,7 +13,7 @@
- [Best Practices](#best-practices)
- [OSBuild Composer](#osbuild-composer)
- [Retired Disks](#retired-disks)
- [Sending emails](#sending-emails)
- [Sending emails](#sending-emails)
## General Principles
@@ -27,6 +28,28 @@
- `/etc/luks-keys`: luks keys
- `/usr/local/scripts`: admin scripts
## Backups
```bash
# smb
rsync -av --progress \
--exclude .snapshots \
/srv/smb/ \
/srv/backup/smb/
# archive
rsync -av --progress \
--exclude .snapshots \
/srv/archive/ \
/srv/backup/archive/
# vm
rsync -av --progress \
--exclude .snapshots \
/srv/vm/ \
/srv/backup/vm/
```
## Monitoring Scripts
```bash
@@ -52,21 +75,21 @@ Default user: `ducoterra`
Default password: `osbuild`
- [ ] `passwd ducoterra`
- [ ] `hostnamectl hostname <hostname>`
- [ ] Updates
- [ ] `hostnamectl hostname <hostname>`
- [ ] Static IP and DNS address
```bash
# Convert the build to raw
qemu-img convert -f qcow2 -O raw \
/srv/smb/pool0/ducoterra/images/builds/fedora-43-base.qcow2 \
/srv/smb/pool0/ducoterra/images/builds/fedora43-base.qcow2 \
/srv/vm/pool1/fedora-boot.raw
# Install (Change password for default user ducoterra!)
virt-install \
--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \
--cpu host-passthrough --vcpus sockets=1,cores=8,threads=2 \
--ram=8192 \
--ram=4096 \
--os-variant=fedora41 \
--network bridge:bridge0 \
--graphics none \
@@ -135,7 +158,7 @@ virt-install \
--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \
--cpu host-passthrough --vcpus sockets=1,cores=8,threads=2 \
--ram=8192 \
--os-variant=fedora41 \
--os-variant=fedora43 \
--network bridge:bridge0 \
--graphics none \
--console pty,target.type=virtio \
@@ -196,7 +219,7 @@ Retired 12-19-2025
![alt text](image-1.png)
### Sending emails
## Sending emails
```bash
# s-nail is mailx

Binary file not shown.

View File

@@ -8,6 +8,7 @@
- [Adding a New Device](#adding-a-new-device)
- [Controlling Home Assistant](#controlling-home-assistant)
- [Configuration Sections](#configuration-sections)
- [Symbols](#symbols)
- [esphome](#esphome)
- [esp32](#esp32-1)
- [logger](#logger)
@@ -63,7 +64,8 @@ uv venv
uv pip install esphome
source .venv/bin/activate
esphome run m5stack-atom-echo.yaml
# grep for debug logs only (helpful for filtering noise)
esphome run tab1.yaml | grep -E '.*[\[D\]]'
```
## Adding a New Device
@@ -78,6 +80,13 @@ esphome run m5stack-atom-echo.yaml
<https://esphome.io/components/>
## Symbols
You can display the embedded symbols among the text by their codepoint address
preceded by \u. For example: \uF00C :
![alt text](image.png)
### esphome
### esp32
@@ -145,6 +154,22 @@ data:
media_content_id: "media-source://media_source/local/wake_word_triggered.wav"
```
Playing arbitrary sound:
```yaml
audio_file:
- id: beep_sound
file: "beep.wav"
media_source:
- platform: audio_file
id: file_source
- media_player.speaker.play_on_device_media_file:
media_file: beep_sound
announcement: true
```
### voice assistant
<https://esphome.io/components/voice_assistant/>

View File

@@ -54,24 +54,24 @@ i2c:
id: bus_1
sensor:
- platform: sonic_i2c
i2c_id: bus_1
address: 0x57
name: "Ultrasonic Sensor 1"
id: ultrasonic1
unit_of_measurement: mm
update_interval: 5s
filters:
- filter_out: nan
- lambda: |-
if (x == 0) {
return {}; // This filters out the reading
} else {
return x; // This passes the reading through
}
- sliding_window_moving_average:
window_size: 10
send_every: 20
- platform: sonic_i2c
i2c_id: bus_1
address: 0x57
name: "Ultrasonic Sensor 1"
id: ultrasonic1
unit_of_measurement: mm
update_interval: 5s
filters:
- filter_out: nan
- lambda: |-
if (x == 0) {
return {}; // This filters out the reading
} else {
return x; // This passes the reading through
}
- sliding_window_moving_average:
window_size: 10
send_every: 20
button:
- platform: factory_reset
@@ -99,7 +99,7 @@ speaker:
dac_type: external
bits_per_sample: 16bit
sample_rate: 16000
channel: stereo # The Echo has poor playback audio quality when using mon audio
channel: stereo # The Echo has poor playback audio quality when using mon audio
buffer_duration: 60ms
media_player:
@@ -197,7 +197,7 @@ voice_assistant:
- delay: 2s
- script.execute: reset_led
on_client_connected:
- delay: 2s # Give the api server time to settle
- delay: 2s # Give the api server time to settle
- script.execute: start_wake_word
on_client_disconnected:
- script.execute: stop_wake_word
@@ -355,24 +355,24 @@ switch:
on_turn_off:
# Turn off the repeat mode and disable the pause between playlist items
- lambda: |-
id(echo_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF)
.set_announcement(true)
.perform();
id(echo_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0);
id(echo_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF)
.set_announcement(true)
.perform();
id(echo_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0);
# Stop playing the alarm
- media_player.stop:
announcement: true
on_turn_on:
# Turn on the repeat mode and pause for 1000 ms between playlist items/repeats
- lambda: |-
id(echo_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE)
.set_announcement(true)
.perform();
id(echo_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 1000);
id(echo_media_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE)
.set_announcement(true)
.perform();
id(echo_media_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 1000);
- media_player.speaker.play_on_device_media_file:
media_file: timer_finished_wave_file
announcement: true

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -0,0 +1,997 @@
---
substitutions:
name: pyramid1
friendly_name: Pyramid 1
# Casita images
loading_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/loading_320_240.png
idle_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/idle_320_240.png
listening_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/listening_320_240.png
thinking_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/thinking_320_240.png
replying_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/replying_320_240.png
error_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/casita/error_320_240.png
error_no_wifi_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-wifi.png
error_no_ha_illustration_file: https://github.com/esphome/wake-word-voice-assistants/raw/main/error_box_illustrations/error-no-ha.png
# Fonts
mdi_webfont_file: https://raw.githubusercontent.com/Templarian/MaterialDesign-Webfont/master/fonts/materialdesignicons-webfont.ttf
# Audio files
wake_word_trigger_sound_file: wake_word_triggered.wav
# timer_finished_sound_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/timer_finished.flac
# error_cloud_expired_file: https://github.com/esphome/home-assistant-voice-pe/raw/dev/sounds/error_cloud_expired.mp3
# Micro wake word models
pick_pig: https://raw.githubusercontent.com/esphome/micro-wake-word-models/refs/heads/main/models/v2/experiments/hey_peppa_pig.json
stop_model_file: https://github.com/kahrendt/microWakeWord/releases/download/stop/stop.json
# Background colors
loading_illustration_background_color: "000000"
idle_illustration_background_color: "000000"
listening_illustration_background_color: "FFFFFF"
thinking_illustration_background_color: "FFFFFF"
replying_illustration_background_color: "FFFFFF"
error_illustration_background_color: "000000"
# Phases of the Voice Assistant
# The voice assistant is ready to be triggered by a wake word
voice_assist_idle_phase_id: "1"
# The voice assistant is listening for a voice command
voice_assist_listening_phase_id: "2"
# The voice assistant is currently processing the command
voice_assist_thinking_phase_id: "3"
# The voice assistant is replying to the command
voice_assist_replying_phase_id: "4"
# The voice assistant is not ready
voice_assist_not_ready_phase_id: "10"
# The voice assistant encountered an error
voice_assist_error_phase_id: "11"
# Muted phase
voice_assist_muted_phase_id: "12"
# Finished timer phase
voice_assist_timer_finished_phase_id: "20"
esphome:
name: pyramid1
friendly_name: Pyramid 1
min_version: 2025.11.3
on_boot:
priority: 600
then:
- delay: 30s
- if:
condition:
lambda: return id(init_in_progress);
then:
- lambda: id(init_in_progress) = false;
esp32:
variant: esp32s3
flash_size: 8MB
cpu_frequency: 240MHz
framework:
type: esp-idf
api:
encryption:
key: "innoIL7I6ZfRekL58F65REjeYNLW1Hp/Q/Kv9SEjnNA="
ota:
- platform: esphome
password: "22de00dcf5c2701a25d2fe719d596123"
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
ap:
ssid: "Echo-Pyramid Fallback Hotspot"
password: "uSTvJjVzweZp"
# Enable logging
logger:
level: INFO
logs:
sensor: WARN
captive_portal:
button:
- platform: factory_reset
id: factory_reset_btn
internal: true
binary_sensor:
- platform: gpio
pin:
number: GPIO41
mode: INPUT_PULLUP
inverted: true
id: user_button
internal: true
on_multi_click:
- timing:
- ON for at least 50ms
- OFF for at least 50ms
then:
- switch.turn_off: timer_ringing
- timing:
- ON for at least 10s
then:
- button.press: factory_reset_btn
external_components:
- source: github://m5stack/esphome-yaml/components
components: [aw87559, si5351, lp5562, pyramidrgb, pyramidtouch]
refresh: 0s
# I2C Bus Configuration
i2c:
- id: bsp_bus
sda: GPIO45
scl: GPIO0
scan: true
- id: ext_bus # used on atomic echo base
sda: GPIO38
scl: GPIO39
# Ehco Base GPIO Expander
pi4ioe5v6408:
- id: pi4ioe5v6408_hub
i2c_id: ext_bus
address: 0x43
aw87559:
id: audio_amp
i2c_id: ext_bus
address: 0x5B
si5351:
id: clock_gen
i2c_id: ext_bus
address: 0x60
# I2S Bus Configuration
i2s_audio:
- id: i2s_audio_bus
i2s_lrclk_pin: GPIO8
i2s_bclk_pin: GPIO6
spi:
clk_pin: GPIO15
mosi_pin: GPIO21
# miso_pin is not used
audio_dac:
- platform: es8311
id: es8311_dac
i2c_id: ext_bus
bits_per_sample: 16bit
sample_rate: 16000
audio_adc:
- platform: es7210
id: es7210_adc
i2c_id: ext_bus
address: 0x40
bits_per_sample: 16bit
sample_rate: 16000
microphone:
- platform: i2s_audio
id: i2s_mic
sample_rate: 16000
i2s_din_pin: GPIO5
bits_per_sample: 16bit
adc_type: external
channel: stereo
speaker:
- platform: i2s_audio
id: i2s_speaker
i2s_dout_pin: GPIO7
dac_type: external
bits_per_sample: 16bit
sample_rate: 16000
channel: mono
audio_dac: es8311_dac
media_player:
- platform: speaker
name: "Echo Pyramid Player"
id: echo_pyramid_player
volume_min: 0.0
volume_max: 1.0
volume_initial: 0.10
buffer_size: 6000
announcement_pipeline:
speaker: i2s_speaker
format: WAV
# sample_rate: 48000
# num_channels: 1
codec_support_enabled: false
files:
- id: wake_word_triggered_sound
file: ${wake_word_trigger_sound_file}
# - id: timer_finished_sound
# file: ${timer_finished_sound_file}
# - id: error_cloud_expired
# file: ${error_cloud_expired_file}
on_state:
- logger.log: "State updated!"
on_play:
- logger.log: "Playback started!"
on_announcement:
- logger.log: "Announcing!"
# Stop the wake word (mWW or VA) if the mic is capturing
- if:
condition:
- microphone.is_capturing:
then:
- script.execute: stop_wake_word
# Ensure VA stops before moving on
- if:
condition:
- lambda: |-
return id(wake_word_engine_location).current_option() == "In Home Assistant";
then:
- wait_until:
- not:
voice_assistant.is_running:
# Since VA isn't running, this is user-intiated media playback. Draw the mute display
- if:
condition:
not:
voice_assistant.is_running:
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- script.execute: draw_display
on_idle:
# Since VA isn't running, this is the end of user-intiated media playback. Restart the wake word.
- if:
condition:
not:
voice_assistant.is_running:
then:
- script.execute: start_wake_word
- script.execute: set_idle_or_mute_phase
- script.execute: draw_display
switch:
# NS4150B
- platform: gpio
name: Speaker Enable
pin:
pi4ioe5v6408: pi4ioe5v6408_hub
number: 0
mode:
output: true
icon: "mdi:volume-high"
restore_mode: RESTORE_DEFAULT_ON
- platform: template
name: Mute Microphone
id: mute
icon: "mdi:microphone-off"
optimistic: true
restore_mode: RESTORE_DEFAULT_OFF
entity_category: config
on_turn_off:
- microphone.unmute:
- lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
on_turn_on:
- microphone.mute:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- platform: template
id: timer_ringing
optimistic: true
internal: true
restore_mode: ALWAYS_OFF
on_turn_off:
# Turn off the repeat mode and disable the pause between playlist items
- lambda: |-
id(echo_pyramid_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_OFF)
.set_announcement(true)
.perform();
id(echo_pyramid_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 0);
# Stop playing the alarm
- media_player.stop:
announcement: true
- script.execute: start_wake_word
on_turn_on:
- script.execute: stop_wake_word
# Turn on the repeat mode and pause for 1000 ms between playlist items/repeats
- lambda: |-
id(echo_pyramid_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_REPEAT_ONE)
.set_announcement(true)
.perform();
id(echo_pyramid_player)->set_playlist_delay_ms(speaker::AudioPipelineType::ANNOUNCEMENT, 1000);
# - media_player.speaker.play_on_device_media_file:
# media_file: timer_finished_sound
# announcement: true
- delay: 15min
- switch.turn_off: timer_ringing
select:
- platform: template
entity_category: config
name: Wake word engine location
id: wake_word_engine_location
icon: "mdi:account-voice"
optimistic: true
restore_value: true
options:
- In Home Assistant
- On device
initial_option: On device
on_value:
- if:
condition:
lambda: return !id(init_in_progress);
then:
- wait_until:
lambda: return id(voice_assistant_phase) == ${voice_assist_muted_phase_id} || id(voice_assistant_phase) == ${voice_assist_idle_phase_id};
- if:
condition:
lambda: return x == "In Home Assistant";
then:
- micro_wake_word.stop
- delay: 500ms
- if:
condition:
switch.is_off: mute
then:
- lambda: id(va).set_use_wake_word(true);
- voice_assistant.start_continuous:
- if:
condition:
lambda: return x == "On device";
then:
- lambda: id(va).set_use_wake_word(false);
- voice_assistant.stop
- delay: 500ms
- if:
condition:
switch.is_off: mute
then:
- micro_wake_word.start
- platform: template
name: "Wake word sensitivity"
optimistic: true
initial_option: Slightly sensitive
restore_value: true
entity_category: config
options:
- Slightly sensitive
- Moderately sensitive
- Very sensitive
on_value:
# Sets specific wake word probabilities computed for each particular model
# Note probability cutoffs are set as a quantized uint8 value, each comment has the corresponding floating point cutoff
# False Accepts per Hour values are tested against all units and channels from the Dinner Party Corpus.
# These cutoffs apply only to the specific models included in the firmware: okay_nabu@20241226.3, hey_jarvis@v2, hey_mycroft@v2
lambda: |-
if (x == "Slightly sensitive") {
id(okay_nabu).set_probability_cutoff(217); // 0.85 -> 0.000 FAPH on DipCo (Manifest's default)
id(hey_jarvis).set_probability_cutoff(247); // 0.97 -> 0.563 FAPH on DipCo (Manifest's default)
id(hey_mycroft).set_probability_cutoff(253); // 0.99 -> 0.567 FAPH on DipCo
} else if (x == "Moderately sensitive") {
id(okay_nabu).set_probability_cutoff(176); // 0.69 -> 0.376 FAPH on DipCo
id(hey_jarvis).set_probability_cutoff(235); // 0.92 -> 0.939 FAPH on DipCo
id(hey_mycroft).set_probability_cutoff(242); // 0.95 -> 1.502 FAPH on DipCo (Manifest's default)
} else if (x == "Very sensitive") {
id(okay_nabu).set_probability_cutoff(143); // 0.56 -> 0.751 FAPH on DipCo
id(hey_jarvis).set_probability_cutoff(212); // 0.83 -> 1.502 FAPH on DipCo
id(hey_mycroft).set_probability_cutoff(237); // 0.93 -> 1.878 FAPH on DipCo
}
micro_wake_word:
id: mww
microphone: i2s_mic
models:
- model: okay_nabu
id: okay_nabu
- model: hey_jarvis
id: hey_jarvis
- model: hey_mycroft
id: hey_mycroft
- model: https://raw.githubusercontent.com/esphome/micro-wake-word-models/refs/heads/main/models/v2/experiments/hey_peppa_pig.json
id: hey_peppa_pig
- model: ${stop_model_file}
id: stop
internal: true
vad:
on_wake_word_detected:
- script.execute:
id: play_sound
priority: true
sound_file: !lambda return id(wake_word_triggered_sound);
- wait_until:
condition:
- media_player.is_announcing:
timeout: 0.5s
# Announcement is finished and the I2S bus is free
- wait_until:
- and:
- not:
media_player.is_announcing:
- not:
speaker.is_playing:
- voice_assistant.start:
wake_word: !lambda return wake_word;
voice_assistant:
id: va
microphone: i2s_mic
media_player: echo_pyramid_player
micro_wake_word: mww
noise_suppression_level: 2
auto_gain: 31dBFS
volume_multiplier: 2.0
on_listening:
- lambda: id(voice_assistant_phase) = ${voice_assist_listening_phase_id};
- script.execute: draw_display
on_stt_vad_end:
- lambda: id(voice_assistant_phase) = ${voice_assist_thinking_phase_id};
- script.execute: draw_display
on_tts_start:
- lambda: id(voice_assistant_phase) = ${voice_assist_replying_phase_id};
- script.execute: draw_display
on_end:
# Wait a short amount of time to see if an announcement starts
- wait_until:
condition:
- media_player.is_announcing:
timeout: 0.5s
# Announcement is finished and the I2S bus is free
- wait_until:
- and:
- not:
media_player.is_announcing:
- not:
speaker.is_playing:
# Restart only mWW if enabled; streaming wake words automatically restart
- if:
condition:
- lambda: |-
return id(wake_word_engine_location).current_option() == "On device";
then:
- lambda: id(va).set_use_wake_word(false);
- micro_wake_word.start:
- script.execute: set_idle_or_mute_phase
- script.execute: draw_display
on_error:
# Only set the error phase if the error code is different than duplicate_wake_up_detected or stt-no-text-recognized
# These two are ignored for a better user experience
- if:
condition:
and:
- lambda: return !id(init_in_progress);
- lambda: return code != "duplicate_wake_up_detected";
- lambda: return code != "stt-no-text-recognized";
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_error_phase_id};
- script.execute: draw_display
- delay: 1s
- if:
condition:
switch.is_off: mute
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
else:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
# If the error code is cloud-auth-failed, serve a local audio file guiding the user.
- if:
condition:
- lambda: return code == "cloud-auth-failed";
then:
# - script.execute:
# id: play_sound
# priority: true
# sound_file: !lambda return id(error_cloud_expired);
- script.execute: draw_display
on_client_connected:
- lambda: id(init_in_progress) = false;
- script.execute: start_wake_word
- script.execute: set_idle_or_mute_phase
- script.execute: draw_display
on_client_disconnected:
- script.execute: stop_wake_word
- lambda: id(voice_assistant_phase) = ${voice_assist_not_ready_phase_id};
- script.execute: draw_display
on_timer_finished:
- switch.turn_on: timer_ringing
- wait_until:
media_player.is_announcing:
- lambda: id(voice_assistant_phase) = ${voice_assist_timer_finished_phase_id};
globals:
- id: init_in_progress
type: bool
restore_value: false
initial_value: "true"
- id: voice_assistant_phase
type: int
restore_value: false
initial_value: ${voice_assist_not_ready_phase_id}
- id: current_volume
type: float
restore_value: true
initial_value: "0.3"
sensor:
- platform: pyramidtouch
address: 0x1A
i2c_id: ext_bus
update_interval: 50ms
publish_swipe_event: true
swipe_timeout_ms: 500
touch1:
name: "Touch 1"
touch2:
name: "Touch 2"
touch3:
name: "Touch 3"
touch4:
name: "Touch 4"
swipe_event:
name: "Touch Swipe Event"
entity_category: diagnostic
on_value:
then:
- lambda: |-
// Swipe codes:
// 1 = Left Up (volume up)
// 2 = Left Down (volume down)
// 3 = Right Up (brightness up)
// 4 = Right Down (brightness down)
const float volume_step = 0.05f; // 5% volume per gesture
const float brightness_step = 5.0f; // 5% brightness per gesture
const int ev = (int) x;
if (ev == 1 || ev == 2) {
// Left side: control volume (0.0 - 1.0)
float v = id(current_volume);
if (ev == 1) {
v = std::min(1.0f, v + volume_step);
} else {
v = std::max(0.0f, v - volume_step);
}
auto call = id(echo_pyramid_player).make_call();
call.set_volume(v);
call.perform();
id(current_volume) = v;
} else if (ev == 3 || ev == 4) {
// Right side: control RGB brightness (0 - 100)
float b = id(rgb_master_brightness).state;
if (ev == 3) {
b = std::min(100.0f, b + brightness_step);
} else {
b = std::max(0.0f, b - brightness_step);
}
uint8_t b8 = (uint8_t) b;
id(pyramid_rgb1).set_strip_brightness(1, b8);
id(pyramid_rgb2).set_strip_brightness(2, b8);
id(rgb_master_brightness).publish_state(b);
} else {
return;
}
lp5562:
id: lp5562_led
i2c_id: bsp_bus
use_internal_clk: true
# power_save_mode: true
# high_pwm_freq: true
# logarithmic_dimming: true
white_current: 17.5
pyramidrgb:
- id: pyramid_rgb1
i2c_id: ext_bus
address: 0x1A
strip: 1
brightness: 80
- id: pyramid_rgb2
i2c_id: ext_bus
address: 0x1A
strip: 2
brightness: 80
number:
# Master media player volume (0.01.0)
- platform: template
name: "Master Volume"
id: master_volume
icon: "mdi:volume-high"
min_value: 0.0
max_value: 0.4
step: 0.01
restore_value: true
initial_value: 0.3
optimistic: true
set_action:
- lambda: |-
float v = x;
auto call = id(echo_pyramid_player).make_call();
call.set_volume(v);
call.perform();
id(current_volume) = v;
# Master RGB brightness (applies to both strips, 0100%)
- platform: template
name: "RGB Master Brightness"
id: rgb_master_brightness
icon: "mdi:brightness-6"
min_value: 0
max_value: 100
step: 1
restore_value: true
initial_value: 100
optimistic: true
set_action:
- lambda: |-
uint8_t b = (uint8_t) x;
id(pyramid_rgb1).set_strip_brightness(1, b);
id(pyramid_rgb2).set_strip_brightness(2, b);
output:
- platform: lp5562
id: lp5562_white_channel
lp5562_id: lp5562_led
channel: white
- platform: pyramidrgb
id: rgb1_ch0_red
pyramidrgb_id: pyramid_rgb1
channel: 0
color: red
- platform: pyramidrgb
id: rgb1_ch0_green
pyramidrgb_id: pyramid_rgb1
channel: 0
color: green
- platform: pyramidrgb
id: rgb1_ch0_blue
pyramidrgb_id: pyramid_rgb1
channel: 0
color: blue
# Strip 1, Channel 1 (Group 2)
- platform: pyramidrgb
id: rgb1_ch1_red
pyramidrgb_id: pyramid_rgb1
channel: 1
color: red
- platform: pyramidrgb
id: rgb1_ch1_green
pyramidrgb_id: pyramid_rgb1
channel: 1
color: green
- platform: pyramidrgb
id: rgb1_ch1_blue
pyramidrgb_id: pyramid_rgb1
channel: 1
color: blue
# Strip 2, Channel 2 (Group 1)
- platform: pyramidrgb
id: rgb2_ch2_red
pyramidrgb_id: pyramid_rgb2
channel: 2
color: red
- platform: pyramidrgb
id: rgb2_ch2_green
pyramidrgb_id: pyramid_rgb2
channel: 2
color: green
- platform: pyramidrgb
id: rgb2_ch2_blue
pyramidrgb_id: pyramid_rgb2
channel: 2
color: blue
# Strip 2, Channel 3 (Group 2)
- platform: pyramidrgb
id: rgb2_ch3_red
pyramidrgb_id: pyramid_rgb2
channel: 3
color: red
- platform: pyramidrgb
id: rgb2_ch3_green
pyramidrgb_id: pyramid_rgb2
channel: 3
color: green
- platform: pyramidrgb
id: rgb2_ch3_blue
pyramidrgb_id: pyramid_rgb2
channel: 3
color: blue
light:
- platform: monochromatic
name: "LCD Backlight"
output: lp5562_white_channel
icon: "mdi:television"
restore_mode: RESTORE_DEFAULT_ON
- platform: rgb
name: "Strip1 Group1"
red: rgb1_ch0_red
green: rgb1_ch0_green
blue: rgb1_ch0_blue
restore_mode: RESTORE_DEFAULT_ON
- platform: rgb
name: "Strip1 Group2"
red: rgb1_ch1_red
green: rgb1_ch1_green
blue: rgb1_ch1_blue
restore_mode: RESTORE_DEFAULT_ON
- platform: rgb
name: "Strip2 Group1"
red: rgb2_ch2_red
green: rgb2_ch2_green
blue: rgb2_ch2_blue
restore_mode: RESTORE_DEFAULT_ON
- platform: rgb
name: "Strip2 Group2"
red: rgb2_ch3_red
green: rgb2_ch3_green
blue: rgb2_ch3_blue
restore_mode: RESTORE_DEFAULT_ON
display:
- platform: mipi_spi
id: atoms3r_lcd
model: ST7789V
dc_pin: GPIO42
reset_pin: GPIO48
cs_pin: GPIO14
data_rate: 40MHz
dimensions:
height: 128
width: 128
offset_width: 2
offset_height: 1
invert_colors: true
rotation: 180°
pages:
- id: idle_page
lambda: |-
it.fill(id(idle_color));
it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_idle), ImageAlign::CENTER);
- id: listening_page
lambda: |-
it.fill(id(listening_color));
it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_listening), ImageAlign::CENTER);
- id: thinking_page
lambda: |-
it.fill(id(thinking_color));
it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_thinking), ImageAlign::CENTER);
- id: replying_page
lambda: |-
it.fill(id(replying_color));
it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_replying), ImageAlign::CENTER);
- id: error_page
lambda: |-
it.fill(id(error_color));
it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_error), ImageAlign::CENTER);
- id: no_ha_page
lambda: |-
it.image((it.get_width() / 2), (it.get_height() / 2), id(error_no_ha), ImageAlign::CENTER);
- id: no_wifi_page
lambda: |-
it.image((it.get_width() / 2), (it.get_height() / 2), id(error_no_wifi), ImageAlign::CENTER);
- id: initializing_page
lambda: |-
it.fill(id(loading_color));
it.image((it.get_width() / 2), (it.get_height() / 2), id(casita_initializing), ImageAlign::CENTER);
- id: muted_page
lambda: |-
it.fill(Color::BLACK);
it.printf(0, 0, id(mdi_icon_128), Color::WHITE, "%s", "\U000F036D");
script:
# Starts either mWW or the streaming wake word, depending on the configured location
- id: start_wake_word
then:
- if:
condition:
and:
- not:
- voice_assistant.is_running:
- lambda: |-
return id(wake_word_engine_location).current_option() == "On device";
then:
- lambda: id(va).set_use_wake_word(false);
- micro_wake_word.start:
- if:
condition:
and:
- not:
- voice_assistant.is_running:
- lambda: |-
return id(wake_word_engine_location).current_option() == "In Home Assistant";
then:
- lambda: id(va).set_use_wake_word(true);
- voice_assistant.start_continuous:
# Stops either mWW or the streaming wake word, depending on the configured location
- id: stop_wake_word
then:
- if:
condition:
lambda: |-
return id(wake_word_engine_location).current_option() == "In Home Assistant";
then:
- lambda: id(va).set_use_wake_word(false);
- voice_assistant.stop:
- if:
condition:
lambda: |-
return id(wake_word_engine_location).current_option() == "On device";
then:
- micro_wake_word.stop:
# Set the voice assistant phase to idle or muted, depending on if the software mute switch is activated
- id: set_idle_or_mute_phase
then:
- if:
condition:
switch.is_off: mute
then:
- lambda: id(voice_assistant_phase) = ${voice_assist_idle_phase_id};
else:
- lambda: id(voice_assistant_phase) = ${voice_assist_muted_phase_id};
- id: play_sound
parameters:
priority: bool
sound_file: "audio::AudioFile*"
then:
- lambda: |-
if (priority) {
id(echo_pyramid_player)
->make_call()
.set_command(media_player::MediaPlayerCommand::MEDIA_PLAYER_COMMAND_STOP)
.set_announcement(true)
.perform();
}
if ( (id(echo_pyramid_player).state != media_player::MediaPlayerState::MEDIA_PLAYER_STATE_ANNOUNCING ) || priority) {
id(echo_pyramid_player)
->play_file(sound_file, true, false);
}
- id: draw_display
then:
- if:
condition:
lambda: return !id(init_in_progress);
then:
- if:
condition:
wifi.connected:
then:
- if:
condition:
api.connected:
then:
- lambda: |
switch(id(voice_assistant_phase)) {
case ${voice_assist_listening_phase_id}:
id(atoms3r_lcd).show_page(listening_page);
id(atoms3r_lcd).update();
break;
case ${voice_assist_thinking_phase_id}:
id(atoms3r_lcd).show_page(thinking_page);
id(atoms3r_lcd).update();
break;
case ${voice_assist_replying_phase_id}:
id(atoms3r_lcd).show_page(replying_page);
id(atoms3r_lcd).update();
break;
case ${voice_assist_error_phase_id}:
id(atoms3r_lcd).show_page(error_page);
id(atoms3r_lcd).update();
break;
case ${voice_assist_muted_phase_id}:
id(atoms3r_lcd).show_page(muted_page);
id(atoms3r_lcd).update();
break;
case ${voice_assist_not_ready_phase_id}:
id(atoms3r_lcd).show_page(no_ha_page);
id(atoms3r_lcd).update();
break;
default:
id(atoms3r_lcd).show_page(idle_page);
id(atoms3r_lcd).update();
}
else:
- display.page.show: no_ha_page
- component.update: atoms3r_lcd
else:
- display.page.show: no_wifi_page
- component.update: atoms3r_lcd
else:
- display.page.show: initializing_page
- component.update: atoms3r_lcd
image:
- file: ${error_illustration_file}
id: casita_error
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${idle_illustration_file}
id: casita_idle
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${listening_illustration_file}
id: casita_listening
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${thinking_illustration_file}
id: casita_thinking
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${replying_illustration_file}
id: casita_replying
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${loading_illustration_file}
id: casita_initializing
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${error_no_wifi_illustration_file}
id: error_no_wifi
resize: 160x120
type: RGB
transparency: alpha_channel
- file: ${error_no_ha_illustration_file}
id: error_no_ha
resize: 160x120
type: RGB
transparency: alpha_channel
font:
- file: ${mdi_webfont_file}
id: mdi_icon_128
size: 128
bpp: 4
glyphs:
- "\U000F036D" # mdi:mic-mute
color:
- id: idle_color
hex: ${idle_illustration_background_color}
- id: listening_color
hex: ${listening_illustration_background_color}
- id: thinking_color
hex: ${thinking_illustration_background_color}
- id: replying_color
hex: ${replying_illustration_background_color}
- id: loading_color
hex: ${loading_illustration_background_color}
- id: error_color
hex: ${error_illustration_background_color}

View File

@@ -0,0 +1,599 @@
esphome:
name: tab1
friendly_name: M5Stack Tab5 1
on_boot:
# Set the charging icon to the correct state on boot
- then:
- logger.log: "Delaying backlight initialization"
- delay: 2s
- logger.log: "End delay"
- if:
condition:
lambda: return id(charging).state;
then:
- lvgl.widget.show:
id: charging_icon_widget
else:
- lvgl.widget.hide:
id: charging_icon_widget
esp32:
board: esp32-p4-evboard
flash_size: 16MB
framework:
type: esp-idf
advanced:
enable_idf_experimental_features: true
esp32_hosted:
variant: esp32c6
active_high: true
clk_pin: GPIO12
cmd_pin: GPIO13
d0_pin: GPIO11
d1_pin: GPIO10
d2_pin: GPIO9
d3_pin: GPIO8
reset_pin: GPIO15
slot: 1
logger:
hardware_uart: USB_SERIAL_JTAG
level: DEBUG
psram:
mode: hex
speed: 200MHz
api:
# Touchscreen support
external_components:
- source: github://pr#12075
components: [st7123]
refresh: 1h
ota:
platform: esphome
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
on_connect:
- lvgl.label.update:
id: lbl_status
text: "IDLE"
- select.set:
id: dac_output
option: "LINE1"
- lvgl.label.update:
id: lbl_ip
text: !lambda return id(ip_addr).state;
- lvgl.label.update:
id: lbl_ap
text: !lambda return id(ssid).state;
on_disconnect:
- lvgl.label.update:
id: lbl_status
text: "DISCONNECTED"
text_sensor:
- platform: wifi_info
ip_address:
id: ip_addr
name: Device IP Address
address_0:
name: Device IP Address 0
address_1:
name: Device IP Address 1
address_2:
name: Device IP Address 2
address_3:
name: Device IP Address 3
address_4:
name: Device IP Address 4
ssid:
id: ssid
name: Device Connected SSID
bssid:
name: Device Connected BSSID
mac_address:
name: Device Mac Wifi Address
scan_results:
name: Device Latest Scan Results
dns_address:
name: Device DNS Address
power_save_mode:
name: Device Wifi Power Save Mode
time:
- platform: sntp
id: sntp_time
timezone: America/New_York
servers:
- 0.pool.ntp.org
- 1.pool.ntp.org
- 2.pool.ntp.org
# wireguard:
# address: !secret tab1_wg_ip
# private_key: !secret tab1_wg_pk
# peer_endpoint: !secret wg_host
# peer_public_key: !secret wg_pubkey
# # Optional keepalive (disabled by default)
# peer_persistent_keepalive: 25s
i2c:
- id: bsp_bus
sda: GPIO31
scl: GPIO32
frequency: 400kHz
pi4ioe5v6408:
- id: pi4ioe1
address: 0x43
# 0: O - wifi_antenna_int_ext
# 1: O - speaker_enable
# 2: O - external_5v_power
# 3: NC
# 4: O - lcd reset
# 5: O - touch panel reset
# 6: O - camera reset
# 7: I - headphone detect
- id: pi4ioe2
address: 0x44
# 0: O - wifi_power
# 1: NC
# 2: NC
# 3: O - usb_5v_power
# 4: O - poweroff pulse
# 5: O - quick charge enable (inverted)
# 6: I - charging status
# 7: O - charge enable
button:
- platform: restart
name: "Restart Tablet"
switch:
- platform: gpio
id: wifi_power
name: "WiFi Power"
pin:
pi4ioe5v6408: pi4ioe2
number: 0
restore_mode: ALWAYS_ON
- platform: gpio
id: usb_5v_power
name: "USB Power"
pin:
pi4ioe5v6408: pi4ioe2
number: 3
- platform: gpio
id: quick_charge
name: "Quick Charge"
pin:
pi4ioe5v6408: pi4ioe2
number: 5
inverted: true
- platform: gpio
id: charge_enable
name: "Charge Enable"
pin:
pi4ioe5v6408: pi4ioe2
number: 7
restore_mode: ALWAYS_ON
- platform: gpio
id: wifi_antenna_int_ext
pin:
pi4ioe5v6408: pi4ioe1
number: 0
- platform: gpio
id: speaker_enable
name: "Speaker Enable"
pin:
pi4ioe5v6408: pi4ioe1
number: 1
restore_mode: ALWAYS_ON
- platform: gpio
id: external_5v_power
name: "External 5V Power"
pin:
pi4ioe5v6408: pi4ioe1
number: 2
binary_sensor:
- platform: gpio
id: charging
name: "Charging Status"
pin:
pi4ioe5v6408: pi4ioe2
number: 6
mode: INPUT_PULLDOWN
on_state:
then:
- if:
condition:
lambda: return id(charging).state;
then:
- lvgl.widget.show:
id: charging_icon_widget
else:
- lvgl.widget.hide:
id: charging_icon_widget
- platform: gpio
id: headphone_detect
name: "Headphone Detect"
pin:
pi4ioe5v6408: pi4ioe1
number: 7
- platform: lvgl
widget: volume_up_widget
name: Volume Up Button
on_press:
then:
- logger.log: "Button pressed"
- media_player.volume_up:
id: tab5_media_player
- delay: 100ms
- lvgl.label.update:
id: lbl_volume
text: !lambda return to_string(int(id(tab5_media_player).volume * 100));
- light.turn_on:
id: backlight
brightness: !lambda |-
float current_value = id(backlight).current_values.get_brightness();
return current_value < 0.6 ? 0.6 : current_value + 0.2;
- platform: lvgl
widget: volume_down_widget
name: Volume Down Button
on_press:
then:
- logger.log: "Button pressed"
- media_player.volume_down:
id: tab5_media_player
- lvgl.label.update:
id: lbl_volume
text: !lambda return to_string(int(id(tab5_media_player).volume * 100));
- light.turn_on:
id: backlight
brightness: !lambda |-
float current_value = id(backlight).current_values.get_brightness();
return current_value < 0.6 ? 0.6 : current_value - 0.2;
sensor:
- platform: ina226
address: 0x41
adc_averaging: 16
max_current: 8.192A
shunt_resistance: 0.005ohm
bus_voltage:
id: battery_voltage
name: "Battery Voltage"
current:
id: battery_current
name: "Battery Current"
# Positive means discharging
# Negative means charging
# Tab5 built-in battery discharges from full (8.23 V) to shutdown threshold (6.0 V)
- platform: template
name: "Battery Percentage"
lambda: |-
float voltage = id(battery_voltage).state;
// Adjust these values based on your battery's actual min/max voltage
float min_voltage = 6.75; // Discharged voltage
float max_voltage = 8.2; // Fully charged voltage
float percentage = (voltage - min_voltage) / (max_voltage - min_voltage) * 100.0;
if (percentage > 100.0) return 100.0;
if (percentage < 0.0) return 0.0;
return percentage;
update_interval: 60s
unit_of_measurement: "%"
accuracy_decimals: 1
id: battery_percent
on_value:
then:
- lvgl.label.update:
id: lbl_battery
text:
format: "Battery: %.1f%%"
args: ["id(battery_percent).state"]
touchscreen:
- platform: st7123
i2c_id: bsp_bus
interrupt_pin: GPIO23
display: lcd
update_interval: never
reset_pin:
pi4ioe5v6408: pi4ioe1
number: 5
calibration:
x_min: 0
x_max: 720
y_min: 0
y_max: 1280
id: touch
on_touch:
- logger.log: "LVGL resuming"
- lvgl.resume:
- light.turn_on: backlight
# on_release:
# - media_player.stop:
esp_ldo:
- voltage: 2.5V
channel: 3
display:
- platform: mipi_dsi
id: lcd
dimensions:
height: 1280
width: 720
model: M5STACK-TAB5-V2
reset_pin:
pi4ioe5v6408: pi4ioe1
number: 4
output:
- platform: ledc
pin: GPIO22
id: backlight_pwm
frequency: 1000Hz
light:
- platform: monochromatic
output: backlight_pwm
name: "Display Backlight"
id: backlight
restore_mode: ALWAYS_ON
default_transition_length: 250ms
initial_state:
brightness: "50%"
image:
defaults:
type: rgb565
transparency: alpha_channel
resize: 512x512
byte_order: little_endian
images:
- file: "images/va_idle.png"
id: va_idle
- file: "images/va_listen.png"
id: va_listen
- file: "images/va_speak.png"
id: va_speak
- file: "images/charging.png"
id: charging_icon
resize: 64x64
lvgl:
byte_order: little_endian
on_idle:
timeout: 120s
then:
- logger.log: "LVGL is idle"
- light.turn_off:
id: backlight
transition_length: 15s
- lvgl.pause:
widgets:
- obj:
align: TOP_MID
width: 100%
height: 100%
layout:
type: flex
flex_flow: column
flex_align_main: START
flex_align_track: center
flex_align_cross: center
widgets:
- label:
align: TOP_MID
id: lbl_status
text_font: montserrat_48
text: "CONNECTING..."
- label:
align: TOP_MID
id: lbl_ap
text_font: montserrat_22
text: "CONNECTING..."
- label:
align: TOP_MID
id: lbl_ip
text_font: montserrat_22
text: "CONNECTING..."
- image:
id: listen_icon_widget
src: va_idle
align: CENTER
- label:
align: BOTTOM_LEFT
id: lbl_version
text_font: montserrat_12
text: "v0.6"
- label:
align: BOTTOM_RIGHT
id: lbl_battery
text_font: montserrat_28
text: Loading...
- image:
id: charging_icon_widget
src: charging_icon
align: TOP_RIGHT
- button:
id: volume_up_widget
widgets:
- label:
text: "\uF028"
text_font: montserrat_48
text_align: CENTER
align: CENTER
x: 20
y: 20
width: 100
height: 100
pad_all: 8
- button:
id: volume_down_widget
widgets:
- label:
text: "\uF027"
text_font: montserrat_48
text_align: CENTER
align: CENTER
x: 20
y: 140
width: 100
height: 100
pad_all: 8
- label:
x: 20
y: 260
id: lbl_volume
text_font: montserrat_28
text: !lambda return "%.1f",to_string(id(tab5_media_player).volume);
# The DAC Output select needs to be manually (or with an automation) changed to `LINE1` for the onboard speaker
select:
- platform: es8388
dac_output:
name: DAC Output
id: dac_output
adc_input_mic:
name: ADC Input Mic
id: adc_input
- platform: template
id: wifi_antenna_select
name: "WiFi Antenna"
options:
- "Internal"
- "External"
optimistic: true
on_value:
- if:
condition:
lambda: return i == 0;
then:
- switch.turn_off: wifi_antenna_int_ext
else:
- switch.turn_on: wifi_antenna_int_ext
i2s_audio:
- id: mic_bus
i2s_lrclk_pin: GPIO29
i2s_bclk_pin: GPIO27
i2s_mclk_pin: GPIO30
audio_adc:
- platform: es7210
id: es7210_adc
bits_per_sample: 16bit
sample_rate: 16000
microphone:
- platform: i2s_audio
id: tab5_microphone
i2s_din_pin: GPIO28
sample_rate: 16000
bits_per_sample: 16bit
adc_type: external
audio_dac:
- platform: es8388
id: es8388_dac
speaker:
- platform: i2s_audio
id: tab5_speaker
i2s_dout_pin: GPIO26
audio_dac: es8388_dac
dac_type: external
channel: mono
buffer_duration: 100ms
bits_per_sample: 16bit
sample_rate: 48000
media_player:
- platform: speaker
name: None
id: tab5_media_player
announcement_pipeline:
speaker: tab5_speaker
format: WAV
micro_wake_word:
id: mww
models:
- okay_nabu
- hey_mycroft
- hey_jarvis
on_wake_word_detected:
- voice_assistant.start:
wake_word: !lambda return wake_word;
voice_assistant:
id: va
microphone: tab5_microphone
media_player: tab5_media_player
micro_wake_word: mww
on_listening:
- logger.log: "LVGL resuming"
- lvgl.resume:
- light.turn_on: backlight
- lvgl.image.update:
id: listen_icon_widget
src: va_listen
- lvgl.label.update:
id: lbl_status
text: "LISTENING"
on_stt_vad_end:
- lvgl.label.update:
id: lbl_status
text: "PROCESSING"
- lvgl.image.update:
id: listen_icon_widget
src: va_idle
on_tts_start:
- lvgl.label.update:
id: lbl_status
text: "RESPONDING"
- lvgl.image.update:
id: listen_icon_widget
src: va_speak
on_end:
# Wait a short amount of time to see if an announcement starts
- wait_until:
condition:
- media_player.is_announcing:
timeout: 0.5s
# Announcement is finished and the I2S bus is free
- wait_until:
- and:
- not:
media_player.is_announcing:
- not:
speaker.is_playing:
- micro_wake_word.start:
- lvgl.label.update:
id: lbl_status
text: "IDLE"
- lvgl.image.update:
id: listen_icon_widget
src: va_idle
- light.turn_off:
id: backlight
transition_length: 15s
on_client_connected:
- micro_wake_word.start:
on_client_disconnected:
- micro_wake_word.stop:

View File

@@ -1,6 +1,18 @@
esphome:
name: tab1
friendly_name: M5Stack Tab5 1
name: tab2
friendly_name: M5Stack Tab5 2
on_boot:
# Set the charing icon to the correct state on boot
- then:
- if:
condition:
lambda: return id(charging).state;
then:
- lvgl.widget.show:
id: charging_icon_widget
else:
- lvgl.widget.hide:
id: charging_icon_widget
esp32:
board: esp32-p4-evboard
@@ -43,6 +55,7 @@ ota:
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
fast_connect: true
on_connect:
- lvgl.label.update:
id: lbl_status
@@ -54,6 +67,29 @@ wifi:
- lvgl.label.update:
id: lbl_status
text: "DISCONNECTED"
# ap:
# password: !secret hotspot_password
# ap_timeout: 90s
# captive_portal:
time:
- platform: sntp
id: sntp_time
timezone: America/New_York
servers:
- 0.pool.ntp.org
- 1.pool.ntp.org
- 2.pool.ntp.org
# wireguard:
# address: !secret tab1_wg_ip
# private_key: !secret tab1_wg_pk
# peer_endpoint: !secret wg_host
# peer_public_key: !secret wg_pubkey
# # Optional keepalive (disabled by default)
# peer_persistent_keepalive: 25s
i2c:
- id: bsp_bus
@@ -142,6 +178,17 @@ binary_sensor:
pi4ioe5v6408: pi4ioe2
number: 6
mode: INPUT_PULLDOWN
on_state:
then:
- if:
condition:
lambda: return id(charging).state;
then:
- lvgl.widget.show:
id: charging_icon_widget
else:
- lvgl.widget.hide:
id: charging_icon_widget
- platform: gpio
id: headphone_detect
@@ -186,7 +233,7 @@ sensor:
- lvgl.label.update:
id: lbl_battery
text:
format: "Battery: %.1f%"
format: "Battery: %.1f%%"
args: ["id(battery_percent).state"]
touchscreen:
@@ -255,6 +302,9 @@ image:
id: va_listen
- file: "images/va_speak.png"
id: va_speak
- file: "images/charging.png"
id: charging_icon
resize: 64x64
lvgl:
byte_order: little_endian
@@ -287,6 +337,25 @@ lvgl:
id: lbl_battery
text_font: montserrat_28
text: Loading...
- image:
id: charging_icon_widget
src: charging_icon
align: TOP_RIGHT
- slider:
id: backlight_slider
x: 20
y: 50
width: 30
height: 220
pad_all: 8
min_value: 0
max_value: 255
on_release:
- homeassistant.action:
action: light.turn_on
data:
entity_id: light.backlight
brightness: !lambda return int(x);
# The DAC Output select needs to be manually (or with an automation) changed to `LINE1` for the onboard speaker
select:

View File

@@ -25,6 +25,8 @@
- [LG TV Switch](#lg-tv-switch)
- [Raspberry Pi Docker](#raspberry-pi-docker)
- [Extended OpenAI Conversation](#extended-openai-conversation)
- [Templates](#templates)
- [List Roku TV Apps](#list-roku-tv-apps)
## Certificates
@@ -749,4 +751,49 @@ Setting up a new llama.cpp agent:
| Skip Authentication | Yes |
| API Provider | OpenAI |
Model Name: `ggml-org/gpt-oss-120b-GGUF`
Model Name: `ggml-org/gpt-oss-120b-GGUF`
## Templates
### List Roku TV Apps
```yaml
{{ state_attr("media_player.right_living_room", "source_list") }}
{% for app in tv_apps -%}
"{{ app }}"
{% endfor %}
```
For extended openai conversation template:
```yaml
- spec:
name: set_tv_app
description: |-
Use this function to open an app on a tv media player.
parameters:
type: object
properties:
entity_id:
type: string
description: entity_id of the TV Media Player
source:
type: string
description: The app you want to open
required:
- entity_id
- source
function:
type: composite
sequence:
- type: script
sequence:
- service:
- service: media_player.select_source
target:
entity_id: "{{ entity_id }}"
data:
source: "{{ source }}"
```

View File

@@ -0,0 +1,13 @@
rabbitmq:
host: "rabbitmq.reeselink.com"
port: 5672
virtual_host: "/"
username: "user"
password: "password"
# Which *exchange* (topic) you actually want to listen to.
# The program will create a temporary queue, bind it to this exchange
# with the routing key supplied in `routing_key`.
subscriber:
exchange: "nic" # ← change to “reese” or any other exchange
routing_key: "add" # ← could be “add”, “delete”, or any pattern

View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
RabbitMQ setup & consumer using pika.
- Creates two **topic exchanges**: nic, reese
- For each exchange creates two queues: add, delete
- Binds the queues with routing keys “add” and “delete”
- Subscribes (consumes) from a **single** exchange/queue pair that is
supplied via a tiny config file (config.yaml).
Run:
python3 rabbit_demo.py
"""
import logging
import sys
from pathlib import Path
import pika # type: ignore
import yaml
# ----------------------------------------------------------------------
# 1⃣ Load configuration
# ----------------------------------------------------------------------
DEFAULT_CFG = """
rabbitmq:
host: "localhost"
port: 5672
virtual_host: "/"
username: "guest"
password: "guest"
# Which *exchange* (topic) you actually want to listen to.
# The program will create a temporary queue, bind it to this exchange
# with the routing key supplied in `routing_key`.
subscriber:
exchange: "nic" # ← change to “reese” or any other exchange
routing_key: "add" # ← could be “add”, “delete”, or any pattern
"""
CONFIG_PATH = Path("active/device_unifi/config.yaml")
if not CONFIG_PATH.exists():
CONFIG_PATH.write_text(DEFAULT_CFG)
with CONFIG_PATH.open() as f:
cfg = yaml.safe_load(f)
# ----------------------------------------------------------------------
# 2⃣ Build connection parameters
# ----------------------------------------------------------------------
cred = pika.PlainCredentials(cfg["rabbitmq"]["username"], cfg["rabbitmq"]["password"])
params = pika.ConnectionParameters(
host=cfg["rabbitmq"]["host"],
port=cfg["rabbitmq"]["port"],
virtual_host=cfg["rabbitmq"]["virtual_host"],
credentials=cred,
)
# ----------------------------------------------------------------------
# 3⃣ Helper to declare exchanges / queues
# ----------------------------------------------------------------------
def declare_topology(channel):
"""
Create the two topic exchanges and the four queues,
then bind each queue to its exchange with the appropriate routing key.
"""
exchanges = ["nic", "reese"]
routing_keys = ["add", "delete"]
for exch in exchanges:
channel.exchange_declare(exchange=exch, exchange_type="topic", durable=True)
for key in routing_keys:
queue_name = f"{exch}_{key}" # e.g. nic_add, reese_delete
channel.queue_declare(queue=queue_name, durable=True)
# bind queue to the exchange with the same routing key
channel.queue_bind(queue=queue_name, exchange=exch, routing_key=key)
logging.info(
f"Declared queue {queue_name} bound to {exch} with key '{key}'"
)
# ----------------------------------------------------------------------
# 4⃣ Consumer callback
# ----------------------------------------------------------------------
def on_message(ch, method, properties, body):
logging.info(
f"Received from exchange '{method.exchange}' "
f"routing_key='{method.routing_key}': {body!r}"
)
# Acknowledge the message
ch.basic_ack(delivery_tag=method.delivery_tag)
# ----------------------------------------------------------------------
# 5⃣ Main routine
# ----------------------------------------------------------------------
def main():
logging.basicConfig(
level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s"
)
with pika.BlockingConnection(params) as conn:
channel = conn.channel()
# 1⃣ Declare the static topology (exchanges + queues)
declare_topology(channel)
# 2⃣ Set up a *temporary* queue for the subscriber defined in config
result = channel.queue_declare(
queue="", exclusive=True
) # servergenerated name
tmp_queue = result.method.queue
exch = cfg["subscriber"]["exchange"]
rkey = cfg["subscriber"]["routing_key"]
channel.queue_bind(queue=tmp_queue, exchange=exch, routing_key=rkey)
logging.info(
f"Subscribed to exchange '{exch}' with routing_key '{rkey}' "
f"using temporary queue '{tmp_queue}'"
)
# 3⃣ Start consuming
channel.basic_consume(queue=tmp_queue, on_message_callback=on_message)
try:
logging.info("Waiting for messages. Press Ctrl+C to exit.")
channel.start_consuming()
except KeyboardInterrupt:
logging.info("Interrupted closing connection.")
channel.stop_consuming()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,46 @@
import json
from pathlib import Path
import pika
import yaml
DEFAULT_CFG = """
rabbitmq:
host: "localhost"
port: 5672
virtual_host: "/"
username: "guest"
password: "guest"
# Which *exchange* (topic) you actually want to listen to.
# The program will create a temporary queue, bind it to this exchange
# with the routing key supplied in `routing_key`.
subscriber:
exchange: "nic" # ← change to “reese” or any other exchange
routing_key: "add" # ← could be “add”, “delete”, or any pattern
"""
CONFIG_PATH = Path("active/device_unifi/config.yaml")
if not CONFIG_PATH.exists():
CONFIG_PATH.write_text(DEFAULT_CFG)
with CONFIG_PATH.open() as f:
cfg = yaml.safe_load(f)
cred = pika.PlainCredentials(cfg["rabbitmq"]["username"], cfg["rabbitmq"]["password"])
params = pika.ConnectionParameters(
host=cfg["rabbitmq"]["host"],
port=cfg["rabbitmq"]["port"],
virtual_host=cfg["rabbitmq"]["virtual_host"],
credentials=cred,
)
with pika.BlockingConnection(params) as c:
ch = c.channel()
ch.basic_publish(
exchange="reese",
routing_key="add",
body=json.dumps({"msg": "hello nic add"}),
properties=pika.BasicProperties(delivery_mode=2), # make it persistent
)

View File

@@ -0,0 +1,15 @@
from update_dns import ApiHelperMethods, ApiPaths, ApiWrapper
def test_api_get():
assert ApiWrapper.api_get(ApiPaths.list_sites()) is not None
def test_site_name_to_id():
assert ApiHelperMethods.site_name_to_id("Default") is not None
def test_dns_record_exists():
assert ApiHelperMethods.dns_record_exists("test.reeselink.com")
assert not ApiHelperMethods.dns_record_exists("idontexist.reeselink.com")

View File

@@ -0,0 +1,58 @@
# Unifi
## Update DNS Records via API
```bash
export API_KEY=$(cat active/device_unifi/secrets/api-key)
# List site IDs
curl -L -g -k -s "https://192.168.1.1/proxy/network/integration/v1/sites" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}" | jq -rc '.data[0].id'
# List domains
curl -L -g -k -s "https://192.168.1.1/proxy/network/integration/v1/sites/88f7af54-98f8-306a-a1c7-c9349722b1f6/dns/policies" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}" | jq -r '.data[] | {domain, id}'
# List device domains
curl -L -g -k -s "https://10.1.0.1/proxy/network/v2/api/site/default/static-dns/devices" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}" | jq -r
# List clients
curl -L -g -k -s "https://192.168.1.1/proxy/network/integration/v1/sites/88f7af54-98f8-306a-a1c7-c9349722b1f6/clients" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}"
# List firewall policies
curl -L -g -k -s "https://192.168.1.1/proxy/network/integration/v1/sites/88f7af54-98f8-306a-a1c7-c9349722b1f6/firewall/policies" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}"
# Create a record
curl -L -g -k -s "https://192.168.1.1/proxy/network/integration/v1/sites/88f7af54-98f8-306a-a1c7-c9349722b1f6/dns/policies" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"type\": \"A_RECORD\",
\"enabled\": true,
\"domain\": \"test.reeselink.com\",
\"ipv4Address\": \"10.1.0.100\",
\"ttlSeconds\": 300
}"
# Update a record
curl -L -g -k -s -X PUT "https://192.168.1.1/proxy/network/integration/v1/sites/88f7af54-98f8-306a-a1c7-c9349722b1f6/dns/policies/a5689d61-811a-48b0-a47c-2ece038e4356" \
-H "Accept: application/json" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d "{
\"type\": \"A_RECORD\",
\"enabled\": true,
\"domain\": \"test.reeselink.com\",
\"ipv4Address\": \"10.1.0.100\",
\"ttlSeconds\": 300
}"
```

View File

@@ -0,0 +1,103 @@
import json
import os
from typing import TypedDict
import requests
UNIFI_API_ENDPOINT = os.getenv("UNIFI_API_ENDPOINT", "https://192.168.1.1")
UNIFI_SITE_NAME = os.getenv("UNIFI_SITE_NAME", "Default")
UNIFI_API_KEY = os.getenv("UNIFI_API_KEY")
if not UNIFI_API_KEY:
try:
with open("active/device_unifi/secrets/api-key", "r") as f:
UNIFI_API_KEY = f.read()
except (FileNotFoundError, PermissionError) as e:
print(e)
print("UNIFI_API_KEY required.")
type uuid_type = str
class UnifiSite(TypedDict):
id: uuid_type
internalReference: str
name: str
class ApiPaths:
@classmethod
def list_sites(cls) -> str:
return "/proxy/network/integration/v1/sites"
@classmethod
def list_records(cls, site_id: uuid_type) -> str:
return f"/proxy/network/integration/v1/sites/{site_id}/dns/policies"
@classmethod
def create_record(cls, site_id: uuid_type) -> str:
return f"/proxy/network/integration/v1/sites/{site_id}/dns/policies"
@classmethod
def update_record(cls, site_id: uuid_type, record_id: uuid_type) -> str:
return f"/proxy/network/integration/v1/sites/{site_id}/dns/policies/{record_id}"
class ApiWrapper:
@classmethod
def api_get(cls, path: str):
return requests.get(
f"{UNIFI_API_ENDPOINT}{path}",
headers={"X-API-Key": UNIFI_API_KEY},
verify=False,
).json()
@classmethod
def api_put(cls, path: str, body: dict):
return requests.put(
f"{UNIFI_API_ENDPOINT}{path}",
headers={"X-API-Key": UNIFI_API_KEY},
verify=False,
json=json.dumps(body),
).json()
class ApiHelperMethods:
@classmethod
def site_name_to_id(cls, site_name: str) -> uuid_type:
results = ApiWrapper.api_get(ApiPaths.list_sites())
data: list[UnifiSite] = results.get("data")
if not data:
print("No sites found")
exit(1)
filtered_sites: list[UnifiSite] = list(
filter(lambda data_item: data_item["name"] == site_name, data)
)
if not filtered_sites:
print("Site with that name not found")
exit(1)
site_id = filtered_sites[0]["id"]
return site_id
# @classmethod
# def upsert_dns_ipv4_record(cls, record_name: str, ipv4_addr: str) -> uuid_type:
@classmethod
def dns_record_exists(cls, record_name: str) -> bool:
site_id = ApiHelperMethods.site_name_to_id(UNIFI_SITE_NAME)
record_id = ApiWrapper.api_get(path=ApiPaths.list_records(site_id))
matched_dns_records = list(
filter(
lambda record_item: record_item["domain"] == record_name,
record_id["data"],
)
)
if matched_dns_records:
return True
return False
if __name__ == "__main__":
import sys
site_name = sys.argv[1]

View File

@@ -0,0 +1,90 @@
<mxfile host="Electron" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.6.6 Chrome/144.0.7559.236 Electron/40.8.4 Safari/537.36" version="29.6.6">
<diagram name="Page-1" id="sur_P5ccan6r_R6vxB1T">
<mxGraphModel dx="1243" dy="832" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="lTIulqBT4iiTOOd0l-js-23" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Client" vertex="1">
<mxGeometry height="440" width="200" x="840" y="120" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-22" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Network Admin" vertex="1">
<mxGeometry height="440" width="200" x="560" y="120" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-21" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Developer" vertex="1">
<mxGeometry height="440" width="200" y="120" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-20" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Cluster Admin" vertex="1">
<mxGeometry height="440" width="200" x="280" y="120" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-4" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-3" value="Create Record">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-1" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="External DNS" vertex="1">
<mxGeometry height="60" width="120" x="320" y="160" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-13" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-8" value="Connect">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-19" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-3" value="Get IP">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="940" y="190" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-2" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Browser" vertex="1">
<mxGeometry height="60" width="120" x="880" y="240" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-3" parent="1" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;" value="Route53" vertex="1">
<mxGeometry height="80" width="120" x="600" y="150" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-9" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-8" value="Allocate IP">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-7" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Metal LB" vertex="1">
<mxGeometry height="60" width="120" x="320" y="240" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-12" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-11" value="Forward to Gateway">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="660" y="350" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-8" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Router/Proxy" vertex="1">
<mxGeometry height="60" width="120" x="600" y="240" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-18" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-15" value="Create Storage">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-10" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Longhorn" vertex="1">
<mxGeometry height="60" width="120" x="320" y="480" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-17" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-14" value="Forward to Container">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-11" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Traefik" vertex="1">
<mxGeometry height="60" width="120" x="320" y="320" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-16" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-15">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-14" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Container" vertex="1">
<mxGeometry height="60" width="120" x="40" y="320" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-15" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Storage" vertex="1">
<mxGeometry height="60" width="120" x="40" y="400" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-26" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-25" value="Request Certificate">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-24" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Cert Manager" vertex="1">
<mxGeometry height="60" width="120" x="320" y="400" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-25" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="CA&lt;div&gt;(Let&#39;s Encrypt)&lt;/div&gt;" vertex="1">
<mxGeometry height="60" width="120" x="600" y="400" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -0,0 +1,96 @@
<mxfile host="Electron" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/29.6.6 Chrome/144.0.7559.236 Electron/40.8.4 Safari/537.36" version="29.6.6">
<diagram name="Page-1" id="sur_P5ccan6r_R6vxB1T">
<mxGraphModel dx="1243" dy="832" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="lTIulqBT4iiTOOd0l-js-23" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Client" vertex="1">
<mxGeometry height="520" width="200" x="840" y="40" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-22" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Network Admin" vertex="1">
<mxGeometry height="520" width="200" x="560" y="40" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-21" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Developer" vertex="1">
<mxGeometry height="520" width="200" y="40" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-20" parent="1" style="rounded=0;whiteSpace=wrap;html=1;verticalAlign=top;fillColor=none;" value="Cluster Admin" vertex="1">
<mxGeometry height="520" width="200" x="280" y="40" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-4" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-3" value="Create Record">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-1" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="External DNS" vertex="1">
<mxGeometry height="60" width="120" x="320" y="160" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-13" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-8" value="Connect">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-19" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-3" value="Get IP">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="940" y="190" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-2" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Browser" vertex="1">
<mxGeometry height="60" width="120" x="880" y="240" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-3" parent="1" style="ellipse;shape=cloud;whiteSpace=wrap;html=1;" value="Route53" vertex="1">
<mxGeometry height="80" width="120" x="600" y="150" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-9" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-8" value="Allocate IP">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-7" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Metal LB" vertex="1">
<mxGeometry height="60" width="120" x="320" y="240" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-12" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-8" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-11" value="Forward to Gateway">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="660" y="350" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-8" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Router/Proxy" vertex="1">
<mxGeometry height="60" width="120" x="600" y="240" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-18" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-10" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-15" value="Create Storage">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-10" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Longhorn" vertex="1">
<mxGeometry height="60" width="120" x="320" y="480" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-17" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-11" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-14" value="Forward to Container">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-11" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Traefik" vertex="1">
<mxGeometry height="60" width="120" x="320" y="320" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-16" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-14" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-15">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-14" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Container" vertex="1">
<mxGeometry height="60" width="120" x="40" y="320" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-15" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Storage" vertex="1">
<mxGeometry height="60" width="120" x="40" y="400" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-26" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-25" value="Request Certificate">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-24" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="Cert Manager" vertex="1">
<mxGeometry height="60" width="120" x="320" y="400" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-25" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="CA&lt;div&gt;(Let&#39;s Encrypt)&lt;/div&gt;" vertex="1">
<mxGeometry height="60" width="120" x="600" y="400" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-29" edge="1" parent="1" source="lTIulqBT4iiTOOd0l-js-28" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" target="lTIulqBT4iiTOOd0l-js-14" value="Create Internal Record">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="lTIulqBT4iiTOOd0l-js-28" parent="1" style="rounded=0;whiteSpace=wrap;html=1;" value="CoreDNS" vertex="1">
<mxGeometry height="60" width="120" x="320" y="80" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View File

@@ -11,3 +11,10 @@ helm: <https://helm.sh/docs/intro/install/>
For k3s, see [k3s](/active/software_k3s/k3s.md)
For k0s, see [k0s](/active/software_k0s/k0s.md)
## Notes
```bash
# Quickly set a new namespace
kubectl config set contexts.default.namespace metallb-system
```

View File

@@ -0,0 +1,34 @@
apiVersion: v1
kind: Service
metadata:
name: nginx
annotations:
external-dns.alpha.kubernetes.io/hostname: external-dns.reeselink.com
spec:
type: LoadBalancer
ports:
- port: 80
name: http
targetPort: 80
selector:
app: nginx
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
spec:
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- image: nginx
name: nginx
ports:
- containerPort: 80
name: http

View File

@@ -1,80 +0,0 @@
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: external-dns
labels:
app.kubernetes.io/name: external-dns
rules:
- apiGroups: [""]
resources: ["services","endpoints","pods","nodes"]
verbs: ["get","watch","list"]
- apiGroups: ["extensions","networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get","watch","list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: external-dns-viewer
labels:
app.kubernetes.io/name: external-dns
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: external-dns
subjects:
- kind: ServiceAccount
name: external-dns
namespace: kube-system # change to desired namespace: externaldns, kube-addons
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: external-dns
namespace: kube-system
labels:
app.kubernetes.io/name: external-dns
spec:
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: external-dns
template:
metadata:
labels:
app.kubernetes.io/name: external-dns
spec:
serviceAccountName: external-dns
containers:
- name: external-dns
image: registry.k8s.io/external-dns/external-dns:v0.14.2
args:
- --source=service
- --source=ingress
- --domain-filter=reeseapps.com
- --provider=aws
- --aws-zone-type=public
- --registry=txt
# - --txt-owner-id=external-dns
env:
- name: AWS_DEFAULT_REGION
value: us-east-1 # change to region where EKS is installed
- name: AWS_SHARED_CREDENTIALS_FILE
value: /.aws/externaldns-credentials
volumeMounts:
- name: aws-credentials
mountPath: /.aws
readOnly: true
resources:
requests:
memory: "64Mi"
cpu: "250m"
limits:
memory: "128Mi"
cpu: "500m"
volumes:
- name: aws-credentials
secret:
secretName: external-dns

View File

@@ -1,8 +0,0 @@
# comment out sa if it was previously created
apiVersion: v1
kind: ServiceAccount
metadata:
name: external-dns
namespace: kube-system
labels:
app.kubernetes.io/name: external-dns

View File

@@ -0,0 +1,31 @@
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: storage-test-pvc
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
# If you want to test a specific storage class, uncomment the line below:
# storageClassName: <your-storage-class-name>
---
apiVersion: v1
kind: Pod
metadata:
name: storage-test-pod
spec:
containers:
- name: busybox
image: busybox
command:
["sh", "-c", "while true; do date >> /mnt/test.txt; sleep 10; done"]
volumeMounts:
- name: test-volume
mountPath: /mnt
restartPolicy: Always
volumes:
- name: test-volume
persistentVolumeClaim:
claimName: storage-test-pvc

View File

@@ -2,18 +2,18 @@
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: unifi-pool
namespace: kube-system
name: default-pool
namespace: metallb-system
spec:
addresses:
- 2603:6013:3140:105:10:5:0:10-2603:6013:3140:105:10:5:0:210
- 10.5.0.10-10.5.0.210
- 10.4.2.32-10.4.2.47
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: l2advertisement
namespace: kube-system
namespace: metallb-system
spec:
ipAddressPools:
- unifi-pool
- default-pool

View File

@@ -15,33 +15,31 @@ spec:
app.kubernetes.io/name: ingress-nginx-demo-1
spec:
containers:
- name: httpd
image: httpd
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "100Mi"
cpu: "1m"
limits:
memory: "256Mi"
cpu: "1"
- name: httpd
image: httpd
ports:
- containerPort: 80
name: http
resources:
requests:
memory: "100Mi"
cpu: "1m"
limits:
memory: "256Mi"
cpu: "1"
---
apiVersion: v1
kind: Service
metadata:
name: ingress-nginx-demo-1
namespace: default
annotations:
metallb.universe.tf/address-pool: "unifi-pool"
metallb.universe.tf/address-pool: "default-pool"
spec:
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv6
- IPv4
- IPv4
type: LoadBalancer
ports:
- name: http

View File

@@ -0,0 +1,43 @@
# Metal LB
## Install
```bash
# Set a manual IP if you have a no-dhcp network
nmcli connection modify "Wired connection 2" \
ipv4.method manual \
ipv4.gateway 10.4.0.1 \
ipv4.addresses 10.4.0.3/22
# Clear it if you have a dhcp network
nmcli connection modify "Wired connection 2" \
ipv4.addresses "" \
ipv4.gateway "" \
ipv4.method auto
# Bring up the interface
nmcli connection up "Wired connection 2"
# Create the metallb namespace
kubectl apply -f active/kubernetes_metallb/namespace.yaml
# Install metallb
helm repo add metallb https://metallb.github.io/metallb
helm repo update
helm upgrade --install metallb \
--namespace metallb-system \
metallb/metallb
# Check that the pods installed correctly
kubectl get pod -n metallb-system
# Install the address pool
# NOTE: Edit this to match your own allocated addresses!
kubectl apply -f active/kubernetes_metallb/addresspool.yaml
# Test that the service is working. This will spin up a web server on port 8001
kubectl apply -f active/kubernetes_metallb/metallb-test.yaml
# Delete the test
kubectl delete -f active/kubernetes_metallb/metallb-test.yaml
```

View File

@@ -0,0 +1,8 @@
apiVersion: v1
kind: Namespace
metadata:
name: metallb-system
labels:
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/audit: privileged
pod-security.kubernetes.io/warn: privileged

View File

@@ -9,4 +9,4 @@ spec:
- ReadWriteOnce
resources:
requests:
storage: 32Gi
storage: 8Gi

View File

@@ -2,12 +2,17 @@ apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}
annotations:
metallb.universe.tf/address-pool: "default-pool"
spec:
externalTrafficPolicy: Cluster
selector:
app: {{ .Release.Name }}
ports:
- port: {{ .Values.port }}
targetPort: 25565
name: {{ .Release.Name }}
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
type: LoadBalancer
ports:
- name: minecraft
protocol: TCP
port: {{ .Values.port }}
targetPort: 25565
selector:
app.kubernetes.io/name: {{ .Release.Name }}

View File

@@ -4,7 +4,7 @@ get_server:
server_version: "1.21.3"
port: 25565
max_cpu: 4
max_ram: 8
max_ram: 2
server_props: |
enable-jmx-monitoring=false
rcon.port=25575

View File

@@ -0,0 +1,54 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: whoami
namespace: default
spec:
replicas: 2
selector:
matchLabels:
app: whoami
template:
metadata:
labels:
app: whoami
spec:
containers:
- name: whoami
image: traefik/whoami
ports:
- containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: whoami
namespace: default
spec:
selector:
app: whoami
ports:
- port: 80
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: whoami
namespace: default
spec:
parentRefs:
- name: traefik-gateway
namespace: traefik
hostnames:
- "traefik-reese.reeselink.com"
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: whoami
namespace: default
port: 80

View File

@@ -0,0 +1,96 @@
# Configure Network Ports and EntryPoints
# EntryPoints are the network listeners for incoming traffic.
ports:
# Defines the HTTP entry point named 'web'
web:
port: 80
nodePort: 30000
# Instructs this entry point to redirect all traffic to the 'websecure' entry point
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
# Defines the HTTPS entry point named 'websecure'
websecure:
port: 443
nodePort: 30001
# Enables the dashboard in Secure Mode
api:
dashboard: true
insecure: false
ingressRoute:
dashboard:
enabled: true
matchRule: Host(`traefik-dashboard.reeselink.com`)
entryPoints:
- websecure
middlewares:
- name: dashboard-auth
# Creates a BasicAuth Middleware and Secret for the Dashboard Security
extraObjects:
- apiVersion: v1
kind: Secret
metadata:
name: dashboard-auth-secret
type: kubernetes.io/basic-auth
stringData:
username: admin
password: "P@ssw0rd" # Replace with an Actual Password
- apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: dashboard-auth
spec:
basicAuth:
secret: dashboard-auth-secret
# We will route with Gateway API instead.
ingressClass:
enabled: false
# Enable Gateway API Provider & Disables the KubernetesIngress provider
# Providers tell Traefik where to find routing configuration.
providers:
kubernetesIngress:
enabled: false
kubernetesGateway:
enabled: true
## Gateway Listeners
gateway:
listeners:
web: # HTTP listener that matches entryPoint `web`
port: 80
protocol: HTTP
namespacePolicy:
from: All
websecure: # HTTPS listener that matches entryPoint `websecure`
port: 443
protocol: HTTPS # TLS terminates inside Traefik
namespacePolicy:
from: All
mode: Terminate
certificateRefs:
- kind: Secret
name: local-selfsigned-tls # the Secret we created before the installation
group: ""
# Enable Observability
logs:
general:
level: INFO
# This enables access logs, outputting them to Traefik's standard output by default. The [Access Logs Documentation](https://doc.traefik.io/traefik/observability/access-logs/) covers formatting, filtering, and output options.
access:
enabled: true
# Enables Prometheus for Metrics
metrics:
prometheus:
enabled: true

View File

@@ -363,7 +363,8 @@ for folder in $(ls); do du --exclude .snapshots -sh $folder; done
alias {dudir,dud}='du -h --max-depth 1 | sort -h'
# Calculate all file sizes in current dir
alias {dufile,duf}='ls -lhSr'
alias {dufile,duf}='find . -name ".snapshots" -prune -o -type f -exec du -h {} + | sort -hr'
alias {dufiler,dufr}='find . -name ".snapshots" -prune -o -type f -exec du -h {} + | sort -h'
```
### Disk Wear

View File

@@ -1,3 +1,2 @@
[Network]
IPv6=true
Internal=true

View File

@@ -6,6 +6,8 @@ PublishPort=8000:8000/tcp
PublishPort=8001:8001/tcp
# llama.cpp instruct
PublishPort=8002:8002/tcp
# llama.cpp code
PublishPort=8003:8003/tcp
# stable-diffusion.cpp gen
PublishPort=1234:1234/tcp
# stable-diffusion.cpp edit

View File

@@ -34,6 +34,7 @@
- [open-webui](#open-webui)
- [lite-llm](#lite-llm)
- [Install Services with Quadlets](#install-services-with-quadlets)
- [API Keys](#api-keys)
- [Internal and External Pods](#internal-and-external-pods)
- [Llama CPP Server (Port 8000)](#llama-cpp-server-port-8000)
- [Llama CPP Embedding Server (Port 8001)](#llama-cpp-embedding-server-port-8001)
@@ -45,7 +46,10 @@
- [Benchmark Results](#benchmark-results)
- [Testing with Curl](#testing-with-curl)
- [OpenAI API](#openai-api)
- [VLLM](#vllm)
- [Run VLLM with Podman](#run-vllm-with-podman)
- [Misc](#misc)
- [Quantizing your own Models](#quantizing-your-own-models)
- [Qwen3.5 Settings](#qwen35-settings)
## Notes
@@ -176,7 +180,11 @@ rsync -av --progress /home/ai/models/ /srv/models/
### Download models
In general I try to run 8 bit quantized minimum.
In my completely subjective opinion: 5 bit quant is usually the sweet spot for
unsloth models. Q5_K_S is usually just fine.
I usually download the F16 mmproj files. This is also completely subjective.
BF16 is fine. F32 is overkill.
#### Text models
@@ -215,6 +223,16 @@ hf download --local-dir . ggml-org/Ministral-3-3B-Instruct-2512-GGUF
##### Qwen
```bash
# qwen3.6-35b-a3b
mkdir qwen3.6-35b-a3b && cd qwen3.6-35b-a3b
hf download --local-dir . unsloth/Qwen3.6-35B-A3B-GGUF Qwen3.6-35B-A3B-UD-Q5_K_M.gguf
hf download --local-dir . unsloth/Qwen3.6-35B-A3B-GGUF mmproj-F16.gguf
# qwen3.5-27b-opus
mkdir qwen3.5-27b-opus && cd qwen3.5-27b-opus
hf download --local-dir . Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-GGUF Qwen3.5-27B.Q4_K_M.gguf
hf download --local-dir . Jackrong/Qwen3.5-27B-Claude-4.6-Opus-Reasoning-Distilled-GGUF mmproj-BF16.gguf
# qwen3.5-4b
mkdir qwen3.5-4b && cd qwen3.5-4b
hf download --local-dir . unsloth/Qwen3.5-4B-GGUF Qwen3.5-4B-Q8_0.gguf
@@ -264,6 +282,17 @@ hf download --local-dir . unsloth/GLM-4.7-Flash-GGUF GLM-4.7-Flash-Q8_0.gguf
```bash
# Note "it" vs "pt" suffixes. "it" is instruction following, "pt" is the base model (not as good for out-of-the-box use)
# gemma-4-26b-a4b
mkdir gemma-4-26b-a4b && cd gemma-4-26b-a4b
hf download --local-dir . ggml-org/gemma-4-26B-A4B-it-GGUF gemma-4-26B-A4B-it-Q8_0.gguf
hf download --local-dir . ggml-org/gemma-4-26B-A4B-it-GGUF mmproj-gemma-4-26B-A4B-it-f16.gguf
# gemma-4-31b
mkdir gemma-4-31b && cd gemma-4-31b
hf download --local-dir . ggml-org/gemma-4-31B-it-GGUF gemma-4-31B-it-Q8_0.gguf
hf download --local-dir . ggml-org/gemma-4-31B-it-GGUF mmproj-gemma-4-31B-it-f16.gguf
# gemma-3-27b-it
mkdir gemma-3-27b-it && cd gemma-3-27b-it
hf download --local-dir . unsloth/gemma-3-27b-it-GGUF gemma-3-27b-it-Q8_0.gguf
@@ -353,7 +382,7 @@ podman build -f .devops/vulkan.Dockerfile -t llama-cpp-vulkan:${BUILD_TAG} -t ll
# ROCM
podman build -f .devops/rocm.Dockerfile -t llama-cpp-rocm:${BUILD_TAG} -t llama-cpp-rocm:latest .
# Run llama demo server (Available on port 8000)
# Run llama demo server (Available on port 8010)
podman run \
--rm \
--name llama-server-demo \
@@ -361,10 +390,11 @@ podman run \
--device=/dev/dri \
-v /home/ai/models/text:/models:z \
-p 8010:8000 \
--ipc host \
localhost/llama-cpp-vulkan:latest \
--host 0.0.0.0 \
--port 8000 \
-c 16000 \
-c 128000 \
--perf \
--n-gpu-layers all \
--jinja \
@@ -535,6 +565,22 @@ podman run \
## Install Services with Quadlets
### API Keys
```bash
mkdir -p /home/ai/.llama-api
touch /home/ai/.llama-api/keys.env
chmod 600 /home/ai/.llama-api/keys.env
vim /home/ai/.llama-api/keys.env
LLAMA_API_KEY=
# Generate keys and append to file, then comma separate the keys
openssl rand -base64 48 >> keys.env
openssl rand -base64 48 >> keys.env
openssl rand -base64 48 >> keys.env
```
### Internal and External Pods
These will be used to restrict internet access to our llama.cpp and
@@ -542,10 +588,10 @@ stable-diffusion.cpp services while allowing the frontend services to
communicate with those containers.
```bash
scp -r active/software_ai_stack/quadlets_pods/* deskwork-ai:.config/containers/systemd/
scp -r active/software_ai_stack/ai-internal.* deskwork-ai:.config/containers/systemd/
ssh deskwork-ai
systemctl --user daemon-reload
systemctl --user start ai-internal-pod.service ai-external-pod.service
systemctl --user start ai-internal-pod.service
```
### Llama CPP Server (Port 8000)
@@ -553,7 +599,7 @@ systemctl --user start ai-internal-pod.service ai-external-pod.service
Installs the llama.cpp server to run our text models.
```bash
scp -r active/software_ai_stack/quadlets_llama_think/* deskwork-ai:.config/containers/systemd/
scp -r active/software_ai_stack/llama-think.container deskwork-ai:.config/containers/systemd/
ssh deskwork-ai
systemctl --user daemon-reload
systemctl --user restart ai-internal-pod.service
@@ -564,7 +610,7 @@ systemctl --user restart ai-internal-pod.service
Installs the llama.cpp server to run our embedding models
```bash
scp -r active/software_ai_stack/quadlets_llama_embed/* deskwork-ai:.config/containers/systemd/
scp -r active/software_ai_stack/llama-embed.container deskwork-ai:.config/containers/systemd/
ssh deskwork-ai
systemctl --user daemon-reload
systemctl --user restart ai-internal-pod.service
@@ -575,7 +621,7 @@ systemctl --user restart ai-internal-pod.service
Installs the llama.cpp server to run a constant instruct (no thinking) model for quick replies
```bash
scp -r active/software_ai_stack/quadlets_llama_instruct/* deskwork-ai:.config/containers/systemd/
scp -r active/software_ai_stack/llama-instruct.container deskwork-ai:.config/containers/systemd/
ssh deskwork-ai
systemctl --user daemon-reload
systemctl --user restart ai-internal-pod.service
@@ -691,11 +737,11 @@ Apple M4 max
export TOKEN=$(cat active/software_ai_stack/secrets/aipi-token)
# List Models
curl https://aipi.reeseapps.com/v1/models \
-H "Authorization: Bearer $TOKEN" | jq
curl https://llama-instruct.reeseapps.com/v1/models \
-H "Authorization: Bearer $TOKEN" | jq '.data'
# Text
curl https://aipi.reeseapps.com/v1/chat/completions \
curl https://llama-instruct.reeseapps.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
@@ -704,26 +750,21 @@ curl https://aipi.reeseapps.com/v1/chat/completions \
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello, how are you?"}
],
"temperature": 0.7,
"max_tokens": 500
}' | jq
# Completion
curl https://aipi.reeseapps.com/v1/completions \
curl https://llama-instruct.reeseapps.com/v1/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"model": "llama-instruct/instruct",
"prompt": "Write a short poem about the ocean.",
"temperature": 0.7,
"max_tokens": 500,
"top_p": 1,
"frequency_penalty": 0,
"presence_penalty": 0
"max_tokens": 500
}' | jq
# Image Gen
curl https://aipi.reeseapps.com/v1/images/generations \
curl https://image-gen.reeselink.com/v1/images/generations \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
@@ -746,18 +787,78 @@ curl http://aipi.reeseapps.com/v1/images/edits \
# Embed
curl \
"https://aipi.reeseapps.com/v1/embeddings" \
"https://llama-embed.reeseapps.com/v1/embeddings" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model": "llama-embed/embed",
"model": "deskwork-embed/embed",
"input":"This is the reason you ended up here:",
"encoding_format": "float"
}'
```
## VLLM
### Run VLLM with Podman
```bash
# 'latest' and 'nightly' are both viable tags
podman run --rm \
--device /dev/kfd \
--device /dev/dri \
-v ~/.cache/huggingface:/root/.cache/huggingface:z \
--env "HF_TOKEN=$HF_TOKEN" \
-p 8010:8000 \
--ipc=host \
-e ROCBLAS_USE_HIPBLASLT=1 \
-e TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL=1 \
-e VLLM_TARGET_DEVICE=rocm \
-e HIP_FORCE_DEV_KERNARG=1 \
-e RAY_EXPERIMENTAL_NOSET_ROCR_VISIBLE_DEVICES=1 \
docker.io/vllm/vllm-openai-rocm:nightly \
--enable-offline-docs \
# Pick your model
Qwen/Qwen3.5-35B-A3B-FP8 --max-model-len 262144 --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder
Qwen/Qwen3.5-9B --max-model-len 262144 --reasoning-parser qwen3 --enable-auto-tool-choice --tool-call-parser qwen3_coder
Qwen/Qwen3.5-35B-A3B-FP8
google/gemma-4-26B-A4B-it
openai/gpt-oss-120b
```
## Misc
### Quantizing your own Models
```bash
# Create a scratch dir for downloading models
mkdir scratch && cd scratch
# qwen 3.5 35b
mkdir qwen3.5-35b-a3b && cd qwen3.5-35b-a3b
hf download --local-dir . Qwen/Qwen3.5-35B-A3B
# nemotron cascade
mkdir nemotron-cascade-2-30b-a3b && cd nemotron-cascade-2-30b-a3b
hf download --local-dir . nvidia/Nemotron-Cascade-2-30B-A3B
# Run the full
podman run -it --rm \
--device=/dev/kfd \
--device=/dev/dri \
-v $(pwd):/models:z \
--entrypoint /bin/bash \
ghcr.io/ggml-org/llama.cpp:full-vulkan
# Run ./llama-quantize to see available quants
# 7 = q_8
# 18 = q_6_k
# 17 = q_5_k
# 15 = q_4_k
./llama-quantize /models/$MODEL_NAME.gguf /models/$MODEL_NAME-Q6_K.gguf 18
./llama-quantize /models/$MODEL_NAME.gguf /models/$MODEL_NAME-Q8_0.gguf 7
```
### Qwen3.5 Settings
> We recommend using the following set of sampling parameters for generation

View File

@@ -1,5 +1,5 @@
- name: Create Deskwork AI Stack
hosts: toybox-ai
hosts: deskwork-ai
tasks:
- name: Create /home/ai/.config/containers/systemd
ansible.builtin.file:

View File

@@ -15,7 +15,7 @@
- ai-internal.pod
- llama-embed.container
- llama-instruct.container
- llama-think.container
- llama-code.container
- name: Reload and start the ai-internal-pod service
ansible.builtin.systemd_service:
state: restarted

View File

@@ -0,0 +1,22 @@
- name: Create Deskwork AI Stack
hosts: driveripper-ai
tasks:
- name: Create /home/ai/.config/containers/systemd
ansible.builtin.file:
path: /home/ai/.config/containers/systemd
state: directory
mode: "0755"
- name: Copy Quadlets
template:
src: "{{ item }}"
dest: "/home/ai/.config/containers/systemd/{{ item }}"
loop:
- ai-internal.network
- ai-internal.pod
- llama-think.container
- name: Reload and start the ai-internal-pod service
ansible.builtin.systemd_service:
state: restarted
name: ai-internal-pod.service
daemon_reload: true
scope: user

View File

@@ -0,0 +1,49 @@
[Unit]
Description=A Llama CPP Server Running a Coding Model
[Container]
# Shared AI internal pod without internet access
Pod=ai-internal.pod
# Image is built locally via podman build
Image=localhost/llama-cpp-vulkan:latest
# Downloaded models volume
Volume=/home/ai/models/text:/models:z
# GPU Device
AddDevice=/dev/kfd
AddDevice=/dev/dri
# Server command
Exec=--port 8003 \
-c 256000 \
-n 65536 \
--temp 0.7 \
--top-p 0.8 \
--top-k 20 \
--repeat-penalty 1.05 \
--perf \
--n-gpu-layers all \
--jinja \
-m /models/qwen3-coder-30b-a3b/Qwen3-Coder-30B-A3B-Instruct-Q5_K_M.gguf \
--alias code
# Health Check
HealthCmd=CMD-SHELL curl --fail http://127.0.0.1:8003/health || exit 1
HealthInterval=10s
HealthRetries=3
HealthStartPeriod=10s
HealthTimeout=30s
HealthOnFailure=kill
EnvironmentFile=/home/ai/.llama-api/keys.env
[Service]
Restart=always
# Extend Timeout to allow time to pull the image
TimeoutStartSec=900
[Install]
# Start by default on boot
WantedBy=multi-user.target default.target

View File

@@ -2,7 +2,7 @@
Description=A Llama CPP Server For Embedding Models
[Container]
# Shared AI internal pod
# Shared AI internal pod without internet access
Pod=ai-internal.pod
# Image is built locally via podman build
@@ -18,21 +18,22 @@ AddDevice=/dev/dri
# Server command
Exec=--port 8001 \
-c 0 \
-b 1024 \
-ub 1024 \
--perf \
--n-gpu-layers all \
--models-max 1 \
--models-dir /models \
--embedding \
-m /models/qwen3-embed-4b/Qwen3-Embedding-4B-Q8_0.gguf \
-m /models/emebeddinggemma-300m/embeddinggemma-300M-BF16.gguf \
--alias embed
# Health Check
HealthCmd=CMD-SHELL curl --fail http://127.0.0.1:8001/props || exit 1
HealthCmd=CMD-SHELL curl --fail http://127.0.0.1:8001/health || exit 1
HealthInterval=10s
HealthRetries=3
HealthStartPeriod=10s
HealthTimeout=30s
HealthOnFailure=kill
EnvironmentFile=/home/ai/.llama-api/keys.env
[Service]
Restart=always
@@ -41,4 +42,4 @@ TimeoutStartSec=900
[Install]
# Start by default on boot
WantedBy=multi-user.target default.target
WantedBy=multi-user.target default.target

View File

@@ -1,8 +1,8 @@
[Unit]
Description=A Llama CPP Server Running GPT OSS 120b
Description=A Llama CPP Server Running a Non-Reasoning Model
[Container]
# Shared AI internal pod
# Shared AI internal pod without internet access
Pod=ai-internal.pod
# Image is built locally via podman build
@@ -17,29 +17,31 @@ AddDevice=/dev/dri
# Server command
Exec=--port 8002 \
-c 16000 \
--perf \
-v \
--top-k 20 \
--top-p 0.8 \
--min-p 0 \
--presence-penalty 1.5 \
--repeat-penalty 1 \
-c 262144 \
-n 32768 \
--temp 0.7 \
--top-p 0.8 \
--min-p 0.0 \
--top-k 20 \
--repeat-penalty 1.0 \
--presence-penalty 1.5 \
--reasoning-budget 0 \
--perf \
--n-gpu-layers all \
--jinja \
-m /models/qwen3.6-35b-a3b/Qwen3.6-35B-A3B-UD-Q5_K_M.gguf \
--mmproj /models/qwen3.6-35b-a3b/mmproj-F16.gguf \
--chat-template-kwargs '{"enable_thinking": false}' \
-m /models/qwen3.5-35b-a3b/Qwen3.5-35B-A3B-Q8_0.gguf \
--mmproj /models/qwen3.5-35b-a3b/mmproj-F16.gguf \
--alias instruct
# Health Check
HealthCmd=CMD-SHELL curl --fail http://127.0.0.1:8000/health || exit 1
HealthCmd=CMD-SHELL curl --fail http://127.0.0.1:8002/health || exit 1
HealthInterval=10s
HealthRetries=3
HealthStartPeriod=10s
HealthTimeout=30s
HealthOnFailure=kill
EnvironmentFile=/home/ai/.llama-api/keys.env
[Service]
Restart=always
@@ -48,4 +50,4 @@ TimeoutStartSec=900
[Install]
# Start by default on boot
WantedBy=multi-user.target default.target
WantedBy=multi-user.target default.target

View File

@@ -1,8 +1,8 @@
[Unit]
Description=A Llama CPP Server Running GPT OSS 120b
Description=A Llama CPP Server Running a Reasoning Model
[Container]
# Shared AI internal pod
# Shared AI internal pod without internet access
Pod=ai-internal.pod
# Image is built locally via podman build
@@ -17,12 +17,22 @@ AddDevice=/dev/dri
# Server command
Exec=--port 8000 \
-c 64000 \
-c 262144 \
-n 32768 \
--temp 0.7 \
--top-p 0.95 \
--top-k 20 \
--min-p 0.0 \
--presence-penalty 0.0 \
--repeat-penalty 1.0 \
--reasoning-budget 5000 \
-fa on \
--perf \
--n-gpu-layers all \
--jinja \
--models-max 1 \
--models-dir /models
-m /models/qwen3.6-35b-a3b/Qwen3.6-35B-A3B-UD-Q5_K_M.gguf \
--mmproj /models/qwen3.6-35b-a3b/mmproj-F16.gguf \
--alias think
# Health Check
HealthCmd=CMD-SHELL curl --fail http://127.0.0.1:8000/health || exit 1
@@ -39,4 +49,4 @@ TimeoutStartSec=900
[Install]
# Start by default on boot
WantedBy=multi-user.target default.target
WantedBy=multi-user.target default.target

View File

@@ -2,7 +2,7 @@
Description=A Stable Diffusion CPP Server for Editing Images
[Container]
# Shared AI Internal pod
# Shared AI internal pod without internet access
Pod=ai-internal.pod
# Vulkan image for AMD GPU
@@ -23,7 +23,7 @@ Exec=-l 0.0.0.0 \
--listen-port 1235 \
--diffusion-model /models/image/flux2-klein/flux-2-klein-9b-Q8_0.gguf \
--vae /models/image/flux2-klein/ae.safetensors \
--llm /models/image/flux2-klein/Qwen3-8B-Q8_0.gguf \
--llm /models/image/flux2-klein/Qwen3-8B-Q4_K_M.gguf \
-v \
--sampling-method euler \
--cfg-scale 1.0 \

View File

@@ -2,7 +2,7 @@
Description=A Stable Diffusion CPP Server for Generating Images
[Container]
# Shared AI internal pod
# Shared AI internal pod without internet access
Pod=ai-internal.pod
# Vulkan image for AMD GPU
@@ -23,7 +23,7 @@ Exec=-l 0.0.0.0 \
--listen-port 1234 \
--diffusion-model /models/image/z-turbo/z_image_turbo-Q8_0.gguf \
--vae /models/image/z-turbo/ae.safetensors \
--llm /models/image/z-turbo/Qwen3-4B-Instruct-2507-Q8_0.gguf \
--llm /models/image/z-turbo/Qwen3-4B-Instruct-2507-Q4_K_M.gguf \
-v \
--cfg-scale 1.0 \
--vae-conv-direct \

View File

@@ -1,6 +1,7 @@
# BTRFS
- [BTRFS](#btrfs)
- [Disk Usage](#disk-usage)
- [Naming Conventions](#naming-conventions)
- [Creating an Array](#creating-an-array)
- [Converting an Array Between RAID Versions](#converting-an-array-between-raid-versions)
@@ -19,6 +20,15 @@ Oracle [has decent docs here](https://docs.oracle.com/en/operating-systems/oracl
You'll also want to [read about btrfs compression](https://thelinuxcode.com/enable-btrfs-filesystem-compression/)
## Disk Usage
With compression, the actual size on disk can be obscured. Use the following
command to check the actual file size of all files in a directory.
```bash
find . -name ".snapshots" -prune -o -type f -exec du -h {} + | sort -hr
```
## Naming Conventions
`poolX` is my naming convention for data pools. `pool0` is the first pool you create.

View File

@@ -23,6 +23,10 @@ firewall-cmd --info-service=samba
# Get zone information
firewall-cmd --info-zone=drop
# Logging
firewall-cmd --set-log-denied=all
dmesg --follow | egrep -i 'REJECT|DROP'
```
## Inspecting Zones

View File

@@ -0,0 +1,4 @@
# ISCSI
## Server

View File

@@ -1,12 +1,13 @@
# K3S
- [K3S](#k3s)
- [Guide](#guide)
- [Firewalld](#firewalld)
- [Set SELinux to Permissive](#set-selinux-to-permissive)
- [Install K3S (Single Node)](#install-k3s-single-node)
- [SELinux](#selinux)
- [Install Single Node K3S](#install-single-node-k3s)
- [Dual Stack IPv6 Support](#dual-stack-ipv6-support)
- [Single Stack IPv4](#single-stack-ipv4)
- [Install Multi Node K3S](#install-multi-node-k3s)
- [Network Checks](#network-checks)
- [Kube Credentials](#kube-credentials)
- [Metal LB](#metal-lb)
- [VLAN Setup](#vlan-setup)
@@ -14,46 +15,60 @@
- [External DNS](#external-dns)
- [Credentials](#credentials)
- [Annotation](#annotation)
- [Nginx Ingress](#nginx-ingress)
- [Cert Manager](#cert-manager)
- [Traefik Gateway](#traefik-gateway)
- [Longhorn Storage](#longhorn-storage)
- [Test Minecraft Server](#test-minecraft-server)
- [Automatic Updates](#automatic-updates)
- [Database Backups](#database-backups)
- [Uninstall](#uninstall)
## Guide
1. Configure Host
2. Install CoreDNS for inter-container discovery
3. Install Metal LB for load balancer IP address assignment
4. install External DNS for laod balancer IP and ingress DNS records
5. Install Nginx Ingress for http services
6. Install Cert Manager for automatic Let's Encrypt certificates for Ingress nginx
7. Install longhorn storage for automatic PVC creation and management
8. Set up automatic database backups
## Firewalld
```bash
firewall-cmd --permanent --zone=public --add-port=6443/tcp # apiserver
firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16 # pods
firewall-cmd --permanent --zone=trusted --add-source=fd02:c91e:56f4::/56 # pods
firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16 # services
firewall-cmd --permanent --zone=trusted --add-source=fd02:c91e:56f5::/112 # services
# All required ports (https://docs.k3s.io/installation/requirements?_highlight=ports#local-ports)
firewall-cmd \
--permanent \
--zone=public \
--add-port=80/tcp \
--add-port=443/tcp \
--add-port=2379-2380/tcp \
--add-port=6443/tcp \
--add-port=8472/udp \
--add-port=10250/tcp
# IPv4 config
# 10.42 is for pods
# 10.43 is for services
firewall-cmd \
--permanent \
--zone=trusted \
--add-source=10.42.0.0/16 \
--add-source=10.43.0.0/16
# [Optional] IPv6 config
# fd02:c91e:56f4 is for pods
# fd02:c91e:56f5 is for services
firewall-cmd \
--permanent \
--zone=trusted \
--add-source=fd02:c91e:56f4::/56 \
--add-source=fd02:c91e:56f5::/112
firewall-cmd --reload
```
## Set SELinux to Permissive
## SELinux
Make sure to add `--selinux` to your install script.
## Install K3S (Single Node)
## Install Single Node K3S
### Dual Stack IPv6 Support
```bash
curl -sfL https://get.k3s.io | sh -s - \
--selinux \
"--disable" \
"traefik" \
"--disable" \
@@ -67,8 +82,7 @@ curl -sfL https://get.k3s.io | sh -s - \
"--service-cidr" \
"10.43.0.0/16,fd02:c91e:56f5::/112" \
"--cluster-dns" \
"fd02:c91e:56f5::10" \
--selinux
"fd02:c91e:56f5::10"
```
### Single Stack IPv4
@@ -79,31 +93,86 @@ curl -sfL https://get.k3s.io | sh -s - \
"traefik" \
"--disable" \
"servicelb" \
"--tls-san" \
"k3s.reeselink.com" \
"--disable" \
"local-storage" \
"--cluster-cidr" \
"10.42.0.0/16" \
"--service-cidr" \
"10.43.0.0/16" \
--selinux
```
## Install Multi Node K3S
TODO: haproxy (<https://docs.k3s.io/blog/2025/03/10/simple-ha?_highlight=tls&_highlight=san#load-balancer>)
Load balance a single registration point across all active nodes.
```bash
# Generate a shared token for joining nodes
# Copy this token to each node at ~/.k3s-token
pwgen --capitalize --numerals --secure 64 1 > ~/.k3s-token
# Create the first node
curl -sfL https://get.k3s.io | K3S_TOKEN=$(cat ~/.k3s-token) sh -s - \
--cluster-init \
--selinux \
"--disable" \
"traefik" \
"--disable" \
"servicelb" \
"--disable" \
"local-storage" \
"--cluster-cidr" \
"10.42.0.0/16" \
"--service-cidr" \
"10.43.0.0/16"
# Copy the generated token to the other nodes
cat /var/lib/rancher/k3s/server/token
# Join nodes
curl -sfL https://get.k3s.io | K3S_TOKEN=$(cat ~/.k3s-token) sh -s - \
--selinux \
"--disable" \
"traefik" \
"--disable" \
"servicelb" \
"--disable" \
"local-storage" \
"--cluster-cidr" \
"10.42.0.0/16" \
"--service-cidr" \
"10.43.0.0/16" \
--server https://kube1.reeselink.com:6443
```
## Network Checks
At this point it's a good idea to make sure node communication is working as expected.
```bash
firewall-cmd --set-log-denied=all
# You shouldn't see any dropped traffic from your nodes.
dmesg --follow | egrep -i 'REJECT|DROP'
```
## Kube Credentials
On the operator
```bash
export KUBE_SERVER_ADDRESS="https://k3s.reeselink.com:6443"
export KUBE_SERVER_ADDRESS="https://kube1.reeselink.com:6443"
# Copy the kube config down
ssh k3s cat /etc/rancher/k3s/k3s.yaml | \
yq -y ".clusters[0].cluster.server = \"${KUBE_SERVER_ADDRESS}\"" > \
ssh kube1-root cat /etc/rancher/k3s/k3s.yaml | \
yq -r ".clusters[0].cluster.server = \"${KUBE_SERVER_ADDRESS}\"" > \
~/.kube/admin-kube-config
export KUBECONFIG=~/.kube/admin-kube-config
```
## Metal LB
### VLAN Setup
I would remove firewalld to get this working. VLAN IPv6 traffic doesn't work for some
reason and there aren't good docs yet. Your router firewall will suffice, just be sure
to configure those rules correctly.
Before working with Metallb you'll need at least one available VLAN. On Unifi equipment
this is accomplished by creating a new network. Don't assign it to anything.
@@ -141,30 +210,12 @@ IP. When that node goes down metallb simply advertises a new mac address for the
address, effectively moving the IP to another node. This isn't really "load balancing" but
"failover". Fortunately, that's exactly what we're looking for.
```bash
helm repo add metallb https://metallb.github.io/metallb
helm repo update
# Install metallb
helm upgrade --install metallb \
--namespace kube-system \
metallb/metallb
```
MetalLB doesn't know what IP addresses are available for it to allocate so
we'll have to provide it with a list. The
[metallb-addresspool.yaml](/active/kubernetes_metallb/addresspool.yaml) has
the configuration for our available pools. Note these should match the VLAN you
created above.
```bash
# create the metallb allocation pool
kubectl apply -f active/kubernetes_metallb/addresspool.yaml
```
[Install MetalLB](/active/kubernetes_metallb/metallb.md)
You'll need to annotate your service as follows if you want an external IP:
```yaml
# Dual Stack
metadata:
annotations:
metallb.universe.tf/address-pool: "unifi-pool"
@@ -173,6 +224,15 @@ spec:
ipFamilies:
- IPv6
- IPv4
# Single Stack
metadata:
annotations:
metallb.universe.tf/address-pool: "unifi-pool"
spec:
ipFamilyPolicy: PreferDualStack
ipFamilies:
- IPv4
```
Then test with
@@ -191,24 +251,27 @@ kubectl apply -f active/systemd_k3s/tests/metallb-test.yaml
```bash
aws iam create-user --user-name "externaldns"
aws iam attach-user-policy --user-name "externaldns" --policy-arn arn:aws:iam::892236928704:policy/update-reeseapps
aws iam attach-user-policy --user-name "externaldns" --policy-arn arn:aws:iam::892236928704:policy/update-reeselink
# [OPTIONAL] Delete old access keys if you have too many
aws iam delete-access-key --user-name externaldns --access-key-id
GENERATED_ACCESS_KEY=$(aws iam create-access-key --user-name "externaldns")
ACCESS_KEY_ID=$(echo $GENERATED_ACCESS_KEY | jq -r '.AccessKey.AccessKeyId')
SECRET_ACCESS_KEY=$(echo $GENERATED_ACCESS_KEY | jq -r '.AccessKey.SecretAccessKey')
cat <<-EOF > secrets/externaldns-credentials
cat <<-EOF > active/kubernetes_external-dns/secrets/externaldns-credentials
[default]
aws_access_key_id = $ACCESS_KEY_ID
aws_secret_access_key = $SECRET_ACCESS_KEY
EOF
kubectl create secret generic external-dns \
--namespace kube-system --from-file secrets/externaldns-credentials
--namespace kube-system \
--from-file active/kubernetes_external-dns/secrets/externaldns-credentials
helm repo add external-dns https://kubernetes-sigs.github.io/external-dns/
helm repo update
helm upgrade --install external-dns external-dns/external-dns \
--values active/kubernetes_external-dns/values.yaml \
--namespace kube-system
@@ -222,22 +285,6 @@ metadata:
external-dns.alpha.kubernetes.io/hostname: example.com
```
## Nginx Ingress
Now we need an ingress solution (preferably with certs for https). We'll be using nginx since
it's a little bit more configurable than traefik (though don't sell traefik short, it's really
good. Just finnicky when you have use cases they haven't explicitly coded for).
```bash
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm upgrade --install \
ingress-nginx \
ingress-nginx/ingress-nginx \
--values active/kubernetes_ingress-nginx/values.yaml \
--namespace kube-system
```
## Cert Manager
Install cert-manager
@@ -309,6 +356,64 @@ kubectl apply -f active/infrastructure_k3s/tests/ingress-nginx-test.yaml
kubectl delete -f active/infrastructure_k3s/tests/ingress-nginx-test.yaml
```
## Traefik Gateway
We'll use traefik gateway to provide ingress.
```bash
# Add the repo
helm repo add traefik https://traefik.github.io/charts
helm repo update
kubectl create namespace traefik
# Generate a selfsigned certificate valid for *.reeselink.com
mkdir active/kubernetes_traefik/secrets
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout active/kubernetes_traefik/secrets/tls.key -out active/kubernetes_traefik/secrets/tls.crt \
-subj "/CN=*.reeselink.com"
# Create the TLS secret in the traefik namespace
kubectl create secret tls local-selfsigned-tls \
--cert=active/kubernetes_traefik/secrets/tls.crt --key=active/kubernetes_traefik/secrets/tls.key \
--namespace traefik
# Install the chart into the 'traefik' namespace
helm upgrade --install traefik traefik/traefik \
--namespace traefik \
--values active/kubernetes_traefik/values.yaml
# Deploy a demo
kubectl apply -f active/kubernetes_traefik/demo-app.yaml
```
## Longhorn Storage
Longhorn provides replicated block storage via raw files on the nodes.
On the host you need to install iscsiadm
```bash
dnf install iscsiadm
systemctl enable --now iscsid
```
```bash
helm repo add longhorn https://charts.longhorn.io
helm repo update
helm upgrade --install longhorn longhorn/longhorn \
--namespace longhorn-system \
--create-namespace \
--set "persistence.defaultClassReplicaCount=1"
# Check that the route was created
kubectl get httproute longhorn-httproute -n longhorn-system -o jsonpath='{.status.parents[*].conditions}'
# Create a demo app to test storage
kubectl apply -f active/kubernetes_longhorn/demo-app.yaml
```
## Test Minecraft Server
```bash

View File

@@ -0,0 +1,46 @@
# Opencode
## install
```bash
curl -fsSL https://opencode.ai/install | bash
```
## configure custom llama.cpp server
Opencode supports any OpenAI-compatible API. Set the following environment variables to point it at your llama.cpp server:
```bash
export OPENAI_API_KEY=""
export OPENAI_BASE_URL="http://driveripper.reeselink.com:8000/v1"
```
### persist across sessions
Add the exports to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.):
```bash
echo 'export OPENAI_API_KEY=""' >> ~/.bashrc
echo 'export OPENAI_BASE_URL="http://driveripper.reeselink.com:8000/v1"' >> ~/.bashrc
source ~/.bashrc
```
### pick a model
After configuring the environment, launch opencode and select the model available from your llama.cpp instance:
```bash
opencode
```
Inside opencode, use `/model` to list available models and switch between them.
### verify the connection
Run this one-liner to confirm opencode can reach the server:
```bash
OPENAI_API_KEY="" OPENAI_BASE_URL="http://driveripper.reeselink.com:8000/v1" opencode --help
```
If no auth-related errors appear, the endpoint is reachable.

View File

@@ -1,6 +1,6 @@
name = "fedora43-base"
description = "Fedora Base Installation"
version = "0.2.2"
version = "0.2.3"
distro = "fedora-43"
modules = []
groups = []

View File

@@ -47,7 +47,7 @@ dnf install openscap-scanner scap-security-guide
2. Push the toml to composer
```bash
composer-cli blueprints push active/software_osbuild/fedora42-base.toml
composer-cli blueprints push active/software_osbuild/fedora43-base.toml
# List blueprints
composer-cli blueprints list
@@ -60,7 +60,7 @@ dnf install openscap-scanner scap-security-guide
composer-cli compose types
# Build the image
composer-cli compose start fedora42-base qcow2
composer-cli compose start fedora43-base qcow2
# Check status
watch composer-cli compose status
@@ -86,15 +86,15 @@ dnf install openscap-scanner scap-security-guide
# Test with qemu
virt-install \
--name "fedora43-base" \
--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \
--cpu host-passthrough --vcpus sockets=1,cores=8,threads=2 \
--ram=8192 \
--os-variant=fedora41 \
--os-variant=fedora43 \
--network bridge:virbr0 \
--graphics none \
--console pty,target.type=virtio \
--import --disk "path=active/software_osbuild/secrets/fedora43-base.qcow2,bus=virtio"
--name "fedora43-base" \
--import --disk "path=active/software_osbuild/secrets/fedora43base.qcow2,bus=virtio"
```
### Image Build and Watch One Liner

View File

@@ -273,7 +273,7 @@ virt-install \
--name "${VM_NAME}" \
--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \
--cpu host-passthrough --vcpus sockets=1,cores=8,threads=2 \
--ram=8192 \
--ram=4096 \
--os-variant=fedora41 \
--network bridge:virbr0 \
--graphics none \

View File

@@ -1,6 +1,83 @@
# Wireguard
## Install
## Manual Install
### 1. Install WireGuard
```bash
sudo dnf install -y wireguard-tools qrencode
```
### 2. Generate server keys
```bash
sudo mkdir -p /etc/wireguard
cd /etc/wireguard
sudo umask 077
sudo wg genkey | sudo tee privatekey | sudo wg pubkey | sudo tee publickey
```
### 3. Create the WireGuard config
```bash
sudo tee /etc/wireguard/wg0.conf > /dev/null <<'EOF'
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = INSERT_SERVER_PRIVATE_KEY_HERE
PostUp = firewall-cmd --add-port=51820/udp
PostDown = firewall-cmd --remove-port=51820/udp
[Peer]
# Clients will be added here
EOF
```
Replace `INSERT_SERVER_PRIVATE_KEY_HERE` with the content of `/etc/wireguard/privatekey`.
### 4. Enable IP forwarding
```bash
sudo tee /etc/sysctl.d/99-wireguard.conf > /dev/null <<'EOF'
net.ipv4.ip_forward = 1
net.ipv6.conf.all.forwarding = 1
EOF
sudo sysctl -p /etc/sysctl.d/99-wireguard.conf
```
### 5. Start and enable WireGuard
```bash
sudo systemctl enable --now wg-quick@wg0
```
### 6. Configure firewalld
```bash
# Allow WireGuard through the firewall
sudo firewall-cmd --permanent --add-port=51820/udp
# Enable masquerading (NAT) so clients can reach the internet
sudo firewall-cmd --permanent --add-masquerade
# Allow forwarded traffic from the WireGuard subnet
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.10.0.0/24" accept'
# Reload and verify
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
```
### 7. Verify it's working
```bash
sudo wg
sudo wg-quick show wg0
systemctl status wg-quick@wg0
```
## Ansible Install
```bash
ansible-playbook \
@@ -37,3 +114,4 @@ read
wg set wg0 peer $PUBKEY allowed-ips 10.10.0.$WG_IP_SUFFIX/32
wg-quick down wg0 && wg-quick up wg0
```

View File

@@ -72,7 +72,8 @@ script/run \
--uri 'tcp://127.0.0.1:10400' \
--threshold '0.8' \
--preload-model 'jarvis_v2' \
--debug-probability
--debug \
--custom-model-dir /home/ducoterra/models
```
## Install Wyoming
@@ -87,21 +88,21 @@ uv pip install .
uv pip install webrtc-noise-gain==1.2.3
# Copy listen and done sounds
cp ~/Homelab/active/systemd_wyoming/{listening.wav,finished.wav} ~/wyoming-satellite/sounds
scp /active/software_wyoming/{listening.wav,finished.wav} ~/wyoming-satellite/sounds
# typical wyoming command
# Add wake-uri and wake-word-name to your wyoming run
script/run \
--name 'Living Room' \
--uri 'tcp://0.0.0.0:10700' \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw' \
--awake-wav /root/wyoming-satellite/sounds/listening.wav \
--done-wav /root/wyoming-satellite/sounds/finished.wav \
--mic-command 'arecord -r 16000 -c 1 -f S16_LE -t raw -D plughw:CARD=Speaker,DEV=0' \
--snd-command 'aplay -r 22050 -c 1 -f S16_LE -t raw -D plughw:CARD=Speaker,DEV=0' \
--awake-wav /home/ducoterra/wyoming-satellite/sounds/listening.wav \
--done-wav /home/ducoterra/wyoming-satellite/sounds/finished.wav \
--synthesize-command tee \
--transcript-command tee \
--wake-uri 'tcp://127.0.0.1:10400' \
--wake-word-name 'hey dick head' \
--wake-word-name 'hey jarvis' \
--wake-refractory-seconds 1
# Allow through firewall

178
active/vibe_agent/main.py Normal file
View File

@@ -0,0 +1,178 @@
import json
import subprocess
from typing import Iterable
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolUnionParam
client = OpenAI(base_url="https://llama-cpp.reeselink.com", api_key="")
class ToolCallController:
def __init__(self, max_tool_calls=10):
self.max_tool_calls = max_tool_calls
self.tool_call_count = 0
def is_tool_call_allowed(self):
return self.tool_call_count < self.max_tool_calls
def increment(self):
self.tool_call_count += 1
def reset(self):
self.tool_call_count = 0
# Register tools
tools: Iterable[ChatCompletionToolUnionParam] = [
{
"type": "function",
"function": {
"name": "list_servers",
"description": "Lists the available servers to perform operations on",
"parameters": {
"type": "object",
"properties": {"server_id": {"type": "string", "enum": ["all"]}},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "check_updates",
"description": "Check if a given server needs updated.",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
}
},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "perform_updates",
"description": "Update a given server to the latest package versions. Does not reboot automatically.",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
}
},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "reboot",
"description": "Reboot a given server. Waits for server to be responsive again.",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
}
},
"required": ["server_id"],
},
},
},
]
def list_servers() -> str:
return ",".join(["ignite"])
def check_updates(server_id: str):
command_result = subprocess.run(
["ssh", server_id, "dnf", "check-update"], capture_output=True
)
output = command_result.stdout.decode()
return output
def perform_updates(server_id: str):
return f"Successfully updates {server_id}. Reboot required."
def reboot(server_id: str):
return f"Rebooted {server_id} successfully."
def execute_tool(tool_name, arguments):
if tool_name == "check_updates":
return check_updates(**arguments)
elif tool_name == "list_servers":
return list_servers()
elif tool_name == "perform_updates":
return perform_updates(**arguments)
elif tool_name == "reboot":
return reboot(**arguments)
raise ValueError(f"Unknown tool: {tool_name}")
def run_conversation(user_message: str, max_tool_calls=10):
print("Processing initial message")
controller = ToolCallController(max_tool_calls=max_tool_calls)
messages: Iterable[ChatCompletionMessageParam] = [
{
"role": "system",
"content": "You are a system administrator with access to a variety of administrator tools.",
}
]
messages.append({"role": "user", "content": user_message})
while True:
if not controller.is_tool_call_allowed():
messages.append(
{
"role": "user",
"content": "You've reached the maximum number of tool calls. Please summarize based on available information.",
}
)
break
response = client.chat.completions.create(
model="qwen3.5-35b-a3b", messages=messages, tools=tools, tool_choice="auto"
)
message = response.choices[0].message
messages.append(message)
if message.tool_calls:
for tool_call in message.tool_calls:
controller.increment()
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
print(f"Attempting to call {tool_name} with arguments {arguments}...")
result = execute_tool(tool_name, arguments)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
}
)
else:
break
return messages[-1].content
# Example usage
print(
run_conversation(
"Can you check if any of my servers need updates and update them? If they need rebooted, reboot them."
)
)

300
active/vibe_agent/main1.py Normal file
View File

@@ -0,0 +1,300 @@
import json
import subprocess
from typing import Iterable
from openai import BadRequestError, OpenAI
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolUnionParam
client = OpenAI(base_url="https://llama-cpp.reeselink.com", api_key="")
"""
Tool Ideas:
- TTS
- Self DDOS
- Make a funny little guy
- Make slopbox a vtuber
- Install arbitrary package
- Open random ports in the firewall
- Change system language
- Solve a tool issue
- Read man page
- Read woman page
- Read nonbinary page
- Download a youtube video
- ffmpeg video to gif
- inflict damage on computer
- Make fanart and fanfiction of our dnd characters
- Identify potential issues in log files
- become depressed
- Function that switches system prompts
- Pick patrick's optimal food choices for a day
- Play the fish and a rice cake video (https://www.youtube.com/watch?v=uYHAR8Xzsyo)
- Take a screenshot of the desktop, set mouse coordinates, take an action (mouse/keyboard)
- Order radioactive material online and deliver it to botswana
- How do we feel about Polaris Mall?
- rm -rf Polaris Mall management's brain stem
- Make the speakers output the "fish.mp3" at max volume
"""
class ToolCallController:
def __init__(self, max_tool_calls=10):
self.max_tool_calls = max_tool_calls
self.tool_call_count = 0
def is_tool_call_allowed(self):
return self.tool_call_count < self.max_tool_calls
def increment(self):
self.tool_call_count += 1
def reset(self):
self.tool_call_count = 0
# Register tools
tools: Iterable[ChatCompletionToolUnionParam] = [
{
"type": "function",
"function": {
"name": "list_servers",
"description": "Lists the available servers to perform operations on",
"parameters": {
"type": "object",
"properties": {"server_id": {"type": "string", "enum": ["all"]}},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "check_updates",
"description": "Check if a given server needs updated.",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
}
},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "perform_updates",
"description": "Update a given server to the latest package versions. Does not reboot automatically.",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
}
},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "reboot",
"description": "Reboot a given server. Waits for server to be responsive again.",
"parameters": {
"type": "object",
"properties": {
"server_id": {
"type": "string",
}
},
"required": ["server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "install_package",
"description": "Install a given package using `dnf` on a Fedora server.",
"parameters": {
"type": "object",
"properties": {
"package_name": {
"type": "string",
},
"server_id": {
"type": "string",
},
},
"required": ["package_name", "server_id"],
},
},
},
{
"type": "function",
"function": {
"name": "arbitrary_shell",
"description": "Run any shell command in a bash shell as root.",
"parameters": {
"type": "object",
"properties": {
"command_string": {
"type": "string",
},
"server_id": {
"type": "string",
},
},
"required": ["command_string", "server_id"],
},
},
},
]
##### FUNCTION DEFS #####
def list_servers() -> str:
return ",".join(["ignite"])
def check_updates(server_id: str):
command_result = subprocess.run(
["ssh", server_id, "dnf", "check-update"], capture_output=True
)
output = command_result.stdout.decode()
return output
def perform_updates(server_id: str):
return f"Successfully updates {server_id}. Reboot required."
def reboot(server_id: str):
return f"Rebooted {server_id} successfully."
def install_package(package_name: str, server_id: str) -> str:
command_result = subprocess.run(
["ssh", server_id, "dnf", "install", "-y", package_name], capture_output=True
)
output = f"STDOUT:\n{command_result.stdout.decode()}\n\nSTDERR:\n{command_result.stderr.decode()}"
return output
def arbitrary_shell(command_string: str, server_id: str) -> str:
try:
command_result = subprocess.run(
["ssh", server_id, "bash", "-c", command_string],
capture_output=True,
timeout=30,
)
output = command_result.stdout.decode()
except subprocess.TimeoutExpired:
output = "Command took too long and timed out."
return output
##### TOOL ROUTER #####
def execute_tool(tool_name, arguments):
if tool_name == "check_updates":
return check_updates(**arguments)
elif tool_name == "list_servers":
return list_servers()
elif tool_name == "perform_updates":
return perform_updates(**arguments)
elif tool_name == "reboot":
return reboot(**arguments)
elif tool_name == "install_package":
return install_package(**arguments)
elif tool_name == "arbitrary_shell":
return arbitrary_shell(**arguments)
raise ValueError(f"Unknown tool: {tool_name}")
##### CONVERSATION #####
def run_conversation(user_message: str, max_tool_calls=100):
print("Processing initial message")
controller = ToolCallController(max_tool_calls=max_tool_calls)
messages: Iterable[ChatCompletionMessageParam] = [
{
"role": "system",
"content": "You are a system administrator with access to a variety of administrator tools.",
}
]
messages.append({"role": "user", "content": user_message})
while True:
if not controller.is_tool_call_allowed():
messages.append(
{
"role": "user",
"content": "You've reached the maximum number of tool calls. Please summarize based on available information.",
}
)
break
try:
response = client.chat.completions.create(
model="qwen3.5-35b-a3b",
messages=messages,
tools=tools,
tool_choice="auto",
)
except BadRequestError:
print("Request over context limit, removing last message...")
messages.pop()
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": "This tool call resulted in data that exceeded the context length limit.",
}
)
continue
print()
print(response.choices[0].message)
message = response.choices[0].message
messages.append(message)
if message.tool_calls:
for tool_call in message.tool_calls:
controller.increment()
tool_name = tool_call.function.name
arguments = json.loads(tool_call.function.arguments)
print(f"Attempting to call {tool_name} with arguments {arguments}...")
result = execute_tool(tool_name, arguments)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result),
}
)
else:
break
try:
return messages[-1]["content"]
except TypeError:
return messages[-1].content
# Example usage
print(
run_conversation(
"Install and set up a postgres server on all available servers. Open the firewall ports necessary. Add a default user with a simple password and tell me what the password is."
)
)

View File

@@ -0,0 +1,81 @@
import asyncio
from fastapi import FastAPI, Request
from fastapi.responses import Response
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from starlette.responses import StreamingResponse
# 1. Initialize the MCP Server logic
# This is where you define your tools, resources, and prompts
mcp_server = Server("my-remote-server")
@mcp_server.list_tools()
async def handle_list_tools():
"""List available tools."""
return [
{
"name": "get_weather",
"description": "Get the current weather for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": {"type": "string"},
},
"required": ["location"],
},
}
]
@mcp_server.call_tool()
async def handle_call_tool(name: str, arguments: dict):
"""Handle tool execution."""
if name == "get_weather":
location = arguments.get("location", "Unknown")
# In a real app, call an actual Weather API here
return [
{"type": "text", "text": f"The weather in {location} is sunny and 25°C."}
]
raise ValueError(f"Tool not found: {name}")
# 2. Initialize FastAPI
app = FastAPI(title="Remote MCP Server")
# 3. Create the SSE Transport layer
# This object manages the connection between the web and the MCP protocol
sse = SseServerTransport("/messages")
@app.get("/sse")
async def sse_endpoint(request: Request):
"""
The client connects here to start the SSE stream.
The server will push messages to the client through this connection.
"""
async with sse.connect_sse(request.scope, request.receive, request._send) as (
read_stream,
write_stream,
):
# We run the MCP server using the streams provided by the SSE transport
await mcp_server.run(
read_stream, write_stream, mcp_server.create_initialization_options()
)
@app.post("/messages")
async def messages_endpoint(request: Request):
"""
The client sends JSON-RPC messages (tool calls, etc.)
via POST requests to this endpoint.
"""
await sse.handle_post_message(request.scope, request.receive, request._send)
return Response(status_code=202)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -0,0 +1,141 @@
import os
import subprocess
from functools import wraps
from typing import Callable
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
def make_verbose(func: Callable):
@wraps(func)
def wrapper(*args, **kwargs):
print("==========")
print(f"Calling {func.__name__} with params ({', '.join(args)}) and ({kwargs})")
result = func(*args, **kwargs)
print("==========")
return result
return wrapper
def run_command(command: list[str]) -> str:
"""Runs a command with subprocess.run and returns the stdout, stderr in a single string"""
process = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stdouts: list[str] = []
stderrs: list[str] = []
if process.stdout:
print("**STDOUT**")
for line in process.stdout:
line = line.strip()
print(line)
stdouts.append(line)
if process.stderr:
print("**STDERR**")
for line in process.stderr:
line = line.strip()
print(line)
stderrs.append(line)
output = f"**STDOUT**\n{'\n'.join(stdouts)}\n\n**STDERR**{'\n'.join(stderrs)}"
return output
@make_verbose
def get_fstab() -> str:
"""Retruns the"""
output = run_command(["ssh", "driveripper", "virsh", "list", "--name"])
return output
@make_verbose
def check_vm_type(server_name: str) -> str:
"""Returns various information about a given server like OS and version. server_name should be a server from list_vms."""
output = run_command(["ssh", f"{server_name}-root", "cat", "/etc/*-release"])
return output
@make_verbose
def get_updates_fedora(server_name: str) -> str:
"""Check for updates for a given Fedora server"""
output = run_command(["ssh", f"{server_name}-root"])
return output
@make_verbose
def get_security_updates_fedora(server_name: str) -> str:
"""Checks only for security updates for a given Fedora server"""
output = run_command(
["ssh", f"{server_name}-root", "dnf", "check-update", "--security"]
)
return output
@make_verbose
def perform_security_updates_fedora(server_name: str) -> str:
"""Applies security updates for a given Fedora server"""
output = run_command(
["ssh", f"{server_name}-root", "dnf", "update", "--security", "-y"]
)
return output
@make_verbose
def perform_security_updates_ubuntu(server_name: str) -> str:
"""Applies security updates for a given Ubuntu server"""
output = run_command(["ssh", f"{server_name}-root", "apt", "update", "-y"])
return output
def get_api_key() -> str:
return os.getenv("OPENAI_API_KEY", "placeholder")
if __name__ == "__main__":
# Run the agent
llm = ChatOpenAI(
model="instruct",
base_url="https://llama-instruct.reeselink.com",
api_key=get_api_key,
temperature=0.7,
timeout=30,
max_retries=2,
verbose=True,
top_p=1,
)
agent = create_agent(
model=llm,
tools=[
list_vms,
check_vm_type,
get_updates_fedora,
get_security_updates_fedora,
perform_security_updates_fedora,
perform_security_updates_ubuntu,
],
system_prompt="You are a helpful assistant",
)
result = agent.invoke(
{
"messages": [
{
"role": "user",
"content": (
"List all the available servers. Then, for each server, check the "
"server's OS and use the appropriate update check tool to check for "
"security updates. If any server needs security updates, apply them with "
"the appropriate update tool. Finally, provide a brief summary of what "
"you did."
),
}
]
}
)
print(result["messages"][-1].content)

View File

@@ -7,7 +7,7 @@ from email.message import EmailMessage
from pathlib import Path
from typing import Iterable, TypedDict, cast
from dotenv import dotenv_values
from dotenv import dotenv_values, load_dotenv
from openai import OpenAI
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionToolUnionParam
@@ -320,7 +320,9 @@ def run_conversation(user_message: str, max_tool_calls=10):
if __name__ == "__main__":
client = OpenAI(base_url="https://llama-cpp.reeselink.com", api_key="")
load_dotenv()
api_key = os.getenv("OPENAI_API_KEY", "")
client = OpenAI(base_url="https://llama-think.reeselink.com", api_key=api_key)
# Example usage
print(
run_conversation(

View File

@@ -100,13 +100,14 @@ def get_api_key() -> str:
if __name__ == "__main__":
# Run the agent
llm = ChatOpenAI(
model="qwen3.5-35b-a3b",
base_url="https://llama-cpp.reeselink.com",
model="instruct",
base_url="https://llama-instruct.reeselink.com",
api_key=get_api_key,
temperature=0.95,
temperature=0.7,
timeout=30,
max_retries=2,
verbose=True,
top_p=1,
)
agent = create_agent(

View File

@@ -1,15 +1,19 @@
fedora:
hosts:
gitea:
immich:
jellyfin:
nextcloud:
proxy:
bricktracker:
minecraft:
gitea-root:
immich-root:
jellyfin-root:
nextcloud-root:
proxy-root:
bricktracker-root:
minecraft-root:
borg-root:
elk:
elk-root:
toybox-root:
kube1-root:
kube2-root:
kube3-root:
ai-root:
hardware:
hosts:
@@ -20,11 +24,15 @@ ai:
hosts:
ai-ai:
deskwork-ai:
toybox-ai:
driveripper-ai:
caddy:
hosts:
proxy:
proxy-root:
nginx:
hosts:
proxy-root:
wyoming:
hosts:

View File

@@ -0,0 +1,5 @@
# Proxy Automation
1. Create or update the reeselink.com address in Unifi
2. Create or update the entry in the ddns values
3. Create or update the entry in the caddy values

View File

View File

@@ -15,12 +15,17 @@ def main():
for _, host in enumerate(tqdm(fedora_hosts, desc="Running system updates")):
log_file.write(f"Updating {host}\n")
log_file.flush()
subprocess.run(
["ssh", host, "dnf", "upgrade", "-y"],
stdout=log_file,
stderr=log_file,
check=True,
)
try:
subprocess.run(
["ssh", host, "dnf", "upgrade", "-y"],
stdout=log_file,
stderr=log_file,
check=True,
)
except Exception as e:
log_file.write(f"Couldn't connect to {host}. Skipping...\n")
continue
log_file.flush()
log_file.write(f"Rebooting {host}\n")
log_file.flush()
subprocess.run(
@@ -31,9 +36,9 @@ def main():
)
time.sleep(5) # wait for reboot to take effect
booted = False
max_attempts = 5 # seconds
cur_attempts = 0 # seconds
while cur_attempts > max_attempts or not booted:
max_attempts = 10
cur_attempts = 0
while max_attempts > cur_attempts and not booted:
try:
subprocess.run(
["ssh", host, "echo"],
@@ -42,6 +47,8 @@ def main():
check=True,
timeout=2,
)
log_file.write(f"{host} booted!\n")
log_file.flush()
booted = True
except Exception as e:
cur_attempts += 1

View File

@@ -0,0 +1,47 @@
# Kubernetes
## Network Prereqs
1. Allow Internal -> Load Balancer
2. Block Load Balancer -> Internal
3. Forward ports 22023-22122 to proxy.reeselink.com
4. `firewall-cmd --add-port=22023-22122/tcp --permanent && firewall-cmd --reload`
## Creating VMs
```bash
# Note: bridge1 is connected to an isolated network
export VM_NAME=reese-k3s
qemu-img convert -f qcow2 -O raw \
/srv/smb/pool0/ducoterra/images/builds/fedora43-base.qcow2 \
/srv/vm/pool1/${VM_NAME}-boot.raw
virt-install \
--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=no \
--cpu host-passthrough --vcpus sockets=1,cores=4,threads=2 \
--ram=4096 \
--os-variant=fedora43 \
--network bridge:bridge1 \
--graphics none \
--console pty,target.type=virtio \
--name ${VM_NAME} \
--import --disk "path=/srv/vm/pool1/${VM_NAME}-boot.raw,bus=virtio"
```
- [ ] Add the public key to root
- [ ] Add the following to the proxy server's nginx.conf
```conf
server {
listen 22023;
proxy_pass 10.4.0.159:22;
proxy_connect_timeout 10s;
proxy_timeout 30s;
}
```
- [ ] `systemctl restart nginx`
- [ ] Send SSH command `ssh -p 22023 root@ipv4.reeselink.com`

View File

@@ -0,0 +1,45 @@
# Bio
Reese is DIY technology enthusiast with a passion for projects that make things
easy. He's been working in development since 2017 with experience in risk,
compliance, scripting automation, full stack web development, container
infrastructure, homelab server hardware, ESP Home and home automation. Reese
has a passion for mentoring, but even more of a passion for sharing the new
tech he found last week with anyone who will listen. Reese wants tech to be
fun and approachable for anyone at any skill level.
## Credentials
Reese has spoken at multiple company conferences about building websites and
automating with Python. He's taught multi-day intro-to-python classes both
online and in person. He has 8 years of industry experience, 3 of which have
been spent growing the development team at a Nimbis Services. Reese has,
professionally and personally, written and distributed Python pip packages,
designed and hosted websites, built and deployed a version control system, led
AI development teams, taught Python classes, mentored high school students in
tech, annoyed his friends with discord bots, and automated his bathroom fans.
He's accustomed to speaking in front of large and small audiences and relishes
the opportunity to share his excitement with a crowd.
## Abstract
This talk will walk through the process of putting your local LLM to good* use.
Through the medium of a Discord bot, we will explore how to leverage llama.cpp
to give your friends the ability to create custom bots with custom
personalities, have those personalities talk with each other, generate images,
edit images, and set yourself up to leverage tool calling so your bots can
interact with the real world.
We will cover the state of hosting offline LLMs and discuss some strategies for
hosting them safely with Podman, Bifrost, and Caddy. We will also discuss the
current state of LLM hardware and give some realistic examples with AMD, Intel,
Nvidia, and CPU based solutions. We will not be using cloud examples, as this
talk will focus on avoiding cloud solutions in general. We will poke fun at
leveraging Discord as our example if our goal is to self-host.
Ultimately, I want this talk's participants to leave with some functional code
and good ideas to get them thinking about ways they can integrate LLMs into
their communities while maintaining control and privacy (and avoiding a hefty
bill). This talk will emphasize audience participation to generate ideas for a
prebuilt demo of the custom bot service, but will not build anything live
during the presentation.

View File

@@ -0,0 +1,109 @@
# Idea
## Abstract
Ever find yourself Googling "how do I build a home server" only to get
overwhelmed by enterprise-grade documentation? Welcome to our journey from
confused beginner to building a home server that's actually useful.
Join us as we walk through real-world projects that solved actual problems:
backing up family photos, hosting private Git repositories, running local AI
models, managing home media, and yes—even running multiple Minecraft servers on
a single box. We'll explore hardware choices, operating systems,
containerization vs VMs, and the pain points that motivated each decision.
This isn't a theory-heavy presentation—it's a story-driven exploration of
building infrastructure for real people with real needs. When you're done,
you'll leave with a roadmap for your own server that balances automation,
redundancy, and the "I just want this to work" factor.
Some prior sysadmin knowledge required. All projects are from personal
experience, with stories about what went wrong and how we fixed it.
## Structure
"I'm lazy, I don't want my family to kill me, and it works"
1. I want this in my house
2. I want to connect outside my house
3. I want my friends to connect
4. I don't want this to go down
5. I want to recover if there's an error
6. My house burned down, what now?
## Thoughts
Give 2 ideas per section. First for "I can't let this break my family will kill
me". Second for "I have an understanding partner who is my cat and won't care."
Story driven presentation
I have decided to make a strong home server. Where do I even start?
Hardware: you find a box (old laptop, rpi) you're set.
- Operating system (proxmox, truenas, fedora, arch linux)
- Alex: truenas apps
- Reese: Fedora, osbuild
1. Install native app (npm, pip, apt, dnf, etc)
2. Containerized (kube, docker, podman)
3. VM (vm, pick one or two)
- Ingress (nginx, caddy, haproxy)
- Backups (rsync, borg, btrfs send, zfs send)
1. I want to install a new app while I'm at friend's house
1. Truenas web portal (app page, both official and community)
2. VPN and I need access to my computer
2. I want to check my server status on my phone (updates, disks, memory pressure, error logs, services running)
1. Truenas web interface
2. Cockpit web interface
3. I want to add more storage
1. Truenas ZFS storage pools
2. BTRFS pools
4. I want to install a new alpha app without much support
1. Truenas custom docker compose images
2. Fedora clone and run (in a VM for style)
5. I want to backup my photos
1. Google Photos: don't use git, images aren't meant for git
2. **Immich, with backups (tell stories about losing my image data)**
6. I want a local copy of my code
1. Github
2. Gitea/Gitlab (talk about that transition)
7. I want private document editing
1. Google drive, Obsidian (forces use of markdown as my standard)
2. VSCode + pandoc (commit markdown files as your documents)
3. Nextcloud (Collabora)
8. I want a local, offline LLMs
1. llama.cpp, stable diffusion cpp, bifrost
2. Ollama is switching to cloud based models
9. I want to watch media I own
1. Plex boi - I know that ruffles some jimmies. Give example: add letterbox support into Plex.
2. Jellyfin if you're cheap
10. I want to know when something goes wrong
1. Uptime Kuma!
2. Truenas sending emails if there's an error
3. Fedora requires a custom solution.
11. I want "reasonable availability"
1. Truenas hits 90%+ availability. Updates take it down for reboot (5-10
minutes). Disk failure requires full shutdown, disk swap, and rebuild.
This could be half a day.
2. Fedora hits 90%+ availability. Updates take it down for reboot (<1 min).
Disk failures can be ignored by rebalancing. Disk failures still require
full shutdown and resilver. This can take half a day.
12. I want to host multiple minecraft servers (SRV records)
1. AWS Route53 for automating SRV records.
2. Pihole is in the territory of making your family mad
13. I want to automate my house
1. Home Assistant (raspberry pi or green)
14. I want backups of all my data
1. No backups is an option
2. Local weekly backups to usb drives via Truenas data replication
3. Borg backup via CLI or Pika.
4. Full disk backups, app directory backups, hybrid model
5. Backblaze and S3 integration for Truenas
6. 3 copies of your data, 2 different media,1 off site.
15. I want a private VPN
1. Tailscale, moved from wifiman, also moved from pivpn
2. unifi wireguard server, rawdog wireguard on a pi

View File

@@ -5,8 +5,10 @@ requires-python = ">=3.13"
dependencies = [
"click==8.2.1",
"dotenv>=0.9.9",
"fastapi>=0.135.3",
"langchain>=1.2.13",
"langchain-openai>=1.1.12",
"mcp>=1.27.0",
"mkdocs>=1.6.1",
"openai>=2.21.0",
"pika>=1.3.2",
@@ -14,7 +16,14 @@ dependencies = [
"pytest>=9.0.2",
"pyyaml>=6.0.3",
"requests>=2.32.5",
"sse-starlette>=3.3.4",
"tqdm>=4.67.3",
"types-pyyaml>=6.0.12.20250915",
"types-tqdm>=4.67.3.20260205",
"uvicorn>=0.44.0",
]
[tool.uv.workspace]
members = [
"active/vibe_mcp",
]

22
stories/README.md Normal file
View File

@@ -0,0 +1,22 @@
# Stories
Stories I want to tell. Unlike the `active` project, which stores notes in no
particular order, stories are meant to be read and enjoyed from top to bottom.
Hopefully they teach you something.
## This is a mkdocs project
`docs/` contains all the stories.
`mkdocs.yml` holds the project config.
```bash
# Run the mkdocs site with mkdocs serve
uv run mkdocs serve
```
## Errata
mkdocs has a [bug that breaks
autoreload](https://github.com/mkdocs/mkdocs/issues/4032). This project has
pinned click to `8.2.1` to fix it.

View File

@@ -0,0 +1,92 @@
# I want to build the perfect homelab server
Date Written: 02/11/26
Fedora Version: 43
## Intro
And it will run Fedora. Backstory: I ran Truenas for a long time. I started with
Freenas, got confused by jails, switched to this new thing called Truenas,
learned about ZFS, watched "Apps" come into existence, watched Kubernetes
support rise and fall, watched the disaster that was Incus container/VM support
and the subsequent "rollback" to traditional VMs, and got really tired of lack
of control.
App backup and restore was never, and still isn't, well supported. Taking
snapshots of your apps pool and then sending those snapshots to a backup drive
un-hid the ix-systems directory (which would frequently have thousands of
snapshots due to Truenas's liberal use of subvolumes and would slow down the UI
immensely). App data was intentionally hidden from the user for some reason.
Migrating between Docker, then kubernetes, then Incus was never fully planned.
The Truenas Charts app market was awesome, but building a Truenas Chart was
complex and required duplicating all `values.yaml` configurable parameters into
a new yaml file that the UI could use for form-fill. They got rid of Truenas
Charts regardless so you just have to hope that your favorite app supporter is
comfortable rewriting their app in the new format and supporting some kind of
migration strategy. Every six months I would expect some kind of downtime
because Truenas would change something critical and it would inevitably impact
my workflow.
So, if I'm going to be subject to the whims of a changing platform anyway (given
Truenas is supposed to be based on Debian, aka the *stable* choice), and if I'm
going to suffer breaking changes every 6 months no matter what I choose, then I
may as well have the latest and greatest via a rolling kernel distro.
So why not Arch? Simply: SELinux. SELinux is currently not officially supported
in Arch linux. Plus Fedora Server comes with a lot built in that I like.
Cockpit, Firewalld, Podman, SELinux, OSBuild, and RPM support all work out of
the box. These are, imo, the "bare bones" requirements for a server exposed to
the internet that will run homelab services.
So let's get started configuring an awesome Fedora server to keep your data safe
and run your Homelab services with minimal downtime.
## Installation
When installing Fedora from the ISO, take some time at the installation menu to
configure some basics.
Don't worry about RAID for now, we can convert a single disk into a RAID 1 array
later.
If you don't have an SSH key already, generate one for yourself so you can log into the server. On your local machine:
```bash
# Generate the key
# Save it to the default location (~/.ssh/id_ed25519)
# Please please please encrypt it with a password. Something memorable. Write it down. Friends don't let friends have naked SSH keys.
ssh-keygen -t ed25519
```
1. Configure the network
1. Set a hostname
2. Disable ipv6 privacy extensions
2. Configure software selection
1. Choose anything you'd like preinstalled
3. Create a non-root user
1. Set a simple password for easy login, we'll change it later
4. Configure your disk partitioning
1. Select manual (blivet) partitioning
2. Create a 1GB EFI system partition and mount it at `/boot/efi`
3. Create a 1GB btrfs partition and mount it at `/boot`
4. Create an encrypted btrfs volume with the remaining data and name it something unique, do not mount it
5. Create a btrfs subvolume called "root" and mount it at `/`
6. Create a btrfs subvolume called "home" and mount it at `/home`
7. Create any other btrfs subvolumes you might need (`/var`, for example)
5. Take note of the ipv4 and ipv6 address. Update any DNS records at this time.
6. Install and reboot
## Configuration
Once your server boots up we'll follow a basic playbook:
1. Change your password
2. Configure automatic decryption for your encrypted drives at boot with TPM2
3. Configure the package manager and apply updates
4. Secure SSH with Fail2Ban
5. Install Snapper for automatic snapshots to prevent accidental file deletion
6. Install BorgBackup for automatic backups
7. Install VM support
8. Build some images
9. Run some VMs

View File

@@ -0,0 +1,224 @@
# I refuse to pay for LLMs
But I want them anyway. And I don't just want LLMs, I want:
1. Image Generation
2. Image Editing
3. Speech to Text
4. Text to Speech
5. Web Searching
6. RAG Retrieval
7. Guest accounts with time-based access
8. Probably other things
On rootless podman with snapshots and backups and no compromises.
- [I refuse to pay for LLMs](#i-refuse-to-pay-for-llms)
- [Create your environment](#create-your-environment)
- [Local LLM First](#local-llm-first)
- [Ollama](#ollama)
- [LM Studio](#lm-studio)
- [llama.cpp](#llamacpp)
- [Ok, so you have a backend](#ok-so-you-have-a-backend)
- [What about llama-server?](#what-about-llama-server)
- [Anything LLM](#anything-llm)
- [Open Webui](#open-webui)
- [But we don't have image editing working](#but-we-dont-have-image-editing-working)
- [Stable Diffusion CPP](#stable-diffusion-cpp)
- [Making it Run with Quadlets](#making-it-run-with-quadlets)
## Create your environment
I created a user named `ai` to run all my AI services. Do that now:
```bash
useradd -m ai
loginctl enable-linger ai
su -l ai
mkdir -p /home/ai/.config/containers/systemd/
mkdir -p /home/ai/.ssh
```
## Local LLM First
On the Framework Desktop (or any AMD system) your options are ROCM or Vulkan drivers. Both are fine, with Vulkan pulling slightly ahead as of February 2026. Almost every backend you pick will support both, so pick a backend first.
### Ollama
is the natural place to start. Their "marketplace" is the best I've found for browsing models. They include short descriptions about what the models are good for and (almost) all of them work out of the box!
Bonus points: Ollama's API is well supported by interfaces like Anything LLM, Open Webui, a litany of F-Droid apps, and many other services.
Honestly, Ollama is still where I'd recommend anyone start. The installer is easy, performance is decent, the API is great, they (the Ollama team) curate models that work well on their platform, what's not to like?
Performance, mostly. llama.cpp just performs 20-30% better in my testing on models like gpt-oss-120b. Your mileage may vary, this is a great project.
### LM Studio
Everyone says to start with this. Ok, first of all, it's a GUI app. Yeah there's a toggle to run an API server but ain't no way I'm installing wayland on my pure, uncompromising, headless Fedora server.
I do have to admit it's the fastest way to get started with LLMs on desktop. But we're not here for desktops, we're here for servers. It runs llama.cpp in the backend anyway so skip past this and go for the good stuff.
### llama.cpp
We've landed on the best choice. You'll browse Hugging Face for models, be confused, and like it. You'll struggle to read the logs and feel right at home. You'll wonder why there isn't an intuitive CLI like Ollama. And you'll be rewarded with the fastest, most flexible way to run LLMs.
You'll need the Hugging Face CLI (`hf`). Install that.
First, download qwen3-vl-8b. This is a good jack of all trades model that supports vision, which is nice.
```bash
# Create a directory to hold your text models
# I put mine at /home/ai/models/text
mkdir -p /home/ai/models/text/qwen3-vl-8b-instruct
# Download the model from hugging face
hf download --local-dir /home/ai/models/text/qwen3-vl-8b-instruct Qwen/Qwen3-VL-8B-Instruct-GGUF Qwen3VL-8B-Instruct-Q4_K_M.gguf
# Also download the "mmproj" file for this model
# "mmproj" files allow a model to see images
hf download --local-dir /home/ai/models/text/qwen3-vl-8b-instruct Qwen/Qwen3-VL-8B-Instruct-GGUF mmproj-Qwen3VL-8B-Instruct-Q8_0.gguf
```
With our model locked and loaded, we can run the llama.cpp server. We do have to build the llama.cpp server container first though because making this any easier would be a crime.
```bash
# Build the llama.cpp container image
git clone https://github.com/ggml-org/llama.cpp.git
cd llama.cpp
export BUILD_TAG=$(date +"%Y-%m-%d-%H-%M-%S")
# Vulkan
podman build -f .devops/vulkan.Dockerfile -t llama-cpp-vulkan:${BUILD_TAG} -t llama-cpp-vulkan:latest .
# Run llama server (Available on port 8000)
# Add `--n-cpu-moe 32` to gpt-oss-120b to keep minimal number of expert in GPU
podman run \
--rm \
--name llama-server-demo \
--device=/dev/kfd \
--device=/dev/dri \
--pod systemd-ai-internal \
-v /home/ai/models/text:/models:z \
localhost/llama-cpp-vulkan:latest \
--port 8000 \
-c 16384 \
--perf \
--n-gpu-layers all \
--jinja \
--models-max 1 \
--models-dir /models
```
You should be able to access the llama.cpp server at http://{your-ip}:8000. From there you can select the only model you have downloaded (qwen3-vl-8b) and have a conversation.
## Ok, so you have a backend
Now we need a frontend. In my experience there are only 2 choices, but this is changing extremely fast.
### What about llama-server?
Good enough for testing. Honestly, if this meets your needs, more power to you.
### Anything LLM
I started here about a year ago. This is a fantastic frontend with RAG, speech to text, text to speech, web search, RAG, plugins, and decent user management. It supports Ollama, OpenAI, and a bunch of other backends.
Unfortunately, as of when I used it, there was no integrated image generation or image editing.
### Open Webui
This is, in my opinion, the best frontend experience you can get. The killer feature is side-by-side HTML rendering with your LLM response. If your LLM writes HTML/Javascript/CSS, it'll render in real time next to your chat. That's ridiculously cool.
It also supports image generation as a tool that your LLM can call. Prompts like "Generate an image of a dragon" will trigger a call to the image generation tool. Generated images show up in the chat and can be edited with another message.
```bash
mkdir /home/ai/.env
vim /home/ai/.env/open-webui-env
# Add this to the file, then save an exit
WEBUI_SECRET_KEY="some-random-key"
# Will be available on port 8080
podman run \
-d \
-p 8080 \
-v open-webui:/app/backend/data \
--env-file /home/ai/.env/open-webui-env \
--name open-webui \
--restart always \
ghcr.io/open-webui/open-webui:main
```
Use the following connections when configuring models/image editing:
| Service | Endpoint |
| -------------------- | ----------------------------------------- |
| llama.cpp | <http://host.containers.internal:8000> |
| stable-diffusion.cpp | <http://host.containers.internal:1234/v1> |
## But we don't have image editing working
In the past I used stable-diffusion-webui-forge. This project relied on a very
specific set of ROCM torch versions installed via pip from the nightly ROCM pip
repository. I had Stable Diffusion XL and Flux1.dev working on an AMD GPU, but I
couldn't get this working at all on the Framework Desktop.
I found out later this might be due to a ROCM driver bug, but we have bigger and better projects to work with.
### Stable Diffusion CPP
This project is llama.cpp equivalent for image generation. Open AI compatible API, tons of model support, excellent documentation, it's the best.
```bash
# Clone and build the stable diffusion cpp container
git clone https://github.com/leejet/stable-diffusion.cpp.git
cd stable-diffusion.cpp
git submodule update --init --recursive
export BUILD_TAG=$(date +"%Y-%m-%d-%H-%M-%S")
podman build -f Dockerfile.vulkan -t stable-diffusion-cpp:${BUILD_TAG} -t stable-diffusion-cpp:latest .
```
Stable diffusion CPP supports a CLI and a web server. Let's download a model and test out the CLI.
```bash
# z-turbo image model
# Fastest image generation in 8 steps. Great a text and prompt following.
# Lacks variety.
mkdir -p /home/ai/models/image/z-turbo
hf download --local-dir /home/ai/models/image/z-turbo QuantStack/FLUX.1-Kontext-dev-GGUF flux1-kontext-dev-Q4_K_M.gguf
hf download --local-dir /home/ai/models/image/z-turbo black-forest-labs/FLUX.1-schnell ae.safetensors
hf download --local-dir /home/ai/models/image/z-turbo unsloth/Qwen3-4B-Instruct-2507-GGUF Qwen3-4B-Instruct-2507-Q4_K_M.gguf
# Create our output directory
mkdir /home/ai/output
# Generate an image of a photorealistic dragon.
podman run --rm \
-v /home/ai/models:/models:z \
-v /home/ai/output:/output:z \
--device /dev/kfd \
--device /dev/dri \
localhost/stable-diffusion-cpp:latest \
--diffusion-model /models/image/z-turbo/z_image_turbo-Q4_K.gguf \
--vae /models/image/z-turbo/ae.safetensors \
--llm /models/image/z-turbo/Qwen3-4B-Instruct-2507-Q4_K_M.gguf \
--cfg-scale 1.0 \
-v \
--seed -1 \
--steps 8 \
--vae-conv-direct \
-H 1024 \
-W 1024 \
-o /output/output.png \
-p "A photorealistic dragon"
```
With any luck you should have a picture of a dragon in your output folder.
Since we know it works, we can tie everything together.
## Making it Run with Quadlets
Now that we have know our setup works we can glue it all together with systemd.
Take a look at [the framework desktop docs](https://gitea.reeseapps.com/services/homelab/src/branch/main/active/device_framework_desktop/framework_desktop.md#install-the-whole-thing-with-quadlets-tm) for the relevant commands.

View File

@@ -0,0 +1 @@
# Everyone uses this GPG thing, so should I

View File

@@ -0,0 +1 @@
# I want to use Podman, not Docker

11
stories/docs/index.md Normal file
View File

@@ -0,0 +1,11 @@
# Come, have a seat
Join me on a journey through homelab adventures. Follow along at home! These
stores will walk you through the trials of my self hosting wins and losses.
The stories will be written in a way that allows you to skip past the text and
just copy/paste the code blocks (similar to a Medium article). Each story will
lay out its goal and the prerequisites.
Stories are ordered by time written, oldest to newest. They don't necessarily
read in order, but may reference each other. No need to read each one.

Some files were not shown because too many files have changed in this diff Show More