SYSTEM ACTIVE ARCANUM ONLINE: calculating... ENTRIES: 008
DEPLOY_COUNT: 085 LAST_DEPLOY: 2026.09.07 @ 16:30 EDT PAGES_MODIFIED: 5
CHANGELOG :: view deploy history
v0232026.09.07lab.html — retitled car-AI entry: “The Oracle Rides Shotgun” → “The Ghost in the Glovebox”
v0222026.09.07lab.html — new entry: Home Vision (Pi 5 + Touch Display 2 wall panel, MagicMirror client + host-status view)
v0212026.09.06lab.html — new entry: The Oracle Rides Shotgun (PTT car AI over WireGuard + USB tether)
v0202026.07.26workshop.html — Fibonacci Reliquary entry: photos, Three.js 3D viewer, STL download (V2)
v0192026.07.26journal.html — design notes content, cursive fonts, ink drips, faded-edge screenshots
v0182026.07.26journal.html — initial deployment, handwritten parchment theme
v0172026.07.26index.html — journal links fixed, all pages interconnected
v0162026.07.26workshop.html — subtitle updated, sketches darkened
v0152026.07.26index.html — card title fonts (Alfa Slab, Orbitron, Caveat), nav themed
v0142026.07.26lab.html — deploy counter fixed, VHS tracking tuned, [REDACTED] refs cleaned
v0132026.07.26lab.html — deploy counter fix, VHS tracking tuned
v0122026.07.26lab.html — VHS tracking band added, scan lines fixed
v0112026.07.26lab.html — uptime counter, changelog, status bar expanded
v0102026.07.26lab.html — terminal margins, ominous messages, subtitle updated
v0092026.07.26lab.html — terminal margins, ominous messages, scan lines enhanced
v0082026.07.26lab.html — initial deployment, sci-fi theme
v0072026.07.26workshop.html — Da Vinci sketches darkened, clutter added
v0062026.07.26workshop.html — initial deployment, parchment theme
v0052026.07.26index.html — rebranded to The Arcanum, new fonts, nav links fixed
v0042026.07.25index.html — mobile responsive, [REDACTED] Easter egg fixed
v0032026.07.25index.html — will-o-wisp, candle glow, scattered trinkets
v0022026.07.25[REDACTED].html — [REDACTED] game, touch support added
v0012026.07.25index.html — initial deployment, dark academia theme
2026.09.07
Home Vision
Complete

A second Raspberry Pi 5 and a 10″ touch display, mounted on the wall — not a mirror you glance at, but a surface you touch. The idea: MagicMirror as the ambient “screensaver,” and a single swipe to pull up a live view of whether the house’s systems are actually up. The heavy lift was never the software — it was getting a rotated touch panel to behave like a finished appliance instead of a Pi with a browser open.

Two boxes, one job each: the existing Arcanum host serves MagicMirror; the new panel is a pure display client that renders it and adds its own status view on top. No MagicMirror install on the panel at all — nothing competing for a 1GB Pi.

Step 1 — The panel that wouldn’t light up

Fresh flash, DSI ribbon seated, GPIO power connected — and a dead black screen. The instinct is to reseat cables and swap ports (I did, all of it). The actual fix was the least-invasive one I should have tried first: apt full-upgrade + rpi-eeprom-update. The Touch Display 2 is newer than a lot of OS images, and its backlight only turns on after the Pi initializes it over DSI — so “no backlight” wasn’t a dead panel, it was stale firmware that didn’t know how to talk to it. Lesson filed: on new hardware, update firmware/kernel before touching a single cable.

sudo apt update && sudo apt full-upgrade -y
sudo rpi-eeprom-update -a
sudo reboot
Step 2 — Landscape, and the double-rotation trap

The panel is natively portrait (1200×1920); the wall mount is landscape. The trap: rotating in two places at once. A video=…,rotate=90 on the kernel cmdline rotates the console, but X’s modesetting driver ignores it and comes up native — so adding an xrandr rotate on top stacks a second rotation and squishes everything into a portrait sliver. The clean answer is exactly one source of rotation: drop the cmdline rotate, let X own it via a server-level config so the screen is landscape from the first frame.

# /usr/share/X11/xorg.conf.d/90-dsi-rotate.conf
Section "Monitor"
    Identifier "DSI-1"
    Option "Rotate" "left"
EndSection
Step 3 — Touch that follows the picture

Rotating the display doesn’t rotate the touch — so taps landed 90° out of phase (touch bottom-left, dot appears bottom-right). Two gotchas here: the tool xinput wasn’t even installed (so the mapping had been silently failing), and the touch device isn’t named “touch” — it’s ili_v3, so every grep touch came up empty. The fix is a coordinate-transformation matrix that composes the compensating 90° rotation:

xinput set-prop "ili_v3" "Coordinate Transformation Matrix" 0 -1 1 1 0 0 0 0 1

A clean diagonal swipe in libinput debug-events confirmed the digitizer itself was linear and healthy — it was purely a mapping problem, and the matrix put finger and pixel back together at all four corners.

Step 4 — The status view (the “is my stuff up?” panel)

Browser JavaScript can’t ping hosts directly, so the panel needs a tiny local backend. A ~40-line Python service (standard library only — no framework to break on a 1GB box) runs concurrent TCP socket checks against the three things worth knowing about — the internet (Cloudflare DNS), the NAS, and the Arcanum host — and serves the result as JSON. An Arcanum-styled page polls it every 10 seconds and flips each tile teal for up or red for down. A systemd unit keeps it alive across reboots.

# TCP reachability — no ping, no subprocess, no PATH gremlins
def check(host, port):
    try:
        with socket.create_connection((host, port), timeout=2):
            return True
    except OSError:
        return False
Step 5 — One screen, two views (the swipe)

MagicMirror guards itself with X-Frame-Options: SAMEORIGIN, so it refuses to load in an iframe from any other origin. The fix is a small nginx reverse proxy on the panel that proxies MagicMirror and strips that one header (keeping the WebSocket upgrade so live modules keep ticking). Then a wrapper page stacks two full-screen layers — the mirror and the status view — with transparent gesture strips at the top and bottom edges: swipe up for controls, swipe down (or 30 seconds idle) back to the mirror.

# nginx: strip the frame guard, keep sockets alive
location / {
    proxy_pass http://arcanum-host:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_hide_header X-Frame-Options;
    proxy_read_timeout 120s;
}
Step 6 — Making it an appliance

Console autologin, then a guarded startx that only fires on the physical console (never on SSH), launching Chromium in kiosk mode at the wrapper. The last detail is the one that separates “finished” from “half-baked”: a stray mouse pointer on a touch screen. Killed at the source — not hidden after idle, but never drawn at all:

exec startx -- -nocursor

Power on → autologin → kiosk → MagicMirror, with the status view a swipe away. No keyboard, no pointer, survives a power cycle — and it’s on the UPS.

Dependencies: Raspberry Pi 5 (1GB) + Raspberry Pi Touch Display 2 (10″); Raspberry Pi OS Lite + a minimal X/Chromium kiosk (xserver-xorg, xinit, chromium, xinput, xserver-xorg-legacy, fonts-noto-color-emoji); nginx (reverse proxy + header strip); a standard-library Python status service under systemd. Server lives on the Arcanum host; this panel is the client. Least-invasive first — update firmware before you ever reach for a screwdriver. Still ahead: the printed enclosure with a motion-sensor cutout, and the “IYKYK” control tiles (EV charging, the smart lock).

raspberry-pi touch-display-2 magicmirror kiosk nginx reverse-proxy systemd home-dashboard how-to
2026.09.06
The Ghost in the Glovebox
Complete

The private mind from the last entry was already summonable from a phone — but in the car it meant fumbling: unlock, “Hey Siri,” “Ask [assistant],” wait, then the question. Too many steps at speed. The goal: a single dedicated device that lives in the car — one button, one mic — that talks straight to the model running on the GPU at home, over the phone’s own cellular, with no cloud, no subscription, and no Siri in the middle.

The full chain, end to end: button → mic → Whisper (speech→text) → WireGuard tunnel → the GPU at home → a synthesized voice → the car speakers. Every link had to work from cellular, hands-off, on a cold boot.

Step 1 — A device with a button and a mic

A Raspberry Pi 5 (cheaper and far more capable than the tiny Zero it beat out), an I2S MEMS mic, and a momentary push-button — soldered up and glued into a case. Two hardware lessons landed early: a counterfeit SD card that corrupted on first boot (the entire “won’t boot” red herring), and an aluminum case that acts as a Faraday cage and strangles the Pi’s Wi-Fi — which quietly pushed the whole design toward a wired phone link anyway.

The non-obvious OS decision: it needs the full Desktop OS, not Lite. Bluetooth audio (A2DP) simply won’t come up headless — the audio session’s Bluetooth monitoring is disabled without a desktop session. Days of “why won’t it pair” evaporated on a reimage.

Step 2 — The loop, in software

Press-to-talk records the mic; a tiny local Whisper model turns speech to text right on the Pi’s CPU (no GPU needed for the small model); the text goes to the model at home; the reply is spoken back with Piper TTS — a Scottish voice named Alba.

The decision that paid off: instead of hitting the model’s raw API, route through the chat UI’s API (Open WebUI) so every exchange is logged and shows up later at ai.lan — the car conversations live in the same history as everything else. Sign in for a token, create or resume a chat, post the messages.

# Bluetooth swallows the first fraction of a second while the sink wakes,
# so every reply gets 1 second of silence prepended -- the voice never gets clipped.
SILENCE_PAD_SECONDS = 1.0
Step 3 — The link home, over cellular

The model lives on the home LAN; the car is on cellular. WireGuard bridges the two: the Pi dials home and gets a private address on the home network, so the GPU box answers from anywhere as if it were in the living room. It’s a split tunnel — only home-bound traffic goes through the VPN, everything else rides the raw cellular path.

# wg0.conf -- only these subnets route through the tunnel:
AllowedIPs = 10.0.0.0/24, 192.168.2.0/24

⚠ The lesson learned the hard way: raising the tunnel while sitting on the home Wi-Fi makes it hijack the local subnet from itself and locks you out of SSH. It’s only ever correct on cellular — so the real test has to be off-network.

Step 4 — The reliability pivot: a cable, not a hotspot

The original plan was the phone’s Wi-Fi hotspot. It turned out to be the single weakest link there is: iOS sleeps the hotspot ~90 seconds after the last device drops, and a non-Apple device can’t wake it. Cold-boot in the driveway = nothing to join.

The fix: USB tethering — a wire. No sleep timeout, no failed re-join, no Faraday-cage signal problem. The phone enumerates as a wired network device and hands the Pi a cellular data path over the cable. Rock solid where the hotspot was a coin flip.

# The Apple USB stack, so Linux can talk to the phone:
sudo apt install usbmuxd libimobiledevice-utils
idevicepair pair          # tap "Trust This Computer" on the phone
# the phone then appears as a wired iface with a 172.20.10.x cellular lease

The subtle bug this created: the Pi now had several network interfaces (the console cable, the phone, Wi-Fi), and Linux hands out eth0/eth1 names in probe order — so the phone and the onboard port would trade names between boots, and any config bound to a name landed on the wrong device. The fix: pin the profile to the onboard port’s permanent MAC address, not its name. (Do it through nmcli, not by hand-editing the netplan YAML — NetworkManager owns that file and will silently overwrite a hand edit.) Name-shuffle bug: dead.

Step 5 — Make it boot-and-go (no keyboard in the car)

Three pieces so a cold power-on comes up completely hands-off:

# 1) A NetworkManager dispatcher that raises the tunnel the moment the
#    phone's cellular subnet (172.20.10.x) appears -- works for USB *or*
#    hotspot, and NEVER fires on home Wi-Fi (so the lockout can't happen).
if ip -4 addr show | grep -q 'inet 172\.20\.10\.'; then wg-quick up wg0; fi

# 2) A systemd service that launches the assistant on boot.
# 3) A patience patch: wait for home to be reachable through the tunnel
#    BEFORE signing in, instead of firing immediately and crash-looping.
wait_for_home()   # polls host:port every 5s until the tunnel is up

Two gotchas the logs confessed: a stray copy of the script still running in a login session held the button’s GPIO (GPIO busy) and blocked the service; and the first dispatcher silently did nothing because the shell variables $1/$2 got written as literal text. Rewritten to be argument-independent, it fired first try. The reboot test finally came up cold — no keyboard, tether up, tunnel up, assistant waiting for a button press.

Dependencies: Raspberry Pi 5 + I2S mic + a momentary button; faster-whisper (STT, CPU); the home model via Open WebUI’s API; Piper TTS (the Alba voice); WireGuard (split tunnel); usbmuxd / libimobiledevice for the iPhone USB tether; a NetworkManager dispatcher + a systemd service. Now installed in the car and working end-to-end — power held fine off the Tesla’s USB-C with the phone capped at an 80% charge. Never trust the pipe; the cold-boot reboot test is the only proof that counts, and it counted.

raspberry-pi whisper piper-tts wireguard usb-tether ollama push-to-talk self-hosting how-to
2026.08.09
The Internet in a Jar
Complete

The internet went out for two days. The lights and the LAN stayed up — so the house had power and a network, and nothing to read. The fix: pull the reference material down while you're online and serve it locally, so an outage stops mattering. Kiwix serves .zim files — compressed, offline snapshots of whole knowledge bases: Wikipedia, a medical encyclopedia, a dictionary, tens of thousands of books, repair guides for everything.

The full chain, end to end: .zim files on the NAS → Kiwix container → a clean local name.

Step 1 — A folder of knowledge, and a server for it

Drop the .zim files in a folder on the always-on box and run Kiwix pointed at them. Grab content from library.kiwix.org — and download it directly on the NAS over ethernet, not through a laptop over Wi-Fi (a ~100GB file copied over SMB will crawl for 16 hours; pulled straight to the box on gigabit it's a fraction of that).

services:
  kiwix:
    image: ghcr.io/kiwix/kiwix-serve:latest
    ports: ["8090:8080"]
    volumes: ["/volume1/nomad/zim:/data"]
    entrypoint: ["/bin/sh", "-c"]
    command: ["kiwix-serve --port=8080 /data/*.zim"]   # serves EVERY zim in the folder
    restart: unless-stopped
Step 2 — Two gotchas the logs will confess

This container fought back; the container log named the cause each time.

# 1) Folder permissions: the container runs as a non-root user and couldn't read the share.
#    -> grant read on the folder ("everyone"/users, read-only), apply to sub-folders.
# 2) Port bind: overriding the entrypoint drops the image's helper that adds --port,
#    so kiwix defaults to port 80 and a non-root process CAN'T bind <1024.
#    -> pass --port=8080 explicitly (>=1024), map it out as 8090:8080.

The wildcard /data/*.zim only expands under a real shell, which is why the entrypoint override is there — and it means the folder is now a drop zone: add a .zim, restart the container, and it appears in the library automatically.

Step 3 — A name for it

Same local-DNS + reverse-proxy trick as everything else — http://wiki.lan. No WebSocket header needed this time; Kiwix serves plain pages.

Dependencies: Docker, Kiwix, storage (full Wikipedia with images is ~100GB; a broad set runs ~180GB), and .zim files from library.kiwix.org. The content is dated snapshots — re-pull every 6–12 months if you want it current. Never trust the pipe; verify with docker ps that the container stays Up and not restarting.

kiwix zim offline-first docker nas self-hosting how-to
2026.08.08
A Private Mind, Summoned
Complete

Cloud AI is convenient and it sends every word you type to someone else's servers. The alternative is to run the model on your own hardware — private, free per query, and yours. The problem: the capable models want a real GPU, and a GPU box is a power-hungry thing to leave running 24/7. So the build splits the work: the muscle (a GPU machine) sleeps until summoned; the interface lives on an always-on low-power box; and one tap wakes the whole thing.

The full chain, end to end: phone → wake the GPU box → chat UI (always-on) → the model on the GPU.

Step 1 — Serve the model on the GPU box

Ollama runs the model and exposes a local API. By default it only listens to itself; one variable opens it to the LAN so other devices can reach it.

# On the GPU machine (persists across reboots):
setx OLLAMA_HOST "0.0.0.0"     # listen on all interfaces, not just localhost
# then fully restart Ollama.  ollama pull <a-coding-model> ; ollama pull <a-chat-model>
Step 2 — Put a real interface in front of it, on the always-on box

Open WebUI is a ChatGPT-style front end. Run it in Docker on the box that's always on (a NAS is perfect), and point it at the GPU machine's Ollama. Now the interface is always reachable; the GPU only has to be awake when you actually chat.

services:
  open-webui:
    image: ghcr.io/open-webui/open-webui:main
    ports: ["3000:8080"]
    environment:
      - OLLAMA_BASE_URL=http://10.0.0.11:11434   # the GPU box
    volumes: ["/volume1/docker/open-webui:/app/backend/data"]
    restart: unless-stopped
Step 3 — A clean name instead of an IP:port

A local DNS record (your network resolver) maps a friendly name to the box; a reverse proxy strips the port. http://10.0.0.25:3000 becomes http://ai.lan.

# Local DNS record:      ai.lan  ->  10.0.0.25
# Reverse proxy:         http://ai.lan:80  ->  http://localhost:3000
Step 4 — Wake it with a tap

The GPU box sleeps to save power (Wake-on-LAN from S3 wakes it in seconds). A phone shortcut SSHes into the always-on box and fires a magic packet, then opens the chat — one button, or "Hey [assistant], summon."

# BIOS: enable Wake on LAN / "Power on by PCI-E", disable ErP.  Windows: disable Fast Startup,
# NIC "Wake on Magic Packet" = on.  Then, from any always-on Linux box on the LAN:
wakeonlan c8:7f:54:00:00:00      # the GPU box's MAC -> it wakes from sleep
The one non-obvious gotcha — tools

Some models don't support function-calling ("tools"). If the UI attaches tools to every request, those models either hard-error ("does not support tools") or, if they do support tools, dutifully emit tool-call JSON instead of answering. The fix isn't in the chat settings or the function-calling mode — it's a per-model capability: turn off "Builtin Tools" on the model itself. Read the container log when a model won't answer; it names the cause every time.

→ FULL DEEP DIVE: Ollama → Open WebUI → Wake-on-LAN, step by step

Dependencies: Ollama (GPU box), Open WebUI (Docker, always-on box), a local DNS resolver + reverse proxy, Wake-on-LAN (BIOS + NIC + a magic-packet sender). Two models, two personas via system prompts — a terse one for code, a warm one for conversation.

ollama open-webui llm docker wake-on-lan reverse-proxy self-hosting how-to
2026.08.03
Network-Wide Ad Blocking
Complete

Every device on a network quietly phones home — ads, trackers, telemetry. Browser blockers only cover the browser; they do nothing for the smart TV, the phone apps, or the pile of IoT gadgets chattering in the background. The fix works one layer down: block it at DNS, the address book every device consults before it can load anything. One box says "that ad domain lives nowhere," and the ad never even downloads — on every screen in the house at once.

The full chain, end to end: device → Pi-hole (DNS) → the rest of the internet. Here's how it goes up.

Step 1 — Run the resolver in a container

Pi-hole runs happily in Docker. The catch is networking. On a default bridge network the container hides behind the host, so Pi-hole sees every device as the same gateway address and you lose all per-device visibility. The fix is a macvlan network: it hands the container its own IP on the real LAN, as if it were a separate little box plugged into the switch.

# docker-compose (macvlan): the container gets its OWN LAN IP
services:
  pihole:
    image: pihole/pihole:latest
    hostname: pihole
    mac_address: 02:1a:2b:3c:4d:5e        # pin it — see Step 2
    networks:
      lan:
        ipv4_address: 10.0.0.53           # outside the DHCP pool
    environment:
      TZ: "Region/City"
      FTLCONF_dns_listeningMode: "all"
    restart: unless-stopped

networks:
  lan:
    driver: macvlan
    driver_opts: { parent: eth0 }
    ipam:
      config:
        - subnet: 10.0.0.0/24
          gateway: 10.0.0.1
          ip_range: 10.0.0.53/32
Step 2 — Pin the MAC so the reservation never rots

A macvlan container invents a random MAC on each build. Reserve its IP on the router and that reservation is bound to that MAC — so the next time you rebuild the container, Docker hands it a new MAC, the reservation orphans, and one day the address gets leased to something else. You get a mystery DNS outage weeks later and never connect it to "that time I updated Pi-hole." Pin a fixed, locally-administered MAC (any address starting 02:) in the compose file, reserve that on the router, and it's rebuild-proof forever.

Step 3 — Point the whole house at it

Tell the router to hand out the resolver's IP as the DNS server for every DHCP client. Now every device gets ad-blocking automatically on connect — no per-device setup.

# Router DHCP settings:
#   DNS server handed to clients:  10.0.0.53   (Pi-hole)
#   Secondary:                     (leave blank — see the trap below)

The backup-DNS trap. The obvious move is to add a public DNS (say 8.8.8.8) as a secondary "in case Pi-hole is down." Don't. Devices don't use the secondary only when the primary fails — they query whichever answers fastest, effectively at random. So a chunk of traffic silently skips Pi-hole and the ads leak back in, unpredictably. A public secondary quietly defeats the whole thing. Run the resolver solo and make the box reliable instead.

Dependencies: Docker (macvlan support), Pi-hole, and a router that lets you set the DHCP-handed-out DNS. Note: some ISP gateways won't let you change that field — the fix there is to bridge the gateway and run your own router behind it. Single resolver = single point of failure; the trade for 100% filtering is that a resolver outage takes DNS with it, so a 15-second router revert is your escape hatch.

pi-hole dns docker macvlan ad-blocking self-hosting how-to
2026.07.30
Bottomless Photo Storage — Visage Obscurus
Complete

This site runs off a Raspberry Pi with a modest SD card, so a growing pile of photographs was never going to fit. The fix behind the Visage Obscurus gallery: keep the images on a NAS with storage to spare, have the Pi reach over the LAN to serve them, and let Cloudflare cache them at its edge — without ever exposing the NAS to the internet, and without a single photo leaking where it was taken.

The full chain, end to end: NAS → Pi (read-only mount) → Nginx → Cloudflare → browser. Here is every step.

Step 1 — Export the share from the NAS

On the NAS, create a dedicated Photos shared folder and enable NFS on it. Grant the LAN subnet read-only access — the web server only ever needs to read. Note the export path the NAS reports (something like /volume1/Photos); the Pi needs it exactly, spaces and all.

# NAS NFS rule (read-only), granted to the LAN:
#   client:   10.0.0.0/24     (the whole home subnet)
#   privilege: Read Only
#   squash:   map all users to admin  (so the Pi can read every file)
Step 2 — Mount it on the Pi, read-only

Install the NFS client, make a mount point, and add a resilient /etc/fstab entry. The flags matter: ro keeps it read-only, and x-systemd.automount + nofail mean a NAS reboot can never wedge the Pi — the mount reconnects on the next access instead of hanging boot.

sudo apt install nfs-common
sudo mkdir -p /mnt/nas_photos

# /etc/fstab — note \040 escapes a space in the export path:
NAS_IP:/volume1/Photos  /mnt/nas_photos  nfs  \
  ro,noatime,nofail,_netdev,x-systemd.automount,x-systemd.idle-timeout=600  0  0

sudo systemctl daemon-reload && sudo mount -a
Step 3 — Serve it through Nginx

Point a /photos/ location at the mount with alias. Nginx streams each file straight off the NAS as if it were local. The 30-day Cache-Control header is load-bearing — it is what makes the next step possible.

location /photos/ {
    alias /mnt/nas_photos/;
    autoindex off;
    expires 30d;
    add_header Cache-Control "public";
    access_log off;
}
Step 4 — Let Cloudflare cache the edge

Because the site already sits behind a Cloudflare tunnel, that Cache-Control header lets Cloudflare hold each image at its edge. The first request for a photo is a MISS (served from the NAS); every request after is a HIT, served from Cloudflare and never touching the NAS again. The drives spin up once per image per region, then go quiet.

# Prove it from anywhere:
curl -I https://example.com/photos/test.jpg
# first call  -> cf-cache-status: MISS
# second call -> cf-cache-status: HIT   (now served from the edge)
Step 5 — Strip the metadata before it ships

This is the step that matters most. A phone photo carries EXIF: camera, exposure — and GPS coordinates. Serving originals would publish the photographer's location in every file. The import script (Python + Pillow, run wherever the share is writable — the Pi mount is read-only on purpose) rebuilds each image from raw pixels only, so no metadata survives into what ships.

# auto-rotate using EXIF orientation, THEN discard all metadata
img = ImageOps.exif_transpose(Image.open(src))
# frombytes() rebuilds from pixel data alone — EXIF/GPS/ICC all gone
clean = Image.frombytes(img.mode, img.size, img.tobytes())
clean.convert("RGB").save(out, "JPEG", quality=86, optimize=True)

The useful EXIF (camera, focal length, aperture, shutter, ISO) is read into a JSON manifest for the photo captions before it is thrown away — so the gallery can still show the camera details, but the pixels that ship carry nothing. The script also emits responsive widths (400/800/1600px) and a tiny blurred placeholder so images fade in gracefully, and it keys off a sha256 of the original bytes so re-running it is idempotent.

Step 6 — Verify the strip worked

Never trust; verify. Compare the original against the served derivative — the coordinates should simply vanish.

mdls -name kMDItemLatitude -name kMDItemLongitude original.jpeg
# -> kMDItemLatitude = 40.7502...   (real location)
mdls -name kMDItemLatitude -name kMDItemLongitude gallery/served-1600.jpg
# -> kMDItemLatitude = (null)       (nothing shipped)

Original reported coordinates; the served file reported (null). Proven, not assumed.

→ FULL DEEP DIVE: NAS → Pi → Nginx → EXIF-strip, step by step

Dependencies: NFS (nfs-common on the Pi), Nginx, a Cloudflare tunnel. Pipeline: Python 3 + Pillow. The manifest is a single photos.json the gallery fetches — the same fetch-a-manifest pattern the rest of the Arcanum already uses. The gallery page reads it client-side and hangs each photo in a frame.

nfs nginx cloudflare nas pillow exif-strip how-to
2026.07.27
Live Server Telemetry
Complete

The command ticker along the bottom of this page now reports the actual state of the machine serving it \u2014 RAM, CPU load, temperature, disk, uptime, process count, active connections. Each line is tagged [LIVE] when the real feed is connected, [sim] when it falls back to simulated values.

A static site can't read its own server \u2014 the browser is sandboxed. The workaround: a shell script on the Pi samples the OS and writes a small stats.json into the web root; the page fetches it every five seconds. If the file is missing, the ticker simulates. If it appears, the ticker upgrades itself \u2014 no page changes required.

How it's done \u2014 the sampler
# reads /proc + vcgencmd, writes stats.json (overwrite, not append)
mem_total_kb=$(grep MemTotal /proc/meminfo | awk '{print $2}')
temp=$(vcgencmd measure_temp | grep -o '[0-9.]*')
cat > stats.json <<EOF
{ "ram_used": "$ram_used", "temp": $temp, "cpu": $cpu, ... }
EOF
How it's done \u2014 the schedule
# crontab -e  \u2014 two lines give a ~30s refresh
* * * * * /var/www/site/update-stats.sh
* * * * * sleep 30; /var/www/site/update-stats.sh

Cron's finest resolution is one minute, so a second line with sleep 30 doubles the cadence. The file is rewritten in place with > (never >>), so it stays ~300 bytes forever \u2014 no disk growth, negligible card wear.

Dependencies: bash, coreutils, cron. On a Raspberry Pi, vcgencmd for temperature; elsewhere read /sys/class/thermal. The page reads stats.json with a cache-busting fetch every 5s.

cron bash telemetry raspberry-pi how-to
2026.07.27
Liquid Glass Shelves
Complete

The Infinite Library's shelves are transparent glass suspended in a cosmic background — defined only by luminous edges, with a faint diffusion that blooms the nebula light passing through them.

The trick is to make the glass nearly invisible: no fill, let the border and a light backdrop-filter do all the work. The bookcase itself is a near-zero-alpha background so it doesn't read as a solid panel.

How it's done — the case
.bookcase {
  background: rgba(255, 255, 255, 0.01);
  backdrop-filter: blur(1px) brightness(1.05) saturate(1.2);
  -webkit-backdrop-filter: blur(1px) brightness(1.05) saturate(1.2);
  border: 1px solid rgba(180, 210, 255, 0.14);
  border-top: 1px solid rgba(220, 200, 255, 0.22);  /* rim catches light */
  border-radius: 16px;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,0.1),
    0 0 60px rgba(80, 120, 200, 0.03);
  overflow: hidden;
}

The shelf plank is a thicker glass bar that diffuses the background significantly — a stronger blur plus a bright top edge and a soft bottom edge sell the "sheet of glass" read.

How it's done — the plank
.shelf::after {
  height: 6px;
  background: rgba(10, 15, 30, 0.3);
  backdrop-filter: blur(4px) brightness(1.1);
  border-top: 1px solid rgba(180, 210, 255, 0.2);
  border-bottom: 1px solid rgba(140, 180, 240, 0.08);
  box-shadow: 0 2px 12px rgba(100, 160, 255, 0.06),
              inset 0 1px 0 rgba(255,255,255,0.06);
}

Dependencies: none — pure CSS. Note: backdrop-filter requires a modern browser (Safari needs the -webkit- prefix). There must be content behind the element for the blur to sample, so the cosmic background sits at a lower z-index.

css glassmorphism backdrop-filter how-to
2026.07.26
In-Browser 3D Model Viewer
Complete

The Workshop's Fibonacci Reliquary has an interactive 3D viewer — visitors can grab the model, spin it, zoom, and inspect it before downloading the STL. It auto-rotates when idle, rendered in brass gold against a dark field.

It's built on Three.js with two add-ons: STLLoader (parses the binary STL) and OrbitControls (drag/zoom/rotate). Loaded via an ES-module import map from a CDN — no build step, no bundler.

How it's done — load the libraries
<script type="importmap">
{
  "imports": {
    "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
    "three/addons/": "https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/"
  }
}
</script>
How it's done — scene + model
import * as THREE from 'three';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, w/h, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;      // smooth momentum
controls.autoRotate = true;         // spin when idle

new STLLoader().load('files/model.stl', (geo) => {
  geo.computeVertexNormals();        // smooth shading
  const mat = new THREE.MeshPhongMaterial({ color: 0xc9a84c, shininess: 30 });
  const mesh = new THREE.Mesh(geo, mat);
  // center + scale-to-fit from the bounding box, then add to scene
  scene.add(mesh);
});

A render loop calls controls.update() and renderer.render(scene, camera) each frame; a resize handler keeps the aspect ratio correct. The STL itself is written with a dependency-free binary writer (see the reliquary print note).

Dependencies: Three.js r160 + STLLoader + OrbitControls (all from jsDelivr CDN). Requires WebGL. The model file (~3.7 MB) is served statically from /files/.

three.js webgl stl 3d-printing how-to
2026.07.26
One-Command Deploy Script
Complete

Editing happens locally; a single command pushes the whole site to the Pi. deploy.sh bumps a version counter, stamps the deploy time, and SCPs every asset — HTML, images, STL files, favicons — in one shot. SSH keys mean no password prompt.

How it's done — the script
#!/bin/bash
REMOTE="[email protected]:/var/www/yoursite/"

# read + bump the counter (force base-10, see gotcha below)
RAW=$(grep -o 'id="deployCount">[0-9]*' lab.html | grep -o '[0-9]*')
NEXT=$(printf "%03d" $(( 10#$RAW + 1 )))
sed -i '' "s/id=\"deployCount\">[0-9]*/id=\"deployCount\">${NEXT}/" lab.html

scp ./*.html "$REMOTE"
scp ./favicon.* ./apple-touch-icon.png "$REMOTE" 2>/dev/null
[ -d images ] && scp -r images "$REMOTE"
[ -d files ]  && scp -r files "$REMOTE"
How it's done — passwordless SSH
# generate a dedicated key (don't overwrite an existing one)
ssh-keygen -t ed25519 -f ~/.ssh/id_site
ssh-copy-id -i ~/.ssh/id_site [email protected]
# then in ~/.ssh/config:
Host yourhost.local
    IdentityFile ~/.ssh/id_site

Gotcha: Bash reads leading-zero numbers as octal, so 008 and 009 throw "value too great for base." The fix is $((10#$RAW)), which forces base-10 regardless of leading zeros.

Dependencies: bash, OpenSSH (scp/ssh-keygen/ssh-copy-id). macOS sed needs the -i '' form; Linux uses -i. No other tooling.

bash deployment ssh automation how-to
2026.07.24 — 2026.07.26
Project: The Arcanum
Active

Deployed a personal site from zero. Raspberry Pi 5 running Nginx, routed through Cloudflare Tunnel. No ports forwarded. Public IP hidden behind Cloudflare edge. Domain registered: the-arcanum.com.

Stack: static HTML/CSS/JS. Vanilla. No frameworks. Scroll-linked animations, IntersectionObserver for fade-ins, CSS transitions. Will-o-wisp particle system tied to scroll progress. Hidden Easter egg somewhere on the site. Good luck finding it.

Mobile responsive. Touch events for game on iOS. Served by Nginx from the web root, running alongside other home services. Cloudflared runs as a systemd service — survives reboots.

→ ABOUT THIS BUILD: full setup guide

self-hosting cloudflare raspberry-pi html-css nginx
2026.07.24
Lab Initialized
Complete

This space is now operational. A log for homelab experiments, self-hosting projects, local LLM benchmarks, networking, and anything that runs on silicon. Entries are chronological. Most recent first.

meta init
Balthazar >> LOCATE: Infinite Library // DEPTH: 122