The Web Site
The site is generated from the repository, like the PDF manual. It lists the radios, their screens and features, holds the documentation, and carries two tools that run in the browser with nothing sent to a server: the web flasher (back up, install, restore; web-flasher.md) and the Codeplug Editor (codeplug-editor.md). The site is the recommended way for users to install RadioFoundry and edit a codeplug (Installing); this page is for people who change it.
python3 site/build.py # build/site/
python3 site/build.py --serve # build, then http://localhost:8000 (WebUSB needs localhost or https)
python3 site/build.py --firmware build/dist # with release images for the flasher (tools/release.py --out)
python3 site/build.py --strict # any warning fails (CI)
site/tests/run.sh # the flasher's and the editor's tests (Node 20+)
The generator needs two Python packages, markdown-it-py and resvg_py (pip install -r site/requirements.txt, or prefix the
command with uv run --no-project --with-requirements site/requirements.txt). The pages need no build step and no
npm packages: plain HTML, CSS and ES modules. tools/ci.sh builds the site and runs the tests (Python step).
Cloudflare Pages builds and deploys it (see Hosting):
| Event | Does |
|---|---|
Push to main |
Builds the site with site/cloudflare-build.sh and deploys it to radiofoundry.org |
| Push to another branch of this repository | Builds a preview at its own *.radiofoundry.pages.dev address; a pull request shows the link. Pull requests from forks get no preview |
| Release published | .github/workflows/site.yml calls the project's deploy hook, so main is built again with the new release's images for the flasher |
| By hand | Actions > Web site > Run workflow (the same deploy hook), or Retry deployment in the Pages project |
The pull request's unit tests job also builds the site (--strict) and runs the flasher's tests (tools/ci.sh's
Python step).
Hosting
The site is static files on Cloudflare Pages, project radiofoundry, connected to this repository through
Cloudflare's GitHub app. Pages builds in its own build image (Python and Node.js preinstalled) and switches the site to
a new deployment in one step, so a deployment has no downtime.
| Part | Setup |
|---|---|
| Build | Command site/cloudflare-build.sh, output directory build/site, root directory the repository root. The script installs site/requirements.txt, fetches the newest release's rfoundry-* images from the GitHub API and runs site/build.py --strict. A failed build leaves the last deployment up |
| Branches | Production branch main; every other branch of this repository builds a preview |
| Variables | PYTHON_VERSION 3.12 (both environments). RFOUNDRY_SENTRY_DSN in production only, so previews send no error reports |
| Custom domain | radiofoundry.org on the project (Custom domains). The www name redirects to it |
| Release rebuild | The project's deploy hook, as CLOUDFLARE_DEPLOY_HOOK in the site environment (main and release tags only), which site.yml calls |
| Analytics | Cloudflare Web Analytics, turned on in the project's Metrics tab. Pages adds the beacon to each deployment; it sets no cookies |
| 404 | Pages serves the build's 404.html for a missing page |
| Roll back | The project's Deployments list: Rollback on an earlier production deployment |
Sources
Nothing on the site is copied by hand. Each page reads its source at build time:
| Shows | Comes from |
|---|---|
| Radios, their order and status | The board registry, boards/<radio>/board.toml (tools/boards.py json --for site). order sorts them; the lowest is the home page's radio |
| Planned radios | The rows of the radio survey "Summary table" that name no board, by their Port cell (tools/registry_docs.py): Planned, Planned, hardware needed, In progress, #N (badge "In progress", linked to the pull request) or Not planned (not listed), each with an optional . note |
| A radio's one-line note for visitors | board.toml [status] summary, the same line the README and the user manual show; a catalog may translate it as radio.note.<radio>. A planned radio's: the note of its Port cell, which a catalog may translate as planned.note.<slug of the survey name> |
| A radio's name, bands, processor, screen, bootloader steps, flash backend, codeplug location | board.toml, the CPU from platform/<p>/platform.toml, the backend from the [flash] family, the codeplug location from the board's hal_flash_map_t |
| A radio's status badge (Supported, In development) | board.toml [status] level: supported reads Supported, testing and bring-up read In development |
| Which features a radio has, per variant | make BOARD=<radio> [VARIANT=<v>] print-config and print-required; what it can't have is config.mk BOARD_UNSUPPORTED (shown ✕) and what isn't written for it yet BOARD_NOT_WRITTEN (shown …), with their reasons, through the registry |
| Feature names, help, dependencies | config/features.mk (the comment above each switch, the needs checks) |
| Feature groups, titles, flash cost, notes | The tables in feature-matrix.md |
| Variants and why each leaves a feature out | config/variants/<id>.mk (build-config.md) |
| Screenshots | docs/ux/screens/<gallery>/ (make -C tests/host ui-gallery), six per radio page in two rows of three: the first of featured in site/data/screens.json the radio has. The Screenshots page (/screenshots/) shows every screen each radio's build has, a section per board, under the groups headings of screens.json, in its order. The home page's carousel lists its screens in site/templates/home.html, from every gallery, one height |
| Flash layout, codec profiles, stock files, key hashes | The board registry (tools/rfoundry/rfoundry/registry.json, read through boards.py) and stockfw.py, exported to data/flash-boards.json |
| Documentation | Every Markdown file under docs/ |
| Firmware images | Release manifests and images (--firmware; the workflow downloads the newest release) |
The build fails when a source doesn't parse, and warns (an error with --strict) when a feature has no row in the
feature matrix, a screen isn't in screens.json or names a group it doesn't list, a string is missing from the English
catalog (a screen's caption included), the Screenshots page lacks a board or a screen that board's build has, or a page
links to a file the build didn't write.
data/*.json is what the pages read and doubles as a public API for other tools: radios.json, features.json,
variants.json, flash-boards.json, screens.json, releases.json, site.json.
The Screenshots page
/screenshots/ has a section per board, from the registry in its order, and a link per board at the top. Each section
shows every shot in the board's gallery whose screens.json entry needs only switches the board's full build has (the
radio page's six use the same test). A board's screens are one width: a gallery 160 pixels wide or less at twice its
size (image-rendering: pixelated), a wider one as it is. A color page's shots in other highlight colors and themes
(<page>__<color>) follow the page, hidden until the visitor ticks "Also show other highlight colors and themes" (CSS
:has(), no script). site.js switches sections without reloading and keeps the address #<radio>, so a link can
name a radio (each radio page links to its own). Without the script, CSS shows the section the address names
(:target), else the first.
Nothing on the page names a screen, a group or a radio:
| To add | Touch |
|---|---|
| A screen | tests/host/uiSim.c (gallery[]) and make -C tests/host ui-gallery; its entry in site/data/screens.json (group, needs); its caption screen.<name> in site/i18n/en.json |
| A group | groups in screens.json and its heading screens.group.<group> in en.json |
| A radio | boards/<radio>/ as Adding a radio says; board.toml hardware.gallery must name a display class the gallery has (tools/boards.py check) |
Layout
| Path | What |
|---|---|
site/build.py |
Entry point |
site/rfoundrysite/repo.py |
Reads the sources above |
site/rfoundrysite/build.py |
Assembles the pages and data files |
site/rfoundrysite/docs.py |
docs/ to HTML: GitHub heading ids, .md links to .html, links out of docs/ to the repository |
site/rfoundrysite/device.py |
Radio images: frame, overlay geometry, boot screen composite |
site/rfoundrysite/social.py |
The link preview image (static/img/social.png, every page's og:image): each radio with a different screen, rasterized with resvg |
site/rfoundrysite/tmpl.py, site/templates/ |
Page markup (below) |
site/i18n/ |
String catalogs (below) |
site/static/css/tokens.css, site.css |
Design tokens, then layout and components |
site/static/js/site.js |
Theme toggle, menu, features filter, screen overlay on radio pages, the home page's screenshot carousel, the Screenshots page's radio links |
site/static/js/flasher/ |
The web flasher (below) |
site/static/js/codeplug/ |
Codeplug decoding and encoding, CSV, the radio transport, contacts sources (codeplug-editor.md) |
site/static/js/editor/ |
The Codeplug Editor page |
site/rfoundrysite/radioid_regions.py |
Writes codeplug/sources/regions.js from a RadioID.net user.csv (country and state names only) |
site/static/js/tools.js |
The Tools page (codeplug conversion) |
site/static/img/radios/src/<radio>.svg |
Radio illustrations (below) |
site/tests/ |
Flasher and editor tests: fixtures.py makes inputs with rfoundry, the HR_C7000 tools and tests/host/cpsSim; the *.test.mjs files check the JavaScript |
Templates and design
Markup lives in site/templates/*.html and partials/, with four kinds of placeholder: {{ value }} (escaped),
{{{ html }}} (as built), {{t key}} (a translated string) and {{> partial }}. ../../ is the path to the site
root (static files, data) and ../../ the path to the root of the page's language (links between pages). Loops
stay in Python, which renders a partial per item.
Every page's footer ends with a faint build stamp: the commit the site was built from (-dirty with uncommitted
changes, linked to the commit) and the build time in UTC (data/site.json has the same commit and the date).
All visual decisions are variables in tokens.css: colors for light and dark, type sizes, spacing, radii.
site.css only uses them. A redesign replaces tokens.css, then site.css and the templates; the generator doesn't
change. Readability comes first (principles): 18 px base type, high contrast, nothing
squeezed.
Glossary
docs/glossary.json is the one source. python3 tools/glossary.py --write generates the two glossary pages from it:
user/22-glossary.md (entries with scope user) and glossary.md (scope dev).
Never edit those pages by hand. tools/ci.sh fails when they are stale.
| Field | Meaning |
|---|---|
term |
The name as the page shows it. A plain word (lowercase, or one capital) also matches in any case and with a plural s. An acronym or mixed-case name matches exactly. |
aliases |
Other spellings and plurals. Each follows the same matching rule. |
category |
One of categories in the file. It is the page's section. |
scope |
user: listed in the user glossary, previewed in the whole site. dev: listed in the developer glossary, previewed outside the user guide. |
definition |
One or two short sentences. Say what the standard calls it when a vendor uses another name. |
link |
Optional path under docs/ of the section that explains it. The preview's "More" link goes there, else to the glossary row. |
preview |
false keeps a common word (Monitor, Zone) in the glossary but never marks it in the text. |
The site build (docs.py) wraps a term in <abbr class="gl" title="..."> at build time. The Markdown stays unmarked, so GitHub
shows plain text. Rules:
- Whole words only. Not inside code, headings, link text, a table's first column, hyphenated words or paths.
- The first use in each section (a section ends at the next
#or##heading). <!-- glossary: off -->in a page turns marking off for that page.<!-- glossary: off TG, SFR -->turns off those terms.
site.js turns each <abbr> into a popover on hover, keyboard focus and tap, with a "More" link (Enter follows it from the
keyboard). Without JavaScript the title shows the definition. site/tests/glossary_test.py tests the rules.
To add a term, add an entry to the file, run python3 tools/glossary.py --write and python3 tools/glossary.py --check.
python3 tools/glossary.py --unexplained lists all-capital words the docs use that have no entry (pin names and
identifiers with digits or underscores are skipped). Add an entry for each one that matters to a reader, or its name to excluded.
Translating
site/i18n/languages.json lists the languages. The default (English) is at the site root, others under /<code>/.
Each language has a flat catalog site/i18n/<code>.json; a missing key falls back to English. The flasher and the
Tools page get their strings from the same catalog (embedded in the page). Text that comes from the repository
(feature help, variant reasons) is English at its source; a catalog can override it with feature.<SWITCH>.title,
feature.<SWITCH>.help, variant.<id>.title, variant.<id>.summary or group.<slug>.
A translated documentation page goes in docs/i18n/<code>/<path under docs>. Pages without one are shown in
English with a notice.
To add a language: copy en.json to <code>.json, translate, add the language to languages.json.
Radio images
Each radio has an original illustration, site/static/img/radios/src/<radio>.svg, drawn from straight-on reference
photos (the photos stay outside the repository: product photos are not ours to publish). Rules:
- viewBox
0 0 W H, transparent background, no external references or embedded images; - exactly one
<rect id="screen" x y width height/>, black, transform-free and not covered by anything, on the display's visible area, with the aspect ratio of the radio's gallery (5:4 for 160x128 and 160x120i5, 22:31 for 240x320i10x5); - for the radio's web control page (
docs/spec/tcp-services.md5.1): each key it shows carriesid="key-<name>", the name being the board'sKEY_*in lower case withoutKEY_(key-fn_left); the channel knobid="encoder"and a drawn LEDid="led". Keys whose shape doesn't match a key (a d-pad drawn as one piece) get transparent hit rects (fill="none" pointer-events="all") at the end. A key off the front face (side keys, top keys) also carriesdata-edge="left|right|top|bottom", the drawing's edge it is on: the page puts a labeled button for it outside that edge. Every key of the radio is tagged, or named indata-undrawn="name ..."on the<svg>element when the drawing doesn't show it (a front view without the side keys); the page makes those buttons; tools/svg_keys.py checkchecks the tags: eachkey-id names one of the radio's keys, once, and no key is missing. The site build runs the same check, so a drawing that lost its tags fails CI. After drawing or redrawing a radio,tools/svg_keys.py tag <radio>adds the tags: by the labels in the drawing ("MENU", "1", "*"), then by the last tagged version in git, mapped onto the new drawing (edges, the LED, the knob and hit rects come along). It prints each tag and what it couldn't place. Check the result on the web control page before committing;- a radio with a keyed hand microphone may have
<radio>-mic.svgbeside its drawing, for the web control page only: the samekey-rule, the ids counted once across both files, and gradient ids of its own (the page puts both drawings in one document).
The build writes static/img/radios/<radio>-frame.svg (screen black), <radio>.svg (with the boot screen) and the
screen's position in data/radios.json (frame_geometry: viewBox units and percentages), so any screenshot can be
laid over the frame; the radio pages do that when a screenshot is clicked. A radio without an illustration gets a
generic drawing from board.toml [frame].
Error logging and bug reports
The flasher and the Codeplug Editor log what they do in memory (static/js/diag.js). Neither keeps the log after
the page closes.
| Entry | Holds |
|---|---|
| Note | A step: the radio found and its firmware, a write plan's size, a flasher step, each line of the flasher's log box |
| Wire | One line per debug link or CPS frame (link.js): command, address, length, result. Never the data |
| Error | Every error shown to the user, once, with the stage it happened in, its code and params, stack and causes |
Every error also goes to the browser console ([rfoundry] <stage> failed:). Failure messages carry Report a Problem
(static/js/report.js). It downloads a zip with report.json (the log, the browser, the tool's state) and, if the
user ticks them, the edited codeplug and the one last read from the radio.
Automatic error reports go to Sentry when the build has RFOUNDRY_SENTRY_DSN set (a production variable of the
Pages project; site.json reports.sentry_dsn). Without it nothing is sent and the privacy
note is left off the pages. With it:
| Sent | When |
|---|---|
| The error, its stage and code, the tool's state (radio model, firmware version, task; no codeplug contents) | Every error except ones the user caused (port chooser closed, Stop) |
| The whole report and any codeplug the user ticked | Only when the user clicks Send Report |
Automatic reports go through diag.scrub(), which removes callsigns, DMR IDs and email addresses. The Sentry bundle
is pinned with an SRI hash in report.js. It loads without breadcrumbs or other default integrations, so it sends only
what diag.error hands it. ?reporting=0 turns sending off, as it does for the flash log. Ad blockers often block
Sentry; the page then works the same and offers the download only.