Developer guide¶
For people who add devices, test them, answer reports or change the tool. Someone who only wants to flash a device reads the User guide.
1. Orientation¶
The tool is a Python command-line program (3.10 or newer, one dependency: PyYAML; bleak only for Bluetooth). It runs from a checkout as python ota.py; it reads
profiles, partitions and the firmware cache from there and is not meant to be installed as a package.
| Path | What is in it |
|---|---|
ota.py |
Entry point and the command line (arguments, the help text, the command functions) |
shelly_ota/ |
The modules, one topic each (below) |
devices/*.yaml, devices/profiles.lock |
One profile per device, plus the revision and content hash of each |
partitions/<Profile>-stock.csv |
Shelly's partition table as an ESPHome partitions: CSV, one per profile |
docs/ |
User docs, docs/devices/<Profile>.md one page per device |
fw/ |
Cache of the official Shelly packages (git-ignored, never committed) |
out/, logs/, devkit-configs/ |
Built ZIPs, send logs, DevKit configs (git-ignored; out/ can hold credentials) |
tests/ |
unittest suite, fixtures/ |
.github/ |
Issue forms (ISSUE_TEMPLATE/), the test workflow, labels.yml and sync_labels.py |
CHANGELOG.md, ROADMAP.md, CREDITS.md, NOTICE, LICENSE |
Project files (Apache 2.0) |
_todo/ |
Local notes and drafts (git-ignored, not part of the project) |
Modules in shelly_ota/:
| Module | Job |
|---|---|
firmware.py |
Update API, download, cache layout in fw/shelly/<Device>/<version>/, meta.json |
profile.py |
Reads and checks the profile YAML, the lock, the revision |
builder.py |
build: replace the app part, size and SHA-256, the manifest, the report, the bootloader swap checks |
bootswap.py |
Takes ESPHome's bootloader and a clean otadata out of a factory image; header and partition table checks |
compile.py |
build --compile: esphome or Docker runner, the cache volume, packages: resolution |
lint.py |
Checks an ESPHome YAML: partition table, offset, allow_partition_access |
secrets_check.py |
Looks for your credentials inside the image |
sender.py |
send / restore: model check, slot check, local web server, Shelly.Update, waits, retries |
rpc.py |
Shelly RPC client (HTTP, digest auth) |
logwatch.py, logview.py |
Reads the UDP debug log, shows progress; log --replay |
ble.py |
Bluetooth LE RPC (ble command) |
devicegen.py |
add-device: builds a profile from an official ZIP |
devkit.py |
Flash image of a package for a dev board |
buildinfo.py |
Tool version for logs and reports |
ui.py |
Messages, colors, prompts |
2. Set up¶
- Python 3.10 or newer, Git, and (for compile tests and device work) the
esphomecommand or Docker. - Clone, then:
git clone https://github.com/oxynatOr/free-shelly-ota
cd free-shelly-ota
pip install -r requirements.txt
pip install -e .[ble] # optional: editable install with Bluetooth
- Run
python ota.py -h,python ota.py profilesandpython ota.py list. - Fill the firmware cache for the devices you work on:
python ota.py fetch <Profile> --insecure. The official packages are Shelly's property and are not in the repository; many tests need them. - Check your Git identity before the first commit:
git config user.nameandgit config user.email. Use your own, or GitHub's noreply address. - Docker users: Docker has to be running for compile tests; the cache volume is
free-shelly-esphome-cache.
3. Tests and CI¶
python -m unittest discover tests
- The suite runs from the repository root.
pytestalso works (python -m pytest -q). - Tests that need the official ZIPs use
tests/needs_fw.pyand are skipped withoutfw/shelly. A fresh checkout and CI therefore run less than your machine does. Run the suite with the cache before you open a pull request. tests/test_readme.pykeeps the README table and the overview indocs/devices/README.mdin step with the profiles (counts, rows, symbols). If you add or change a profile, this test tells you what to update.- The profile revision test fails if a profile changed without a higher
revision:(step 10). - CI (
.github/workflows/tests.yml) runsunitteston Python 3.10 and 3.12 for pushes tomainand for pull requests, without the firmware cache. - New code needs a test. Prefer small synthetic inputs over copies of Shelly firmware.
4. Rules of the project¶
The rules are in CONTRIBUTING.md: evidence for every hardware claim (and not confirmed for unmeasured pins), no private data, no dumps, ZIPs or firmware in the repository, credits for reporters, the breaking-change statement, opt-in for risky behavior, English docs, and the rule for AI-assisted work. Read it before your first pull request. What follows is what needs project context.
- States. ✅ means the whole chain ran on real hardware, 🟡 flashed but part unchecked, 🔬 ran on a dev board, 🧪 builds only. Do not raise a state without a report or log that shows it. The README and the device overview must agree (
test_readme.py). - Observed or assumed. Separate what was seen ("seen on one unit") from what was read from firmware or assumed. Name the number of units.
- Results go in their tables: dev-board runs in
docs/devkit.md#seen-on-a-board, field results on the device page, each with date, device or board, and what the log showed. - Other people's devices. Before you build or recommend a package, compare the installed loader with the package's
boot_min_version(Bootloader and slots). A package that skips the bootloader looks fine and fails later. Be stricter for devices you cannot reach. - Never make a refusal pass silently. A refusal that was too strict or too loose is a bug: write a test, then fix it.
5. Branches, commits and pull requests¶
mainis the release line. Work on a branch:feature/…,fix/…,docs/…,device/…,chore/…,release/x.y.z.- Small commits, one topic each. End the message with the attribution lines your tooling requires.
- Update the CHANGELOG under
## [Unreleased](Added / Changed / Fixed, and the breaking statement). - Run the tests with the firmware cache.
- Open a pull request against
main. Describe what changed, what you tested (device, board or tests only) and the breaking/visible statement. - Issue labels live in
.github/labels.yml;python .github/sync_labels.pyprints what would change (dry run;--applyneedsgh auth login; it never deletes). - Pushing, merging, releases, comments and issue changes are decisions of the maintainer. Ask before you do any of them on someone's behalf.
6. Add a new device¶
Goal: a profile, its partition table, a device page, a place in the lists, and an honest state. Takes minutes; the hardware test is the long part.
- Get the official package of the model. Either
fetchwith a profile (if the update id is known) or download the ZIP from Shelly's update API / a device. A package is a ZIP withmanifest.json, the parts (boot,pt,app,fs, ...) and their sizes and hashes. - Read the manifest. Note
appsize, the partition names (ptn),bootmin version, whetherotadatais in the package. - Create the profile:
python ota.py add-device <official.zip>
python ota.py add-device <variant.zip> --parent <BaseProfile> # a variant, e.g. Zigbee
Check the generated devices/<Profile>.yaml against What a profile contains: app_slot_size, pt_offset, platform,
update_id, app_ptn. The profile starts at revision: 1.
4. Check the bootloader value. Set boot_min_version: "1.0.9" unless a report shows the device needs another value. Reason: devices that already run loader 1.0.3
would otherwise skip the new bootloader (min_version).
5. Write the partition CSV: python ota.py partition-csv <Profile> -o partitions/<Profile>-stock.csv. Check that the offset of the table matches pt_offset and that the app slot size
equals app_slot_size.
6. Run python ota.py profiles --update-lock and commit devices/profiles.lock with the profile.
7. Build a test package with a minimal ESPHome config (copy the structure of a similar device page) and run inspect --device. It must verify.
8. Write the device page docs/devices/<Profile>.md. Copy a similar page (for example PlugMG3.md) and keep its sections: facts table, state badges, Before you start,
ESPHome config, Pins, Flash it, What to expect, Way back, the warning block. Mark the state 🧪 and every unmeasured pin "not confirmed". Do not copy log lines from
another device.
9. Add the device to the lists:
- the overview table in docs/devices/README.md (profile, chip, table offset, slot size, state, note),
- devices/README.md (profile table),
- the README only when the state is ✅ or 🟡, and the count line,
- .github/ISSUE_TEMPLATE/device-report.yml (device list),
- docs/devkit.md (board table) and docs/commands.md only if they list profiles by name,
- CHANGELOG.md (Added, with the requester's thanks and the breaking statement: a new profile is not breaking),
- CREDITS.md if someone asked for it.
10. Run the tests. test_readme.py names what is still out of step.
11. Offer a devkit run (step 8) if a suitable board exists.
12. Open the pull request. In the description: source package version and its date, the checks done, what is untested.
A variant (Zigbee, beta layout) uses --parent or a separate profile with its own CSV; it appears as its own row. For a beta layout, see PlugMG3-beta and the
Beta firmware section; build and send refuse a package or a device that does not fit a profile's layout.
7. Find out what a device does¶
Profiles hold no pins. Pins come from three sources, in order of trust: a measurement on the board, a flash dump read with the stock firmware's strings, a dump analysis.
- Dump the stock flash over UART (the device off mains):
esptool read-flash 0x0 <size> dump.bin, then check flash encryption and secure boot withespefuse summary(Recovery over UART). The dump holds device secrets: keep it local. - Find the layout with the
analyze-shelly-dumpskill in the project folder (parse_partitions.py): partition table, offset, flash size, chip hints, firmware version. - Read the stock app for pin names, drivers (BL0942, relay, LED, NTC, button) and calibration. Decompiling is covered by the
reverse-engineeringskill; write down the source of every finding. - Verify on hardware with a small ESPHome config that only switches a pin or reads a sensor. A pin is "confirmed" only when seen working on a real unit.
- Put the results on the device page with their source. Longer write-ups (stock behavior, a bootloader analysis) belong in the notes area, not in the user docs.
- If a datasheet or stock behavior surprised you (for example the BL0942 RX pull-up on the PM Mini, the multiplexed CF/ZX pin, active-low RGB LED), note it on the page; others will hit it.
8. The test ladder¶
Each rung proves more and costs more. State the rung in the PR and on the device page.
| Rung | What you do | What it proves | State |
|---|---|---|---|
| 1 | build + inspect --device |
The package is well formed and fits | 🧪 |
| 2 | DevKit: ota.py devkit <Profile>, flash a spare board, send |
The installer, the bootloader swap, the slot logic and the log run | 🔬 |
| 3 | Real device over OTA | The chain works on the hardware | 🟡 |
| 4 | Real device: relay, meter, button, LED, a second OTA | The device really works | ✅ |
DevKit rules:
- Use a spare board that matches the chip and flash size (see the table in devkit.md). Never a device.
python -m esptool --port COMx flash-idfirst;espefuse summarybefore and after the first update (the updater may burn BLOCK3).- Take the log from
send --log-file; the first run needsrestoreif the target slot is 1. - Add a row to Seen on a board with date, board, profile and what the serial monitor and
send --watchshowed. Then set the profile to 🔬. - The board has no relay, meter or button; do not report those as checked.
9. Handle a device report¶
- Read the whole form and the log. Check
ota.py --versionof the report, the profile revision, the stock version, and the installed loader (Boot: cur ...). - Classify what happened: ran through / refused by a tool check / device did not download / wrong slot / bootloader skipped (
update? 0) / silent after the update / hardware quirk. - Compare with the device page and with similar reports. Look for the known causes first: slot not 0,
min_version, power cycle, layout, signal strength, VLAN. - Reproduce on a DevKit board when you can (the same stock version as the report:
devkit --zipor--version). - Update the docs:
- state symbol and the Note column (README and
docs/devices/README.md), - the device page: what the reporter saw, versions, loader, quirks,
- the CHANGELOG (
Changed), with the reporter's name and issue link and the "not breaking" reason for a docs-only change, CREDITS.md.- Answer in the issue in the reporter's language when you can, in plain words, with the next step. Ask for a log if missing (clean of passwords, keys and MACs). Do not post from someone else's account or change issues without the maintainer's go-ahead.
- If the tool was wrong, write a failing test first, then fix. A refusal that was too strict or too loose is a bug of the tool.
10. Change a profile¶
- Edit
devices/<Profile>.yaml. - Raise
revision:by one. python ota.py profiles --update-lock.- Update
partitions/<Profile>-stock.csvif the layout changed (partition-csv), and the device page. - CHANGELOG: this is visible (the build report shows the new revision). Say if it is breaking: a changed
boot_min_versionorapp_slot_sizecan makebuildrefuse an old command. - Run the tests; the revision test fails if you skip step 2 or 3.
11. Change the tool¶
- Find the module in the table above. The command line lives in
ota.py, the behavior inshelly_ota/. - Write the test first in
tests/test_<topic>.py. Use fixtures intests/fixtures/, no real firmware. - Keep refusals specific: say what is wrong, the values, and a
Fix:line. See the messages inbuilder.pyandbootswap.py. - New options: add them to the argument group in
ota.py, topython ota.py <command> -h, to Commands (table, default, description), and to the CHANGELOG.tests/test_help.pychecks that the help text is in order. - New behavior that risks a device is opt-in.
- Messages use the helpers in
ui.py(WARNING:,NOTE:,Error:,OK:stay in the text so logs read the same). - Update the docs that mention the behavior: the device pages, Troubleshooting, Bootloader and slots.
- Do not commit anything from
fw/,out/orlogs/. - Run
python -m unittest discover testswith and withoutfw/populated.
Where the real tool behavior is subtle, read these before changing it: the send flow in sender.py (model check → slot check → reachability → update → watch), the bootloader
swap in bootswap.py, and the reasons in Bootloader and slots.
12. Release¶
Semantic Versioning; 0.x is experimental. The version is __version__ in shelly_ota/__init__.py; python ota.py --version prints it with the git state.
- Create
release/x.y.zfrommain. - Move
[Unreleased]inCHANGELOG.mdto## [x.y.z] - YYYY-MM-DDand add a Summary with the breaking and visible changes, even when there are none. Update the counts in the README (N profiles: ✅ … 🟡 … 🔬 … 🧪 …) andROADMAP.mdstate line. - Raise
__version__. - Run the whole suite with the firmware cache, then
python ota.py profiles(the lock must be clean). - Update
docs/versions.mdonly if the version format changed. - Open the pull request; the maintainer merges it.
- The maintainer tags
vx.y.zand creates the GitHub release with the CHANGELOG entry. Do not tag, push or release on your own. - Announcement drafts (forum, Reddit, Discord) are written separately in the notes area and posted by the maintainer.
- After the release: delete merged branches only when the maintainer says so.
13. Related repositories and tools¶
oxynatOr/esphome-configs: ready-made ESPHome configs for devices (strips, bulbs, plugs), built to work with this tool. It has its own rules and checks; configs for a device belong there, not in the docs here.oxynatOr/esphome-kp18058: the KP18058 light component used by the Multicolor Bulb.- ESPHome devices database (devices.esphome.io): device pages for the community. Pages drafted for the Plug M, Plug PM and a Plug S correction are kept as drafts; they have not been submitted.
- Tasmota's
mgos32-to-tasmota32and the Plug US and Gen4 projects: the work this tool builds on; see Credits. - Skills in the project folder:
analyze-shelly-dump(partition table and layout from a dump) and the reverse-engineering skill (for stock firmware and loader analysis).
Checklists¶
New device (PR)
- Profile from the official package,
revision: 1,boot_min_versiondecided -
partitions/<Profile>-stock.csv, offset and slot size match -
profiles.lockupdated - Test package builds,
inspect --deviceclean - Device page with 🧪, pins "not confirmed" unless measured
- Lists updated (devices overview, profile table, issue form, README if ✅/🟡, devkit table)
- CHANGELOG entry with the breaking statement and thanks
- Tests pass with the firmware cache; no secrets, dumps or ZIPs in the diff
Report handled
- State, note and counts updated in README and overview
- Device page, CHANGELOG and
CREDITS.mdname the reporter - Reply drafted; no personal data in anything public
- Tool bug? A test first
Release
- CHANGELOG with the Summary and breaking/visible statement
- Version, README counts and ROADMAP date set
- Suite green with the cache; lock clean
- PR open; tag and release left to the maintainer
Tip
Not sure where something belongs? Open an issue or ask in the pull request. Small fixes to the docs are always welcome.