# installer — the offline Windows flasher for a sub vendo

The program a shop uses to put firmware onto a NodeMCU sub vendo, and
to read the station MAC that has to be written on the enclosure. It
must work in a shop with no signal: the `.bin`, esptool and the CH340
driver travel inside it, and nothing here resolves a hostname, phones
home, or asks for an account.

A flashed NodeMCU is inert until an operator with console access
approves it against a specific main vendo. Entitlement does not belong
in this program, and putting it here would only produce a tool that
fails in the one place it is used. The comparable competitor's flasher
requires internet; this one not needing it is a deliberate advantage,
not parity.

This directory is the sources and the build. The `.exe` is produced on
Windows. This machine is macOS and cannot emit that artefact —
PyInstaller does not cross-compile. Do not run PyInstaller here and
call the result the installer.

The design is section 11 of `.claude/plans/sub-vendo-firmware-v1.md`.
This README is how to build it, how to use it, and what a tech does
when something goes wrong.

## What it does

1. Writes the firmware this installer was built with onto a NodeMCU
   (ESP-12E / DevKit V3 / LoLin, board id `nodemcuv2`). There is no
   file picker. An operator cannot flash a different image than the
   one support will assume.
2. After a successful write, reads the **station MAC** via
   `esptool read_mac` and shows it large, with a copy button and the
   instruction to write it on the enclosure. On ESP8266 the eFuse base
   MAC *is* the station address; the soft-AP address is derived from
   it, and a sticker on the module is often that AP address or nothing
   at all. The console enrols the station.
3. Cross-checks by reading the sketch's boot banner after reset. The
   sketch prints, at 115200 baud:

   ```
   sub vendo
   sta=<mac>  setup_ap=<ssid>  heap=<n>
   ```

   Those two lines are how we know the image that booted is the sub
   vendo sketch. The banner does not yet carry a firmware version
   (`FW_VERSION` has not landed in the sketch); when it does, a `fw=`
   field is picked up automatically. Until then the firmware version
   comes from the signed manifest rather than from the unit — see
   Versioning.
4. Optionally **erases everything and writes a fresh image**, which
   wipes pairing stored in LittleFS. That is the factory reset. It is
   described in those words because "factory reset" is not what a
   customer thinks it is, and writing `firmware.bin` at `0x0` alone
   does *not* clear LittleFS — the filesystem lives past the sketch.

It does **not** send `PAIR <code>` over serial. The firmware does not
accept that line today; building the installer half of something the
sketch cannot take would be a dead control. Future work, when the
sketch grows the 30-second window described in the plan.

It does **not** include a web flasher. Considered and deferred: a page
that uses WebSerial needs HTTPS, cannot be served from a main vendo,
needs internet at flash time, and is a second application to keep
alive. Additive later, not a substitute for this program.

## Versioning

Two numbers, and they moved apart on purpose.

`VERSION` in this directory is **the flasher's own version**. Bump it when
the flasher changes. Do not bump it for a firmware release.

The firmware carries its own version inside its signed manifest, and the
flasher reads it out: the title bar, the status line and the flash log all
name it, so "which image did you flash?" is answered from the screen the
tech is looking at rather than inferred from which `.exe` they happened to
have.

This used to be one number, and the file that said so
(`flasher/version.py`) explains at length why it changed and what stops the
split costing a support conversation. The short version: a firmware
declares `min_installer_version`, so an old flasher handed a newer image
refuses it in words rather than writing something it does not understand.

## How the `.bin` reaches a unit

Not inside this program. The image is cut and signed on the release
machine:

```
pio run -d firmware -e nodemcuv2
manage.py build_firmware_bundle --version X.Y.Z --key <update key>
```

That writes `dist/sub-vendo-firmware-X.Y.Z.vendofw` — the image plus a
manifest signed with the fleet's update key, the same key a box checks a
release bundle against. Ship that file to the operator; it goes in the
folder they unzipped, next to `SubVendoFlasher.exe`.

The flasher takes the one `.vendofw` beside it, checks the signature
against `update-key.pub` built into the program, checks the image against
the digest in that signed manifest, and refuses anything that fails either.
Two `.vendofw` files in the folder is also a refusal: it will not choose
for you, because then nobody could say which image a unit got.

**What this bought.** A sketch change is now a signed file to send, not a
Windows build and a fresh unsigned `.exe` for every operator to talk
SmartScreen past. **What it did not cost.** An operator still cannot flash
an image that was not released — that used to be true because the bytes
were inside the program, and it is now true because nobody without the
update private key can produce a file this will write. There is still no
file picker.

`payload/` is documented in `payload/README.md`. Nothing in it is
committed except that file.

## The CH340 driver

The DevKit uses a WCH CH340, USB ID `1A86:7523`, not a CP2102. macOS
enumerates it on its own (confirmed against a real unit on 2026-08-24).
Windows does not, which is why the installer has to carry a driver at
all.

| | |
|---|---|
| File | `CH341SER.EXE` — WCH's one-click Windows installer |
| Chip | CH340, USB ID `1A86:7523` |
| Download | [CH341SER.EXE](https://www.wch-ic.com/downloads/CH341SER_EXE.html) from WCH, or the [ZIP](https://www.wch-ic.com/downloads/CH341SER_ZIP.html) of the same installer. wch.cn carries the same files. |
| Place it at | `firmware/installer/payload/CH341SER.EXE` **before** `build.bat` |

`build.bat` will not produce an installer without it, and it will
refuse a file that is not a Windows executable. Do not substitute a
random "CH340 driver" from a mirror: the vendor's installer is the
one a tech can rerun if Windows Update later breaks the device, and
it is the one the flasher's **Install USB driver** button launches
(elevated). The same `.exe` is also placed loose next to
`SubVendoFlasher.exe` in the zip, so it can be double-clicked without
opening the flasher.

This tree cannot download the driver. A build on a machine that has
not been given the file stops, on purpose.

## How to build it on Windows

One command, from this directory, on a Windows machine that has:

- Python 3.11 or newer from [python.org](https://www.python.org/downloads/),
  with "Add python.exe to PATH" ticked. Not the Microsoft Store alias.
- `payload/CH341SER.EXE` already placed (see above).
- `update-key.pub` present in this directory. No firmware image is needed to
  build the installer any more; it is a separate signed file (see above).

```
build.bat
```

That creates `.venv`, installs the pins in `requirements.txt` and
`requirements-build.txt`, copies and hashes the firmware, checks the
driver, and runs PyInstaller. The artefacts:

```
dist/SubVendoFlasher.exe
dist/sub-vendo-flasher-<version>/
  SubVendoFlasher.exe
  CH341SER.EXE
  README.txt
dist/sub-vendo-flasher-<version>.zip
```

Ship the zip. The `.exe` inside is unsigned until a code-signing
choice is made (see below).

To exercise the same program from source on a developer machine, from
this directory. The GUI needs Tk; python.org Windows builds include it,
Homebrew Python on macOS often does not (`brew install python-tk@3.13`).
The CLI flags below do not need Tk.

```
python -m venv .venv
.venv\Scripts\pip install -r requirements.txt     (Windows)
.venv/bin/pip install -r requirements.txt         (macOS / Linux)
.venv\Scripts\python -m flasher                   (Windows, GUI)
.venv/bin/python -m flasher                       (macOS / Linux, GUI)
.venv/bin/python -m flasher --print-payload
.venv/bin/python -m flasher --list-ports
.venv/bin/python -m flasher --read-mac --port COM3
```

`--read-mac` does not write flash. It is safe against a unit another
workstream is using.

On macOS, `python build.py --stage-only` (or just `python build.py`)
pins the `.bin` so the GUI can run from source. It will not emit a
Windows `.exe`, and it will say so.

Tests, from this directory, so the Django pytest.ini at the repo root
is not the one that wins:

```
.venv/bin/python -m pytest
```

## How to use it in a shop

1. If Windows has never seen a NodeMCU, click **Install USB driver**
   and finish WCH's installer (Administrator). Then **Refresh**.
2. Plug in the **bare module** on USB. Unplug the coin acceptor and
   the relay first. A unit with those attached often does not appear
   as a port at all: the coil inrush drags the rail down and the
   CH340 never enumerates. It looks like a board that is powered and
   invisible.
3. Pick the port. CH340 devices sort to the top and are labelled.
   There is no hold-the-button step: the DevKit wires DTR/RTS to
   RST/GPIO0, and esptool resets unaided (confirmed 2026-08-24).
4. Leave **Erase everything** off for a firmware-only write (pairing
   on the unit is kept). Turn it on when the unit is moving to a
   different main vendo, or when stored pairing must go.
5. Click **Flash**. Wait. On success the station MAC is the large
   number. **Copy** it. Write it on the enclosure.
6. The boot banner underneath is the cross-check. If it says
   `sub vendo` and the `sta=` line matches, the image that booted is
   the one this installer writes.

**Read station MAC** is the same readout without writing flash: use it
when the label came off, or when a flash succeeded and the readout
did not.

The unit then still needs Wi-Fi credentials and a pair code, typed at
its own provisioning access point (`avifi-setup-` plus the last three
octets of the station MAC). This program does not do that trip.

## What a tech does when something goes wrong

**The unit does not appear as a port.** Install the CH340 driver on
Windows. Unplug the acceptor and the relay. Try another cable and a
USB2 port — some USB3 controllers never talk to a CH340. Bluetooth
COM ports on the same machine are a red herring; pick the CH340.

**Flash fails part-way.** Bare module, USB2, try again. Leave Erase
off unless you intend to wipe pairing. A CH340 on a tired hub is the
usual cause, not a corrupt `.bin`.

**Flash said it succeeded, MAC is blank.** The image is already on
the chip. Unplug, replug, **Read station MAC**. Do not flash again
with Erase on for a serial hiccup — that wipes pairing.

**Boot banner missing, MAC present.** Write the esptool MAC on the
enclosure anyway. The banner is the cross-check, not the source of
the address. Read station MAC after an unplug/replug if you want the
`sub vendo` line.

**Boot banner MAC disagrees with esptool.** On ESP8266 they should be
the same. Write the esptool value, tell support, reflash.

**The unit joins the shop Wi-Fi and never appears on the console.**
The main vendo identifies a unit from its own ARP neighbour table, so
the unit's frames must arrive carrying the unit's own MAC. An access
point doing NAT rather than bridging presents everything behind it as
one address, and two sub vendos collapse onto one row. The symptom is
indistinguishable from a firmware fault. Bridge, do not NAT.

**"Windows protected your PC" / SmartScreen / antivirus quarantine.**
See Code signing below. Until a certificate is bought, the operator
path is More info → Run anyway, and a tech talking someone through
that is expected.

## Code signing — the choice that still has to be made

An unsigned `.exe` draws SmartScreen warnings and antivirus false
positives. Flashing tools are a well-known false-positive category:
they bundle a serial driver, they write to hardware, and they are
frequently repackaged. Discovering this on the first support call is
the expensive way. There is no third option that makes the warning
go away for free.

As of 2026:

| Option | What it buys | What it costs | What it does not buy |
|---|---|---|---|
| Ship unsigned | Nothing. Every operator hits "More info → Run anyway", and Defender / third-party AV will occasionally quarantine the file. | $0, plus support time on every first run. | A publisher name. Reputation. |
| OV (organisation-validated) Authenticode certificate | The publisher name on the file. SmartScreen *eventually* quiets down as download volume builds. | Roughly **$200–$400 / year** at a major CA (Sectigo toward the low end, DigiCert toward the high). Resellers exist around $100. Keys now live on a USB token or a cloud HSM, not a `.pfx` in a drawer. | Instant SmartScreen reputation. Since 2024/2026 Microsoft no longer grants EV a shortcut here either. |
| EV (extended-validation) Authenticode certificate | Stronger vetting, organisation name as a verified publisher, required for kernel-mode drivers (this program is not one). | Roughly **$300–$700 / year**, plus the token. | Instant SmartScreen bypass. That EV perk has been removed. |
| Azure Artifact Signing (Trusted Signing) | Cloud signing, no token to ship, short-lived certs. | **~$10 / month** (Basic, 5,000 signatures). | Availability. Microsoft currently offers this to US / Canada / EU / UK entities. A Philippine organisation typically cannot use it. |

Recommendation for the product owner: **budget an OV certificate**
unless the signing entity is in Azure's eligibility list, in which
case Artifact Signing is cheaper and simpler. EV is not worth the
premium for this program. Signing must also timestamp (RFC 3161), or
the signature dies with the certificate.

Until that lands, `build.bat` produces an unsigned zip and says so.
The PyInstaller spec sets `upx=False` because UPX'd binaries are a
second, independent AV tripwire on top of the unsigned-flasher one.

esptool is GPL-2.0. The packaged `.exe` includes it, so the
distributed installer is a combined work of this program and esptool.
That is the same arrangement NodeMCU PyFlasher ships under.

## What this tree was tested on, and what only Windows can verify

Runnable here (macOS):

- The unit tests in `tests/` (banner parse, MAC canonicalisation,
  esptool argument construction, firmware signature and digest checks,
  driver-file rejection, CLI flags).
- `python build.py --stage-only` — checks the driver and the public key,
  and confirms it bundles no firmware.
- `python -m flasher --print-payload` and `--list-ports`.
- The functions the window calls (`flash_unit`, `read_station_mac`,
  payload pin, esptool argv). `--read-mac` was **not** pointed at a
  board: `/dev/cu.usbserial-110` is not present, no CH340 enumerated.
  This tree will not flash a unit another workstream is using.

Not runnable here, and not claimed:

- `build.bat` / PyInstaller producing `SubVendoFlasher.exe`.
- The GUI window itself: Homebrew Python 3.13 has no `_tkinter`.
  python.org Windows builds include Tk; that is where the window
  is verified.
- SmartScreen, Defender, and the CH340 driver installer.
- A real flash of a NodeMCU from the Windows GUI, including Erase
  everything, the post-flash MAC readout, and the boot-banner
  cross-check on that OS.
- COM port names, UAC elevation of `CH341SER.EXE`, and clipboard
  behaviour under Windows.

The developer flash path remains `pio run -e nodemcuv2 -t upload` on
macOS. No macOS or Linux installer is in v1.
