9e8f1bbeed
Dashboard / frontend (push) Failing after 0s
Dashboard / api (push) Failing after 0s
Lint PowerShell / powershell-lint (ubuntu-latest) (push) Failing after 1s
Python Lint / Lint Python with Ruff (push) Failing after 1s
ShellCheck / Lint shell scripts (push) Failing after 1s
Matrix Smoke / linux-smoke (push) Failing after 1s
Matrix Smoke / distro: cachyos (push) Failing after 15s
Matrix Smoke / distro: linux-mint-21.3 (push) Failing after 15s
Matrix Smoke / distro: debian-12 (push) Failing after 5m21s
Matrix Smoke / distro: fedora-41 (push) Failing after 4m56s
Matrix Smoke / distro: ubuntu-24.04 (push) Failing after 2m13s
Matrix Smoke / distro: rocky-9 (push) Failing after 10m39s
Matrix Smoke / distro: manjaro (push) Failing after 12m11s
Matrix Smoke / distro: opensuse-tw (push) Failing after 11m53s
Matrix Smoke / distro: archlinux (push) Failing after 20m3s
Matrix Smoke / distro: ubuntu-22.04 (push) Failing after 13m49s
Validate .env Schema / tier-1-env-validation (push) Successful in 52s
Validate .env Schema / tier-2-env-validation (push) Successful in 44s
Validate .env Schema / tier-3-env-validation (push) Successful in 52s
Validate .env Schema / tier-4-env-validation (push) Successful in 51s
Validate Extensions Catalog / Check catalog is up-to-date (push) Failing after 9m47s
Secret Scan / Scan for secrets (push) Failing after 21m4s
Validate Docker Compose / Validate Docker Compose files (push) Has been cancelled
Python Type Check / Type check with mypy (push) Has been cancelled
Validate .env Schema / tier-0-env-validation (push) Has been cancelled
Test Linux / integration-smoke (push) Has been cancelled
Lint PowerShell / powershell-lint (windows-latest) (push) Has been cancelled
Matrix Smoke / macos-smoke (push) Has been cancelled
379 lines
14 KiB
Python
379 lines
14 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
generate-setup-card.py — produce a printable setup card PNG for a ODS unit.
|
||
|
||
Use this when shipping a unit: pre-configure its setup-mode Wi-Fi AP (a unique
|
||
SSID + password per device), feed those creds plus the setup URL into this
|
||
script, get back a 4×6 portrait card you can print + laminate + drop in the box.
|
||
|
||
The card carries:
|
||
* Top: "ODS" wordmark + the device's mDNS name (e.g. "ods.local")
|
||
* Two big QR codes:
|
||
- Left: Wi-Fi join QR (Android + iOS recognize the WIFI:T:...;S:...;P:...;; format)
|
||
- Right: setup URL — opens straight to the first-boot wizard
|
||
* Plain-text fallback at the bottom (SSID / password / URL) for the
|
||
inevitable phone that won't auto-detect the QR
|
||
* Optional serial / batch line for fulfillment tracking
|
||
|
||
This is a tooling artifact, not a runtime feature. It only needs to run on
|
||
the operator's machine (or the fulfillment pipeline), not on the device itself.
|
||
|
||
Usage:
|
||
python3 generate-setup-card.py \\
|
||
--ssid 'ODS-Setup-A4F2' \\
|
||
--password 'xxxxxxxx' \\
|
||
--setup-url 'http://192.168.7.1/setup' \\
|
||
--device-name 'ods.local' \\
|
||
--serial 'DRM-2026-A4F2' \\
|
||
--output card-A4F2.png
|
||
|
||
python3 generate-setup-card.py \\
|
||
--mode factory-owner \\
|
||
--ssid 'ODS-Setup-A4F2' \\
|
||
--password 'xxxxxxxx' \\
|
||
--owner-url 'http://auth.ods-a4f2.local/magic-link/...' \\
|
||
--device-name 'ods-a4f2.local' \\
|
||
--output owner-card-A4F2.pdf
|
||
|
||
Requires: Pillow + qrcode. Imports lazily so `--help` works without them.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
# Card geometry — 4×6 inches @ 300 DPI = 1200×1800px portrait.
|
||
CARD_W = 1200
|
||
CARD_H = 1800
|
||
MARGIN = 80
|
||
|
||
# Brand-ish palette. Matches the dashboard's dark theme but printable.
|
||
COLOR_BG = (15, 15, 19) # near-black, but not pure black (prints better)
|
||
COLOR_FG = (228, 228, 231) # near-white
|
||
COLOR_ACCENT = (167, 139, 250) # purple, matches --theme-accent
|
||
COLOR_MUTED = (140, 140, 150)
|
||
|
||
|
||
def build_wifi_qr_payload(ssid: str, password: str, security: str = "WPA") -> str:
|
||
"""Return the standard Wi-Fi join URI Android/iOS will recognize.
|
||
|
||
Format: WIFI:T:<security>;S:<ssid>;P:<password>;H:false;;
|
||
|
||
Special characters in SSID/password must be escaped (\\:, \\;, \\\\, \\").
|
||
"""
|
||
def esc(s: str) -> str:
|
||
return (
|
||
s.replace("\\", "\\\\")
|
||
.replace(";", "\\;")
|
||
.replace(",", "\\,")
|
||
.replace(":", "\\:")
|
||
.replace('"', '\\"')
|
||
)
|
||
|
||
effective_security = "nopass" if not password else security
|
||
payload = f"WIFI:T:{effective_security};S:{esc(ssid)};"
|
||
if effective_security != "nopass" and password:
|
||
payload += f"P:{esc(password)};"
|
||
payload += "H:false;;"
|
||
return payload
|
||
|
||
|
||
def render_qr(text: str, target_px: int):
|
||
"""Return a Pillow Image of the QR sized to ~target_px x target_px."""
|
||
import qrcode # noqa: PLC0415 — lazy import keeps --help fast
|
||
from PIL import Image # noqa: PLC0415
|
||
from qrcode.constants import ERROR_CORRECT_M
|
||
|
||
# ERROR_CORRECT_M handles ~15% damage which is fine for a printed card.
|
||
# box_size is the pixel size of each "module" (QR cell); we scale up.
|
||
qr = qrcode.QRCode(
|
||
version=None,
|
||
error_correction=ERROR_CORRECT_M,
|
||
box_size=10,
|
||
# Keep the spec-recommended four-module quiet zone. The QR sits on a
|
||
# dark card background, so this white margin is the only separator a
|
||
# scanner sees at the code edge.
|
||
border=4,
|
||
)
|
||
qr.add_data(text)
|
||
qr.make(fit=True)
|
||
img = qr.make_image(fill_color="black", back_color="white").convert("RGB")
|
||
# qrcode picks the cell size; final image dimensions vary by data length.
|
||
# We rescale to the target so the card layout is predictable.
|
||
#
|
||
# NEAREST is critical: the default resampling (BICUBIC) antialiases the
|
||
# cell edges, which turns the pure black/white QR into ~190 grayscale
|
||
# colors. That still scans in many cases, but printed cards must be
|
||
# crisp — a phone camera in suboptimal lighting can fail on the
|
||
# antialiased version. NEAREST keeps every pixel pure black or pure
|
||
# white, matching the source modules exactly.
|
||
return img.resize((target_px, target_px), resample=Image.Resampling.NEAREST)
|
||
|
||
|
||
def render_card(
|
||
ssid: str,
|
||
password: str,
|
||
setup_url: str | None,
|
||
device_name: str,
|
||
security: str = "WPA",
|
||
serial: str | None = None,
|
||
mode: str = "setup",
|
||
owner_url: str | None = None,
|
||
):
|
||
"""Compose the full card image. Returns a Pillow Image."""
|
||
from PIL import Image, ImageDraw # noqa: PLC0415
|
||
|
||
if mode not in {"setup", "factory-owner"}:
|
||
raise ValueError("mode must be setup or factory-owner")
|
||
right_url = owner_url if mode == "factory-owner" else setup_url
|
||
if not right_url:
|
||
raise ValueError("--owner-url is required for factory-owner mode" if mode == "factory-owner" else "--setup-url is required")
|
||
right_caption = "2. OPEN ODS TALK" if mode == "factory-owner" else "2. OPEN SETUP"
|
||
tagline = "Scan to join. Scan to talk." if mode == "factory-owner" else "Scan to set up. Scan to chat."
|
||
fallback_url_label = "owner url" if mode == "factory-owner" else "then visit"
|
||
|
||
card = Image.new("RGB", (CARD_W, CARD_H), COLOR_BG)
|
||
draw = ImageDraw.Draw(card)
|
||
|
||
title_font = _load_font(size=80, bold=True)
|
||
heading_font = _load_font(size=42, bold=True)
|
||
body_font = _load_font(size=32)
|
||
small_font = _load_font(size=24)
|
||
# Note: the monospace value font is no longer eagerly loaded — the
|
||
# password-overflow fix (auto-shrinking via _fit_font_to_width) picks
|
||
# a size per-row instead of using a single fixed mono_font.
|
||
|
||
# --- Header band ---
|
||
draw.text(
|
||
(MARGIN, MARGIN),
|
||
"ODS",
|
||
font=title_font,
|
||
fill=COLOR_ACCENT,
|
||
)
|
||
draw.text(
|
||
(MARGIN, MARGIN + 100),
|
||
device_name,
|
||
font=heading_font,
|
||
fill=COLOR_FG,
|
||
)
|
||
draw.text(
|
||
(MARGIN, MARGIN + 160),
|
||
tagline,
|
||
font=body_font,
|
||
fill=COLOR_MUTED,
|
||
)
|
||
|
||
# --- QR pair ---
|
||
qr_size = (CARD_W - MARGIN * 3) // 2 # two QRs + margin between
|
||
qr_y = 400
|
||
wifi_qr = render_qr(build_wifi_qr_payload(ssid, password, security), qr_size)
|
||
url_qr = render_qr(right_url, qr_size)
|
||
card.paste(wifi_qr, (MARGIN, qr_y))
|
||
card.paste(url_qr, (MARGIN * 2 + qr_size, qr_y))
|
||
|
||
# QR captions
|
||
draw.text(
|
||
(MARGIN, qr_y + qr_size + 20),
|
||
"1. JOIN WI-FI",
|
||
font=heading_font,
|
||
fill=COLOR_ACCENT,
|
||
)
|
||
draw.text(
|
||
(MARGIN * 2 + qr_size, qr_y + qr_size + 20),
|
||
right_caption,
|
||
font=heading_font,
|
||
fill=COLOR_ACCENT,
|
||
)
|
||
|
||
# --- Plain-text fallback block ---
|
||
fallback_y = qr_y + qr_size + 130
|
||
draw.text(
|
||
(MARGIN, fallback_y),
|
||
"if a QR won't scan:",
|
||
font=small_font,
|
||
fill=COLOR_MUTED,
|
||
)
|
||
|
||
rows = [
|
||
("network", ssid),
|
||
("password", password if password else "(open)"),
|
||
(fallback_url_label, right_url),
|
||
]
|
||
# The value column starts at x=MARGIN+240 and must fit within the right
|
||
# margin (CARD_W - MARGIN). Anything wider gets shrunk to fit OR wrapped
|
||
# across lines. Max-length WPA2 passwords (63 chars) and long mDNS URLs
|
||
# both blow past the default mono font width otherwise — and a setup
|
||
# card where the password runs off the right edge defeats the point of
|
||
# having a fallback block.
|
||
value_x = MARGIN + 240
|
||
value_max_width = CARD_W - MARGIN - value_x # pixels available
|
||
row_y = fallback_y + 50
|
||
for label, value in rows:
|
||
draw.text((MARGIN, row_y), label.upper(), font=small_font, fill=COLOR_MUTED)
|
||
value_font = _fit_font_to_width(draw, value, value_max_width, base_size=36, min_size=18, monospace=True)
|
||
draw.text(
|
||
(value_x, row_y - 6),
|
||
value,
|
||
font=value_font,
|
||
fill=COLOR_FG,
|
||
)
|
||
row_y += 70
|
||
|
||
# --- Footer / serial ---
|
||
footer_y = CARD_H - MARGIN - 30
|
||
draw.text(
|
||
(MARGIN, footer_y),
|
||
"ODS is open-source — light-heart-labs.com",
|
||
font=small_font,
|
||
fill=COLOR_MUTED,
|
||
)
|
||
if serial:
|
||
bbox = draw.textbbox((0, 0), serial, font=small_font)
|
||
text_w = bbox[2] - bbox[0]
|
||
draw.text(
|
||
(CARD_W - MARGIN - text_w, footer_y),
|
||
serial,
|
||
font=small_font,
|
||
fill=COLOR_MUTED,
|
||
)
|
||
|
||
return card
|
||
|
||
|
||
def _fit_font_to_width(draw, text: str, max_width: int, base_size: int = 36,
|
||
min_size: int = 18, monospace: bool = False):
|
||
"""Return the largest font (at or below ``base_size``) whose ``text``
|
||
measures ``<= max_width`` pixels. Floors at ``min_size`` even if the
|
||
text is still wider, on the principle that a readable
|
||
fallback is more useful than a value clipped to the next-line.
|
||
|
||
Needed for the password row — WPA2 supports up to 63 characters, and
|
||
a 36-pt monospace render of that is wider than the available column.
|
||
Without auto-shrink, the value runs off the right edge of the card.
|
||
"""
|
||
for size in range(base_size, min_size - 1, -2):
|
||
font = _load_font(size=size, monospace=monospace)
|
||
try:
|
||
bbox = draw.textbbox((0, 0), text, font=font)
|
||
width = bbox[2] - bbox[0]
|
||
except (AttributeError, OSError):
|
||
# Pillow's fallback bitmap font doesn't honor textbbox cleanly
|
||
# on every platform — assume it fits and return base size.
|
||
return font
|
||
if width <= max_width:
|
||
return font
|
||
# Floor: return the smallest size and accept the overflow as a last resort.
|
||
return _load_font(size=min_size, monospace=monospace)
|
||
|
||
|
||
def _load_font(size: int, bold: bool = False, monospace: bool = False):
|
||
"""Best-effort font loader. Falls back to Pillow's default bitmap font
|
||
if no truetype font is available — the card still renders, just less
|
||
pretty. The card is meant to be printed, so we look for common system
|
||
fonts first.
|
||
"""
|
||
from PIL import ImageFont # noqa: PLC0415
|
||
|
||
candidates: list[str] = []
|
||
if monospace:
|
||
candidates += [
|
||
"C:\\Windows\\Fonts\\consola.ttf", # Windows Consolas
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||
"/System/Library/Fonts/Menlo.ttc",
|
||
]
|
||
elif bold:
|
||
candidates += [
|
||
"C:\\Windows\\Fonts\\arialbd.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||
"/System/Library/Fonts/Helvetica.ttc",
|
||
]
|
||
else:
|
||
candidates += [
|
||
"C:\\Windows\\Fonts\\arial.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||
"/System/Library/Fonts/Helvetica.ttc",
|
||
]
|
||
|
||
for path in candidates:
|
||
if Path(path).exists():
|
||
try:
|
||
return ImageFont.truetype(path, size=size)
|
||
except OSError:
|
||
continue
|
||
return ImageFont.load_default()
|
||
|
||
|
||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="Generate a printable setup or factory-owner card for a ODS unit.",
|
||
)
|
||
parser.add_argument("--mode", default="setup", choices=["setup", "factory-owner"],
|
||
help="Card mode. setup prints Wi-Fi + setup URL; factory-owner prints Wi-Fi + owner ODS Talk QR")
|
||
parser.add_argument("--ssid", required=True, help="Wi-Fi SSID of the device's setup AP")
|
||
parser.add_argument("--password", default="", help="Wi-Fi password (empty for open network)")
|
||
parser.add_argument("--security", default="WPA", choices=["WPA", "WEP", "nopass"],
|
||
help="Wi-Fi security type (default WPA)")
|
||
parser.add_argument("--setup-url", default=None,
|
||
help="URL to open after joining the AP (e.g. http://192.168.7.1/setup)")
|
||
parser.add_argument("--owner-url", default=None,
|
||
help="Owner magic-link URL for factory-owner cards")
|
||
parser.add_argument("--device-name", default="ods.local",
|
||
help="The mDNS name printed on the card (default ods.local)")
|
||
parser.add_argument("--serial", default=None,
|
||
help="Optional serial / batch identifier printed in the footer")
|
||
parser.add_argument("--format", choices=["png", "pdf"], default=None,
|
||
help="Output format. Defaults to PNG unless the output path ends in .pdf")
|
||
parser.add_argument("--output", "-o", required=True,
|
||
help="Output path")
|
||
return parser.parse_args(argv)
|
||
|
||
|
||
def main(argv: list[str] | None = None) -> int:
|
||
args = parse_args(argv)
|
||
|
||
if args.security == "nopass" and args.password:
|
||
print("error: --security nopass cannot be combined with --password", file=sys.stderr)
|
||
return 2
|
||
if args.mode == "setup" and not args.setup_url:
|
||
print("error: --setup-url is required in setup mode", file=sys.stderr)
|
||
return 2
|
||
if args.mode == "factory-owner" and not args.owner_url:
|
||
print("error: --owner-url is required in factory-owner mode", file=sys.stderr)
|
||
return 2
|
||
|
||
try:
|
||
import PIL # noqa: F401, PLC0415
|
||
import qrcode # noqa: F401, PLC0415
|
||
except ImportError as exc:
|
||
print(f"error: missing dependency: {exc.name}. "
|
||
"Install with: pip install 'qrcode[pil]'", file=sys.stderr)
|
||
return 2
|
||
|
||
card = render_card(
|
||
ssid=args.ssid,
|
||
password=args.password,
|
||
setup_url=args.setup_url,
|
||
device_name=args.device_name,
|
||
security=args.security,
|
||
serial=args.serial,
|
||
mode=args.mode,
|
||
owner_url=args.owner_url,
|
||
)
|
||
|
||
out_path = Path(args.output)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_format = args.format or ("pdf" if out_path.suffix.lower() == ".pdf" else "png")
|
||
if out_format == "pdf":
|
||
card.save(out_path, format="PDF", resolution=300.0)
|
||
else:
|
||
card.save(out_path, format="PNG", dpi=(300, 300))
|
||
print(f"wrote {out_path} ({CARD_W}×{CARD_H} @ 300 DPI = 4×6 inches)")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|