Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .github/workflows/ci-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@ concurrency:
cancel-in-progress: true

jobs:
boards-sync:
name: Boards sync check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Check env names are in sync
run: python3 build/scripts/check_boards_sync.py

build:
name: Build (${{ matrix.env }})
needs: [boards-sync]
runs-on: ubuntu-latest
strategy:
matrix:
env: [esp32_wroom, esp32_c3, esp32_s3]
env: [esp32_n4, esp32c3_n4, esp32s3_n4r2]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ jobs:
strategy:
matrix:
include:
- env: esp32_wroom
- env: esp32_n4
chip: esp32
bootloader_offset: "0x1000"
- env: esp32_c3
- env: esp32c3_n4
chip: esp32c3
bootloader_offset: "0x0"
- env: esp32_s3
- env: esp32s3_n4r2
chip: esp32s3
bootloader_offset: "0x0"
steps:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ A fully 3D-printed, modular split-flap display with an ESP32-based web interface

## Flash Firmware

Comment thread
DrewFerg11 marked this conversation as resolved.
You don't need PlatformIO or a build environment to get started. **[Flash the latest firmware straight from your browser](https://drewferg11.github.io/Split-Flap-Display/firmware/install-web-flasher/)** over USB. No code, no compilers, just a data cable and Chrome, Edge, or Opera on desktop. The installer auto-detects your board (WROOM, C3, or S3) and walks you through Wi-Fi setup right after flashing.
You don't need PlatformIO or a build environment to get started. **[Flash the latest firmware straight from your browser](https://drewferg11.github.io/Split-Flap-Display/firmware/install-web-flasher/)** over USB. No code, no compilers, just a data cable and Chrome, Edge, or Opera on desktop. The installer auto-detects which chip you have (ESP32, ESP32-C3, or ESP32-S3) and walks you through Wi-Fi setup right after flashing.

Prefer to build it yourself? See the [manual install guide](https://drewferg11.github.io/Split-Flap-Display/firmware/install-manual/).

Expand All @@ -28,6 +28,7 @@ Prefer to build it yourself? See the [manual install guide](https://drewferg11.g
- **WiFi configuration:** set up via access point on first boot, then connects to your network
- **MQTT support:** integrate with Home Assistant or other MQTT brokers
- **OTA updates:** update firmware and filesystem over the air without USB
- **Multiple ESP32 boards supported:** ESP32, ESP32-C3, and ESP32-S3 — see [supported hardware](https://drewferg11.github.io/Split-Flap-Display/firmware/hardware/) for specific boards and chip details

## Contributing

Expand Down
35 changes: 35 additions & 0 deletions build/boards.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
{
"env": "esp32_n4",
"label": "ESP32 DevKit (30-pin)",
"flash": "4 MB",
"psram": null,
"chipFamily": "ESP32",
"chip": "esp32",
"bootloaderOffset": "0x1000",
"littlefsOffset": "0x310000",
Comment thread
DrewFerg11 marked this conversation as resolved.
"released": true
},
{
"env": "esp32c3_n4",
"label": "ESP32-C3 SuperMini / C3-Zero",
"flash": "4 MB",
"psram": null,
"chipFamily": "ESP32-C3",
"chip": "esp32c3",
"bootloaderOffset": "0x0",
"littlefsOffset": "0x310000",
"released": true
},
{
"env": "esp32s3_n4r2",
"label": "ESP32-S3 SuperMini / S3-Zero",
"flash": "4 MB",
"psram": "2 MB quad",
"chipFamily": "ESP32-S3",
"chip": "esp32s3",
"bootloaderOffset": "0x0",
"littlefsOffset": "0x310000",
"released": true
}
]
Comment thread
DrewFerg11 marked this conversation as resolved.
158 changes: 158 additions & 0 deletions build/scripts/check_boards_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# FAIL CI IF THE ENV NAME LISTS DRIFT APART
#
# The env name for a board is written down in four places: platformio.ini
# (source of truth for what actually builds), ci-build.yml's PR matrix,
# release.yml's release matrix, and build/boards.json (the metadata the
# release scripts read). A prior drift incident let a board build in CI
# while being invisible to the release pipeline - see
# plans/ESP-BOARDS-PART-1-PLAN.md. This script is the guard against that
# recurring. It also checks the per-board chip and bootloader offset, which
# release.yml's matrix duplicates from boards.json and which differ per chip.
#
# Usage: check_boards_sync.py
# Exits non-zero (with a description) on any disagreement.

import json
import os
import re
import sys

ROOT = os.path.join(os.path.dirname(__file__), "..", "..")

NAMING_RULE = re.compile(r"^esp32([a-z]\d+)?_n\d+(r\d+)?(_ota)?$")


def read(path):
with open(os.path.join(ROOT, path), encoding="utf-8") as f:
return f.read()


def pio_envs():
text = read("platformio.ini")
names = re.findall(r"^\[env:([A-Za-z0-9_]+)\]", text, re.MULTILINE)
names = [n for n in names if n != "ota"]
build_envs = [n for n in names if not n.endswith("_ota")]
ota_envs = [n for n in names if n.endswith("_ota")]
return build_envs, ota_envs, names


def ci_build_envs():
text = read(".github/workflows/ci-build.yml")
m = re.search(r"env:\s*\[([^\]]+)\]", text)
if not m:
raise RuntimeError("could not find env matrix in ci-build.yml")
return [e.strip() for e in m.group(1).split(",")]


def release_matrix():
# Parse release.yml's build matrix into one dict per entry, e.g.
# {"env": "esp32_n4", "chip": "esp32", "bootloader_offset": "0x1000"}.
# Hand-rolled rather than pyyaml so this stays dependency-free like the
# rest of the checks here.
text = read(".github/workflows/release.yml")
m = re.search(r"^(\s*)include:\s*$", text, re.MULTILINE)
if not m:
raise RuntimeError("could not find matrix include block in release.yml")
indent = len(m.group(1))
entries = []
for line in text[m.end() :].splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
if len(line) - len(line.lstrip()) <= indent:
break # dedented back out of the include block
if stripped.startswith("- "):
entries.append({})
stripped = stripped[2:]
if not entries:
continue
kv = re.match(r"([A-Za-z0-9_]+):\s*(.*)$", stripped)
if kv:
entries[-1][kv.group(1)] = kv.group(2).strip().strip("\"'")
return entries


def norm_offset(value):
# Compare flash offsets numerically so "0x0" and "0x00" don't false-fail.
try:
return int(str(value), 16)
except (TypeError, ValueError):
return value


def boards_json():
with open(os.path.join(ROOT, "build", "boards.json"), encoding="utf-8") as f:
return json.load(f)


def main():
errors = []

build_envs, ota_envs, all_pio_envs = pio_envs()
ci_envs = ci_build_envs()
rel_matrix = release_matrix()
rel_envs = [e["env"] for e in rel_matrix if "env" in e]
boards = boards_json()
boards_all = [b["env"] for b in boards]
boards_released = [b["env"] for b in boards if b["released"]]

if set(build_envs) != set(ci_envs):
errors.append(
"platformio.ini build envs %s != ci-build.yml matrix %s"
% (sorted(build_envs), sorted(ci_envs))
)

if set(build_envs) != set(boards_all):
errors.append(
"platformio.ini build envs %s != build/boards.json envs %s"
% (sorted(build_envs), sorted(boards_all))
)

if set(rel_envs) != set(boards_released):
errors.append(
"release.yml matrix envs %s != build/boards.json released envs %s"
% (sorted(rel_envs), sorted(boards_released))
)

# release.yml duplicates each board's chip and bootloader offset in its
# matrix, and those two DO vary per board (0x1000 on ESP32, 0x0 on C3/S3).
# Nothing else catches a mismatch: the merged factory image would be built
# with the wrong bootloader offset and the board simply wouldn't boot.
boards_by_env = {b["env"]: b for b in boards}
for entry in rel_matrix:
env = entry.get("env")
board = boards_by_env.get(env)
if board is None:
continue # unknown env is already reported by the set comparison above
for yml_key, json_key, normalize in (
("chip", "chip", str),
("bootloader_offset", "bootloaderOffset", norm_offset),
):
if yml_key not in entry:
errors.append(
"release.yml matrix entry '%s' is missing '%s'" % (env, yml_key)
)
elif normalize(entry[yml_key]) != normalize(board.get(json_key)):
errors.append(
"release.yml %s for env '%s' is '%s' but build/boards.json %s is '%s'"
% (yml_key, env, entry[yml_key], json_key, board.get(json_key))
)

for name in all_pio_envs:
if not NAMING_RULE.match(name):
errors.append(
"env '%s' does not match the naming rule esp32[<chip>]_n<MB>[r<MB>][_ota]"
% name
)

if errors:
print("check_boards_sync: FAILED")
for e in errors:
print(" - " + e)
sys.exit(1)

print("check_boards_sync: OK (%d board envs in sync)" % len(build_envs))


if __name__ == "__main__":
main()
15 changes: 9 additions & 6 deletions build/scripts/generate_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
# Usage: generate_manifest.py <version> <download-base-url> <out-dir>

import json
import os
import sys

BOARDS = [
{"env": "esp32_wroom", "chipFamily": "ESP32"},
{"env": "esp32_c3", "chipFamily": "ESP32-C3"},
{"env": "esp32_s3", "chipFamily": "ESP32-S3"},
]
BOARDS_JSON = os.path.join(os.path.dirname(__file__), "..", "boards.json")


def load_boards():
with open(BOARDS_JSON) as f:
boards = json.load(f)
return [b for b in boards if b["released"]]


def asset_name(env, version, kind):
Expand All @@ -22,7 +25,7 @@ def asset_name(env, version, kind):

def build_manifest(version, download_base_url, kind, offset):
builds = []
for board in BOARDS:
for board in load_boards():
filename = asset_name(board["env"], version, kind)
builds.append(
{
Expand Down
25 changes: 16 additions & 9 deletions build/scripts/render_release_notes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
# the AI step failed or was skipped, the file is missing/empty and this section is
# omitted entirely. Never blocks a release.

import json
import os
import sys

Expand All @@ -31,11 +32,13 @@
DISCORD_URL = "https://discord.gg/RCvks4XXXH"
ISSUES_URL = "https://github.com/DrewFerg11/Split-Flap-Display/issues"

BOARDS = [
("esp32_wroom", "ESP32 (WROOM)"),
("esp32_c3", "ESP32-C3"),
("esp32_s3", "ESP32-S3"),
]
BOARDS_JSON = os.path.join(os.path.dirname(__file__), "..", "boards.json")


def load_boards():
with open(BOARDS_JSON) as f:
boards = json.load(f)
return [b for b in boards if b["released"]]


def install_section():
Expand All @@ -49,7 +52,8 @@ def install_section():

def flash_table(meta_dir):
rows = []
for env, label in BOARDS:
for board in load_boards():
env, label = board["env"], board["label"]
path = os.path.join(meta_dir, "flash-%s.txt" % env)
if not os.path.exists(path):
continue
Expand All @@ -74,6 +78,10 @@ def flash_table(meta_dir):
)


def boards_supported_section():
return "\n".join("- %s" % board["label"] for board in load_boards())


def ai_summary_section(summary_file):
if not summary_file or not os.path.exists(summary_file):
return ""
Expand Down Expand Up @@ -108,9 +116,7 @@ def main():
{prerelease_note}
{summary}
### Boards supported
- ESP32 (WROOM)
- ESP32-C3
- ESP32-S3
{boards_supported}

### Installation
{install_section}
Expand All @@ -124,6 +130,7 @@ def main():
version=version,
prerelease_note=prerelease_note,
summary=ai_summary_section(summary_file),
boards_supported=boards_supported_section(),
install_section=install_section(),
docs=DOCS_URL,
flash_table=flash_table(meta_dir),
Expand Down
2 changes: 1 addition & 1 deletion docs/build/dual-display/bom.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Required for all builds regardless of power or board option.

| Item | Qty | Notes | Link |
|------|-----|-------|------|
| ESP32 DevKit V1 (ESP-WROOM-32) | 1 | | [Amazon](https://a.co/d/09N3pLDK) |
| ESP32 DevKit (30-pin) | 1 | | [Amazon](https://www.amazon.com/dp/B09GK74F7N) |
| 5V 28BYJ-48 Stepper Motor | 16 | | [Amazon](https://a.co/d/0iAvSJma) |
| A3144 Hall Effect Sensor | 16 | | [Amazon](https://a.co/d/0gNrxkEA) |
| N52-D3x1mm magnet | 16 | Stronger than the original design magnets | [Amazon](https://a.co/d/032d6gsi) |
Expand Down
4 changes: 2 additions & 2 deletions docs/build/dual-display/power.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ TODO

Both options deliver power to the first modules (+5V and GND), which daisy-chains through all subsequent boards. See the [Controller Board Assembly](build-guide/controller-board.md) for the full connection diagram.

!!! danger "Don't power through the ESP32 USB port"
Avoid powering the display chain through the ESP32's USB port, especially on smaller variants (ESP32-C3, ESP32-S3). This works fine for testing a few modules at a time, but not the entire display. The USB traces aren't rated for motor current and will overheat. Power the boards directly from the 5V supply.
!!! danger "Don't power the full chain through the ESP32 USB port"
Powering a few modules at a time through USB is fine for testing — that's not enough current to be a problem. What you shouldn't do is run the full display that way: no board's USB traces are rated for that much motor current, and they will overheat. The smaller ESP32-C3 and S3 boards have much less current headroom than the full-size ESP32 and are the most likely to actually be damaged pushing USB power too far, so be extra cautious with those. Once you're building out the full display, power the boards directly from the 5V supply.
3 changes: 3 additions & 0 deletions docs/build/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ There are two build options for this project:
| Enhanced flap accuracy[^3] | ✅ | ✅ |
| DIY PCB option | ✅ | ✅ |
| Purchased PCB option | ✅ | ✅ |
| Supported ESP32 boards[^4] | ESP32, ESP32-C3, ESP32-S3 | ESP32 |

[^1]: _Code is backwards compatible for non-dual setups_

[^2]: _Square enclosure (currently) only supports 37. Original (rounded) enclosure supports both counts._

[^3]: _Only comes with the square enclosure._

[^4]: _Dual display currently requires the ESP32 (`esp32_n4`) — it's the only board built with two hardware I²C buses enabled. The S3 has the same two-I²C-controller capability but that build isn't planned for the S3 SuperMini/Zero (found less reliable and slower than the ESP32 in testing) — the upcoming 44-pin S3 board is a different, more capable design and will get it instead. The C3 has only one I²C controller, so it can't do dual regardless. See [Supported Hardware](../firmware/hardware.md) for chip details and specific boards._
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
The firmware uploads in two parts — the firmware binary and the **LittleFS filesystem** (which contains the web interface). If the web interface is blank or missing, you likely only uploaded the firmware. Run the filesystem upload step as well. See the [firmware setup guide](firmware/install-manual.md).

??? question "How do I update the firmware over Wi-Fi (OTA)?"
Set an OTA password in the settings page, then add that password as the `auth` flag in `platformio.ini` and use an `*_ota` environment (e.g. `esp32_wroom_ota`) for subsequent uploads.
Set an OTA password in the settings page, then add that password as the `auth` flag in `platformio.ini` and use an `*_ota` environment (e.g. `esp32_n4_ota`) for subsequent uploads.

??? question "Does it support MQTT / smart home integration?"
Yes — the firmware has built-in MQTT support, including dual-bus awareness. You can publish display updates from Home Assistant, Node-RED, or any MQTT broker. Configure the broker connection in the settings page after flashing.
Expand Down
Loading
Loading