Small Node + TypeScript service that converts ONVIF camera events into MQTT topics and publishes snapshots.
This project was created with AI assistance.
- GitHub Raptor Mini (Preview)
- ChatGPT-5.3-Codex
- Ingests ONVIF events using PullPoint polling (default) or Push/Notify (optional)
- Publishes event state topics to MQTT (
ON/OFF) - Publishes per-camera and app status topics (retained)
- Publishes snapshot images on demand, on event, or on a schedule
- Copy
config.yaml.exampletoconfig.yaml. - Edit camera, MQTT, and credential values.
- Run locally:
npm install
npm run devProduction run:
npm run build
npm startDocker run:
docker build -t mqtt-camera-controller .
docker run -v $(pwd)/config.yaml:/app/config.yaml:ro -e NODE_ENV=production -e CONFIG_PATH=/app/config.yaml mqtt-camera-controllerYou can also use docker-compose.yml.
Runtime config load order (highest to lowest):
- Path passed directly to
loadConfig() CONFIG_PATHenvironment variable./config.yaml
config.yaml.example is for editing/reference only. In tests, NODE_ENV=test allows fallback to the example config.
mqtt:
server: "localhost"
port: 1883
basetopic: "mqtt"
client: "mqtt-client"
password_file: /run/secrets/mqtt_password- Base topic key is
mqtt.basetopic(fallback:mqtt.baseTopic). - Use password files (
password_file) instead of plaintext passwords where possible.
logging:
level: info- Valid levels:
debug,info,warn,error LOG_LEVELenvironment variable is also supported- If both config and env are set, config level is used
homeassistant:
enabled: true
prefix: homeassistant
retain: true
components:
events: true
snapshot: true
snapshotCommand: true
appReloadCommand: true- Discovery is enabled by default.
- Set
homeassistant.enabled: falseto disable discovery publication. - Discovery publishes entities for event binary sensors, snapshot camera, per-camera command select, and an app command select.
- Discovery entities use dual availability topics (
<baseTopic>/<camera>/statusand<baseTopic>/status) withavailability_mode: all. - Discovery includes retained camera metadata on
<baseTopic>/<camera>/metawith camera IP, ONVIF port, and event mode. homeassistant.components.appReloadCommandcontrols the app-level command select entity (enabled by default).
rateLimit:
enabled: true
cooldownMs: 3000rateLimitis a root-level setting and applies globally to all cameras.- Cooldown applies only to event-triggered snapshots (
snapshot.onEvent), not periodic or manual command snapshots. - During cooldown, event snapshots are ignored and logged at debug level.
cooldownMs: 0disables cooldown behavior.
Use separate ONVIF and snapshot settings:
cameras:
frontdoor:
host: 192.168.1.10
port: 80
username: admin
password_file: /run/secrets/frontdoor_password
snapshot:
type: url
address: "http://192.168.1.10/snapshot.jpg"
onEvent:
types: [motion]
delay: 0
interval: 60000
event:
mode: pull
pull:
endpointSelection: auto
durations:
motion: 10- ONVIF calls use camera root
host+port(/onvif/device_service). - Pull endpoint selection (
event.pull.endpointSelection) controls event endpoint usage:auto(default): try camera-reported endpoint first, then fallback to configured host/portcamera: always use camera-reported endpointconfigured: always force configured host/port
- Snapshot retrieval uses
snapshot.addressas the source endpoint. - If
snapshotis omitted for a camera, periodic and on-event snapshots are disabled for that camera. snapshot.intervalis in milliseconds and defaults to0when omitted (0means disabled).snapshot.onEventmust be an object with requiredtypesand optionaldelayin milliseconds (default0).- Valid
types:motion,line,people,vehicle,animal,all - If
typescontainsall, it must be the only value in the list.
- Valid
snapshot.enabledis not used by runtime and should be omitted.
Snapshot credentials priority (applies to both snapshot.type: url and snapshot.type: stream):
snapshot.password_filesnapshot.username+snapshot.password- Credentials embedded in
snapshot.address
For stream snapshots, resolved credentials are injected into the ffmpeg input URL and encoded safely for reserved characters.
Event topics:
<baseTopic>/<cameraName>/motion<baseTopic>/<cameraName>/line<baseTopic>/<cameraName>/people<baseTopic>/<cameraName>/vehicle<baseTopic>/<cameraName>/animal
Event publishing behavior:
- Payloads are
ONandOFF - Event topics are retained
- On startup, each camera publishes retained
OFFfor all canonical event types - Event
durations(seconds) control ON hold time before OFF publish
Status topics:
- Camera status:
<baseTopic>/<cameraName>/status(retained) - App status:
<baseTopic>/status(retained) - Status payloads are
ONLINEandOFFLINE - App status uses MQTT Last Will (
OFFLINE) on unexpected disconnect
Snapshot topic:
<baseTopic>/<cameraName>/image- Payload is binary image data
- Snapshot publishes are non-retained
Camera command topic:
<baseTopic>/<cameraName>/command
Camera command behavior:
- Publish payload
snapshot(case-insensitive) to request an immediate snapshot - Unsupported command payloads are ignored (debug log)
- On success, image data is published to
<baseTopic>/<cameraName>/image - If snapshot configuration is missing or invalid for that camera, no image will be published
Global app command topic:
<baseTopic>/command
Global app command behavior:
- Publish payload
reload(case-insensitive) to request a runtime config reload - Unsupported payloads are ignored (debug log)
- Reload uses the same transactional flow as
SIGHUP(including rollback on failure)
Mapping is topic-aware (not broad keyword matching):
RuleEngine/MotionRegionDetector/Motion->motionRuleEngine/CellMotionDetector/Motion->motionRuleEngine/PeopleDetector/People->peopleRuleEngine/LineCrossDetector/LineCross->lineRuleEngine/TPSmartEventDetector/TPSmartEvent:IsVehicle->vehicleIsPet->animal
Unknown ONVIF topics and unknown TP-Link sub-events are logged at debug level with extracted parameters.
Pull mode (default):
- Polls camera PullPoint endpoint for events
- Endpoint source policy is controlled by
event.pull.endpointSelection(autoby default) - Camera status transitions to
ONLINEonly after healthy pull activity
Push mode (optional):
- App hosts a notify endpoint and receives camera POST notifications
- Optional auto-subscribe can request camera-side CreateSubscription
Example push config:
cameras:
driveway:
host: 192.168.1.11
port: 80
event:
mode: push
push:
autoSubscribe: true
notify:
baseUrl: "https://my-host.example"
port: 8080
basePath: "/onvif/notify"Notify URL/port reconciliation behavior:
- If
notify.baseUrlincludes a port andnotify.portis omitted, the app listens on thebaseUrlport. - If
notify.baseUrlomits a port andnotify.portis set, the app appendsnotify.portto the callback URL used for subscription. - If both are set and differ, the app keeps split behavior (callback uses
baseUrlport, listener usesnotify.port) and logs a warning. - If neither provides a port, default is
8080.
Notify path format is ${notify.basePath}/${cameraName}.
ONVIF request strategy is secure-first:
- Try WS-Security UsernameToken first when credentials exist
- Fall back to Basic auth only if needed
- Warn when falling back to Basic over non-TLS (
http://)
No configuration file found: verifyCONFIG_PATHor./config.yaml- Pull URL/path errors: check logs for attempted ONVIF URL and HTTP status
- Push path mismatch: logs include received path and expected base path
- Unknown events: visible at
debuglog level - For low-level namespace traces, use
DEBUG=* - Send
SIGHUPto trigger runtime config reload without restarting the process. - Publish
reloadto<baseTopic>/commandto trigger runtime config reload through MQTT. - Set
MQTT_CAM_CONFIG_RELOAD=trueto watch the active config file path and auto-reload when it changes (debounced).
- Tests:
npm test - Lint:
npm run lint - Build:
npm run build
BSD 3-Clause. See LICENSE.