Skip to content
Conduit

mcp server

send-to-influx

Smart-home automation system to visualise data in Grafana, control your devices and perform deep analysis via the built-in MCP server.

L337-org/send-to-influxcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "send-to-influx": {
      "command": "uvx",
      "args": [
        "send-to-influx"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 26, 2026

send-to-influx

https://github.com/L337-org/send-to-influx

Collect metrics from your smart-home and energy devices into InfluxDB, and then really take control!

The built-in MCP server means that you can use your AI of choice as one place to control your devices, query their live and historical state, and ask complex questions about relationships between different measurements, and spot trends in your data.

Because the data is stored in InfluxDB it can also be displayed in Grafana dashboards and other visualization tools that support it.

It currently supports Hue Bridges, MyEnergi Zappi/Eddi/Harvi devices, Open-Meteo weather, National Grid Carbon Intensity, Octopus Energy, Nuki smart locks, and Speedtest network performance data sources. Most are read-only; Hue lights/plugs and on-demand Speedtest runs can be controlled through the MCP server when you opt in.

It can be installed as a .deb package on supported platforms, or run directly from the source code in a Python virtual environment.

For a full field-by-field reference of what each source collects and the units it's reported in, see UNITS.md.

Contents

Hue Bridge

It will collect occupancy, temperature and light readings from Hue Motion Sensors, on/off state of Smart Plugs and the brightness of lights, but could be modified to collect other data from the bridge.

To create a username and password for the Hue Bridge, follow the instructions here: https://developers.meethue.com/develop/get-started-2/

Hue bridges are commonly reached over https with a self-signed local certificate, so TLS verification is skipped by default (insecure: true). If your bridge has a valid certificate, set insecure: false in the hue settings block to enable verification.

The host setting accepts a hostname, an IPv4 address, or an IPv6 address. Write an IPv6 address plainly (host: "2001:db8::1") - the square brackets a URL needs are added internally, so you do not have to. Brackets are accepted too if you prefer them, and the value you configure is what appears as the host tag in InfluxDB either way.

Multiple Hue bridges

A bridge holds a limited number of devices, so larger installations run more than one. All of them can be collected. The first is the host/user pair every install already has; each further bridge adds a numbered pair - host2/user2, host3/user3, and so on - with that bridge's own username, since a username belongs to one bridge and is not valid on another.

hue:
  host: "downstairs.example.com"
  user: "<downstairs username>"
  host2: "upstairs.example.com"
  user2: "<upstairs username>"

Each bridge is collected by its own worker, started stagger_seconds apart, with its own retry backoff - so a bridge that is switched off or unreachable delays only itself and the others keep recording. send-to-influx --check-config lists any problems - from a source checkout that is sendtoinflux.py --check-config - and a bridge whose username is missing is reported and skipped rather than stopping the rest.

The numbers are labels, not an order. They need not be consecutive, and nothing renumbers them. That matters when removing a bridge: leave the gap. The number is what ties a host to its username, so shifting the ones above it down would pair a bridge with another bridge's username - which shows up as an authentication failure rather than as an obvious mistake.

Settings other than host/user apply to the whole Hue estate: there is no per-bridge timeout, insecure, temperature_units or sensors mapping.

In InfluxDB, every point carries a host tag naming its bridge. Field names are unchanged and unprefixed, so nothing about your existing dashboards breaks when you add a second bridge - but two bridges with a light of the same name write the same field key under different host tags, so filter or group by host to tell them apart.

The collector_status heartbeat carries the same tag, so each bridge's health is recorded separately. Check any existing alert that groups collector_status by source alone. With one bridge that was the whole story; with two it aggregates them, so a healthy bridge can mask a failing one - SELECT last(ok) ... GROUP BY source returns ok=1 while another bridge sits at 0. Group by source, host (or *) to get a series per bridge:

SELECT last("ok") FROM "collector_status" WHERE "source" = 'hue' GROUP BY "source", "host"

Adding an additional Hue bridge

  1. Pick any unused number N. Set the address: sudo send-to-influx-set-credential --set-field hue.hostN <address>
  2. Generate a username on that bridge, following the Hue instructions - press the link button on the bridge you are adding, not on one you already use.
  3. Store it: sudo send-to-influx-set-credential hue-userN (on a plain source checkout, put the username in hue.userN in settings.yaml instead).
  4. send-to-influx --check-config, then restart the service.

Existing bridges are untouched throughout - no username needs re-entering.

--check-config run by hand cannot see credentials stored with send-to-influx-set-credential: systemd only mounts them for the service itself, so a stored username reads as "not set" there. The check says so when that applies, and the collector will still find it - systemctl status send-to-influx shows what the service itself sees.

Removing a Hue bridge

  1. sudo send-to-influx-set-credential hue-userN --remove
  2. Clear the address: sudo send-to-influx-set-credential --set-field hue.hostN ""
  3. send-to-influx --check-config, then restart the service.

Do both steps before restarting: between them the bridge has an address but no username, which --check-config reports and the collector skips. Leave the number unused rather than renumbering the others, and it is free for a future bridge. The removed bridge's history stays in InfluxDB under its own host tag - it is not deleted.

Replacing a Hue bridge

  1. Point the slot at the new bridge: sudo send-to-influx-set-credential --set-field hue.hostN <new address>
  2. Generate a username on the new bridge, then sudo send-to-influx-set-credential hue-userN. This overwrites the stored username; there is no need to remove it first.
  3. send-to-influx --check-config, then restart the service.

Three things worth knowing, none of them obvious:

  • The host tag is the address, so a replacement at a different address starts new series. The old bridge's data stays under the old tag and nothing new is written to it, so a Grafana panel filtered on the old address goes quiet without erroring. Where the new bridge can reuse the old address - a DHCP reservation, or the same DNS name repointed - the series continues unbroken, which is usually what you want.
  • Field names follow device names, not the bridge. Re-paired devices named identically in the Hue app keep their existing field keys; devices named differently produce new keys while the old ones simply stop. sensors name mappings are keyed on device name too, so they carry over only if the names match.
  • A half-finished replacement is safe. With the new address set but the old username still in place, that one bridge fails authentication and retries; every other bridge keeps collecting normally.

MyEnergi Zappi / Eddi / Harvi

Information on how to obtain your API key is available here: https://support.myenergi.com/hc/en-gb/articles/5069627351185-How-do-I-get-an-API-key

Some information on the API fields is available here: https://github.com/twonk/MyEnergi-App-Api

  • Zappi: EV charger. Collects real-time status fields plus daily energy totals (Charge, Import, Export, Genera).
  • Harvi: CT clamp energy monitor. Collects CT clamp power readings (ectp1/ectp2/ectp3) and channel names.
  • Eddi: Hot water diverter. Collects real-time status fields (frequency, voltage, diversion amount, temperatures, etc.).

UK National Grid Carbon Intensity

Real-time national grid carbon intensity (gCO2/kWh) and generation fuel mix (wind, solar, gas, nuclear, etc.) from the National Grid ESO Carbon Intensity API. No API key required. Data updates every 30 minutes.

API documentation: https://carbon-intensity.github.io/api-definitions/

Set include_generation: true in settings to also collect the fuel mix percentages.

Open-Meteo

Free weather data with no API key required. Configure latitude/longitude and choose which fields to collect from the current weather variables (see https://open-meteo.com/en/docs for the full list). Recommended interval is 15 minutes (900 seconds) or longer.

Octopus Energy

Collects half-hourly electricity consumption from your smart meter via the Octopus Energy API. Your API key is available from https://octopus.energy/dashboard/developer/.

If you also configure gas_mprn and gas_meter_serial, gas consumption is collected too, as gas_consumption (see UNITS.md for its unit, which depends on your meter type).

If you are on a time-of-use tariff (e.g. Agile Octopus), you can also configure product_code and tariff_code to collect the current electricity unit rate alongside consumption.

Note: smart meter readings are typically delayed by up to 24 hours, so consumption data always represents a recent-past reading rather than real-time usage.

Nuki Smart Lock (MQTT)

Collects lock state and door-sensor state (plus battery and connectivity fields) from WiFi-connected Nuki smart locks via Nuki's local MQTT API (supported by the Smart Lock 3.0 Pro on firmware 3.5.12+ and all newer WiFi models). send-to-influx holds a persistent subscription open and writes a point the instant a lock or door-sensor state changes, so a door opening and closing between polls is no longer missed. It also takes a full-state snapshot every interval seconds as a safety net (and to keep an idle lock's status heartbeat ticking); because Nuki publishes every state topic with the MQTT retain flag set, that snapshot's brief resubscribe receives the last-known state of every lock provisioned to the broker. Locks need no per-device configuration in settings.yaml: each lock is written as its own point, tagged device=<the lock's name from the Nuki app> (give each lock a distinct name), with the same bare field names for every lock. Group or filter by device to separate them. The broker is not recorded - every lock arrives through one, so it identifies nothing, and changing broker should not start a new series.

Breaking change in 5.3: the lock moved out of the field key and into a device tag. Before 5.3 every lock's state went into one shared point per cycle with each field key prefixed by the lock's name (Front_Door_Lock_stateValue), which made the lock impossible to query as a dimension; the point also carried a host tag naming the broker, which has been dropped. Your existing history is untouched and nothing breaks, but old and new data are in different series, so a dashboard built on prefixed field keys stops gaining new points. UPGRADING.md explains the supplied migration that joins them back together - two phases, with the irreversible delete separately invoked so you can verify first.

Behaviour change in 5.1: MQTT sources (Nuki) now stream state changes as they happen instead of only sampling once per interval, so you'll see denser data - more points, at the moments things actually change. The measurement and field names are unchanged, so existing dashboards and queries keep working as-is; there's no new configuration, and nothing to do on upgrade.

This needs an MQTT broker on your LAN, e.g. Mosquitto. Note that Nuki devices only support plain, unencrypted MQTT (port 1883) to a private-range LAN address - only use this on a network you trust.

Setting up Mosquitto on Debian/Ubuntu:

  1. sudo apt install mosquitto mosquitto-clients - the client tools (used below to verify the setup) are a separate package from the broker, not bundled with it.

  2. Create /etc/mosquitto/conf.d/local.conf (the filename must end in .conf or it is silently ignored):

    listener 1883 0.0.0.0
    allow_anonymous false
    password_file /etc/mosquitto/passwd
    
  3. Create two separate broker credentials - one for the lock (publisher) and one for send-to-influx (subscriber). Don't reuse one login for both; separate credentials mean either can be rotated or revoked independently:

    sudo mosquitto_passwd -c /etc/mosquitto/passwd nuki-lock
    sudo mosquitto_passwd /etc/mosquitto/passwd sendtoinflux
    

    Create the password file before restarting Mosquitto - it refuses to start if the configured password_file doesn't exist yet.

  4. The file is created owned root:root mode 600, which the mosquitto service user can't read, so fix the group and mode:

    sudo chgrp mosquitto /etc/mosquitto/passwd
    sudo chmod 640 /etc/mosquitto/passwd
    

    mosquitto_passwd will warn about this ownership on later runs ("group is not root ...") - that's a known upstream rough edge; do not follow its suggested chown root/chmod 0700 fix, which stops the broker being able to read the file at all.

  5. Restart Mosquitto (sudo systemctl restart mosquitto).

  6. In the Nuki app: Device Administration > MQTT - enter the broker's address, the nuki-lock credentials, and turn "Allow locking" off so the lock only publishes state and never subscribes to command topics (this integration is read-only regardless).

  7. Verify end-to-end with the sendtoinflux credential (the one that goes in settings.yaml):

    mosquitto_sub -h <broker-host> -u sendtoinflux -P <password> -t 'nuki/#' -v
    

    This should immediately print every retained state topic for the lock.

Then fill in the shared mqtt: block in settings.yaml with the sendtoinflux credential and add nuki to sources:. On the packaged install the broker password can be moved into systemd-creds with send-to-influx-set-credential mqtt-password, like any other credential.

If your broker permits anonymous access, you can instead leave username and password blank - the two-credential setup above is the recommended default, not a requirement. To switch an already-authenticated install to anonymous later, blank mqtt.username in settings.yaml and run sudo send-to-influx-set-credential mqtt-password --remove. The install prompts can't do it, since a blank answer there means "keep the current value".

Speedtest

This uses the speedtest-cli python library and will run download and upload tests and store the results.

If every latency probe to a candidate server fails, speedtest-cli reports a nonsensical ping (hardcoded penalty values averaged in place of real samples - can come out as high as 1,800,000 ms) rather than raising an error. speedtest-cli times each latency probe with a hardcoded 10-second connection timeout, so no genuine measurement can exceed 5000 ms; any ping >= 5000 ms is treated as an implausible/failed measurement and raises a connection error (retried with backoff) instead of being written to InfluxDB.

InfluxDB

Both InfluxDB v1 and v2 are supported.

For v1, configure user and password in the influx settings block and use db per source:

influx:
  url: "http://influx.example.com:8086"
  user: "your_user"
  password: "your_password"

For v2, use token and org instead and optionally use bucket per source (falls back to db if bucket is not set):

influx:
  url: "http://influx.example.com:8086"
  token: "your_token"
  org: "your_org"

If your InfluxDB URL uses https with a self-signed or internally-issued certificate, TLS certificate verification will fail by default. Set insecure: true in the influx settings block to skip verification:

influx:
  url: "https://influx.example.com:8086"
  user: "your_user"
  password: "your_password"
  insecure: true

Speedtest-cli is installed as a requirement of this project so no additional download is required.

Information about speedtest-cli is available on their project page: https://github.com/sivel/speedtest-cli

Running the script

  • copy example_settings.yaml to settings.yaml
    • Change the permissions of the file, e.g. chmod 600 settings.yaml, so that it's not readable by other users
    • Fill in the values for your devices and InfluxDB
  • Install runtime requirements with pip install -r requirements.txt
    • On Intel macOS, this builds cryptography (a transitive dependency via the mcp package) from source, since it has shipped no x86_64/universal2 macOS wheel since 49.0.0 - install Rust and OpenSSL 3.x first: brew install rust openssl@3. Apple Silicon and Linux are unaffected.
  • Leave the script running in a screen session and sit back and watch the data roll in.

Alternatively, see Using the .deb package below for a .deb package that installs and runs it under systemd instead.

Log output goes to stderr with timestamps and log level, e.g.:

2026-06-29 14:23:01 WARNING  Source 'hue' failed: connection timeout. Restarting in 5 seconds (attempt 1).

Diagnostics are on stderr so that stdout carries only the program's own output - --dump and --print JSON, and --check-config's verdict. That is what lets you pipe them: sendtoinflux.py --dump --source hue | jq works even when a source fails, because the failure is reported on the other stream. If you redirect logs to a file, redirect stderr: sendtoinflux.py 2> mylog, or > mylog 2>&1 to capture both. Watching a terminal or a screen session needs no change, and neither does the packaged service - the journal captures both streams.

On startup, an INFO line logs the version and which source(s) will run, so restarts are visible in the logs:

2026-06-29 14:23:00 INFO     Starting send-to-influx v1.0 (workers=hue@hue.example.com, zappi, speedtest)

To also write logs to a file, add an optional logfile key to settings.yaml. The file is rotated automatically once it reaches log_max_bytes (default 10 MiB), keeping log_backup_count old copies (default 3):

logfile: "/var/log/send-to-influx.log"
# log_max_bytes: 10485760
# log_backup_count: 3

If you're using the .deb package, you shouldn't need this at all: the package ships its own separate mechanism for a dedicated logfile, described below, which takes the service's messages out of the shared daemon.log/syslog entirely rather than adding a second copy alongside it. Setting logfile yourself on a packaged install is possible (the packaged service's ProtectSystem=strict sandboxing means /var/log/... isn't writable by it directly, so point it at a path under /var/lib/send-to-influx/ - the directory systemd creates for the service and the one it can write - or add your own path to ReadWritePaths= (or a LogsDirectory=) in packaging/send-to-influx.service and rebuild) - but don't point it at /var/log/send-to-influx.log itself, since that's the same file the packaging-level mechanism below already writes to, and you'd end up with two independent writers to one file.

The log level defaults to INFO. Set an optional loglevel key in settings.yaml (e.g. DEBUG, WARNING), or pass -v/--verbose on the command line to force DEBUG regardless of what's configured:

loglevel: "INFO"

By default the script looks for settings.yaml in the project root. To use a settings file at a different location (e.g. /etc/send-to-influx/settings.yaml for a packaged install), pass --settings <path>:

sendtoinflux.py --settings /etc/send-to-influx/settings.yaml

By default, sendtoinflux.py starts one worker per source listed in the sources setting. Each source runs in its own loop using its own interval.

Worker start times are slightly staggered to avoid all collectors firing at exactly the same moment when intervals are equal.

If a source hits a transient failure (e.g. a network error talking to its API or to InfluxDB) - whether running in single-source or multi-source mode - it is automatically restarted with exponential backoff (base 5 s, max 300 s) to avoid tight failure loops. In multi-source mode, only the failed source is retried; other sources keep running. A configuration problem (e.g. a source missing its settings section) is not retried: in single-source mode the process exits with code 1; in multi-source mode that source's worker stops permanently and a critical line is logged, while other sources keep running.

If sources: is empty or absent (and no -s/--source was given), or every configured instance of a requested source turns out unusable (e.g. a Hue install whose bridges have no tokens), the process logs that plainly and exits with code 1 rather than starting nothing while looking healthy. Both causes require manual intervention (an edit to settings.yaml and a restart) and never resolve themselves by waiting, so under the packaged systemd service they are not retried either - see "Exit codes" and "After installing" below.

Collection never waits on InfluxDB. Each point is written to a buffer on disk first - in the state directory, /var/lib/send-to-influx/spool on the packaged install - and sent from there by a thread of its own, so a slow or unreachable InfluxDB costs neither a collector its interval nor a control its cycle. Points wait on disk through an outage of any length, including restarts, kills and power cuts, and are sent oldest first once InfluxDB is back - with one exception: a control deleted while its points are still waiting leaves them in its own spool until a control of the same name runs again; after a crash, at most a handful already sent are sent again, which InfluxDB absorbs. A point the server itself keeps refusing (for example, one past the bucket's retention window, or one for a database that does not exist) is given up on after five separate attempts, spaced from 5 to 40 seconds apart, and while it waits for the next one it holds up nothing else; it waits in retry.jsonl in the same directory, so a restart does not lose it. Unsent points go to whatever database the current settings name, so a rebuilt or renamed database receives the backlog too. Heartbeat status points are never buffered - they are a live signal, so a failed one is simply dropped.

The buffer holds up to buffer_mb megabytes per process in the influx block - 100 by default, from 0 to 1024. It is per process, and each control runs in a process of its own, so an install with three controls can use up to four times the setting. When it is full the oldest points are dropped first, and the log says so. buffer_mb: 0 keeps unsent points in memory only - up to 500 per source, lost if the service stops - and the same happens, with a warning, if the disk cannot be written.

After every collection cycle (success or failure), a collector_status,source=<name> heartbeat point is written to InfluxDB alongside the source's own data, with fields ok (1/0) and consecutive_failures. A dead collector would otherwise only show up as a silent gap in Grafana; this gives you a positive signal to alert on (e.g. ok == 0 or a stale collector_status point). Heartbeats are not written in --print mode, since that mode never sends anything to InfluxDB.

There are a few options that can be passed to the script and a couple of these can help you to debug and also to help you understand your data:

  • To run only one data source, use the 'source' option, e.g. sendtoinflux.py --source zappi.
  • To dump all the data from the Hue Bridge in order to see the names, etc., run sendtoinflux.py --source hue --dump and it will output all the data returned as json. When sources is configured in settings.yaml, --dump must be used together with --source so the script knows which source to dump.
  • To print the data rather than send it to InfluxDB, run sendtoinflux.py --source hue --print and it will output the parsed data structure as json.
  • To check settings.yaml is valid without starting any collectors, run sendtoinflux.py --check-config. It prints Configuration OK and exits 0, or exits 1 with details of what's wrong.
  • To print the installed version, run sendtoinflux.py --version.

Configuration options for multi-source mode:

  • sources: list of source names to run in parallel when --source is not provided
  • stagger_seconds (optional): delay between source starts (default 10)
  • failed source retries: exponential backoff with a 5 second base and 300 second maximum

Using the .deb package

Instead of a screen session, you can install send-to-influx as a systemd-managed service via a .deb package.

Installing from the APT repo

curl -fsSL https://apt.l337.org/send-to-influx.gpg | sudo tee /usr/share/keyrings/send-to-influx.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/send-to-influx.gpg] https://apt.l337.org/ ./" | sudo tee /etc/apt/sources.list.d/send-to-influx.list
sudo apt update
sudo apt install send-to-influx

The repo only keeps the last few releases' .deb files (older versions remain available on the Releases page), and is maintained by L337-org/apt, which aggregates .deb release assets from L337-org projects' GitHub Releases (new releases appear there within the hour).

Building it yourself

packaging/deb/build-deb.sh
sudo dpkg -i send-to-influx_*.deb

The package is architecture-independent (all) - the app and its dependencies are pure Python, and any optional compiled accelerators (e.g. PyYAML's) are stripped from the bundled venv at build time in favour of their pure-Python fallbacks - so it can be built on any Debian/Ubuntu machine (the script requires /usr/bin/python3 and dpkg-deb) and installed on any Debian/Ubuntu architecture, including arm64 (e.g. Raspberry Pi). CI builds the package on an arm64 runner on every push/PR (a required status check) and runs packaging/deb/test-packaging.sh against it - a scenario suite covering upgrade over the latest published release, a fresh debconf-seeded install, plain-upgrade silence over a hand-edited config, restart-on-upgrade of a running service, dpkg-reconfigure semantics, question visibility at debconf's default priority, and purge - as well as catching a future dependency change that would make a compiled extension load-bearing rather than optional before it can merge. The same suite also runs in a Debian 12 container (systemd 252, matching Raspberry Pi OS bookworm), so behaviour differences in older systemd-creds tooling are covered too - the runners' own newer systemd once masked exactly such a difference.

A venv's site-packages normally lives under lib/pythonX.Y/, named after the exact major.minor of the interpreter that created it - which would otherwise tie an installable target to whatever Python version happened to be on the build host (e.g. GitHub's CI runner image) rather than whatever the install target actually has, and those two drift out of sync over time. Since everything in the venv is pure Python (see above), build-deb.sh symlinks every minor from 3.10 through 3.30's lib/pythonX.Y to the one that was actually populated, and declares a matching python3 (>= 3.10), python3 (<< 3.31) dependency, so the package installs correctly regardless of which minor in that range the target's /usr/bin/python3 happens to be (the upper bound keeps the dependency and the symlink range from silently drifting apart - both are bumped together if Python ever gets close to 3.30).

After installing

Either way, the package installs (but does not start) the service, since it needs configuration first:

  • Edit /etc/send-to-influx/settings.yaml (created from example_settings.yaml the first time the package is installed, and never touched by upgrades - only a fresh install or your own edits, or the debconf/send-to-influx-set-credential flows described below, ever write to it).
  • Then enable and start it: systemctl enable --now send-to-influx

On upgrades, a running service is automatically restarted so it actually picks up the new version - upgrades are often unattended (cron/apt timers), where a "please restart" hint in the package output would never be seen, and the old code would otherwise keep running until the next reboot. A stopped service is left stopped; an upgrade never starts anything.

The shipped settings.yaml starts with sources: empty, so starting the service before editing it exits immediately rather than silently running nothing useful - see "Exit codes" above. That exit is deliberately not retried by systemd (RestartPreventExitStatus in the unit), along with a fatal configuration error: both require an edit and a restart, and neither resolves itself by waiting, so there's no point bouncing the service every few seconds until you do.

Logs go to the journal (journalctl -u send-to-influx -f). Under systemd the timestamp is left to the journal rather than written twice: the service detects $JOURNAL_STREAM and drops its own, so a line reads LEVEL message and journalctl, syslog and /var/log/send-to-influx.log each stamp it themselves. Run by hand, or with stderr redirected to a file, the timestamp stays. Journald also forwards to syslog as usual, and the package ships rsyslog and logrotate config (/etc/rsyslog.d/49-send-to-influx.conf, /etc/logrotate.d/send-to-influx, Recommends:, not Depends: - the service works via the journal alone either way) that redirects this service's messages out of the shared daemon.log/syslog into their own /var/log/send-to-influx.log. Nothing needs configuring for this; the redirect is on by default whenever rsyslog is installed, and daily rotation (kept for 7 days) - the same pattern used by packages like haproxy - additionally needs logrotate, which runs it automatically once installed; without logrotate the file is still written correctly, just not rotated. Edit either file directly (both are real, editable config files, preserved across upgrades) to change the retention or filter.

Configuring during install (debconf)

A fresh interactive install of the .deb presents a debconf prompt: your InfluxDB connection details first (asked unconditionally, regardless of what else you answer - useful on its own if you just want to move an existing InfluxDB credential into systemd-creds without touching anything else), then a checklist of which data sources you want to configure now, then - only for the ones you pick - the fields needed to actually reach that source's API (credentials plus things like a Hue bridge hostname or an Octopus meter number; tuning settings like intervals keep their shipped defaults and can be adjusted in settings.yaml afterwards). You're also asked, with a default of "no", whether to enable the remote MCP server - answer yes and it collects the public URL, login username and password for it (the password goes into systemd-creds like any other). A plain package upgrade never prompts and never applies debconf answers: your settings.yaml and credentials are only ever written by a fresh install's prompts or by an explicit sudo dpkg-reconfigure send-to-influx, which re-runs the same flow at any time. Secrets you enter are moved into systemd-creds (see below) and never written into settings.yaml in plaintext. Debconf itself briefly holds what you type in its own separate, chmod 600 password store while postinst runs, then clears each password-type answer back to the empty string immediately after reading it - regardless of whether the subsequent migration into systemd-creds goes on to succeed - see SECURITY.md if you want the detail. Non-secret answers (a Hue bridge hostname, an Octopus meter number, and so on) aren't password-type and stay in debconf's regular database as normal, same as any other package's debconf answers - with one exception: the InfluxDB user/organisation answer is cleared like the secrets are, since for a v1 install it's the InfluxDB username, which is treated as a credential everywhere else. If every required field for a source was answered and your InfluxDB connection details resolved successfully, it's automatically added to sources: in settings.yaml and the InfluxDB database/bucket it needs is created for you where possible - a source with everything else filled in still won't be enabled if InfluxDB itself couldn't be reached or authenticated. When you revisit the prompts with dpkg-reconfigure, a secret left blank counts as provided if it's already stored in systemd-creds, so adding a new source never requires re-entering credentials that are already migrated.

You can leave every question blank and configure settings.yaml by hand instead - nothing here is required. Re-run sudo dpkg-reconfigure send-to-influx at any time to change your answers; secret prompts always come back blank on a reconfigure (debconf's own UI convention for password-type questions - it doesn't redisplay a previous answer), so leaving one blank keeps whatever's already stored rather than clearing it.

Storing secrets in systemd-creds

By default, settings.yaml holds credentials (InfluxDB token/user/password, Hue bridge user, MyEnergi API key, Octopus API key) in plaintext, same as the source-checkout path. On the packaged install (requires systemd >= 250), you can instead move them into systemd-creds - encrypted at rest with a TPM-bound or host-derived key, decrypted only into a restricted, in-memory location for the service's lifetime:

sudo send-to-influx-set-credential influx-token
sudo send-to-influx-set-credential --list

Run send-to-influx-set-credential --list to see the full set of available credential names. Each set call prompts for the value (or reads it from stdin if piped, e.g. echo -n "$TOKEN" | sudo send-to-influx-set-credential influx-token) and replaces the plaintext value in settings.yaml with a note that it's now managed elsewhere - the file stays readable for the rest of its content, but that field is never used from it again. Use send-to-influx-set-credential <name> --remove to remove a credential from systemd-creds again - note this resets the settings.yaml field to its placeholder rather than restoring the secret (the encrypted value is destroyed, not decrypted back into the file), so re-enter the value by hand afterwards if you still need it.

This is entirely optional and per-field - you can mix systemd-creds and plaintext values freely, and the source-checkout/screen-session path is unaffected either way, since systemd-creds only applies under the packaged systemd service.

Remote MCP server

send-to-influx can optionally run a remote MCP server inside the same process, so an MCP client - Claude Desktop or Claude Mobile, or another - can ask natural-language questions about your devices via a custom connector. It is disabled unless both user and password are set in the mcp: settings block - credentials-present is the primary enablement mechanism. Set mcp.disabled: true to force it off regardless of user/password state - useful if the password was migrated to systemd-creds (blanking the YAML fields alone won't disable it there, since the stored credential still gets substituted in at load time) or simply to rule the server in or out while troubleshooting something else.

mcp:
  bind_address: "127.0.0.1:8420"   # loopback/private only - any-interface (0.0.0.0/::) and public IPs are refused
  public_url: "https://mcp.example.org"  # the external HTTPS address your reverse proxy serves
  user: "your-login-name"
  password: "your-login-password"

Key points:

  • You provide the TLS side. The server itself speaks plain HTTP on a private/loopback address; put your own TLS-terminating reverse proxy (nginx, Caddy, ...) in front of it and set public_url to the address the proxy serves. Binding a public interface is refused outright: the server speaks plain HTTP - OAuth login and tokens included - so it must sit behind your TLS-terminating proxy, never face the internet directly.
  • Authentication is OAuth 2.1 (what Claude's connector UI uses, and the remote-MCP standard): add the connector in your MCP client (Claude, say) with URL https://mcp.example.org/mcp, and it registers itself and sends you to a login page gated on mcp.user/mcp.password. Leave the client's OAuth Client ID blank - the server uses dynamic client registration, and filling one in makes the client skip registration so /authorize rejects it. Failed logins are throttled and logged. On the packaged install the password can be stored in systemd-creds: sudo send-to-influx-set-credential mcp-password.
  • Connections survive restarts. OAuth client registrations and refresh tokens are persisted (hashed) in mcp-oauth-state.json - under /var/lib/send-to-influx on the packaged systemd install, or next to the settings file when run by hand (override with mcp.state_file), so package upgrades and reboots don't make the client re-authenticate.
  • Read-only by default. Device-control tools are opt-in per collector and aren't registered at all unless enabled in that collector's own settings block - when none is enabled, the write tools don't exist on the server. The same is true of the control-loop write tools, which need controls.mcp_write on top of controls.enabled.

Example nginx config. Give the server its own subdomain and serve it at the root - its OAuth flow uses several root-level routes (/authorize, /token, /.well-known/oauth-*, /login), so a sub-path of a shared site won't work:

server {
    listen 443 ssl http2;                      # 'listen ... http2' works on old and new nginx;
    listen [::]:443 ssl http2;                 # newer nginx (1.25.1+) prefers a separate 'http2 on;'
    server_name mcp.example.org;               # must match the host in mcp.public_url

    ssl_certificate     /etc/letsencrypt/live/mcp.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mcp.example.org/privkey.pem;

    location / {
        # Literal 127.0.0.1, not 'localhost' - 'localhost' can resolve to ::1 while the
        # server listens on IPv4, which gives a 502. No trailing slash on proxy_pass.
        proxy_pass http://127.0.0.1:8420;
        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto https;

        # Streamable-HTTP / SSE: don't buffer, keep the upstream connection alive,
        # and allow long-lived streams.
        proxy_http_version 1.1;
        proxy_set_header   Connection "";
        proxy_buffering    off;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

Two things that catch people out:

  • public_url must be a bare origin (https://host[:port], no path). The server refuses to start with a path in it, since the OAuth metadata is built from it - so use a subdomain (https://mcp.example.org), not a path prefix (https://example.org/mcp).
  • Leave the OAuth Client ID blank in the connector (see the OAuth point above) - the server registers clients dynamically, and a manually-set client id makes /authorize reject it.

Each controllable collector adds its own tools when you opt it in - writes differ too much between device types to share one tool.

To allow controlling Hue lights and plugs, set hue.mcp_read_write: true. That adds:

  • hue_list_devices - the controllable lights/plugs, each with its id, name, and which controls it supports (on/off, brightness, colour temperature, colour).
  • hue_set_light - a real physical action: turn a light/plug on or off, set brightness (0-100 %), white colour temperature (in kelvin), or colour (an #rrggbb hex or a name like "warm white"). It's capability-aware - asking a plain white bulb for a colour is refused rather than ignored - and setting brightness/temperature/colour turns the light on unless you turn it off.

To allow triggering a speed test on demand, set speedtest.mcp_read_write: true. That adds:

  • speedtest_run - runs a speed test now, on the host running the server, and returns the result (also recording it like a scheduled run). Only one runs at a time per host.

Once connected, the client has these read tools:

  • list_sources - the collectors that can be queried, each with a short description.

  • list_fields - everything needed to write a query or build a chart for one source: the database and measurement to query, the tag keys available to group by, and every field with its InfluxDB type, its unit, what each value means for a coded field like Nuki lock state, and how it may be aggregated. Each of those is omitted rather than nulled where it is not known - including the type, which InfluxDB does not report for every field. That last part - kind - is the one a dashboard gets silently wrong without: a gauge is an instantaneous reading (never sum them), an interval total is a quantity accumulated over its reporting period (sum them to get a total for a day or a week), a counter a running total that resets (read its last value or a difference, never its mean), a state a discrete code or label. Pass detail: true for the per-field descriptions as well, in the same call. Hue is a special case worth knowing about: its field keys are your own device names, so no built-in table can say what they mean. The collector therefore records each device's class alongside the readings (see hue_devices in UNITS.md), and the MCP server reads that back - so a Hue temperature sensor reports °C and a smart plug reports as a state, the same as any other source. Nothing else needs configuring, and it starts working from the first collection.

  • get_current_state - a source's state right now ("is the door locked?", "which lights are on?"). Most sources are read live from the device; Speedtest and Octopus report their latest recorded reading instead.

  • query_history - a field's history over a time range, either as individual points or aggregated (mean/max/min/sum/count/...) into buckets ("how much electricity this month vs last?", "when did the light go off?").

  • get_data_range - how far back a source's data goes, and how long InfluxDB is configured to keep it ("how far back do my records go?", "when did collection start?"). Reports the oldest and newest points actually present and the retention duration separately, because they answer different questions: an install collecting for three years with 30-day retention has 30 days of data, not three years.

  • get_documentation - a one-call reference of what every source reports and what its coded values mean.

  • suggest_dashboard_panels - the same schema turned into chart panels: per field a ready-to-run InfluxQL query, the panel type to draw it as, the aggregation to use and any that would be wrong, a Grafana unit, value mappings decoding a coded field to labels, and an alias naming each series after its tag. It returns panel parts, not a dashboard document - see "Building a Grafana dashboard" below.

Alongside the tools, the server offers resources - the same read data as addressable items a client can list and attach, rather than call:

  • docs://reference - the get_documentation reference as Markdown.
  • schema://<source> - one per configured source: the list_fields payload for it.
  • state://<source> - one per configured source: its state right now.

Anything offered as a resource is also a tool, deliberately: clients vary in how much they use resources, so the tools stay the workhorses and each resource says which tool covers the same data.

Building a Grafana dashboard

Grafana has its own MCP server, which is what creates and edits dashboards. This project does not talk to Grafana at all - it just describes its own data well enough that a model can chart it correctly. Connect both servers to the same client and ask for what you want; the model calls suggest_dashboard_panels here for the queries and panel settings, then Grafana's own dashboard tool to create it.

Some prompts that work, in rough order of ambition:

Using send-to-influx and Grafana, create a dashboard called "Home" with a panel for each Nuki lock's state and battery level. Use suggest_dashboard_panels for the queries and panel settings.

What can you chart from my send-to-influx data? List the sources, then for the two or three most interesting ones show me what panels you would build and why, before creating anything.

Build me a Grafana dashboard relating conservatory temperature, the heater's state and solar radiation over the last week, so I can see whether the sun is doing the heating. Put the temperature and radiation on one panel with two axes and the heater state on a state timeline underneath.

Add a panel to my "Home" dashboard for today's grid import, and check with suggest_dashboard_panels how that field should be aggregated before you pick a function - it is a counter, so a mean would be meaningless.

Two things worth knowing when you ask:

  • The datasource is yours, not ours. suggest_dashboard_panels returns the database and measurement but cannot know your Grafana's datasource uid, so the model has to look that up (or copy it from an existing dashboard). Saying "use my existing InfluxDB datasource" is usually enough.
  • A few units use Grafana's custom-suffix form. Grafana has no identifier for W/m² (solar radiation), gCO2/kWh (carbon intensity) or pence/kWh (tariff rates), so those are emitted as suffix:W/m² and the like, and the axis reads "812 W/m²". The tariff one is shortened to p/kWh, because the suffix repeats on every tick; the full "pence/kWh (inc. VAT)" is what list_fields reports. Octopus's gas reading is the one field with no unit at all - it is kWh or m³ depending on your meter, so no single label is right.

If you would rather assemble the dashboard yourself, suggest_dashboard_panels output drops almost straight into a panel: its query goes in a target with rawQuery set true and resultFormat time_series, its unit and value_mappings go in fieldConfig.defaults, and its alias goes in the target.

There are also prompts - ready-made tasks you pick in the client rather than typing out: home_status (summarise everything now, optionally a focus like "lights"), usage_trends (answer a question from recorded history) and, only where a source has writes enabled, control_device (check state, act, confirm what changed).

Several MyEnergi devices of one type

Each of zappi, eddi and harvi collects one device by default, using the serial at the top of its block, and tags it device=<type> as it always has. To collect more than one, add a devices: list:

zappi:
  db: "zappi_db"
  interval: 300
  fields: ["frq", "vol", "gen"]
  devices:
    - serial: "first_serial"
      label: "Garage"
    - serial: "second_serial"
      label: "Driveway"
      fields: ["frq"]

Each device gets its own collector, so one unreachable device delays only itself. Every entry needs a label, which becomes the device tag and the name the MCP tools use; a device declared at the top of the block may take an optional label too, defaulting to the type name so existing installs keep writing the same tag. fields resolves per device first, then the block-level list, then everything the API returns.

Labels must be unique across all three blocks, because the three types share the one myenergi measurement - two devices with the same label would merge into a single series. Renaming a label starts a new series.

Existing single-device configurations need no change and write exactly the same data as before.

Several collectors writing to one measurement

Some measurements hold points from more than one producer. Speedtest is the clearest case: running a collector on several hosts into one database is exactly how their connections get compared, and every point carries the host tag that tells them apart. Hue is the other: every point carries the bridge it came from, and with more than one bridge that tag is what separates two lights sharing a name. MyEnergi is the third: configure more than one Zappi and each carries the label you gave it in its device tag.

The read tools treat that tag as a dimension rather than flattening it:

  • list_sources names the tag (instance_tag); list_fields lists the values it accepts - those present in the data plus any target configured but not yet collecting.
  • query_history takes an instance to scope a query to one producer. Left out, it reports each producer separately under instances, keyed by value - never merged, because two hosts' ping interleaved in one unlabelled series is a wrong answer rather than an incomplete one, as is one "Kitchen" reading that could be either of two bridges. An unknown value is an error, not an empty result; the accepted values are the producers found in the data plus any target that is configured but has not collected yet, so a newly added bridge and a decommissioned one are both queryable. Hue's older bridge parameter is gone, replaced by instance, which behaves the same way for every source rather than for Hue alone.
  • get_current_state and get_data_range likewise report per producer. The range is worth having per host: a machine added last week and one collecting for a year share a merged span that is true of the measurement and false of both.
  • speedtest_run names the host it ran on, and refuses a request naming a different one - it can only measure the machine the server runs on, since each collecting host runs its own process.

Because InfluxDB applies a query's LIMIT to each series separately once results are grouped, an unscoped query divides the limit across the known producers so the total stays bounded, and reports the figure actually applied as limit_per_instance.

Results are domain-aware throughout: values carry their unit, and coded fields come back with a decoded label alongside the raw number. The same data is also exposed as MCP resources (a documentation reference, and a live-state and schema resource per source) for clients that use them.

The server also offers a few prompts - ready-made tasks you can pick in the client: home status (summarise everything now), usage trends (historical analysis, e.g. "electricity this month vs last"), and, when device control is enabled, control device.

History queries run against InfluxDB through a fixed, parameterised query builder - never a raw query from the model - with field names checked against the measurement's live field list, time ranges normalised in the app, and a capped result size.

Control loops

A control holds something at a target by switching devices: a conservatory at a temperature, using two heaters on smart plugs. It reads its inputs from the data already being collected, runs a PID, and spends each cycle window across the rungs of a stage ladder - so two heaters can average a demand that neither of them can produce on its own.

Controls are off unless you switch them on, because a control actuates devices with nobody watching:

controls:
  enabled: true      # run the stored controls
  mcp_write: true    # let a connected model write them for you

That is deliberately not the per-source mcp_read_write flag. Wanting a heating loop is not the same as granting a connected model device-write access, and one setting governing both would force anyone wanting the first to accept the second.

mcp_write is the second, separate opt-in, and neither implies the other. It lets a connected model create, change and delete controls, effective without a restart - a bigger grant than mcp_read_write, which permits an action now where this permits a standing rule that keeps acting. With it off, a model can still read your controls and compose one for you to save by hand; it just cannot save it itself.

Each control is its own YAML document under the state directory (/var/lib/send-to-influx/controls on the packaged install), not part of settings.yaml: they are created and edited by the running service rather than by hand.

One process per control, supervised. The main process starts one child per stored control, watches a heartbeat from each, and restarts one that dies or stops beating with a growing backoff. Every death is followed by the parent putting that control's devices into their safe state itself - a child that was killed or lost power did not get the chance.

Each control's PID history can be kept for tuning. Set controls.db, as for any source, and every cycle writes its input, setpoint, demand and P, I and D terms to the control measurement, readable through the MCP read tools as source controls, and what each device was commanded to the control_device measurement, which get_control_devices summarises. CONTROLS.md says what each field means and how to read it.

CONTROLS.md is the reference: the document format key by key, the rule language and the things about it that bite, safe states and the active period, the stage ladder, a worked example, recording the PID history, and what --check-config checks. The same reference is available at runtime from the get_control_schema MCP tool, built from the same constants, so a connected model composing a control is not guessing.

Usage

$ ./.venv/bin/python ./sendtoinflux.py --help
usage: sendtoinflux.py [-h] [--version] [--settings SETTINGS] [--check-config] [-v] [-d] [-p] [-s SOURCE]

Send Hue Data to InfluxDB

options:
  -h, --help show this help message and exit
  --version show program's version number and exit
  --settings SETTINGS path to the settings file (default: settings.yaml in the project root)
  --check-config validate settings.yaml and exit (0 if valid, 1 if invalid)
  -v, --verbose enable DEBUG-level logging (overrides the 'loglevel' settings.yaml key)
  -d, --dump dump the data to the console one time and exit. This requires a source to be specified
  -p, --print print the raw data rather than sending it to InfluxDB
  -s, --source SOURCE the source of the data to send to InfluxDB (hue, zappi, etc.). If this parameter is omitted, all sources in the settings file   'sources' list are started. If no sources are configured, the process logs that plainly and exits.
  --control CONTROL run one stored control as this process, rather than collecting. One process per control; the supervisor starts these, and an   operator rarely does
  --heartbeat-fd FD an inherited pipe to beat down once per cycle, so a supervisor can tell a slow control from a dead one. Set by the supervisor   when it starts a control

Exit codes

CodeMeaning
0Normal exit
1A condition that requires manual intervention and never resolves itself by waiting: a fatal configuration error (missing/invalid settings, unknown source name), or nothing to collect (sources: empty/absent with no -s/--source, or every configured instance of a requested source is unusable - e.g. a Hue install whose bridges have no tokens). Under the packaged systemd service, this exit code is marked RestartPreventExitStatus, so the service is not respawned for either cause - see "After installing" below.
2Connection error (a transient failure talking to a source's API or InfluxDB) in --dump mode only - there's no worker loop to retry a one-shot dump with backoff. In continuous mode (single- or multi-source), connection errors are always retried with backoff instead of exiting.

Upgrading

Most releases need nothing done: settings.yaml is never rewritten by an upgrade, and new configuration is optional with a safe default.

Where a release does need a step - a data migration, or a change to what a series looks like - it is described in UPGRADING.md, per version. 5.3 has one, for Nuki users only: the lock moved from the field key into a device tag, and a supplied migration converts existing history. On a packaged install both the migration and a copy of those instructions are at /usr/share/send-to-influx/.

Contributing

Contributions are welcome. See CONTRIBUTING.md for the project layout, code conventions, the checklist for adding a new data source, and local development setup.

Privacy and Security

send-to-influx sends no telemetry and has no author-operated backend - see PRIVACY.md for what data it does handle (yours, sent only to the InfluxDB and device/API endpoints you configure) and SECURITY.md for reporting a vulnerability and operational security notes (credential storage, TLS verification defaults).

↑↓ navigate openesc close