Skip to content

Standalone Usage

The WiFi Fan Controller works completely on its own, without Home Assistant and without pulling optional modules from an external URL. This page covers three things: running the board standalone with its own web interface, loading modules from files already on your disk, and grouping module entities into named sections in that web interface.

Home Assistant is optional, not required. When the board runs standalone, api: can be dropped from your configuration entirely, since there is no Home Assistant instance for it to authenticate against. Its replacement is ESPHome’s built-in web_server: component at version: 3, served directly from the board at http://<board-ip>, with no cloud service and no companion app.

The web server is not enabled by default, and this page does not change that. It was removed from every configuration on 2026-03-06 in commit 895a55d, in response to issue #21, for two reasons that are still valid: an unauthenticated HTTP server on port 80 with full fan control should not run on a customer’s board unasked, and for Home Assistant users it is redundant attack surface with no benefit, since Home Assistant already talks to the board over the encrypted native API. Standalone use is the one case where that second reason does not apply: there is no Home Assistant taking over control, so enabling the web server here is not redundant, it is the control surface.

local: true is mandatory for standalone use, not an optional tuning knob. Without it, the web interface fetches its JavaScript and CSS from esphome.io the first time a browser opens the page. That works fine when the board shares an internet-connected network, but a standalone board has no such guarantee, and the interface simply fails to load on a board with no internet access, which is exactly the case this page is about.

That convenience has a measured flash cost. Compiled against the finished examples/standalone.yaml on a Rev 3.1 board, the firmware uses 884,566 bytes (48.2%) without web_server:, and 992,826 bytes (54.1%) with web_server: {version: 3, local: true}, a delta of 108,260 bytes, about 5.9 percentage points of the app partition. That app partition is 1,835,008 bytes, one of the two OTA slots in the ESPHome default 4 MB flash layout that every configuration in this repository uses unmodified: nothing sets its own partitions: table. Rev 1.0 uses an ESP32 (esp32dev); Rev 2.0 through 3.3 use an ESP32-S2 (esp32-s2-saola-1).

WiFi provisioning does not need Home Assistant either:

  1. Fallback access point (captive_portal:). If the board cannot join your WiFi network on first boot, or after a credential change, it opens its own access point named “Fancontroller Fallback.” Connect to it from a phone or laptop, and a captive portal page lets you enter WiFi credentials.

  2. Browser-over-USB (improv_serial:). Plug the board in over USB, and a compatible browser can provision WiFi credentials directly over the serial connection, using the Improv protocol.

Both components are already present in examples/standalone.yaml, and validate together with web_server: and no api: block.

MQTT is also a viable standalone control path if you already run a broker, though wiring it up is outside the scope of this page.

A minimum standalone configuration needs no speed-control module at all. With nothing loaded beyond the hardware package and web_server:, the fans simply hold whatever speed they were last commanded to, and the web UI still exposes the four fan entities directly for manual control. examples/standalone.yaml goes one step further and loads a module: it is the complete, compiling starting point for the standalone and local-checkout workflows described on the rest of this page.

Pulling a module from GitHub is the default path, and it stays the recommended one for most readers. It is a convenience, not a requirement: every firmware config’s dashboard_import.package_import_url points at a github:// URL, so the standard adoption path genuinely does fetch from GitHub. But GitHub access is not required to use a module. If you would rather work from files already on your own disk, here are three ways to do that.

This is the shape already used throughout this repository, in every module’s own usage header and in every example: a packages: entry with url:, ref:, and files:, optionally carrying vars:.

packages:
temperature_linear:
url: https://github.com/zeroflow/wifi-fancontroller
ref: main
files:
- path: modules/temperature_linear.yaml
vars:
friendly_name: "My Fan Controller"
t_off: "25.0"
t1: "30.0"
t2: "50.0"
fanpercent1: "30.0"
fanpercent2: "100.0"

This is the most convenient path, and the right one for most readers: nothing to download, nothing to keep in sync yourself. ref: is what decides which version of the module you get; pin it to a tag or commit if you want a version that never changes underneath you, or leave it at main to always pull the latest.

Variant B: download the files and use them locally

Section titled “Variant B: download the files and use them locally”

There are two ways to get the files onto your disk, depending on how much of the repository you want:

  • Clone the whole repository with git clone https://github.com/zeroflow/wifi-fancontroller, which gives you hardware-rev-3.1.yaml and every file under modules/ at once.
  • Download a single file, if you only need one module: save modules/temperature_linear.yaml from GitHub’s raw file view into your own project.

Either way, packages: changes from a remote URL to a local !include. The single most important detail here is the shape of that !include. The short scalar form passes no variables at all:

# Passes NO vars: every substitution silently falls back to its module
# default. Fine only if every default is already what you want.
# The path is an example: point it at wherever the file sits on your system.
packages:
temperature_linear: !include modules/temperature_linear.yaml

If you need to set even one variable, the mapping form with file: and vars: is the correct shape:

# The paths are examples: point them at wherever the files sit on your system.
packages:
hardware: !include hardware-rev-3.1.yaml
temperature_linear: !include
file: modules/temperature_linear.yaml
vars:
friendly_name: "My Fan Controller"
t_off: "25.0"
t1: "30.0"
t2: "50.0"
fanpercent1: "30.0"
fanpercent2: "100.0"

Miss this and every substitution quietly takes its module-internal default. There is no error, just a config that behaves differently than the values you thought you set. If you land on “variable not defined,” or a config that ignores values you typed into vars:, check whether the !include is the short scalar form or the mapping form first.

Get the relative path right for your own layout, not the repository’s. !include resolves the path relative to the directory of the file doing the including, not the repository root and not your current working directory. A typical customer layout puts the config next to a modules/ folder in the same directory:

my-fancontroller/
├── my-config.yaml
└── modules/
└── temperature_linear.yaml

From my-config.yaml, that path is modules/temperature_linear.yaml, no ../ needed. The shipped examples/standalone.yaml looks different: it uses ../hardware-rev-3.1.yaml and ../modules/temperature_linear.yaml, because that file lives one directory below the repository root (examples/), while the hardware package and the modules/ folder both sit at the root. Copy the example’s ../ paths into a differently shaped directory and they will not resolve. Match the path to where your own files actually are.

Local use costs something in return: no automatic updates, ref: no longer applies since there is no remote source to pin a version of, and picking up a fix or a new feature means downloading the file again.

Variant C: merge a module into your own config by hand

Section titled “Variant C: merge a module into your own config by hand”

modules/temperature_linear.yaml is the smallest real speed-control module at 252 lines, and it is the same file variants A and B and examples/standalone.yaml already use, so one file carries you through the whole page. Merging it by hand means copying its blocks into the matching top-level key of your own config, and replacing every ${var} placeholder with a fixed value:

  • its substitutions: entries become literal values wherever ${t_off}, ${t1}, and so on appear, or you keep them as your own substitutions: if you want the placeholders to stay
  • its esphome: on_boot: entry merges into your own esphome: block’s on_boot: list
  • its number:, sensor:, output:, and switch: lists merge into your own top-level number:, sensor:, output:, and switch: lists

For example, ${t_off} in the module becomes a literal 25.0 directly in your number: entry once merged, instead of a substitution the module resolves for you:

number:
- platform: template
name: Linear Off Temperature
id: linear_t_off
initial_value: 25.0 # was: ${t_off}
min_value: 20
max_value: 50
step: 0.5
unit_of_measurement: "°C"
optimistic: true

This is worth doing when you intend to modify the module anyway, since you already have to edit the file to make the change. It gets painful fast on the larger modules: modules/temperature_curve_triple_independent.yaml is 1051 lines, and hand-merging that is a lot more to keep straight than swapping one !include line.

All three variants share two things worth knowing before you start.

Several speed-control modules cannot run at once: they all write to the same fan outputs, and having more than one active leads to unpredictable behavior. See the compatibility table on the Modules Overview page for the full list of which modules conflict.

A module also has to be loaded exactly once. Loading modules/temperature_linear.yaml both as a github:// package and as a local !include in the same config collides: esphome config rejects it with a duplicate-entity error, confirmed by an actual run against this repository’s files, because both copies declare the same component ids. Load a module one way or the other, never both.

The web_server: v3 UI can group entities into named, ordered sections instead of one flat list. Any entity carrying a web_server: sub-key (sorting_group_id: or sorting_weight:) requires the web_server: component to be declared in the same merged config, regardless of what the key’s value is: an entity assigned to a group in a config with no web_server: block fails validation with “This option requires component web_server.” If a module wrote that key into its own entity declarations, every Home Assistant user who loads that module without also declaring web_server:, which is the default, documented state for this board, would fail validation on their next update. That is why the assignment lives in your own config, the same place examples/standalone.yaml puts it, rather than inside the module file itself. It is exactly the kind of constraint that otherwise gets rediscovered the hard way, so it is written down here.

The shape, matching examples/standalone.yaml:

web_server:
version: 3
local: true
sorting_groups:
- id: grp_temp_linear
name: "Linear Temperature Control"
sorting_weight: 100
number:
- id: !extend linear_t_off
web_server:
sorting_group_id: grp_temp_linear
sorting_weight: 10

version: 3 is what adds grouping at all; the older default, version: 2, has no concept of sorting_groups:.

The Modules Overview page already notes that !extend is not for adding a new entity, only for changing one that already exists. That caution still holds here: what a grouping assignment does is add a new key to an entity the module already defines, which is exactly the case !extend is for, not the case the caution warns against.

Eleven modules get their own group this way. This table is the fixed set of group IDs and group-level weights that each module’s own reference page uses in its copy-paste snippet:

Group IDModuleWeightBand
grp_temp_linearTemperature Linear100Speed Control
grp_temp_curveTemperature Curve110Speed Control
grp_temp_curve_dualTemperature Curve Dual120Speed Control
grp_temp_curve_tripleTemperature Curve Triple130Speed Control
grp_temp_curve_triple_indTemperature Curve Triple Independent140Speed Control
grp_rpm_piRPM PI Control150Speed Control
grp_temp_pidTemperature PID160Speed Control
grp_stall_guardStall Guard200Safety
grp_fallback_modeFallback Mode210Safety
grp_ds2484_onewireDS2484 1-Wire330Sensors
grp_usr_buttonsUSR Buttons400Controls

Every ID in this table is a fixed literal, copied verbatim from the module’s own page. None of them is built from a substitution variable at config time. The DS2484 1-Wire page explains why that module in particular cannot key its group off its display-name variable.

Inside a group, entities step by 10 in the order the module declares them (10, 20, 30, and so on). Diagnostic entities inside a group start at 900 and step by 10 from there, so diagnostics always sort last regardless of how many other entities the module adds later.

Three modules do not get a group this way, and it is worth saying so plainly rather than leaving the gap to be discovered: bme680, bme680_bsec2, and scd41 declare their entities as nested sub-keys of one combined-sensor platform entry, for example temperature: and humidity: nested inside a single platform: bme680 block, not as top-level list items with their own id:. !extend can only reach top-level ids, a limitation already documented on the Modules Overview page, so it cannot add a web_server: key to these nested entities. Those 14 entities fall back to ESPHome’s own default grouping instead, by entity_category, which works and looks reasonable, but groups by category rather than by module. Each module’s own reference page carries the exact copy-paste snippet for the modules this mechanism does reach.