Tally counts vehicles passing your holiday light show, classifies them as pass-through vs. parked-to-watch, tracks direction, optionally estimates nearby-device/crowd counts, logs everything for reporting, and can trigger FPP playlists/effects live in response to traffic.
Modular hardware tiers — not every install needs the same sensors:
| Option | Hardware | Status |
|---|---|---|
| 1 | 2× HLK-LD2410B radar (driveway/zone) | ✅ Working |
| 2 | MLX90640 thermal array (entrance/street zone) | |
| 3a | Onboard Bluetooth (BLE crowd/device estimate) | ✅ Working |
| 3b | Second USB WiFi adapter (monitor-mode crowd estimate) — use a powered USB hub, see below | |
| 4 | BME280 temperature/humidity | |
| 4b | DHT11 temperature/humidity (not in the original spec) | ✅ Working |
Options 1 and 2 are not mutually exclusive — run either alone, or both for a full entrance+driveway picture. Every module is independently enabled from the Setup page's hardware-selection wizard; the daemon only loads/polls modules you've actually turned on, and one module's hardware being absent never blocks the others.
- Daemon core, SQLite schema (
events,device_scans,environment), conditional module loading, systemd service with auto-restart - LD2410B module fully working — dual-radar sequence-window direction detection and parked-timeout dwell logic, ported from the proven implementation in fpp-sled-mailbox
- MQTT + Home Assistant auto-discovery (per-zone car/direction/parked sensors, daemon status via LWT)
- FPP Command registration: "Trigger Test Vehicle Event" (per zone) and "Trigger Test Crowd Scan" — verify your DB/MQTT/FPP-trigger wiring with zero sensor hardware connected
- Setup page: hardware-selection wizard, zone/direction labels, LD2410B config, FPP trigger assignment, MQTT config, daemon start/stop/restart, registration (soft gate on the web UI only — never on detection/logging)
- Reporting page: live per-zone counters, recent-events table
- Hidden, password-gated, auto-expiring developer calibration route (activation/expiry/audit-log gating implemented; the camera-frame preview itself is not — no camera integration yet)
- Thermal (MLX90640) module implemented — frame differencing / blob detection on the 32×24 grid, centroid tracking, edge-crossing direction classification, parked-dwell detection (whose eventual departure still logs a normal directional pass, per spec), and a best-effort geometric speed estimate from your configured mount height/angle/distance. Unit-tested against synthetic frames (blob detection, background subtraction, direction, parked-then-departure, speed estimate, and graceful idling when the sensor library isn't installed) — but not yet validated against a real MLX90640, since no thermal sensor has been available during development. Treat the blob-threshold and min-travel-columns defaults as a starting point to tune once you have the hardware wired up.
- BLE crowd-scan module (Option 3a) implemented — passive scan via onboard Bluetooth on a configurable interval, counting unique addresses per scan window. Works out of the box on the daemon's default unprivileged user.
- WiFi crowd-scan module (Option 3b) implemented — passive 802.11 probe-request sniffing on a monitor-mode interface, same count-unique-per-window approach. Requires elevated privileges Tally does not grant itself (see "Enabling WiFi crowd scanning" below) — logs a clear, one-time diagnostic and idles rather than retrying forever if it can't open a raw socket.
- When both BLE and WiFi are enabled, the daemon publishes the higher of their two latest readings as a combined estimate, rather than summing them — their identifiers are unrelated address spaces (a phone's BLE and WiFi MACs are randomized independently), so summing would double-count every device visible on both radios.
- Both modules' scan/dedup logic is unit-tested against fake scanner/sniffer implementations (no real BLE crowd or WiFi hardware available to field-test against during development).
The Setup page's Crowd Scan Config card defaults the WiFi interface to
wlan0 — the Pi's onboard adapter. That's only safe if this Pi reaches
its own network some other way (Ethernet, or no network at all);
putting wlan0 into monitor mode while it's your active WiFi connection
will drop that connection. If this Pi is on WiFi for its own network,
plug in a second USB WiFi adapter for scanning and set its interface
name (commonly wlan1) on the Setup page instead. Not auto-detected —
network state can change after any check Tally could do at startup, so
this is a decision left to the builder.
tally.service runs the daemon as the unprivileged fpp user (matching
the rest of Tally, and FPP's own plugin conventions). Raw 802.11 frame
capture needs elevated privileges, which this build does not request on
its own — that's a deliberate scope boundary pending a decision on how
much privilege escalation is appropriate for this plugin to request by
default, not an oversight. If you want to enable it yourself:
sudo setcap cap_net_raw,cap_net_admin=eip /usr/bin/python3.XX
sudo setcap cap_net_admin=eip $(readlink -f $(which iw))(replace python3.XX with your system's actual interpreter binary --
readlink -f $(which python3) finds it -- and be aware the first command
grants that capability to every script run by that interpreter, not
just Tally's daemon; evaluate the tradeoff for your system before doing
this). Both commands are needed, not just the first one: the daemon
opens its own raw socket in-process (covered by the python3 grant), but
it also shells out to iw to hop across channels 1/6/11 during each scan
(confirmed necessary on real hardware — a monitor-mode adapter sits on
whatever channel iw last set it to, so without hopping the module only
ever sees devices probing on that one fixed channel, which read as
"0 devices nearby" even with real phones in range). iw running as its
own subprocess doesn't inherit the daemon's own capability grant, so it
needs the second setcap line to be able to change channels itself. If
only the first is set, WiFi scanning will still run without the earlier
permission error, but stays parked on one channel — check the daemon log
for "Channel hopping unavailable" if scan results seem too low.
You'll also need to put the configured interface into monitor mode
yourself (sudo iw dev wlan0 set type monitor, or wlan1 etc. if you're
using a second adapter) before starting the daemon; Tally doesn't do this
for you. Confirmed on real hardware: the Raspberry Pi's own onboard WiFi
chip (brcmfmac driver, e.g. the 3B+'s built-in adapter) does not
support monitor mode at all (iw fails with "Operation not supported")
— if that's your situation, you need a genuine external USB adapter with
a monitor-mode-capable chipset (confirmed working: RTL8192CU) rather than
defaulting to the onboard adapter.
⚠️ Use a powered USB hub for the WiFi adapter. Confirmed on real hardware: a Pi 3B+ already running two USB-serial radar adapters plus a USB webcam hit genuine under-voltage the moment a USB WiFi adapter was plugged into the Pi's own ports —vcgencmd get_throttledshowed the under-voltage bit set, anddmesglogged repeatedUndervoltage detected!/ USB bus re-enumeration. That reads as the WiFi module randomly failing (Network is down, channel-hopping errors, adapters disappearing and reappearing with a new device number) rather than an obvious power problem, and cost real time to diagnose. If you're running more than one or two USB peripherals on a Pi 3B+ (radar adapters, camera, WiFi adapter), put the newer ones on a powered hub instead of the Pi's own ports. If you ever see WiFi crowd-scan behaving erratically, checkvcgencmd get_throttledbefore suspecting the plugin — a non-zero result (especially0x50000or similar) means the Pi itself is browning out, not a Tally bug.
- BME280 module (Option 4) implemented — polls temperature/humidity
over I2C on a configurable interval (default 10 min), logs to the
environmenttable. Shares the I2C bus with the MLX90640 at a different address, no conflict, no multiplexer needed. Purely contextual data for the Reporting page — no detection logic. - Not yet validated against real BME280 hardware — none has been available during development. The °C→°F conversion and the poll loop's per-reading error recovery are unit-tested against a fake sensor object.
Every module described in the project spec (LD2410B, thermal, BLE, WiFi, BME280) is now implemented.
- Registration wired to a real license server — fpp-tally-license-server (Cloudflare Worker + D1) replaces the local-only registration stub.
- Calibration route: camera-frame capture implemented — ffmpeg + V4L2, works with a generic USB webcam.
- Reporting page: full chart views — per-zone stacked traffic (direction A/B + parked) with 7/30/90/365-day range selection, hourly distribution, and crowd/environment charts.
- Fixed a real bug found via testing: the hourly-distribution chart was silently bucketing by UTC instead of local time.
The project spec calls for a BME280 (I2C), but DHT11 (single-wire digital,
same sensor fpp-sled-mailbox already supports) is what's actually
available to test against on real hardware — see daemon/modules/dht11.py.
Same environment event kind and DB table as BME280; enable whichever one
matches your actual sensor. Not yet validated against a real DHT11 — the
°C→°F conversion and the poll loop's checksum-failure recovery (DHT11
reads regularly fail transiently; that's normal for the protocol, not a
fault condition) are unit-tested against a fake sensor object.
Tested live on a real Raspberry Pi 3B+ (192.168.0.51) running alongside a
production fpp-sled-mailbox install using the same two LD2410B radars —
not a synthetic/container test. This is where "implemented" became
"working" for several modules, and surfaced two real bugs neither
container testing nor unit tests had caught:
- LD2410B (Option 1): confirmed working against real radar hardware.
Found and fixed a real bug in the process: a car sitting parked longer
than
parked_timeout_sproduced a fresh "parked" event on almost every ~1s poll cycle for as long as it stayed put, instead of exactly once — 44 duplicate rows in under 90 seconds during testing. Root cause:on_presence()was unconditionally clearing the "already fired" latch on every poll where the radar reported presence, not just on the rising edge. Fixed, and locked in with a regression test (400 simulated seconds of continuous presence now correctly produces exactly one event). - BLE crowd-scan (Option 3a): confirmed working. First real scan on the Pi's onboard Bluetooth found 12 unique nearby devices.
- DHT11 (Option 4b, not in the original spec): confirmed working. Real reading: 74.5°F / 55% humidity on GPIO4.
- Calibration camera capture: confirmed working end-to-end with a USB webcam — real 1920×1080 JPEG captured through the full activate → session-gated snapshot → HTTP pipeline.
- Two real installer bugs found and fixed, both invisible to
container-based testing (which had no unprivileged web-server user to
expose them):
tally.jsonand the plugin's own log file were leftroot:rootafter install, so the Setup page's Save button and the daemon's own file-based logging would silently fail on a real FPP install. Confirmed by hitting the actualPermissionErrorthrough the real Setup page, not just inspecting file modes. Also worth noting:hdmi_cec.jsonandsled.jsonon the same real Pi were already correctly owned despite neither of those plugins' install scripts doing this chown either — almost certainly hand-fixed via SSH at some point rather than fixed at the source, suggesting the same gap likely exists in those install scripts too. - Confirmed the SLED/Tally serial-port coexistence plan works safely in practice: stopped SLED, ran Tally's LD2410B module against the freed ports, stopped Tally, restarted SLED — verified fully healthy afterward (both radars reopened cleanly, MQTT reconnected, no errors).
What's left before a 1.0: hardware validation of thermal/BME280/WiFi crowd-scan against real sensors (no MLX90640, no second monitor-mode WiFi adapter available during this pass), and the registration/licensing backend's premium-tier logic once that's actually defined.
- New Diagnostics page (linked from Setup, not part of the main nav) —
a live-only view, polled every second, never written to the database:
- LD2410B per-gate readout for sides A/B: moving and static energy per gate (0–8), matching the same engineering-mode detail fpp-sled-mailbox's own diagnostic mode shows. Radars now run in engineering mode from the moment they connect; falls back to basic mode automatically (present/distance/energy only, no per-gate detail) if a unit doesn't accept the mode-switch — direction/parked detection is unaffected either way.
- Raw BLE scan: the sorted list of unique addresses from the most recent scan window, not just the count.
- Raw WiFi scan: same, for probe-request source addresses.
- MLX90640 thermal delta grid: the 32×24 background-subtracted heat grid rendered as a canvas heatmap, with the currently-tracked blob(s) circled. Real detection logic, same as the daemon's own frame-differencing/blob-tracking pipeline — not yet hardware-validated (no MLX90640 available during development), so treat the color scaling as a starting point to eyeball-tune once you have the sensor.
- Camera Reference panel: a live snapshot (refreshed every 1.5s),
positioned next to the radar readout specifically so you can watch a
car cross the zone and see which gate lights up / which thermal blob
tracks it at the same real-world position — useful for tuning
min_energy, gate-based thresholds, and mount angle/height without guessing. Reuses the same camera-capture approach as the hidden calibration route (ffmpeg+ V4L2), but as its own endpoint (diag_snapshot.php) so that route stays untouched and unreferenced by this now-discoverable page. - Each panel shows a "stale" badge once its source module hasn't written fresh data in the expected window (e.g. module disabled, or daemon stopped) rather than showing frozen data as if it were live — BLE/WiFi judge staleness against their own configured scan interval, not a fixed timer, after real testing showed a 30s-interval scan sitting "Stale" for 25+ of every 30 seconds under a naive fixed threshold.
- Camera panel access control: off by default
(
calibration.require_password_on_diagnostics: falsein tally.json) — during active development on a build only you can reach, the camera panel just works, same as the rest of the Diagnostics page. Flip this totruebefore handing the build to anyone else (or leaving it reachable on a network you don't fully trust); doing so gates the panel behind the same bcrypt password + session the hidden calibration route already uses (calibration.password_hash— see "Camera calibration mode" below for how to set it). The two routes intentionally share that one session: unlocking the camera on either page unlocks it on both, since it's the same grant. - WiFi crowd-scan now defaults to the onboard adapter (
wlan0) instead of assuming a second USB adapter is always required. This is only safe when the Pi reaches its own network some other way (e.g. Ethernet) — see the Setup page's Crowd Scan Config card and the README section above for when you need a second adapter instead. Not auto-detected: network state can change after any check Tally could do. - Two real bugs found and fixed testing this on 192.168.0.51, on top
of the ones already logged in v0.6.0:
- Engineering-mode LD2410 frames had no plausibility bound on the distance fields (the basic-mode decoder already had one) — occasional USB-serial noise garbled a distance to a bogus ~32,000+ cm value, which the Diagnostics page would briefly display as a nonsense reading. Fixed by rejecting implausible frames outright, same bound the basic decoder already used.
- The Reporting page's temperature/humidity card and history chart only
checked
modules['bme280']to decide whether to show themselves, so an install running DHT11 without BME280 — like 192.168.0.51 — had real temperature data being logged and MQTT-published the whole time with genuinely no way to see it on the Reporting page. Fixed to checkbme280 OR dht11, matching the check the MQTT/HA-discovery code already had right. - The Diagnostics/calibration camera panels polled on a fixed setInterval
regardless of how long the previous capture took; each capture opens
the V4L2 device fresh via
ffmpeg, which took ~3.2s on this Pi 3B+ — longer than the 1.5s poll gap — so overlapping requests fought over the same device and the feed visibly "started and stopped." Fixed by self-chaining each poll to only fire after the previous one settles, plus a non-blocking flock server-side as defense in depth. - WiFi crowd-scan reported 0 devices with real phones in range: a
monitor-mode adapter stays parked on whatever channel
iwlast set it to, and the module never changed channels, so it only ever saw probe requests on that one fixed channel (confirmed: BLE saw 14 real devices in the same window WiFi saw 0). Fixed by hopping across channels 1/6/11 during each scan window — see the "Enabling WiFi crowd scanning" section above for the secondsetcapthis needs. - Confirmed the Raspberry Pi's own onboard WiFi chip does not support monitor mode at all (driver-level limitation, not a Tally issue) — WiFi crowd-scan needs a genuine external USB adapter with a monitor-mode-capable chipset; confirmed working: RTL8192CU.
- The Setup page's "FPP Triggers" card now actually works. Until this
release it saved playlist/cooldown/timeout config that nothing read —
the daemon logged events and published to MQTT, but never called FPP
to start a playlist.
tally_fpp.py(ported from fpp-sled-mailbox's provenFPPPlayer— sameGET /api/command/...call shape, since POST returns 500 on FPP 10.x) makes it real:- Each of the seven trigger rows (
entrance_direction_a/b,entrance_parked,driveway_direction_a/b,driveway_parked,crowd_threshold) fires its configured playlist the moment the matching event happens, non-blocking. - Only fires while FPP is actively playing something (checked via
status_name == "playing"in/api/fppd/status) — matching the card's own stated design ("Defers to FPP's native Scheduler for show hours"). A car in the driveway at 2pm in July doesn't start anything; fails closed if FPP's status can't even be read. Deliberately not gated onscheduler.status— confirmed on real hardware that field only reads"playing"for a Scheduler-initiated show and reads"manual"(not"playing") for a playlist started any other way, which would have silently blocked every trigger during exactly the "something is actively playing" state this feature exists to react to. cooldown_sis enforced per trigger — a car idling at the sensor's edge won't restart the playlist on every borderline detection.crowd_thresholdfires once on the upward crossing, not on every scan while the crowd stays above the line.play_timeout_sis a safety net, not a normal wait: if the triggered playlist is still FPP's current playlist after that many seconds (e.g. accidentally left on repeat), Tally calls Stop Now so the Scheduler can resume — a playlist ending on its own is the expected path and never touches this.- All of the above verified with a synthetic test harness (fake FPP status/command responses) covering: basic fire, cooldown suppression, disabled-trigger no-op, show-not-active gating, crowd threshold rising-edge-only firing, and the timeout watchdog both firing correctly and correctly not firing once the playlist has already moved on. Not yet exercised against a real scheduled FPP show (no show currently scheduled on the real hardware used for validation, off-season) — the show-active gate means this is inherently something that can only be fully proven during actual show hours.
- Each of the seven trigger rows (
- Raw BLE Scan on the Diagnostics page is now a real table, not just a
bare address list: address type (
public/random), device name, a best-effort vendor guess (Apple/Google/Microsoft/Samsung, from the manufacturer-data company ID), signal strength, and first/last-seen timestamps within that scan window.- This is groundwork for the eventual goal, not the filtering itself: the plan is to dial the crowd-scan module in to catch visiting phones specifically, not every fixed/paired BLE peripheral in range (a smart bulb, a doorbell, a shed speaker) that shows up on every single scan regardless of whether anyone's actually there. Seeing address type/vendor/timestamp per device is what makes that filter decision informed instead of a guess.
crowd_ble.pyswitched fromBleakScanner.discover()(one end-of-scan snapshot) to a detection-callback-based scan, so first/last-seen reflect real per-advertisement timestamps instead of an estimate.address_typecomes from bleak's BlueZ backend raw device properties (confirmed against the actual installed bleak 3.0.2 source, not bleak's own documented stable API) — degrades to unavailable gracefully on any other backend/platform rather than raising.- Still live-only, still never persisted to the DB — same reasoning as the rest of the Diagnostics page.
Via FPP Plugin Manager (once listed), or manually:
cd /home/fpp/media/plugins
git clone https://github.com/focusedonsound/fpp-tally.git
sudo bash fpp-tally/fpp_install.shThen open Content Setup → Tally, check the hardware you have connected, fill in serial ports / zone labels, and Save.
All settings live in /home/fpp/media/config/tally.json (see
config/tally.json.example for the full default shape and every field).
Registration (email, free, one field) gates the web UI only — Setup and
Reporting pages. It never blocks the daemon: detection, logging, MQTT
publishing, and FPP triggers all run regardless of registration status.
Free vs. premium feature split is intentionally undefined for now — the
infrastructure exists so that decision can be made later without a
re-architecture. See fpp-tally-license-server
(planned — not yet built) for the real registration backend; today the
Setup page's registration flag is a local stub (any non-empty email marks
the install as registered).
The hidden calibration route (www/dev-calib-x9f3.php) never sets its own
password — that's deliberate, so a stumbled-upon URL with no password
configured can do nothing. To set one, generate a bcrypt hash and hand-edit
tally.json:
php -r 'echo password_hash("your-password-here", PASSWORD_BCRYPT), PHP_EOL;'Paste the result into tally.json's calibration.password_hash. This mode
is not a community build option — see BUILD_GUIDE.md section on the
hidden camera and the project spec for why.
The same password also gates the Diagnostics page's camera panel, but
only once you turn that on — set tally.json's
calibration.require_password_on_diagnostics to true (it defaults to
false, since the Diagnostics page is Setup-linked and visible, not
hidden like the calibration route). Like the password hash itself, this
is a hand-edit-the-config setting, not exposed anywhere in the web UI —
flip it before deploying anywhere you don't fully control access to.
- Falcon Player (FPP) 9.x or 10.x+
- Raspberry Pi 3B+ or compatible
- Python 3 with
pyserial,paho-mqtt(installed automatically) - A powered USB hub if you're running a USB WiFi adapter (Option 3b) alongside other USB peripherals (radar serial adapters, camera) — see "Enabling WiFi crowd scanning" above for why this isn't optional.
Free for personal, hobbyist, and noncommercial use under the PolyForm Noncommercial License 1.0.0. Using this in a commercial or paid-event display? A separate commercial license is required — contact license.request@christmasinboontontwp.com to arrange one.
Author: Nick Scilingo (FocusedOnSound)
Architecturally inspired by two of the author's other FPP plugins: fpp-sled-mailbox (SQLite logging, MQTT/HA discovery, dashboard, systemd-daemon patterns, and the LD2410B radar logic — ported directly) and fpp-EncoreRadio (email registration / licensing pattern, to be reused for the planned license server).
Bug reports: github.com/focusedonsound/fpp-tally/issues