Implement automatic page scaling feature with viewport adjustments

This commit is contained in:
nearxos
2026-02-18 09:33:44 +02:00
parent a9b3726ace
commit d6b09cdd6f
35 changed files with 5722 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
# When reTerminal (CM4) is connected in USB boot mode (eMMC disable jumper),
# Raspberry Pi Foundation USB device appears (vendor 2b8e). Trigger auto-flash.
# Install: sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
# sudo udevadm control --reload-rules
# The trigger script starts the actual flash via systemd so udev does not block.
SUBSYSTEM=="usb", ATTR{idVendor}=="2b8e", ACTION=="add", \
RUN+="/usr/local/bin/cm4-flash-trigger.sh"

View File

@@ -0,0 +1,165 @@
# Automatic eMMC provisioning for reTerminal DM4 (CM4)
This guide covers:
1. **Auto-flash**: When the reTerminal is switched to boot mode (eMMC disable jumper) and connected via USB to a provisioning host, the host automatically deploys a golden image to the CM4 eMMC.
2. **Backup**: When a device is detected (USB or network), the dashboard asks you to choose **Backup** or **Deploy**. Backup saves the device eMMC to a timestamped file in `backups/`.
3. **Network**: If the device boots over the network and runs the **provisioning client** (see `network-client/`), it registers with the dashboard and appears as "Device (Network)"; you then choose Backup or Deploy. Deploy streams the golden image to the device; Backup uploads the device eMMC to the server.
4. **Cloud-init**: The golden image includes cloud-init so each device configures itself on first boot (hostname, network, packages, kiosk setup).
---
## Part 1: Auto-flash when reTerminal is in boot mode
### How it works
- reTerminal has an **eMMC disable** jumper (see reTerminal docs; often “J2” or “nRPIBOOT”). When the jumper is fitted, the CM4 boots in **USB device mode** and waits for `rpiboot` from the host.
- You connect the reTerminals **USB slave** port to a **provisioning PC** (Linux).
- **udev** detects the Raspberry Pi Foundation USB device (vendor `2b8e`) and runs a trigger script.
- The trigger starts a **flash job** that:
1. Runs **rpiboot** (from the `usbboot` project). The CM4 then exposes its eMMC as a USB mass-storage device.
2. After `rpiboot` exits, finds the new block device (eMMC) and writes your **golden image** to it with `dd`.
- You remove the jumper and power cycle; the reTerminal boots from eMMC and runs **cloud-init** on first boot.
### Provisioning host setup (Linux)
#### 1. Build and install usbboot (rpiboot)
```bash
sudo apt-get install -y libusb-1.0-0-dev
git clone --depth=1 https://github.com/raspberrypi/usbboot
cd usbboot
make
sudo mkdir -p /opt/usbboot
sudo cp rpiboot /opt/usbboot/
```
#### 2. Create golden image and config directory
- Build your golden image (see Part 2) and place it where the script will find it, e.g.:
```bash
sudo mkdir -p /var/lib/cm4-provisioning
sudo cp /path/to/your/golden-reterminal.img /var/lib/cm4-provisioning/golden.img
```
- Or use a different path and set `GOLDEN_IMAGE` when installing the script (see below).
#### 3. Install the auto-flash script and trigger
```bash
# From this repo (chromium-setup/emmc-provisioning/)
SCRIPT_DIR="$(pwd)"
sudo mkdir -p /opt/cm4-provisioning
sudo cp flash-emmc-on-connect.sh /opt/cm4-provisioning/
sudo chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
# Optional: override paths via environment (create env file)
echo 'GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img' | sudo tee /opt/cm4-provisioning/env
echo 'RPIBOOT_DIR=/opt/usbboot' | sudo tee -a /opt/cm4-provisioning/env
echo 'EMMC_SIZE_BYTES=8589934592' | sudo tee -a /opt/cm4-provisioning/env # 8GB; use 17179869184 for 16GB
sudo cp cm4-flash-trigger.sh /usr/local/bin/
sudo chmod +x /usr/local/bin/cm4-flash-trigger.sh
```
If your golden image path or rpiboot path is different, set `GOLDEN_IMAGE`, `RPIBOOT_DIR`, and optionally `EMMC_SIZE_BYTES` in `/opt/cm4-provisioning/env` and source it from the script, or pass them into the systemd-run call in the trigger (e.g. by making the trigger source the env file and export variables before `systemd-run`).
#### 4. Install udev rule
```bash
sudo cp 90-cm4-boot-mode.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger
```
#### 5. Enable provisioning (safety)
Provisioning runs only if the “enabled” file exists:
```bash
sudo mkdir -p /etc/cm4-provisioning
sudo touch /etc/cm4-provisioning/enabled
```
To disable auto-flash, remove that file: `sudo rm /etc/cm4-provisioning/enabled`.
#### 6. Optional: pass environment into the flash job
If you use `/opt/cm4-provisioning/env`, update the trigger so the flash script sees those variables. For example change `/usr/local/bin/cm4-flash-trigger.sh` to:
```bash
#!/usr/bin/env bash
set -a
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
set +a
export GOLDEN_IMAGE RPIBOOT_DIR EMMC_SIZE_BYTES
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
exec systemd-run --no-block --unit=cm4-flash-once --property=Environment="GOLDEN_IMAGE=$GOLDEN_IMAGE" ...
```
Or keep it simple and edit the defaults inside `flash-emmc-on-connect.sh` (e.g. `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`).
### Usage
1. Fit the **eMMC disable** jumper on the reTerminal.
2. Connect the reTerminal **USB slave** port to the provisioning PC.
3. Power the reTerminal (or apply power after USB).
4. On the host, `rpiboot` will run automatically; when it exits, the script will `dd` the golden image to the eMMC. Watch logs: `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f`.
5. When done, remove the jumper and power cycle the reTerminal. It will boot from eMMC; cloud-init will run on first boot.
---
## Part 2: Golden image with cloud-init
Raspberry Pi OS (recent versions) supports **cloud-init** using the **NoCloud** datasource: it reads `user-data`, `meta-data`, and optionally `network-config` from the **boot** (FAT32) partition.
### Creating the golden image
1. **Flash Raspberry Pi OS** (or your base image) to a spare SD card or a loop file.
2. **Mount the boot partition** (first partition, FAT32). On the image file it might be at an offset; use `losetup -P` or mount the SDs partition.
3. **Add cloud-init NoCloud files** on the boot partition (same level as `config.txt`, not in a subfolder for default NoCloud):
- `user-data` main config (packages, runcmd, etc.)
- `meta-data` optional (instance-id, local-hostname)
- `network-config` optional (network config in netplan format)
You can use the examples in this repo:
```bash
# After mounting boot partition at e.g. /mnt/boot
# (On Raspberry Pi OS, boot is often /boot/firmware on the running system, or the first FAT partition of the image)
cp emmc-provisioning/cloud-init/user-data /mnt/boot/
cp emmc-provisioning/cloud-init/meta-data /mnt/boot/
cp emmc-provisioning/cloud-init/network-config /mnt/boot/
```
4. **Customise** `user-data` and `network-config` (hostname, WiFi, packages, Chromium kiosk, etc.).
5. **Copy your kiosk/Chromium scripts** into the image rootfs if needed (e.g. under `/home/pi/` or `/opt/`) and reference them from `user-data` `runcmd` or a systemd unit.
6. **Unmount**, then create a **golden image** from the SD or loop device (e.g. `dd` or `dd` of the whole block device). Use that as `golden.img` on the provisioning host.
### Cloud-init file locations on the Pi
- **NoCloud**: Boot partition root `user-data`, `meta-data`, `network-config`.
- Some images expect them in a subfolder `cloud-init/` or on a separate vfat partition labeled `cidata`; check your OS docs. Standard Raspberry Pi OS NoCloud uses the boot partition root.
### Per-device config (optional)
NoCloud can also use a **seed** partition or **config drive**. For per-device hostname/settings you can:
- Use **meta-data** `instance-id` and `local-hostname` and generate different `meta-data` per device when imaging (e.g. script that writes `meta-data` before flashing), or
- Use a first-boot script that calls a provisioning server (e.g. by serial number) and applies device-specific config; cloud-init can launch that script from `runcmd`.
---
## Summary
| Step | Action |
|------|--------|
| 1 | Build `usbboot`, install `rpiboot` on provisioning host. |
| 2 | Create golden image with cloud-init `user-data`, `meta-data`, `network-config` on boot partition. |
| 3 | Install `flash-emmc-on-connect.sh`, `cm4-flash-trigger.sh`, and udev rule; set `GOLDEN_IMAGE` and enable file. |
| 4 | Put reTerminal in boot mode (jumper), connect USB to host; image is written automatically. |
| 5 | Remove jumper, power cycle; device boots from eMMC and cloud-init runs on first boot. |
This gives you automatic deployment of the golden image to eMMC when the reTerminal is in boot mode, plus first-boot configuration via cloud-init.

View File

@@ -0,0 +1,292 @@
# Portal Styling Guide (Template)
Use this document when building new portals so they match the visual and UX style of the reference portal (e.g. FreePBX / TM VOIP Extensions Portal). Replace placeholders like `[Portal Name]` with your portals name where relevant.
---
## 1. Design philosophy
- **Dark theme:** Dark backgrounds with light text; no light-mode variant in this guide.
- **Accent:** Single accent (teal/cyan gradient) for primary actions, links, and highlights.
- **Clarity:** Clear hierarchy (cards, sections, labels), consistent spacing, readable typography.
- **Consistency:** Same tokens, components, and patterns across all pages (login, app, modals).
---
## 2. Design tokens (CSS variables)
Define these in `:root` (or in a shared CSS file) and use them everywhere instead of hard-coded colors.
### Colors
| Token | Value | Usage |
|-------|--------|--------|
| `--bg-primary` | `#0a0e14` | Page background |
| `--bg-secondary` | `#11151c` | Header, secondary surfaces |
| `--bg-tertiary` | `#1a1f2b` | Inputs, table header, hover states |
| `--bg-card` | `#151a24` | Cards, modals |
| `--accent-primary` | `#00d4aa` | Primary accent (teal) |
| `--accent-secondary` | `#00b894` | Accent variant, secondary accent |
| `--accent-glow` | `rgba(0, 212, 170, 0.15)` | Focus rings, subtle highlights |
| `--text-primary` | `#e6e8eb` | Main text |
| `--text-secondary` | `#8b949e` | Labels, secondary text |
| `--text-muted` | `#5c6370` | Placeholders, disabled, hints |
| `--border-color` | `#2d333b` | Borders (cards, inputs, tables) |
| `--danger` | `#ff6b6b` | Errors, delete, destructive actions |
| `--danger-glow` | `rgba(255, 107, 107, 0.15)` | Danger focus/hover background |
| `--warning` | `#ffd93d` | Warnings |
| `--success` | `#00d4aa` | Success (can match accent) |
| `--gradient-accent` | `linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%)` | Primary buttons, logo text |
### Example `:root` block
```css
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-tertiary: #1a1f2b;
--bg-card: #151a24;
--accent-primary: #00d4aa;
--accent-secondary: #00b894;
--accent-glow: rgba(0, 212, 170, 0.15);
--text-primary: #e6e8eb;
--text-secondary: #8b949e;
--text-muted: #5c6370;
--border-color: #2d333b;
--danger: #ff6b6b;
--danger-glow: rgba(255, 107, 107, 0.15);
--warning: #ffd93d;
--success: #00d4aa;
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
}
```
---
## 3. Typography
- **Body / UI font:** `'Outfit', -apple-system, BlinkMacSystemFont, sans-serif`
- **Monospace (data, code, IDs):** `'JetBrains Mono', monospace`
Load from Google Fonts:
```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
```
- **Body:** `color: var(--text-primary);` `line-height: 1.6;`
- **Labels:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional `text-transform: uppercase;` `letter-spacing: 0.5px;`
- **Card/section titles:** `font-size: 1.1rem;` `font-weight: 600;`
- **Table header:** `font-size: 0.75rem0.8rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;` `color: var(--text-secondary);`
- **Table body:** `font-size: 0.9rem;` monospace for IDs/codes
---
## 4. Page layout
### Global
- **Reset:** `* { margin: 0; padding: 0; box-sizing: border-box; }`
- **Body:** `background: var(--bg-primary);` `color: var(--text-primary);` `min-height: 100vh;` `font-family: 'Outfit', ...`
### Background treatment (optional)
Subtle gradient overlay for depth:
```css
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
pointer-events: none;
z-index: -1;
}
```
### Header (fixed)
- **Container:** `background: var(--bg-secondary);` `border-bottom: 1px solid var(--border-color);` `position: fixed; top: 0; left: 0; right: 0; z-index: 1000;` optional `backdrop-filter: blur(10px);`
- **Top row:** Logo left; status/user/actions right; `padding: 1rem 2rem;` `display: flex; align-items: center; justify-content: space-between;`
- **Tabs row:** Under the top row; `background: var(--bg-tertiary);` `padding: 0.5rem 2rem;` `border-top: 1px solid var(--border-color);` horizontal flex, gap, overflow-x auto for small screens
### Main content
- **Container:** `max-width: 1400px;` `margin: 0 auto;` `padding: 2rem;` `padding-top: calc(2rem + 140px);` (offset for fixed header + tabs). On mobile reduce padding and increase top offset if header stacks.
---
## 5. Logo
- **Wrapper:** flex, `align-items: center;` `gap: 0.75rem;`
- **Icon:** Square (e.g. 40×40px), `background: var(--gradient-accent);` `border-radius: 10px;` optional `box-shadow: 0 4px 20px var(--accent-glow);` emoji or icon inside.
- **Title (h1):** `font-size: 1.5rem;` `font-weight: 600;` `background: var(--gradient-accent);` `-webkit-background-clip: text;` `background-clip: text;` `-webkit-text-fill-color: transparent;`
---
## 6. Tabs (main navigation)
- **Tab button (default):** `padding: 0.75rem 1.5rem;` `background: transparent;` `border: none;` `color: var(--text-secondary);` `font-size: 0.95rem;` `font-weight: 500;` `border-radius: 8px;` flex with icon + label, `gap: 0.5rem;`
- **Hover:** `color: var(--text-primary);` `background: var(--bg-tertiary);`
- **Active:** `background: var(--gradient-accent);` `color: var(--bg-primary);`
- **Tab content:** `display: none;` by default; `.tab-content.active { display: block; }` optional fade-in animation.
---
## 7. Cards
- **Base:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 1.5rem;` `margin-bottom: 1.5rem;`
- **Card header:** flex, `justify-content: space-between;` `align-items: center;` `margin-bottom: 1.5rem;` `padding-bottom: 1rem;` `border-bottom: 1px solid var(--border-color);`
- **Card title:** `font-size: 1.1rem;` `font-weight: 600;` flex with icon + text, `gap: 0.5rem;`
---
## 8. Buttons
- **Base:** `padding: 0.75rem 1.5rem;` `border-radius: 8px;` `font-size: 0.9rem;` `font-weight: 500;` inline-flex, `align-items: center;` `gap: 0.5rem;` `transition: all 0.2s ease;`
- **Primary:** `background: var(--gradient-accent);` `color: var(--bg-primary);` `border: none;` hover: slight `translateY(-2px);` `box-shadow: 0 4px 20px var(--accent-glow);`
- **Secondary:** `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `color: var(--text-primary);` hover: `border-color: var(--accent-primary);`
- **Danger:** `background: transparent;` `border: 1px solid var(--danger);` `color: var(--danger);` hover: `background: var(--danger);` `color: white;`
- **Disabled:** `opacity: 0.5;` `cursor: not-allowed;`
- **Icon-only (small):** e.g. 28×28px, `border-radius: 6px;` same semantic colors (e.g. `.btn-remove` with `--danger`).
---
## 9. Forms
- **Grid:** `display: grid;` `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));` `gap: 1rem;`
- **Form group:** flex column, `gap: 0.5rem;`
- **Label:** `font-size: 0.85rem;` `font-weight: 500;` `color: var(--text-secondary);` optional uppercase + letter-spacing
- **Input / select:** `padding: 0.75rem 1rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 8px;` `color: var(--text-primary);` `font-size: 0.9rem;` monospace for IDs/codes
- **Focus:** `outline: none;` `border-color: var(--accent-primary);` `box-shadow: 0 0 0 3px var(--accent-glow);`
- **Placeholder:** `color: var(--text-muted);`
- **Read-only:** `background: var(--bg-secondary);` `cursor: default;`
- **Checkbox:** `accent-color: var(--accent-primary);` (or custom size e.g. 18×18px)
- **Password field:** wrapper with toggle button; input `padding-right: 3rem;` so toggle doesnt overlap text.
---
## 10. Tables
- **Container:** `overflow-x: auto;` `border-radius: 12px;` `border: 1px solid var(--border-color);`
- **Table:** `width: 100%;` `border-collapse: collapse;`
- **th / td:** `padding: 0.6rem 0.75rem;` `text-align: left;` `border-bottom: 1px solid var(--border-color);` `color: var(--text-primary);`
- **th:** `background: var(--bg-tertiary);` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` `letter-spacing: 0.5px;`
- **tbody tr hover:** `background-color: var(--bg-tertiary);`
- **Last row:** `tr:last-child td { border-bottom: none; }`
- **Data cells:** `font-family: 'JetBrains Mono', monospace;` `font-size: 0.9rem;`
- **Actions column:** right-aligned; min-width for action buttons; `.table-actions { display: flex; gap: 1rem; flex-wrap: wrap; }`
- **Data table variant:** `.data-table` with alternating row background (e.g. `nth-child(even)` subtle `rgba(255,255,255,0.02)`) and same hover.
---
## 11. Badges and status
- **Status badge (e.g. connection):** flex, `align-items: center;` `gap: 0.5rem;` `padding: 0.5rem 1rem;` `background: var(--bg-tertiary);` `border-radius: 20px;` `font-size: 0.85rem;` `border: 1px solid var(--border-color);`
- **Status dot:** 8×8px circle; `.connected { background: var(--success); }` `.error { background: var(--danger); }` optional pulse animation
- **Pill badge (e.g. extension ID):** `padding: 0.25rem 0.75rem;` `background: var(--accent-glow);` `color: var(--accent-primary);` `border-radius: 20px;` `font-weight: 500;`
- **Tech badge (e.g. PJSIP/SIP):** small, `border-radius: 4px;` `font-size: 0.75rem;` `font-weight: 600;` `text-transform: uppercase;` distinct colors per type (e.g. PJSIP blue, SIP purple)
---
## 12. Search and filters
- **Search box:** wrapper relative; input `padding: 0.6rem 1rem 0.6rem 2.5rem;` `width: 250px;` same colors as form inputs; optional `::before` search icon (e.g. 🔍) `left: 0.75rem;`
- **Filter checkbox:** inline-flex, `align-items: center;` `gap: 0.4rem;` `color: var(--text-secondary);` `accent-color: var(--accent-primary);`
---
## 13. Empty and loading states
- **Empty state:** `text-align: center;` `padding: 3rem;` `color: var(--text-muted);`
- **No results:** same idea, `padding: 2rem;`
- **Loading:** flex center, `padding: 2rem;` spinner (e.g. 32×32px border, `border-top-color: var(--accent-primary);` `animation: spin 1s linear infinite;`)
---
## 14. Modals
- **Overlay:** `position: fixed;` `inset: 0;` `background: rgba(0,0,0,0.7);` `z-index: 1000;` flex center; `display: none;` `.active { display: flex; }`
- **Dialog:** `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 16px;` `padding: 2rem;` `max-width: 500px;` `width: 90%;` optional scale-in animation
- **Modal header:** flex, `align-items: center;` `gap: 1rem;` `margin-bottom: 1.5rem;`
- **Modal icon:** e.g. 48×48px, `background: var(--accent-glow);` `border-radius: 12px;` centered content
- **Modal title:** `font-size: 1.25rem;` `font-weight: 600;`
- **Detail sections:** `margin-bottom: 2rem;` section title `font-size: 1.1rem;` `color: var(--accent-primary);` `border-bottom: 1px solid var(--border-color);` `padding-bottom: 0.5rem;`
- **Details grid:** `grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));` `gap: 1rem;` each item: label (small, secondary) + value (primary)
---
## 15. Toasts (notifications)
- **Container:** `position: fixed;` `top: 1rem;` `right: 1rem;` `z-index: 1100;` flex column, `gap: 0.5rem;`
- **Toast:** `padding: 1rem 1.5rem;` `background: var(--bg-card);` `border: 1px solid var(--border-color);` `border-radius: 8px;` flex, `align-items: center;` `gap: 0.75rem;` `max-width: 400px;` slide-in animation
- **Success:** `border-left: 3px solid var(--success);`
- **Error:** `border-left: 3px solid var(--danger);`
---
## 16. Pagination
- **Wrapper:** flex, `justify-content: center;` `align-items: center;` `gap: 0.5rem;` `padding: 1rem;` `border-top: 1px solid var(--border-color);`
- **Button:** `padding: 0.5rem 0.75rem;` `background: var(--bg-tertiary);` `border: 1px solid var(--border-color);` `border-radius: 6px;` `color: var(--text-primary);` `font-size: 0.85rem;` `min-width: 36px;`
- **Hover:** `background: var(--accent-glow);` `border-color: var(--accent-primary);` `color: var(--accent-primary);`
- **Active page:** `background: var(--accent-primary);` `color: var(--bg-primary);`
- **Disabled:** `opacity: 0.4;` `cursor: not-allowed;`
- **Info text:** `color: var(--text-secondary);` `font-size: 0.85rem;` between prev/next
---
## 17. Login page
- **Layout:** full viewport, flex center; `padding: 2rem;`
- **Card:** same tokens as app cards; `max-width: 400px;` `padding: 2.5rem;` `border-radius: 16px;` `box-shadow: 0 8px 32px rgba(0,0,0,0.3);`
- **Logo:** centered; icon (e.g. 64×64px) with gradient + glow; title with gradient text
- **Form:** same form-group and input styles as app; full-width primary submit button
- **Error message:** `color: var(--danger);` `font-size: 0.9rem;` above or below form
- Use the same `:root` variables and body background so login and app feel like one product.
---
## 18. Responsive
- **Breakpoint:** e.g. `@media (max-width: 768px)`
- **Header top:** `flex-direction: column;` `gap: 1rem;` `text-align: center;` reduce padding
- **Tabs:** reduce horizontal padding; allow horizontal scroll if needed
- **Container:** reduce padding; increase `padding-top` if header height grows (e.g. `calc(1rem + 180px)`)
- **Form grid:** `grid-template-columns: 1fr;` for single column on small screens
---
## 19. Checklist for a new portal
- [ ] Copy or recreate the `:root` design tokens.
- [ ] Load **Outfit** and **JetBrains Mono** (or same weights).
- [ ] Use the same header structure: logo + status/user + actions, then tabs.
- [ ] Use `.card`, `.card-header`, `.card-title` for sections.
- [ ] Use `.btn`, `.btn-primary`, `.btn-secondary`, `.btn-danger` for actions.
- [ ] Use `.form-grid`, `.form-group`, and input/select styles for forms.
- [ ] Use `.table-container`, table, `.data-table` and th/td styles for lists.
- [ ] Use same modal overlay/dialog and toast styles.
- [ ] Use same empty, loading, and error states.
- [ ] Apply same login page layout and token usage.
- [ ] Test at 768px width for basic responsive behavior.
- [ ] Replace [Portal Name] and any product-specific labels in this guide for your portal.
---
## 20. Reference files (this repo)
| File | Purpose |
|------|--------|
| `static/css/main.css` | Full implementation of tokens, layout, components |
| `app/templates/base.html` | App shell: fonts, header, tabs, container, toasts |
| `app/templates/login.html` | Login layout and inline tokens (can be moved to main.css) |
| `app/templates/tabs/_dashboard.html` | Example: cards, stats, table container |
| `app/templates/tabs/_users.html` | Example: card, form-grid, form-group, buttons, table |
For a new portal, you can copy `main.css` and adapt it (e.g. change `:root` if you need a different accent), then build your base template and pages to use the same class names and structure described above.

View File

@@ -0,0 +1,146 @@
# CM4 eMMC provisioning on Proxmox (LXC + host)
The auto-flash **runs on the Proxmox host** (where the USB device appears). The **LXC** holds the same scripts and shares the **golden image** directory with the host so you can manage the image from the container.
## What is deployed
| Where | What |
|-------|-----|
| **Proxmox host** | udev rule, trigger script, flash script, rpiboot (after you run the install script), `/var/lib/cm4-provisioning/` (golden image dir), `/etc/cm4-provisioning/enabled` |
| **LXC 201 (cm4-provisioning)** | Same scripts in `/opt/cm4-provisioning/`, same env; `/var/lib/cm4-provisioning/` is a **bind mount** from the host (shared storage for the golden image) |
When you plug the reTerminal in boot mode into the **host**, udev on the host runs the flash (rpiboot + dd). The golden image is read from `/var/lib/cm4-provisioning/golden.img` on the host (same path visible in the LXC).
---
## Deployment that was done
1. **LXC 201** created on Proxmox `10.130.60.224`:
- Hostname: `cm4-provisioning`
- Debian 12, 1 GB RAM, 8 GB rootfs
- Bind mount: host `/var/lib/cm4-provisioning` → container `/var/lib/cm4-provisioning`
2. **On the host**:
- `/opt/cm4-provisioning/flash-emmc-on-connect.sh` flash script
- `/usr/local/bin/cm4-flash-trigger.sh` started by udev
- `/etc/udev/rules.d/90-cm4-boot-mode.rules` run trigger when USB vendor `2b8e` is added
- `/opt/cm4-provisioning/env` `GOLDEN_IMAGE`, `RPIBOOT_DIR`, `EMMC_SIZE_BYTES`
- `/etc/cm4-provisioning/enabled` safety switch (remove to disable auto-flash)
3. **Inside LXC 201**:
- Same scripts in `/opt/cm4-provisioning/` and env (for reference/backup)
- Golden image path: `/var/lib/cm4-provisioning/golden.img` (bind-mounted from host)
- **Dashboard** (optional): Flask app in `/opt/cm4-provisioning/dashboard/` to monitor deployment and show connection steps; see below.
4. **usbboot (rpiboot)** was **not** built on the host (no outbound DNS during deploy). You must install it when the host has internet.
---
## What you need to do
### 1. Build and install rpiboot on the Proxmox host (when it has internet)
On your machine (repo already synced to the host):
```bash
# From your repo
scp chromium-setup/emmc-provisioning/scripts/install-usbboot-on-host.sh root@10.130.60.224:/tmp/
ssh root@10.130.60.224 "bash /tmp/install-usbboot-on-host.sh"
```
Or on the host (if the deploy folder is still there):
```bash
ssh root@10.130.60.224
bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh
```
This installs dependencies, clones usbboot, builds it, and copies `rpiboot` to `/opt/usbboot/`.
### 2. Put the golden image on the host (or in the LXC)
The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**. Because that directory is bind-mounted into the LXC, you can use either:
- **From the host:**
```bash
scp your-golden.img root@10.130.60.224:/var/lib/cm4-provisioning/golden.img
```
- **From the LXC** (e.g. after copying the image into the container elsewhere first):
```bash
pct exec 201 -- ls -la /var/lib/cm4-provisioning/
# Copy to that path inside the container; it's the same as the host path.
```
### 3. Run the provisioning dashboard (optional)
The dashboard shows **connection steps** and **live deployment status** (idle / connecting / flashing / done / error) and a recent flash log. It reads the same `status.json` and `flash.log` that the hosts flash script writes (via the bind-mounted `/var/lib/cm4-provisioning`).
**Inside LXC 201:**
```bash
# Copy dashboard into the container (from host, if you have the repo there)
# Or from your workstation:
# rsync -a chromium-setup/emmc-provisioning/dashboard/ root@10.130.60.224:/tmp/dashboard/
# ssh root@10.130.60.224 "pct push 201 /tmp/dashboard/app.py /opt/cm4-provisioning/dashboard/ && pct push 201 /tmp/dashboard/cm4-dashboard.service /opt/cm4-provisioning/dashboard/ && pct exec 201 -- mkdir -p /opt/cm4-provisioning/dashboard/templates && ..."
# Inside the LXC (pct exec 201 -- bash):
apt-get update && apt-get install -y python3-flask
mkdir -p /opt/cm4-provisioning/dashboard/templates
# Copy app.py, templates/index.html, cm4-dashboard.service into the container (see dashboard/README.md)
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now cm4-dashboard
```
Then open **http://&lt;LXC-201-IP&gt;:5000** (get the IP with `pct exec 201 -- hostname -I`). If the LXC is on a private network, set up port forwarding on the Proxmox host or use a reverse proxy so you can reach the dashboard from your browser.
### 4. Optional: disable or enable auto-flash
- **Disable:**
`ssh root@10.130.60.224 "rm /etc/cm4-provisioning/enabled"`
- **Enable again:**
`ssh root@10.130.60.224 "touch /etc/cm4-provisioning/enabled"`
---
## Usage
1. Place the reTerminal in **boot mode** (eMMC disable jumper).
2. Connect its **USB slave** port to the **Proxmox host** (not to the LXC).
3. Power the reTerminal (or connect after power).
4. On the host, udev will run the trigger and then the flash script (rpiboot, then dd). Watch logs:
```bash
ssh root@10.130.60.224 "journalctl -u cm4-flash-once -f"
# or
ssh root@10.130.60.224 "journalctl -t cm4-flash -f"
```
5. When flashing finishes, remove the jumper and power cycle the reTerminal so it boots from eMMC.
---
## Redeploy / update scripts
From your repo (e.g. after changing scripts):
```bash
./chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.130.60.224
```
That script syncs the repo to the host and reinstalls scripts on both the host and LXC 201. It does **not** overwrite `/opt/cm4-provisioning/env` or `/etc/cm4-provisioning/enabled` if youve changed them; adjust the script if you want that. It also does **not** build usbboot; run `install-usbboot-on-host.sh` on the host when needed.
---
## Summary
| Item | Location |
|------|----------|
| LXC | 201, hostname `cm4-provisioning`, Proxmox `10.130.60.224` |
| Golden image | `/var/lib/cm4-provisioning/golden.img` (host and LXC see the same file) |
| Flash runs on | Proxmox **host** (udev + rpiboot + dd) |
| Build rpiboot on host | Run `scripts/install-usbboot-on-host.sh` on the host when it has internet |
| Dashboard | Flask app in LXC at `http://<LXC-IP>:5000`; switch Flash/Backup mode, list and download backups; see **dashboard/README.md** and section 3 above |
| Backups | Saved under `/var/lib/cm4-provisioning/backups/`. When a device is detected (USB or network), choose **Backup** or **Deploy** in the dashboard. |
| Network deploy/backup | Network-booted devices run **network-client/provisioning-client.sh** and register with the dashboard; they then appear under "Device detected (Network)" and you choose Backup or Deploy. See **network-client/README.md**. |

View File

@@ -0,0 +1,17 @@
# reTerminal DM4 eMMC auto-provisioning
Automatically flash a **golden image** to the CM4 eMMC when the reTerminal is connected in **boot mode** (eMMC disable jumper). Optional **backup** mode saves the current eMMC to a timestamped image file instead. Uses **cloud-init** for first-boot configuration.
| File | Purpose |
|------|--------|
| **EMMC-PROVISIONING-GUIDE.md** | Full setup and usage guide read this first. |
| **flash-emmc-on-connect.sh** | Script that runs `rpiboot` then either flashes the golden image to eMMC or backs up eMMC to a file (mode set via dashboard or `mode` file). |
| **cm4-flash-trigger.sh** | Called by udev when CM4 in boot mode is connected; starts the flash job. |
| **90-cm4-boot-mode.rules** | udev rule: when USB device 2b8e is added, run the trigger script. |
| **cloud-init/** | Example NoCloud files (`user-data`, `meta-data`, `network-config`) for the golden image. |
| **dashboard/** | Flask web UI: auto-detect device (USB or network), prompt **Backup or Deploy**, show status and connection steps. See **dashboard/README.md**. |
| **network-client/** | Script for network-booted devices: register with the dashboard and perform Deploy (pull image, write eMMC) or Backup (upload eMMC). See **network-client/README.md**. |
Quick start: see **EMMC-PROVISIONING-GUIDE.md**.
**Proxmox:** LXC 201 + host setup is documented in **PROXMOX-LXC-DEPLOYMENT.md**. Use **scripts/deploy-to-proxmox.sh** to deploy to a Proxmox host; flash runs on the host, golden image is in a bind-mounted dir shared with the LXC.

View File

@@ -0,0 +1,5 @@
# NoCloud meta-data: enables cloud-init. Optional instance-id for multi-device.
# Copy to the boot (FAT32) partition of your image as 'meta-data'.
instance-id: reterminal-01
# local-hostname: reterminal-01 # optional override

View File

@@ -0,0 +1,21 @@
# Cloud-init network-config (NoCloud). Copy to boot partition as 'network-config'.
# Adjust for your LAN: DHCP or static.
version: 2
ethernets:
eth0:
dhcp4: true
# eth0:
# addresses:
# - 192.168.1.100/24
# gateway4: 192.168.1.1
# nameservers:
# addresses:
# - 8.8.8.8
# Optional WiFi (uncomment and set your SSID/password)
# wlan0:
# dhcp4: true
# access-points:
# "YourSSID":
# password: "YourPassword"

View File

@@ -0,0 +1,34 @@
#cloud-config
# Cloud-init user-data for reTerminal DM4 golden image (eMMC).
# Copy to the boot (FAT32) partition of your image as 'user-data'.
# Raspberry Pi OS uses NoCloud: meta-data, user-data, network-config on boot partition.
package_update: true
package_upgrade: false
packages:
- chromium-browser
- wmctrl
# - python3-pip # uncomment if you need Flask/other apps
# Optional: set hostname from serial or leave default
# hostname: reterminal-%s # %s = first column of meta-data instance-id if set
# Optional: enable I2C/SPI for reTerminal peripherals (LED, buzzer, etc.)
# Uncomment if your image does not already enable these:
# write_files:
# - path: /boot/firmware/config.txt.d/99-reterminal.txt
# content: |
# dtparam=i2c_arm=on
# dtparam=spi=on
# Run once on first boot (e.g. copy kiosk scripts, start Chromium on boot)
runcmd:
# Example: ensure Chromium kiosk autostart
# - systemctl enable chromium-kiosk
- cloud-init single --name cc_final_message
# Power state after first boot (optional)
# power_state:
# mode: reboot
# delay: 1

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Called by udev when CM4 in boot mode is connected. Starts the flash job in the background
# so udev can return immediately. Install to /usr/local/bin/cm4-flash-trigger.sh
FLASH_SCRIPT="${CM4_FLASH_SCRIPT:-/opt/cm4-provisioning/flash-emmc-on-connect.sh}"
exec systemd-run --no-block --unit=cm4-flash-once "$FLASH_SCRIPT"

View File

@@ -0,0 +1,38 @@
# CM4 Provisioning Dashboard
Flask web UI to monitor the eMMC deployment process and show device connection steps.
- **Connection steps**: Numbered instructions for putting the reTerminal in boot mode and connecting it.
- **Live status**: Idle / Connecting (rpiboot) / Flashing / Backup / Done / Error, with optional progress.
- **Backup / Restore**: Toggle between **Flash** (deploy golden image) and **Backup** (save eMMC to a timestamped file when device is connected in boot mode). List and download saved backups.
- **Recent log**: Tail of the flash log (from the host, via the shared bind mount).
The dashboard reads `/var/lib/cm4-provisioning/status.json` and `flash.log`, which the flash script (running on the Proxmox host) updates. When the dashboard runs inside the LXC, that directory is bind-mounted from the host, so it sees the same files.
## Run locally (development)
```bash
cd dashboard
pip install flask # or use venv
python3 app.py
# Open http://localhost:5000
```
## Run in LXC (Proxmox)
1. Copy the dashboard into the container (e.g. to `/opt/cm4-provisioning/dashboard`).
2. Install Flask if needed: `apt install -y python3-flask` or `pip install flask`.
3. Install the systemd unit and enable it:
```bash
cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now cm4-dashboard
```
4. Open `http://<LXC-IP>:5000` (or port-forward from the Proxmox host).
## Environment (optional)
- `CM4_STATUS_FILE` path to status JSON (default: `/var/lib/cm4-provisioning/status.json`).
- `CM4_LOG_FILE` path to flash log (default: `/var/lib/cm4-provisioning/flash.log`).

View File

@@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""
Flask dashboard for CM4 eMMC provisioning.
Monitors deployment status, shows device connection steps, backup/restore.
Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register, then Backup/Deploy).
"""
import json
import os
import time
from pathlib import Path
from flask import Flask, render_template, jsonify, request, send_file, Response
app = Flask(__name__)
BASE_DIR = Path(os.environ.get("CM4_PROVISIONING_DIR", "/var/lib/cm4-provisioning"))
STATUS_FILE = os.environ.get("CM4_STATUS_FILE", str(BASE_DIR / "status.json"))
LOG_FILE = os.environ.get("CM4_LOG_FILE", str(BASE_DIR / "flash.log"))
ACTION_REQUEST_FILE = os.environ.get("CM4_ACTION_REQUEST_FILE", str(BASE_DIR / "action_request"))
DEVICE_SOURCE_FILE = os.environ.get("CM4_DEVICE_SOURCE_FILE", str(BASE_DIR / "device_source"))
BACKUPS_DIR = Path(os.environ.get("CM4_BACKUPS_DIR", str(BASE_DIR / "backups")))
GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.img")))
NETWORK_DEVICES_FILE = Path(os.environ.get("CM4_NETWORK_DEVICES_FILE", str(BASE_DIR / "network_devices.json")))
DEFAULT_STATUS = {
"phase": "idle",
"message": "Waiting for reTerminal in boot mode or network.",
"progress": None,
"updated": None,
}
def read_status():
try:
with open(STATUS_FILE, "r") as f:
data = json.load(f)
out = {**DEFAULT_STATUS, **data}
if out.get("phase") == "waiting_choice":
try:
with open(DEVICE_SOURCE_FILE, "r") as sf:
out["device_source"] = (sf.read() or "").strip() or "usb"
except (FileNotFoundError, OSError):
out["device_source"] = "usb"
return out
except (FileNotFoundError, json.JSONDecodeError):
return DEFAULT_STATUS
def read_log_tail(lines=50):
try:
with open(LOG_FILE, "r") as f:
all_lines = f.readlines()
return "".join(all_lines[-lines:]).strip() if all_lines else ""
except (FileNotFoundError, PermissionError):
return ""
def _load_network_devices():
try:
if NETWORK_DEVICES_FILE.is_file():
with open(NETWORK_DEVICES_FILE, "r") as f:
return json.load(f)
except (json.JSONDecodeError, OSError):
pass
return {"devices": []}
def _save_network_devices(data):
try:
os.makedirs(NETWORK_DEVICES_FILE.parent, exist_ok=True)
with open(NETWORK_DEVICES_FILE, "w") as f:
json.dump(data, f, indent=2)
return True
except (PermissionError, OSError):
return False
def list_backups():
if not BACKUPS_DIR.is_dir():
return []
out = []
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and p.suffix in (".img", ".img.gz"):
try:
st = p.stat()
out.append({"name": p.name, "size": st.st_size, "mtime": st.st_mtime})
except OSError:
pass
return out
@app.route("/")
def index():
return render_template("index.html")
@app.route("/api/status")
def api_status():
return jsonify(read_status())
@app.route("/api/log")
def api_log():
return jsonify({"log": read_log_tail()})
@app.route("/api/pending-devices")
def api_pending_devices():
"""Returns USB (if waiting_choice) and registered network devices so the UI can show Backup/Deploy."""
st = read_status()
usb = None
if st.get("phase") == "waiting_choice":
usb = {"source": "usb", "message": st.get("message", "Device connected (USB). Choose action.")}
data = _load_network_devices()
network = [d for d in data.get("devices", []) if d.get("action") in (None, "wait")]
return jsonify({"usb": usb, "network": network})
@app.route("/api/device-action", methods=["POST"])
def api_device_action():
"""User chose Backup or Deploy for a device. source=usb | network; for network pass mac=."""
body = request.get_json(force=True, silent=True) or {}
source = (body.get("source") or "").strip().lower()
action = (body.get("action") or "").strip().lower()
if action not in ("backup", "deploy"):
return jsonify({"ok": False, "error": "action must be 'backup' or 'deploy'"}), 400
if source == "usb":
try:
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True)
with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action)
return jsonify({"ok": True})
except (PermissionError, OSError):
return jsonify({"ok": False, "error": "Could not write action file"}), 500
if source == "network":
mac = (body.get("mac") or "").strip()
if not mac:
return jsonify({"ok": False, "error": "mac required for network device"}), 400
data = _load_network_devices()
for d in data.get("devices", []):
if (d.get("mac") or "").lower() == mac.lower():
d["action"] = action
d["action_at"] = time.time()
_save_network_devices(data)
return jsonify({"ok": True})
return jsonify({"ok": False, "error": "Device not found"}), 404
return jsonify({"ok": False, "error": "source must be 'usb' or 'network'"}), 400
@app.route("/api/register-device", methods=["POST"])
def api_register_device():
"""Called by a network-booted device to register (mac, ip)."""
body = request.get_json(force=True, silent=True) or request.form
mac = (body.get("mac") or "").strip()
ip = (body.get("ip") or request.remote_addr or "").strip()
if not mac:
return jsonify({"ok": False, "error": "mac required"}), 400
data = _load_network_devices()
devices = data.get("devices", [])
for d in devices:
if (d.get("mac") or "").lower() == mac.lower():
d["ip"] = ip
d["registered_at"] = time.time()
d["action"] = d.get("action") or "wait"
_save_network_devices(data)
return jsonify({"ok": True, "message": "registered"})
devices.append({"mac": mac, "ip": ip, "registered_at": time.time(), "action": "wait"})
data["devices"] = devices
_save_network_devices(data)
return jsonify({"ok": True, "message": "registered"})
@app.route("/api/device-action-poll")
def api_device_action_poll():
"""Network device polls this to get its assigned action (deploy/backup) and URL."""
mac = (request.args.get("mac") or "").strip()
if not mac:
return jsonify({"action": "wait"}), 200
data = _load_network_devices()
base = request.host_url.rstrip("/")
for d in data.get("devices", []):
if (d.get("mac") or "").lower() == mac.lower():
action = d.get("action") or "wait"
if action == "deploy":
return jsonify({"action": "deploy", "url": f"{base}/api/golden-image"})
if action == "backup":
return jsonify({"action": "backup", "upload_url": f"{base}/api/backup-upload?mac={mac}"})
return jsonify({"action": "wait"})
return jsonify({"action": "wait"})
@app.route("/api/golden-image")
def api_golden_image():
"""Stream the golden image for network deploy (device pulls and writes to eMMC)."""
if not GOLDEN_IMAGE.is_file():
return jsonify({"error": "Golden image not found"}), 404
return send_file(
GOLDEN_IMAGE,
mimetype="application/octet-stream",
as_attachment=True,
download_name="golden.img",
)
@app.route("/api/backup-upload", methods=["POST"])
def api_backup_upload():
"""Network device uploads its eMMC backup (raw body)."""
mac = (request.args.get("mac") or "").strip().replace(":", "-")[:20]
if not mac:
return jsonify({"error": "mac query param required"}), 400
BACKUPS_DIR.mkdir(parents=True, exist_ok=True)
name = f"backup-net-{mac}-{int(time.time())}.img"
path = BACKUPS_DIR / name
try:
with open(path, "wb") as f:
while True:
chunk = request.stream.read(1024 * 1024)
if not chunk:
break
f.write(chunk)
return jsonify({"ok": True, "file": name})
except (OSError, IOError) as e:
if path.exists():
path.unlink(missing_ok=True)
return jsonify({"error": str(e)}), 500
@app.route("/api/backups")
def api_backups():
return jsonify({"backups": list_backups()})
@app.route("/api/backups/<path:name>")
def api_backup_download(name):
if ".." in name or "/" in name or "\\" in name:
return jsonify({"error": "invalid name"}), 400
path = BACKUPS_DIR / name
if not path.is_file():
return jsonify({"error": "not found"}), 404
return send_file(path, as_attachment=True, download_name=name)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)

View File

@@ -0,0 +1,16 @@
[Unit]
Description=CM4 eMMC Provisioning Dashboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt/cm4-provisioning/dashboard
ExecStart=/usr/bin/python3 -m flask --app app run --host=0.0.0.0 --port=5000
Restart=on-failure
RestartSec=5
Environment=FLASK_ENV=production
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CM4 eMMC Provisioning</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
/* Portal Styling Guide tokens */
:root {
--bg-primary: #0a0e14;
--bg-secondary: #11151c;
--bg-tertiary: #1a1f2b;
--bg-card: #151a24;
--accent-primary: #00d4aa;
--accent-secondary: #00b894;
--accent-glow: rgba(0, 212, 170, 0.15);
--text-primary: #e6e8eb;
--text-secondary: #8b949e;
--text-muted: #5c6370;
--border-color: #2d333b;
--danger: #ff6b6b;
--danger-glow: rgba(255, 107, 107, 0.15);
--warning: #ffd93d;
--success: #00d4aa;
--gradient-accent: linear-gradient(135deg, #00d4aa 0%, #00b894 50%, #00cec9 100%);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
body::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(circle at 20% 20%, rgba(0, 212, 170, 0.03) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(0, 184, 148, 0.03) 0%, transparent 50%),
linear-gradient(180deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
pointer-events: none;
z-index: -1;
}
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
z-index: 1000;
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-icon {
width: 40px;
height: 40px;
background: var(--gradient-accent);
border-radius: 10px;
box-shadow: 0 4px 20px var(--accent-glow);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
}
.logo-title {
font-size: 1.5rem;
font-weight: 600;
background: var(--gradient-accent);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.main {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
padding-top: calc(2rem + 72px);
}
.card {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
}
.card-title {
font-size: 1.1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card-title .label {
font-size: 1.1rem;
font-weight: 600;
color: var(--text-primary);
}
.subtitle { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1.5rem; }
.steps { list-style: none; }
.steps li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-color);
}
.steps li:last-child { border-bottom: none; }
.step-num {
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
background: var(--bg-tertiary);
color: var(--text-primary);
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.8rem;
}
.step-num.done { background: var(--gradient-accent); color: var(--bg-primary); }
.status-box {
padding: 1rem 1.25rem;
border-radius: 12px;
margin-bottom: 0.75rem;
border: 1px solid var(--border-color);
}
.status-box.idle { background: var(--accent-glow); border-color: var(--accent-primary); }
.status-box.rpiboot,
.status-box.flashing,
.status-box.backup,
.status-box.waiting_choice { background: rgba(255, 217, 61, 0.1); border-color: var(--warning); }
.status-box.done { background: rgba(0, 212, 170, 0.12); border-color: var(--success); }
.status-box.error { background: var(--danger-glow); border-color: var(--danger); }
.status-phase {
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 0.35rem;
}
.status-box.idle .status-phase { color: var(--accent-primary); }
.status-box.rpiboot .status-phase,
.status-box.flashing .status-phase,
.status-box.backup .status-phase,
.status-box.waiting_choice .status-phase { color: var(--warning); }
.status-box.done .status-phase { color: var(--success); }
.status-box.error .status-phase { color: var(--danger); }
.status-message { color: var(--text-primary); font-size: 0.95rem; }
.status-error { color: var(--danger); font-size: 0.9rem; margin-top: 0.5rem; }
.status-updated { color: var(--text-muted); font-size: 0.75rem; margin-top: 0.5rem; }
.progress-wrap {
margin-top: 0.75rem;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--gradient-accent);
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-bar.indeterminate {
width: 40%;
animation: indeterminate 1.2s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(350%); }
}
.log-box {
font-family: 'JetBrains Mono', monospace;
font-size: 0.8rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
max-height: 180px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
color: var(--text-muted);
}
.log-box:empty::before { content: 'No flash log yet.'; color: var(--text-muted); }
.backups-list { list-style: none; }
.backups-list li {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border-color);
gap: 0.75rem;
}
.backups-list li:last-child { border-bottom: none; }
.backups-list a {
color: var(--accent-primary);
text-decoration: none;
font-weight: 500;
}
.backups-list a:hover { text-decoration: underline; }
.backups-meta { color: var(--text-muted); font-size: 0.85rem; font-family: 'JetBrains Mono', monospace; }
.pending-device {
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.5rem;
}
.pending-device .label { font-weight: 500; color: var(--text-primary); }
.pending-device .actions { display: flex; gap: 0.5rem; }
.btn {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
font-family: 'Outfit', sans-serif;
display: inline-flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.btn-primary {
background: var(--gradient-accent);
color: var(--bg-primary);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px var(--accent-glow);
}
.btn-secondary {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.pending-device .btn { padding: 0.5rem 1rem; font-size: 0.85rem; }
code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
background: var(--bg-tertiary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
color: var(--accent-primary);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-muted);
font-size: 0.9rem;
}
details summary {
cursor: pointer;
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 500;
}
@media (max-width: 768px) {
.header { padding: 1rem; flex-direction: column; gap: 0.5rem; text-align: center; }
.main { padding: 1rem; padding-top: calc(1rem + 100px); }
}
</style>
</head>
<body>
<header class="header">
<div class="logo">
<div class="logo-icon"></div>
<h1 class="logo-title">CM4 eMMC Provisioning</h1>
</div>
</header>
<main class="main">
<p class="subtitle">reTerminal DM4 — deploy or backup via USB boot mode or network boot</p>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Connect the device</span></h2>
</div>
<p style="color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.75rem;">Choose one:</p>
<p style="font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">USB boot mode</p>
<ol class="steps">
<li><span class="step-num">1</span> Set the reTerminal to <strong>boot mode</strong>: fit the <strong>eMMC disable</strong> jumper (e.g. J2 / nRPIBOOT).</li>
<li><span class="step-num">2</span> Connect the reTerminals <strong>USB slave</strong> port to the Proxmox host. Power on. The device will appear in “Device detected” below; choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
<li><span class="step-num">3</span> When done, remove the jumper and power cycle so it boots from eMMC.</li>
</ol>
<p style="font-size: 0.9rem; font-weight: 600; margin: 1rem 0 0.25rem 0;">Network boot</p>
<ol class="steps">
<li><span class="step-num">1</span> Enable network boot on the CM4 (e.g. <code>BOOT_ORDER=0x21</code>) and ensure it can reach this server.</li>
<li><span class="step-num">2</span> Boot the device over the network with an environment that runs the <strong>provisioning client</strong> (register + poll for action). It will show under “Device detected (Network)”. Choose <strong>Backup</strong> or <strong>Deploy</strong>.</li>
</ol>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Device detected — choose action</span></h2>
</div>
<p id="pendingHint" style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.75rem;">When a device is detected (USB boot mode or network boot), it will appear below. Choose <strong>Backup</strong> to save its eMMC to a file, or <strong>Deploy</strong> to write the golden image to it.</p>
<div id="pendingDevices"></div>
<p id="noPending" class="empty-state" style="display: none;">No device waiting. Connect a reTerminal in USB boot mode, or ensure a network-booted device has registered.</p>
<h3 style="font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin: 1rem 0 0.5rem 0;">Saved backups</h3>
<ul id="backupsList" class="backups-list"></ul>
<p id="backupsEmpty" class="empty-state" style="display: none;">No backups yet.</p>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title"><span class="label">Deployment status</span></h2>
</div>
<div id="status" class="status-box idle">
<div class="status-phase">Idle</div>
<div class="status-message">Waiting for reTerminal in boot mode.</div>
<div id="statusError" class="status-error" style="display:none;"></div>
<div id="statusUpdated" class="status-updated"></div>
<div id="progressWrap" class="progress-wrap" style="display:none;">
<div id="progressBar" class="progress-bar"></div>
</div>
</div>
<details style="margin-top: 0.75rem;">
<summary>Recent flash log</summary>
<div id="log" class="log-box" style="margin-top: 0.5rem;"></div>
</details>
</div>
</main>
<script>
const statusEl = document.getElementById('status');
const phaseEl = statusEl.querySelector('.status-phase');
const messageEl = statusEl.querySelector('.status-message');
const errorEl = document.getElementById('statusError');
const updatedEl = document.getElementById('statusUpdated');
const progressWrap = document.getElementById('progressWrap');
const progressBar = document.getElementById('progressBar');
const logEl = document.getElementById('log');
const phaseLabels = {
idle: 'Idle',
rpiboot: 'Connecting',
waiting_choice: 'Choose action',
flashing: 'Flashing',
backup: 'Backing up',
done: 'Done',
error: 'Error'
};
function renderStatus(data) {
const phase = data.phase || 'idle';
statusEl.className = 'status-box ' + phase;
phaseEl.textContent = phaseLabels[phase] || phase;
messageEl.textContent = data.message || '';
if (data.error) {
errorEl.textContent = data.error;
errorEl.style.display = 'block';
} else {
errorEl.style.display = 'none';
}
if (data.updated) {
updatedEl.textContent = 'Updated: ' + data.updated;
updatedEl.style.display = 'block';
} else {
updatedEl.style.display = 'none';
}
const progress = data.progress;
const inProgress = phase === 'rpiboot' || phase === 'flashing' || phase === 'backup';
const showProgress = progress != null && (inProgress || phase === 'done');
if (showProgress) {
progressWrap.style.display = 'block';
progressBar.classList.remove('indeterminate');
progressBar.style.width = (progress === null ? 0 : progress) + '%';
if (progress === null && inProgress) {
progressBar.classList.add('indeterminate');
progressBar.style.width = '40%';
}
} else if (inProgress) {
progressWrap.style.display = 'block';
progressBar.classList.add('indeterminate');
progressBar.style.width = '40%';
} else {
progressWrap.style.display = 'none';
}
}
function renderPendingDevices(usb, network) {
const container = document.getElementById('pendingDevices');
const noPending = document.getElementById('noPending');
container.innerHTML = '';
let hasAny = false;
if (usb) {
hasAny = true;
const div = document.createElement('div');
div.className = 'pending-device';
div.innerHTML = '<span class="label">Device connected (USB boot mode)</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></span>';
container.appendChild(div);
}
(network || []).forEach(function(d) {
hasAny = true;
const div = document.createElement('div');
div.className = 'pending-device';
div.innerHTML = '<span class="label">Device (Network): ' + escapeHtml(d.ip || '') + ' — ' + escapeHtml(d.mac || '') + '</span><span class="actions"><button type="button" class="btn btn-secondary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="network" data-mac="' + escapeHtml(d.mac || '') + '" data-action="deploy">Deploy</button></span>';
container.appendChild(div);
});
noPending.style.display = hasAny ? 'none' : 'block';
container.querySelectorAll('button[data-action]').forEach(function(btn) {
btn.onclick = function() {
const source = btn.getAttribute('data-source');
const action = btn.getAttribute('data-action');
const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action };
if (mac) body.mac = mac;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchPending(); fetchStatus(); }
else alert(data.error || 'Failed');
})
.catch(function() { alert('Request failed'); });
};
});
}
function fetchPending() {
fetch('/api/pending-devices').then(function(r) { return r.json(); }).then(function(data) {
renderPendingDevices(data.usb || null, data.network || []);
}).catch(function() { renderPendingDevices(null, []); });
}
function fetchStatus() {
fetch('/api/status')
.then(r => r.json())
.then(renderStatus)
.catch(() => renderStatus({ phase: 'error', message: 'Could not load status.' }));
}
function fetchLog() {
fetch('/api/log')
.then(r => r.json())
.then(data => { logEl.textContent = data.log || ''; })
.catch(() => { logEl.textContent = ''; });
}
function fmtSize(n) {
if (n >= 1e9) return (n / 1e9).toFixed(1) + ' GB';
if (n >= 1e6) return (n / 1e6).toFixed(1) + ' MB';
return (n / 1e3).toFixed(0) + ' KB';
}
function fmtDate(ts) { return new Date(ts * 1000).toLocaleString(); }
function fetchBackups() {
fetch('/api/backups')
.then(r => r.json())
.then(data => {
const list = document.getElementById('backupsList');
const empty = document.getElementById('backupsEmpty');
list.innerHTML = '';
const backups = data.backups || [];
if (backups.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
backups.forEach(b => {
const li = document.createElement('li');
li.innerHTML = '<a href="/api/backups/' + encodeURIComponent(b.name) + '" download>' + escapeHtml(b.name) + '</a>' +
'<span class="backups-meta">' + fmtSize(b.size) + ' · ' + fmtDate(b.mtime) + '</span>';
list.appendChild(li);
});
})
.catch(() => {});
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
fetchStatus();
fetchLog();
fetchPending();
fetchBackups();
setInterval(fetchStatus, 2000);
setInterval(fetchLog, 4000);
setInterval(fetchPending, 2000);
setInterval(fetchBackups, 5000);
</script>
</body>
</html>

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# Auto-flash CM4 eMMC when reTerminal is connected in boot mode (eMMC disable jumper).
# Run this from udev or a systemd service when Raspberry Pi USB boot device (2b8e) is detected.
# Requires: usbboot (rpiboot) built, golden image at GOLDEN_IMAGE path.
set -e
# Load overrides from env file if present
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
# Configuration - adjust paths and size for your setup
RPIBOOT_DIR="${RPIBOOT_DIR:-/opt/usbboot}"
GOLDEN_IMAGE="${GOLDEN_IMAGE:-/var/lib/cm4-provisioning/golden.img}"
# Expected eMMC size in bytes (reTerminal CM4 often 8GB or 16GB). Used to identify the correct block device.
EMMC_SIZE_BYTES="${EMMC_SIZE_BYTES:-$(( 8 * 1024 * 1024 * 1024 ))}"
LOG_TAG="cm4-flash"
STATUS_FILE="${STATUS_FILE:-/var/lib/cm4-provisioning/status.json}"
LOG_FILE="${LOG_FILE:-/var/lib/cm4-provisioning/flash.log}"
log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*"; echo "[$(date -Iseconds)] $*" >> "$LOG_FILE" 2>/dev/null || true; }
write_status() {
local phase="$1" message="$2" progress="${3:-null}" error="${4:-}"
local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
message="${message//\"/\\\"}"; error="${error//\"/\\\"}"
if [[ -n "$error" ]]; then
printf '{"phase":"%s","message":"%s","progress":%s,"error":"%s","updated":"%s"}\n' \
"$phase" "$message" "$progress" "$error" "$ts" > "$STATUS_FILE" 2>/dev/null || true
else
printf '{"phase":"%s","message":"%s","progress":%s,"updated":"%s"}\n' \
"$phase" "$message" "$progress" "$ts" > "$STATUS_FILE" 2>/dev/null || true
fi
}
# Optional: only run if this file exists (safety)
ENABLE_FILE="${ENABLE_FILE:-/etc/cm4-provisioning/enabled}"
if [[ -n "$ENABLE_FILE" && ! -f "$ENABLE_FILE" ]]; then
log "Skipping: $ENABLE_FILE not present"
write_status "idle" "Provisioning disabled (remove /etc/cm4-provisioning/enabled to enable)" "null" 2>/dev/null || true
exit 0
fi
# When a device is detected we ask the user (dashboard): Backup or Deploy?
# These files are used to wait for the user's choice (written by dashboard, read by this script).
BACKUPS_DIR="${BACKUPS_DIR:-/var/lib/cm4-provisioning/backups}"
ACTION_REQUEST_FILE="${ACTION_REQUEST_FILE:-/var/lib/cm4-provisioning/action_request}"
CURRENT_DEVICE_FILE="${CURRENT_DEVICE_FILE:-/var/lib/cm4-provisioning/current_device}"
DEVICE_SOURCE_FILE="${DEVICE_SOURCE_FILE:-/var/lib/cm4-provisioning/device_source}"
WAIT_TIMEOUT="${WAIT_TIMEOUT:-600}"
# Golden image required for deploy
if [[ ! -f "$GOLDEN_IMAGE" ]]; then
log "Golden image not found (required for deploy): $GOLDEN_IMAGE"
write_status "error" "Golden image not found" "null" "Golden image not found. Add golden.img for deploy."
exit 1
fi
RPIBOOT_BIN="$RPIBOOT_DIR/rpiboot"
if [[ ! -x "$RPIBOOT_BIN" ]]; then
log "rpiboot not found: $RPIBOOT_BIN (build usbboot and set RPIBOOT_DIR)"
write_status "error" "rpiboot not installed" "null" "rpiboot not found. Run install-usbboot-on-host.sh on the host."
exit 1
fi
# Ensure status dir exists and start with running state
mkdir -p "$(dirname "$STATUS_FILE")" "$BACKUPS_DIR" 2>/dev/null || true
write_status "rpiboot" "Connecting to CM4 in boot mode…" "0"
# Block devices before rpiboot (so we can detect new one after)
before_devs=$(lsblk -nd -o NAME 2>/dev/null | sort)
log "Starting rpiboot to expose CM4 eMMC as mass storage..."
if ! "$RPIBOOT_BIN"; then
log "rpiboot failed or no device connected"
write_status "error" "rpiboot failed" "null" "rpiboot failed or no device connected"
exit 1
fi
# rpiboot exits when mass storage appears; give udev a moment to create /dev/sdX
sleep 3
# Find new block device (prefer one matching expected eMMC size)
target_dev=""
for dev in /dev/sd[a-z] /dev/sd[a-z][a-z]; do
[[ -b "$dev" ]] || continue
# Skip partitions
[[ "$dev" =~ [0-9]$ ]] && continue
size=$(blockdev --getsize64 "$dev" 2>/dev/null || true)
if [[ -n "$size" ]]; then
# Allow 5% tolerance on size
if (( size >= EMMC_SIZE_BYTES * 95 / 100 && size <= EMMC_SIZE_BYTES * 105 / 100 )); then
target_dev=$dev
break
fi
# Otherwise take first new disk that appeared (fallback)
if [[ -z "$target_dev" && "$before_devs" != *"${dev#/dev/}"* ]]; then
target_dev=$dev
fi
fi
done
if [[ -z "$target_dev" ]]; then
log "No suitable block device found after rpiboot (expected ~${EMMC_SIZE_BYTES} bytes)"
write_status "error" "No eMMC device found" "null" "No suitable block device after rpiboot"
exit 1
fi
# Ask user (dashboard): Backup or Deploy?
write_status "waiting_choice" "Device connected (USB boot mode). Choose Backup or Deploy in the dashboard." "null"
echo "usb" > "$DEVICE_SOURCE_FILE" 2>/dev/null || true
echo "$target_dev" > "$CURRENT_DEVICE_FILE" 2>/dev/null || true
log "Waiting for user choice (Backup or Deploy) in dashboard; timeout ${WAIT_TIMEOUT}s..."
for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
sleep 2
if [[ -f "$ACTION_REQUEST_FILE" ]]; then
action=$(cat "$ACTION_REQUEST_FILE" 2>/dev/null | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
rm -f "$ACTION_REQUEST_FILE" 2>/dev/null || true
if [[ "$action" == "backup" ]]; then
backup_name="backup-$(date +%Y%m%d-%H%M%S).img"
backup_path="$BACKUPS_DIR/$backup_name"
write_status "backup" "Creating backup…" "null"
log "Backing up $target_dev to $backup_path..."
if dd if="$target_dev" of="$backup_path" bs=4M status=progress conv=fsync 2>>"$LOG_FILE"; then
log "Backup complete: $backup_path"
write_status "done" "Backup complete: $backup_name" "100"
else
write_status "error" "Backup failed" "null" "dd failed"
fi
elif [[ "$action" == "deploy" ]]; then
write_status "flashing" "Writing golden image…" "null"
log "Flashing $GOLDEN_IMAGE to $target_dev..."
if dd if="$GOLDEN_IMAGE" of="$target_dev" bs=4M status=progress conv=fsync 2>>"$LOG_FILE"; then
log "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal."
write_status "done" "Flash complete. Remove eMMC disable jumper and power cycle the reTerminal." "100"
else
write_status "error" "Flash failed" "null" "dd failed"
fi
else
write_status "error" "Unknown action" "null" "action_request must be 'backup' or 'deploy'"
fi
rm -f "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null || true
exit 0
fi
done
log "Timeout waiting for user choice"
write_status "idle" "Timeout waiting for choice. Connect the device again to retry." "null"
rm -f "$CURRENT_DEVICE_FILE" "$DEVICE_SOURCE_FILE" 2>/dev/null || true
exit 0

View File

@@ -0,0 +1,27 @@
# Network provisioning client
When a reTerminal (or any Pi) has **network boot** enabled and boots over the network, it can register with the provisioning dashboard and then perform **Deploy** (pull golden image and write to eMMC) or **Backup** (read eMMC and upload to the server) when you choose the action in the dashboard.
## Flow
1. Device boots (e.g. from NFS or a minimal netboot root).
2. Run **provisioning-client.sh** with `PROVISIONING_SERVER` set to the dashboard URL (e.g. `http://<LXC-IP>:5000`).
3. The script registers (MAC + IP) and polls `GET /api/device-action-poll?mac=...`.
4. In the dashboard, the device appears under "Device detected (Network)". You click **Backup** or **Deploy**.
5. The device gets the action from the next poll: **Deploy** → it downloads `GET /api/golden-image` and runs `dd of=/dev/mmcblk0`. **Backup** → it runs `dd if=/dev/mmcblk0` and POSTs to `POST /api/backup-upload?mac=...`.
## Usage on the device
```bash
export PROVISIONING_SERVER=http://192.168.1.10:5000 # dashboard URL
./provisioning-client.sh
```
- **EMMC_DEV**: override eMMC block device (default `/dev/mmcblk0`).
The device must have network access to the dashboard and (for deploy) the dashboard must have `golden.img` in its provisioning directory.
## Integrating into a netboot environment
- Add this script to your netboot root (e.g. NFS-mounted filesystem or initramfs).
- Run it from a first-boot or default login script so that when the device boots from the network it registers and waits for an action. Optionally run it as a systemd service that starts after network is up.

View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Run this script on a reTerminal that has booted over the network (or any Pi with network).
# It registers with the provisioning dashboard and waits for Backup or Deploy, then performs the action.
# Usage: PROVISIONING_SERVER=http://192.168.1.10:5000 ./provisioning-client.sh
# Requires: curl, and for deploy/backup: enough space and write access to eMMC (e.g. /dev/mmcblk0).
set -e
BASE_URL="${PROVISIONING_SERVER:-http://192.168.1.10:5000}"
BASE_URL="${BASE_URL%/}"
EMMC_DEV="${EMMC_DEV:-/dev/mmcblk0}"
MAC=$(cat /sys/class/net/eth0/address 2>/dev/null || cat /sys/class/net/enp*/address 2>/dev/null | head -1 || echo "unknown")
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "")
echo "Registering with $BASE_URL (MAC=$MAC IP=$IP)..."
curl -s -X POST -H "Content-Type: application/json" -d "{\"mac\":\"$MAC\",\"ip\":\"$IP\"}" "$BASE_URL/api/register-device" || true
echo "Polling for action (Backup or Deploy)..."
while true; do
resp=$(curl -s "$BASE_URL/api/device-action-poll?mac=$MAC" 2>/dev/null || echo '{"action":"wait"}')
action=$(echo "$resp" | grep -o '"action":"[^"]*"' | cut -d'"' -f4)
url=$(echo "$resp" | grep -o '"url":"[^"]*"' | cut -d'"' -f4)
upload_url=$(echo "$resp" | grep -o '"upload_url":"[^"]*"' | cut -d'"' -f4)
if [[ "$action" == "deploy" && -n "$url" ]]; then
echo "Deploy: downloading image and writing to $EMMC_DEV..."
if [[ ! -b "$EMMC_DEV" ]]; then
echo "Error: $EMMC_DEV not found"
sleep 10
continue
fi
curl -sL "$url" | dd of="$EMMC_DEV" bs=4M status=progress conv=fsync
echo "Deploy done. Reboot to run from eMMC."
exit 0
fi
if [[ "$action" == "backup" && -n "$upload_url" ]]; then
echo "Backup: reading $EMMC_DEV and uploading..."
if [[ ! -b "$EMMC_DEV" ]]; then
echo "Error: $EMMC_DEV not found"
sleep 10
continue
fi
dd if="$EMMC_DEV" bs=4M status=progress 2>/dev/null | curl -s -X POST -T - "$upload_url"
echo "Backup done."
exit 0
fi
sleep 5
done

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Deploy CM4 eMMC provisioning to a Proxmox host (creates LXC 201, installs scripts on host and in LXC).
# Usage: ./deploy-to-proxmox.sh [proxmox_host]
# Example: ./deploy-to-proxmox.sh root@10.130.60.224
# Requires: ssh key access to root@<host>
set -e
PROXMOX="${1:-root@10.130.60.224}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
echo "Deploying to $PROXMOX ..."
rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh'
ssh "$PROXMOX" bash -s << 'REMOTE'
set -e
DEPLOY=/tmp/emmc-provisioning-deploy
# Ensure LXC 201 exists (create if not)
if ! pct status 201 &>/dev/null; then
echo "Creating LXC 201 (cm4-provisioning)..."
pct create 201 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \
--hostname cm4-provisioning --memory 1024 --swap 0 --cores 1 \
--rootfs local-zfs:8 --net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 0 --features nesting=1 -tag cm4-provisioning
mkdir -p /var/lib/cm4-provisioning
pct set 201 -mp0 /var/lib/cm4-provisioning,mp=/var/lib/cm4-provisioning
fi
# Host: install scripts and udev
mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
cp "$DEPLOY/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
cp "$DEPLOY/cm4-flash-trigger.sh" /usr/local/bin/
chmod +x /usr/local/bin/cm4-flash-trigger.sh
cp "$DEPLOY/90-cm4-boot-mode.rules" /etc/udev/rules.d/
udevadm control --reload-rules
cat > /opt/cm4-provisioning/env << 'ENV'
GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img
RPIBOOT_DIR=/opt/usbboot
EMMC_SIZE_BYTES=8589934592
ENV
touch /etc/cm4-provisioning/enabled
mkdir -p /var/lib/cm4-provisioning/backups
# Start LXC if stopped
pct start 201 2>/dev/null || true
# LXC: install scripts
pct exec 201 -- mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
pct push 201 "$DEPLOY/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
pct exec 201 -- chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
pct push 201 "$DEPLOY/cm4-flash-trigger.sh" /usr/local/bin/cm4-flash-trigger.sh
pct exec 201 -- chmod +x /usr/local/bin/cm4-flash-trigger.sh
pct exec 201 -- bash -c 'echo -e "GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img\nRPIBOOT_DIR=/opt/usbboot\nEMMC_SIZE_BYTES=8589934592" > /opt/cm4-provisioning/env'
# LXC: install dashboard
pct exec 201 -- mkdir -p /opt/cm4-provisioning/dashboard/templates
pct push 201 "$DEPLOY/dashboard/app.py" /opt/cm4-provisioning/dashboard/
pct push 201 "$DEPLOY/dashboard/templates/index.html" /opt/cm4-provisioning/dashboard/templates/
pct push 201 "$DEPLOY/dashboard/cm4-dashboard.service" /opt/cm4-provisioning/dashboard/
echo "Deploy done. Install usbboot on host when online: ssh $PROXMOX 'bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh'"
echo "To enable the dashboard in LXC 201: pct exec 201 -- bash -c 'apt-get install -y python3-flask; cp /opt/cm4-provisioning/dashboard/cm4-dashboard.service /etc/systemd/system/; systemctl daemon-reload; systemctl enable --now cm4-dashboard'"
REMOTE
echo "Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/)."
echo "When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh"
echo "Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see PROXMOX-LXC-DEPLOYMENT.md)."

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Run on the Proxmox HOST (root) when the host has internet.
# Builds usbboot (rpiboot) and installs to /opt/usbboot so the auto-flash can run.
set -e
apt-get update
apt-get install -y libusb-1.0-0-dev git
cd /tmp
rm -rf usbboot
git clone --depth=1 https://github.com/raspberrypi/usbboot
cd usbboot
make
mkdir -p /opt/usbboot
cp rpiboot /opt/usbboot/
echo "usbboot installed at /opt/usbboot/rpiboot"