Compare commits
2 Commits
feat/code-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6900101506 | ||
|
|
396eeb175e |
BIN
content/assets/images/sim7080g-module.jpg
Normal file
BIN
content/assets/images/sim7080g-module.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
@@ -8,7 +8,7 @@ confidence: running
|
|||||||
period: "2020–"
|
period: "2020–"
|
||||||
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
|
tech: [ESP32, LoRa, Zigbee, MQTT, Home Assistant, Snapcast, Raspberry Pi]
|
||||||
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
|
metrics: [{label: "edge nodes", value: 20}, {label: "lighting zones", value: 9}, {label: "automations", value: 20}]
|
||||||
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}]
|
artefacts: [{type: doc, label: "Edge architecture — power · radio · deep sleep", url: "content/edge/l3/edge-architecture.md"}, {type: doc, label: "The failures, kept in the record", url: "content/edge/l3/edge-failures.md"}, {type: doc, label: "Distributed audio — eight endpoints with ducking", url: "content/edge/l3/edge-snapcast.md"}, {type: doc, label: "Vehicle tracker on LTE Cat-M", url: "content/edge/l3/edge-car-tracker.md"}]
|
||||||
public: true
|
public: true
|
||||||
last_verified: 2026-10-26
|
last_verified: 2026-10-26
|
||||||
tags: [edge, iot, esp32, lora, zigbee]
|
tags: [edge, iot, esp32, lora, zigbee]
|
||||||
|
|||||||
51
content/edge/l3/edge-car-tracker.md
Normal file
51
content/edge/l3/edge-car-tracker.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
---
|
||||||
|
id: edge-car-tracker
|
||||||
|
area: edge
|
||||||
|
level: 3
|
||||||
|
title: "Artefact — Vehicle tracker on LTE Cat-M"
|
||||||
|
claim: "A tracker that sleeps between reports · wakes on a schedule or when the car moves · and reports when it leaves a boundary set from the web interface."
|
||||||
|
confidence: in-progress
|
||||||
|
period: "2024–"
|
||||||
|
tech: [ESP32, LTE Cat-M, NB-IoT, SIM7080G, GPS, LiFePO4]
|
||||||
|
metrics: [{label: "battery packs", value: 2}]
|
||||||
|
artefacts: []
|
||||||
|
public: true
|
||||||
|
last_verified: 2026-10-28
|
||||||
|
tags: [edge, tracker, lte, low-power, artefact]
|
||||||
|
---
|
||||||
|
|
||||||
|
The build is on the bench: two LiFePO batteries, a buck converter, an ESP32 with GPS, and a
|
||||||
|
cellular module for the uplink. The car keeps the packs charged while it is running.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
Two behaviours, chosen by the settings rather than hard-coded. When the car is parked the
|
||||||
|
ESP32 sits in deep sleep, wakes on a timer, checks its own configuration, and reports only
|
||||||
|
what the settings ask for. When the car is moving it stays awake and reports on a shorter
|
||||||
|
interval. Either way the interval and the boundary can be changed from the web interface,
|
||||||
|
and a change can force an immediate update rather than waiting for the next wake.
|
||||||
|
|
||||||
|
The geofence is the part that matters. A boundary is drawn from the web interface, and the
|
||||||
|
device is responsible for deciding whether it has left it — which is why the settings have
|
||||||
|
to travel to the device rather than live only in the server.
|
||||||
|
|
||||||
|
## Why LTE Cat-M rather than WiFi
|
||||||
|
|
||||||
|
The tracker lives in a car. WiFi coverage is a driveway, not a road. Cat-M and NB-IoT were
|
||||||
|
chosen because they are designed for exactly this shape of traffic — small payloads, long
|
||||||
|
sleeps, coverage beyond what 4G handsets need — and because the modules are cheap enough to
|
||||||
|
leave installed.
|
||||||
|
|
||||||
|
## What it proves
|
||||||
|
|
||||||
|
A complete path rather than a demo: constrained hardware, a power budget that dictates the
|
||||||
|
schedule, a device that holds enough state to act on its own, and a web application that
|
||||||
|
configures it and shows where it has been.
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
Field testing across the full battery cycle. The sleep interval that is right for a parked
|
||||||
|
car and the interval that is right for a moving one are guesses until the discharge curve
|
||||||
|
has been measured over a week rather than a day.
|
||||||
80
docs/image-brief.md
Normal file
80
docs/image-brief.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Image brief
|
||||||
|
|
||||||
|
Every image on this site gets the same treatment. Consistency is what makes a set look
|
||||||
|
designed; six images treated six ways look worse than six treated identically.
|
||||||
|
|
||||||
|
The palette is fixed by `internal/web/static/site.css`:
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `--bg` | `#0b0d10` | canvas and fade colour. Never pure black |
|
||||||
|
| `--surface` | `#12161b` | screenshot backgrounds |
|
||||||
|
| `--line` | `#242c35` | screenshot borders |
|
||||||
|
| `--line-strong` | `#33404d` | diagram strokes |
|
||||||
|
| `--text-dim` | `#b3bcc8` | diagram labels |
|
||||||
|
| `--link` | `#8ecbff` | rim glow |
|
||||||
|
| `--accent` | `#6ee7c6` | sparing emphasis only |
|
||||||
|
|
||||||
|
## Why white-background product shots fail here
|
||||||
|
|
||||||
|
`#FFFFFF` on a `#0b0d10` page is roughly twenty times the brightness of everything around
|
||||||
|
it. The brightest thing in the frame becomes the backdrop rather than the object, and the
|
||||||
|
eye lands on the backdrop first. One reads as an accident; six read as a scrapbook.
|
||||||
|
|
||||||
|
## Photographs
|
||||||
|
|
||||||
|
- **Canvas** 1600×1000 for hero images, 1200×1200 for detail. Landscape unless the object demands otherwise.
|
||||||
|
- **Background** `#0b0d10`, the page colour. Not black — pure black always looks like a void.
|
||||||
|
- **Subject** cut out, placed right of centre, occupying 55–70% of the canvas height. Off-centre because the fade comes from the left.
|
||||||
|
- **Fade** a `#0b0d10` gradient, 100% opacity at the left edge to 0% at about 55% across. The object emerges from the page and dissolves to the right. This is the move that stops an image looking pasted on.
|
||||||
|
- **Rim glow** `#8ecbff` at 25–40% opacity, 8–14px blur, derived from the cut-out's own alpha channel so it follows real edges. Enough to lift the object off the background. Not enough to look like a gaming mouse.
|
||||||
|
- **Shadow lift** 5–8% on the subject itself. Cut-outs on dark backgrounds lose their dark detail and read as flat stickers otherwise.
|
||||||
|
- **Format** WebP, quality 82, plus a JPEG fallback. Target under 120 KB for a hero.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
Never cut out. Frame them like the site's own panels:
|
||||||
|
|
||||||
|
- corner radius `10px` (`--radius`)
|
||||||
|
- 1px border in `--line`
|
||||||
|
- edges darkened 8%
|
||||||
|
- soft drop shadow
|
||||||
|
- crop out browser chrome where possible
|
||||||
|
|
||||||
|
## Diagrams
|
||||||
|
|
||||||
|
Stay SVG and stay in the palette: `--line-strong` strokes, `--text-dim` labels, `--accent`
|
||||||
|
and `--link` for emphasis. No white fills, no default Mermaid blues.
|
||||||
|
|
||||||
|
## Logos
|
||||||
|
|
||||||
|
One colour, inheriting `currentColor` from the surrounding text. Forty-six client logos in
|
||||||
|
full colour would shriek. If one deserves emphasis, give it `--accent`; the rest stay muted.
|
||||||
|
|
||||||
|
## Prohibited
|
||||||
|
|
||||||
|
White boxes · stock photography · lens flares · 3D renders of objects not owned ·
|
||||||
|
glow strong enough to be the point · any image containing a family member, an address,
|
||||||
|
a credential, or a screen showing personal data.
|
||||||
|
|
||||||
|
## Capture guidance
|
||||||
|
|
||||||
|
Treatment rescues a product shot; it cannot rescue a photograph of a white wall in daylight.
|
||||||
|
|
||||||
|
- dark surface, one light source from one side
|
||||||
|
- shoot slightly above the object
|
||||||
|
- keep the object off-centre in frame
|
||||||
|
- no overhead lighting, no visible background clutter
|
||||||
|
- a phone is fine
|
||||||
|
|
||||||
|
Rack and cabinet shots are the exception: keep them whole and wide, framed like
|
||||||
|
screenshots rather than cut out.
|
||||||
|
|
||||||
|
## Output contract
|
||||||
|
|
||||||
|
Every image lands in `content/assets/images/`, named lowercase and hyphenated with no
|
||||||
|
spaces or dates. Every image used on a page carries a caption naming the object; a product
|
||||||
|
photograph additionally states that it is a product image and where it came from. The
|
||||||
|
original, if it is Sam's own photograph or a sourced product image, is also filed in the
|
||||||
|
repository of the project it belongs to, under `docs/images/`, with provenance recorded in
|
||||||
|
that folder's `README.md`.
|
||||||
35
tools/images/README.md
Normal file
35
tools/images/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Image treatment
|
||||||
|
|
||||||
|
Implements `docs/image-brief.md`. Two steps, in this order.
|
||||||
|
|
||||||
|
## 1. Cut the subject out
|
||||||
|
|
||||||
|
Needs `rembg`. It is not installed on the host, and installing Python packages on NixOS
|
||||||
|
into the system interpreter is a bad idea. Use a container:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker run --rm -v "$PWD/raw:/in" -v "$PWD/build/cut:/out" \
|
||||||
|
-v "$PWD/tools/images/treat.py:/treat.py" \
|
||||||
|
python:3.12-slim sh -c "pip install -q rembg[cpu] pillow onnxruntime && \
|
||||||
|
python /treat.py cutout /in/*.jpg --out /out"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or cut out by hand in GIMP and skip to step 2. For fewer than ten images, hand-cutting is
|
||||||
|
usually better — automatic segmentation struggles with reflective or thin edges.
|
||||||
|
|
||||||
|
## 2. Apply the treatment
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pillow # or the same container, without rembg
|
||||||
|
python3 tools/images/treat.py treat build/cut/*.png --size hero
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands in `content/assets/images/` as WebP at quality 82, canvas `#0b0d10`, subject
|
||||||
|
right of centre, left-to-right fade, rim glow from the alpha channel.
|
||||||
|
|
||||||
|
## Then
|
||||||
|
|
||||||
|
1. Add the page reference: ``
|
||||||
|
2. File the original in the project's own repo under `docs/images/`, with a note in that
|
||||||
|
folder's `README.md` saying where it came from.
|
||||||
|
3. Deploy and check it on the live site at 1440×900 and on a phone.
|
||||||
97
tools/images/treat.py
Executable file
97
tools/images/treat.py
Executable file
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply the site image treatment described in docs/image-brief.md.
|
||||||
|
|
||||||
|
Two jobs, both idempotent and re-runnable:
|
||||||
|
|
||||||
|
cutout remove the background from a product photograph (needs rembg)
|
||||||
|
treat compose onto the dark canvas with the fade and glow (needs Pillow)
|
||||||
|
|
||||||
|
Typical use:
|
||||||
|
|
||||||
|
python3 tools/images/treat.py cutout raw/*.jpg -> build/cut/*.png
|
||||||
|
python3 tools/images/treat.py treat build/cut/*.png -> content/assets/images/*.webp
|
||||||
|
|
||||||
|
If rembg is not available, cut the subject out by hand in GIMP, save as PNG with
|
||||||
|
transparency, and run only the `treat` step.
|
||||||
|
"""
|
||||||
|
import argparse, pathlib, sys
|
||||||
|
|
||||||
|
BG = (0x0b, 0x0d, 0x10) # --bg
|
||||||
|
GLOW = (0x8e, 0xcb, 0xff) # --link
|
||||||
|
SIZES = {"hero": (1600, 1000), "detail": (1200, 1200)}
|
||||||
|
FADE_END = 0.55 # gradient reaches 0 opacity here
|
||||||
|
GLOW_A = 0.32
|
||||||
|
GLOW_BLUR = 11
|
||||||
|
LIFT = 1.06 # shadow lift on the subject
|
||||||
|
|
||||||
|
|
||||||
|
def cutout(paths, outdir):
|
||||||
|
try:
|
||||||
|
from rembg import remove
|
||||||
|
except ImportError:
|
||||||
|
sys.exit("rembg not installed. See tools/images/README.md, or cut out by hand and skip this step.")
|
||||||
|
from PIL import Image
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for p in paths:
|
||||||
|
src = Image.open(p).convert("RGBA")
|
||||||
|
dst = remove(src)
|
||||||
|
target = outdir / (pathlib.Path(p).stem + ".png")
|
||||||
|
dst.save(target)
|
||||||
|
print(f"cut {p} -> {target}")
|
||||||
|
|
||||||
|
|
||||||
|
def treat(paths, outdir, size="hero"):
|
||||||
|
from PIL import Image, ImageFilter, ImageEnhance
|
||||||
|
W, H = SIZES[size]
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for p in paths:
|
||||||
|
subject = Image.open(p).convert("RGBA")
|
||||||
|
|
||||||
|
# fit to 68% of canvas height, keep aspect
|
||||||
|
scale = (H * 0.68) / subject.height
|
||||||
|
subject = subject.resize((max(1, int(subject.width * scale)), int(H * 0.68)), Image.LANCZOS)
|
||||||
|
|
||||||
|
# shadow lift
|
||||||
|
rgb = ImageEnhance.Brightness(subject.convert("RGB")).enhance(LIFT)
|
||||||
|
subject = Image.merge("RGBA", (*rgb.split(), subject.split()[3]))
|
||||||
|
|
||||||
|
canvas = Image.new("RGB", (W, H), BG)
|
||||||
|
|
||||||
|
# place right of centre
|
||||||
|
x = int(W * 0.52)
|
||||||
|
y = (H - subject.height) // 2
|
||||||
|
canvas.paste(subject, (x, y), subject)
|
||||||
|
|
||||||
|
# rim glow from the subject's own alpha
|
||||||
|
alpha = subject.split()[3].filter(ImageFilter.GaussianBlur(GLOW_BLUR))
|
||||||
|
glow = Image.new("RGB", subject.size, GLOW)
|
||||||
|
canvas.paste(glow, (x, y), alpha.point(lambda a: int(a * GLOW_A / 255)))
|
||||||
|
|
||||||
|
# left-to-right fade back to the page colour
|
||||||
|
fade = Image.new("L", (W, 1))
|
||||||
|
end = int(W * FADE_END)
|
||||||
|
for px in range(W):
|
||||||
|
fade.putpixel((px, 0), 255 if px < end else max(0, int(255 * (1 - (px - end) / max(1, W - end)))))
|
||||||
|
fade = fade.resize((W, H))
|
||||||
|
canvas = Image.composite(Image.new("RGB", (W, H), BG), canvas, fade)
|
||||||
|
|
||||||
|
target = outdir / (pathlib.Path(p).stem + ".webp")
|
||||||
|
canvas.save(target, "WEBP", quality=82, method=6)
|
||||||
|
kb = target.stat().st_size / 1024
|
||||||
|
print(f"treat {p} -> {target} ({kb:.0f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("step", choices=["cutout", "treat"])
|
||||||
|
ap.add_argument("paths", nargs="+", type=pathlib.Path)
|
||||||
|
ap.add_argument("--out", type=pathlib.Path, default=None)
|
||||||
|
ap.add_argument("--size", choices=list(SIZES), default="hero")
|
||||||
|
a = ap.parse_args()
|
||||||
|
out = a.out or pathlib.Path("build" if a.step == "cutout" else "content/assets/images")
|
||||||
|
(cutout if a.step == "cutout" else treat)(a.paths, out) if a.step == "cutout" else treat(a.paths, out, a.size)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user