Integrating GeoDrops Soil Moisture Sensors with Home Assistant

(n.b.): this is a Claude.ai summarization of some work that I did to integrate GeoDrops data into Home Assistant. It involves a Python script that utilizes Google BigQuery to pull the data in, then ingests it into Home Assistant via MQTT. It seems to work decently, but your milage may very and I’m very far from a professional programmer/scripter. I know enough to be dangerous conceptually and can use AI to stumble my way through the nitty gritty =)

Overview

This guide shows you how to pull GeoDrops soil moisture sensor data from BigQuery into Home Assistant, creating MQTT sensors that update every 15 minutes. Once integrated, you can use this data to drive irrigation automations (Rachio), mowing schedules (Mammotion), or any other smart home logic.

What you’ll get:

  • Near-current soil moisture readings at 3 depths per sensor (updated every 3-6 hours by sensors)

  • Soil temperature at surface + 3 depths

  • Battery level monitoring

  • Sensor health tracking (sync delay)

  • 7-day sun exposure data

  • Automatic sensor discovery in Home Assistant

  • Data freshness validation

Cost: Free (well within Google Cloud’s free tier at ~0.04% usage with 4 sensors polling 4x/hour)

Prerequisites

Required:

  • Home Assistant OS (tested on current versions)

  • Mosquitto MQTT broker add-on installed and configured

  • GeoDrops soil moisture sensors (obviously!)

  • Google Cloud account (free tier is sufficient)

  • Basic command line familiarity

Estimated setup time: 30-45 minutes

Architecture

GeoDrops Sensors → GeoDrops AI → BigQuery (public dataset)
                                      ↓
                            Python script (every 15 min)
                                      ↓
                            MQTT Broker (Mosquitto)
                                      ↓
                            Home Assistant (auto-discovered sensors)

The script queries GeoDrops’ public BigQuery dataset, validates data freshness, and publishes to MQTT where Home Assistant auto-discovers the sensors.

Data Refresh Cadence

Important: This is NOT real-time data. Battery-powered sensors report on intervals to conserve power:

  • Moisture & Temperature data: Updated every ~3 hours by sensors

  • Battery & Status data: Updated every ~6 hours by sensors

  • GeoDrops AI processing: Additional lag of minutes to hours (variable)

  • Our polling: Every 15 minutes from BigQuery

Typical data age: When you see a reading in Home Assistant, the actual sensor measurement is typically 3-6 hours old, plus any GeoDrops processing lag.

What this means:

  • Don’t use this for emergency cutoffs (e.g., “stop irrigating NOW if moisture spikes”)

  • Perfect for daily/scheduled irrigation decisions (“should I water at 6am?”)

  • Great for trend monitoring and threshold-based automations

  • Battery monitoring is always delayed but adequate for alerts

The 15-minute polling ensures you get updates within 15 minutes of GeoDrops publishing them to BigQuery, but the sensor data itself is already hours old by that point.

Step-by-Step Setup

Part 1: Google Cloud Service Account

  1. Create a Google Cloud Project:

  2. Create Service Account:

    • Navigate to IAM & Admin → Service Accounts

    • Click Create Service Account

    • Name: home-assistant-bigquery (or your preference)

    • Click Create and Continue

  3. Grant Permissions:

    • Add role: BigQuery Job User

    • Add role: BigQuery Data Viewer

    • Click Continue, then Done

  4. Create & Download Key:

    • Find your new service account in the list

    • Click the three dots → Manage keys

    • Add Key → Create new key

    • Choose JSON format

    • Save the downloaded file as geodrops-service-account.json

  5. Upload to Home Assistant:

    • Copy the JSON file to /config/ in your Home Assistant installation

    • You can use the File Editor add-on or SSH/SFTP

Part 2: Get Your Device IDs

You need to map your GeoDrops serial numbers to their internal device IDs.

  1. Run this query in BigQuery Console (https://console.cloud.google.com/bigquery):

sql

SELECT DISTINCT deviceId, mfgSn
FROM `geodrops-prod.db_public.p_sensor_unified`
WHERE mfgSn IN ("YOUR_SERIAL_1", "YOUR_SERIAL_2", "YOUR_SERIAL_3")
LIMIT 10

Replace the serial numbers with yours (found on your GeoDrops devices or in the GeoDrops app).

  1. Note the results - you’ll need both the deviceId and mfgSn for each sensor.

Part 3: Set Up Python Virtual Environment

Using a virtual environment keeps dependencies isolated and prevents them from being wiped during Home Assistant updates.

  1. SSH into your Home Assistant and enter the container:

bash

docker exec -it homeassistant /bin/bash
  1. Create a virtual environment:

bash

cd /config
python3 -m venv geodrops_venv
  1. Activate the virtual environment:

bash

source /config/geodrops_venv/bin/activate
  1. Install required packages:

bash

pip install google-cloud-bigquery paho-mqtt pyyaml
  1. Exit the container:

bash

exit

The virtual environment is now set up at /config/geodrops_venv/ and will persist across Home Assistant updates.

Part 4: Configure MQTT Password

  1. Find your MQTT password:

    • Go to Settings → Devices & Services → MQTT → Configure

    • Note the password (or see alternative methods in troubleshooting section)

  2. Add to secrets.yaml:

Edit /config/secrets.yaml and add:

yaml

mqtt_password: your_actual_mqtt_password_here

Part 5: Install the Script

  1. Create the directory:

bash

mkdir -p /config/python_scripts
  1. Create the script:

Save the attached geodrops_sync.py script to /config/python_scripts/geodrops_sync.py

  1. Update the shebang line (first line):

Change the first line of the script to point to your venv:

python

#!/config/geodrops_venv/bin/python3
  1. Customize the script:

Edit these sections in the script:

  • Line ~16: PROJECT_ID - your Google Cloud project ID

  • Lines ~23-28: DEVICE_MAPPING - your deviceId/serial number/friendly name mappings

  1. Make it executable:

bash

chmod +x /config/python_scripts/geodrops_sync.py
  1. Test it manually:

bash

/config/geodrops_venv/bin/python3 /config/python_scripts/geodrops_sync.py
```

You should see output like:
```
Executing query...
Query returned 4 results
Processing front_slope (334JTQ) - last reading: 2025-11-08 15:30:00 (sync delay: 1.2h)
...
Published 10 retained discovery configs and 10 retained states to MQTT

Part 6: Add to Home Assistant Configuration

Edit /config/configuration.yaml:

yaml

shell_command:
  sync_geodrops: "/config/geodrops_venv/bin/python3 /config/python_scripts/geodrops_sync.py"

automation:
  - alias: "Sync GeoDrops Sensors"
    description: "Query BigQuery for GeoDrops sensor data every 15 minutes"
    trigger:
      - platform: time_pattern
        minutes: "/15"
    action:
      - service: shell_command.sync_geodrops

Important: Note that the shell_command uses the full path to the venv’s python3 interpreter.

Restart Home Assistant.

Part 7: Verify Sensors Appear

  1. After restart, the automation will run on the next 15-minute mark

  2. Go to Settings → Devices & Services → MQTT

  3. You should see your GeoDrops devices listed (e.g., “Front Slope Moisture Sensor”)

  4. Each device will have 10 entities:

    • Moisture Depth 1, 2, 3

    • Temperature Surface, Depth 1, 2, 3

    • Battery

    • Sync Delay

    • 7-Day Sun Exposure

  5. Check Developer Tools → States and search for your sensor names to see current values

Data Freshness & Staleness Detection

The script implements three layers of data validation:

  1. Query window: Only pulls data from the last 12 hours (accounts for GeoDrops processing lag)

  2. Staleness thresholds:

    • 0-6 hours: Normal operation

    • 6-12 hours: Warning logged but data published

    • 12 hours: Data skipped entirely

  3. MQTT expiry: Entities automatically become “unavailable” if no updates received for 45 minutes

This prevents automations from acting on stale data if sensors go offline.

Cost & Usage

Free tier limits: 1 TB of data scanned per month

Your usage with 4 sensors at 4x/hour:

  • ~432 MB/month (~0.04% of free tier)

  • Scaling to 40 sensors: still only ~4.3 GB/month

  • You won’t pay anything unless you drastically change the setup

Troubleshooting

“Not Authorized” MQTT error:

  • Verify your MQTT password in secrets.yaml is correct

  • Try finding the password in /config/.storage/core.config_entries (search for “mqtt”)

  • Ensure Mosquitto broker is running

Script succeeds but sensors don’t update:

  • Check Mosquitto logs: docker logs -f addon_core_mosquitto

  • Try changing MQTT_BROKER = "core-mosquitto" to MQTT_BROKER = "127.0.0.1" in the script

  • Verify the shell_command is using the correct venv path

“No results found” error:

  • Verify your device IDs are correct (re-run the BigQuery query from Part 2)

  • Check that your sensors are actually reporting (log into GeoDrops app)

  • Expand the time window in the query if needed (change INTERVAL 12 HOUR to INTERVAL 24 HOUR)

Entities show “Unknown”:

  • Run the script a second time - first run creates entities, second run populates states

  • Wait for the next 15-minute automation cycle

“Module not found” errors after HA update:

  • Your venv should persist, but verify: /config/geodrops_venv/bin/python3 -c "import google.cloud.bigquery"

  • If missing, reactivate venv and reinstall: source /config/geodrops_venv/bin/activate && pip install google-cloud-bigquery paho-mqtt pyyaml

Alternative: System packages (not recommended): If you don’t want to use venv, you can install directly to the HA container, but packages may be wiped on major updates:

bash

docker exec -it homeassistant pip install google-cloud-bigquery paho-mqtt pyyaml --break-system-packages

Then use python3 instead of the venv path in your configuration.

Future Enhancements

Ideas for extending this integration:

  • Pull 30-day average data for trend analysis

  • Add precipitation forecasting integration

  • Create VPD (Vapor Pressure Deficit) calculations using temp + humidity

  • Integrate with weather station data for irrigation optimization

  • Build dashboards showing moisture trends over time

Credits & Support

Feel free to modify the script for your needs. If you run into issues or have improvements, share them in this thread!

1 Like

Download the script: [geodrops_sync.py](https://gist.github.com/ajbaldwin/03ec9383e43c6e0d3c280520a08d1a0c)

I’ve continued to iterate on my implementation and just uploaded v2 of the script to the same gist location. To use it, just replace the original script - sensor devices are automatically updated the first time the script is run.

  • Quality Control Number (QCN) Sensors - Added three new sensors per device that expose GeoDrops’ Quality Control Numbers for each soil depth with color-coded icons (Good/Poor/Bad/Training states)
  • Long-Term Statistics Support - Added state_class: "measurement" to moisture, temperature, battery, and sync delay sensors enabling historical trend analysis, min/max/mean calculations, and statistics graphs
  • Timestamped Logging - All script output now includes timestamps for easier debugging and troubleshooting
  • Bug Fixes - Fixed missing device_class: "moisture" on Depth 2 and Depth 3 moisture sensors so all three depths now display with proper moisture icons
  • Improved Error Handling - More descriptive error messages when things go wrong

Below are some screenshots of the devices as currently constructed and some of the resultant graphing that the statistics data power.

1 Like

That’s very cool. Thank you for your effort

No worries, I appreciate you guys exposing the data! Next time I have a few free hours, I’ll wire the integration between this data and my Rachio.

Any progress on an official integration yet?

We’ll have another status update announcement soon on our software development progress, since we’re getting close and closer to the first major milestone we need to exit Soft Launch. But specifically for a user friendly irrigation control and home assistance integration, unfortunately we don’t have any dates to announce yet, since part of this actually depends on having >50% user adoption of our new AI Model. I’ll explain this more as part of this upcoming announcement.

If you’re interested in irrigation control in the meantime (some of our users write their own simple program for home automation), we’d encourage that you sign up for a FREE Google Cloud developer account, and access our BigQuery programming API directly to control any irrigation system you desire.

In the mean time, please keep using GeoDrops!
Having everyone keep using GeoDrops is crucial for our AI model to keep learning about soil properties, and becoming more accurate with its soil moisture prediction algorithm.

Thanks,
Lawrence

I updated my Gist above with my current script which polls many, but not all of the same fields as it did before (some fields aren’t published any longer) and also grabs the dominant moisture field, which is what I use for personal charting.

See Lawrence’s explanation here about dominant moisture.

1 Like

Thank you Scythe! Working perfectly for me!

2 Likes

You’re very welcome! Glad to hear that it’s working for you.

In my latest version (not uploaded to GitHub yet), I’m pulling in the MoistureIndex field as that’s proving to be more useful to driving my Rachio (I’ve got GeoDrops data feeding Rachio Flex Daily watering now and that seems to be ok). If you have interest in that, let me know and I’ll upload it.

My current project is to write my own scheduling system within HA using GeoDrops data and HA telling Rachio when/where/how much to water. Helps me controls more variables and addresses some annoying aspects of how Rachio handles watering.

1 Like

I was able to use your instructions, with a little help from chatgpt, to create an automation with my Hunter Hydrawise system that suspends the next day’s scheduled irrigation program if that program’s corresponding geodrops sensor’s max moisture is above 75%. Through all the experimentation I’ve been doing to learn the rate at which my soil retains/loses moisture, I’ve developed a little bit of a brown spot dz problem :joy: … not horrible but enough to make me want to make sure things dry out a little bit more. I also setup a dashboard in Home Assistant.

My long-term plan includes figuring out a home assistant automation that uses dismissing my phone’s wake up alarm to suspend the current program for 30 minutes and then finish it. If my irrigation is running, it cuts my shower pressure in half :rofl:, so if I could get that going then I’d have a lot more freedom in how I setup the programs; wouldn’t have to work so hard to make sure any program is finished by a certain time.

2 Likes

That’s great stuff! I’ve definitely been guilty of overwatering and am trying to do better with data coming from GeoDrops. I switched control of my system to my new automation earlier this week, so fingers crossed!

Re: the long-term plan - you might not have an iPhone, but if you do, I generally use the shift of focus when I wake up (Sleep Focus → Off) as the trigger for my “wake-up” automations (turning on grow lights in my bedroom in my case). Keep in mind that that HA can only see if a focus is on or not (not which specific focus it is), so design accordingly.

1 Like

(n.b.: This is an alternative Part 3/Part 4/Part 6 for Scythe’s original GeoDrops → Home Assistant guide. Same overall pipeline, same BigQuery dataset — but it runs the sync as a proper AppDaemon add-on instead of a hand-built venv reached via docker exec, and it fixes a few BigQuery field names that have drifted since the original script was written. Parts 1 and 2 are unchanged from the original; everything else below replaces the corresponding original section.)

(AI Note: Just as Claude was used for the original, this updated version is created as the final result of a Claude Sonnet 5 working session I completed and tested. Use at your own discretion.)

(Terminology note: as of Home Assistant 2026.2, Settings → Add-ons was renamed Settings → Apps — same underlying Supervisor-managed Docker containers, just relabeled. This guide uses the current “Apps” terminology below. Unfortunately AppDaemon has its own, unrelated notion of “apps” — the Python scripts you write in its apps/ folder — so where both meanings could be confused, this guide spells out “AppDaemon app” for the script and just “App” for the Settings → Apps install.)

Why a different Part 3

The original approach (docker exec -it homeassistant /bin/bash, build a venv under /config, wire it up with shell_command: + a time_pattern automation) works, but it has a couple of rough edges worth avoiding if you’re setting this up fresh:

  • docker exec into the Core container requires the SSH App with “Protection mode” turned off — real shell access to the host, not just your HA config. If you don’t already have that App installed for other reasons, this guide doesn’t require it at all.
  • Pip-installed packages living inside /config survive Core updates in practice, but it’s not a guarantee — the App’s own troubleshooting section already flags “Module not found” after updates as a known failure mode.
  • shell_command has a default execution timeout and blocks HA’s executor while the script runs.

Running this as an AppDaemon app sidesteps all three: dependencies are declared once in the App’s config and reinstalled automatically on startup, AppDaemon is a separate container so a Core update can’t touch it, and scheduling is a native in-process callback (self.run_every) instead of a shell-out.

Updated Architecture

GeoDrops Sensors → GeoDrops AI → BigQuery (public dataset)
                                      ↓
                     AppDaemon app (self.run_every, every 15 min)
                                      ↓
                            MQTT Broker (Mosquitto)
                                      ↓
                     Home Assistant (auto-discovered sensors)

Data freshness, staleness thresholds, and MQTT expire_after behavior are all unchanged from the original guide — see that post for the full explanation. Short version: sensor readings are typically 3-6 hours old by the time you see them, this polls BigQuery every 15 minutes, and entities go unavailable if nothing’s been published in 45 minutes.


Part 3: Install AppDaemon

  1. Install AppDaemon: Settings → Apps → Install app (bottom right) → scroll to the “Home Assistant Community Apps” section (this is built in — you don’t need to add any repository URL manually) → AppDaemon → Install, then start it once. This creates its config folder at addon_configs/a0d7b954_appdaemon/ (the slug may differ slightly depending on your setup — check Settings → Apps → AppDaemon for the exact slug).

  2. Declare the Python dependencies: Settings → Apps → AppDaemon → Configuration tab, add under python_packages:

    python_packages:
      - google-cloud-bigquery
      - paho-mqtt
    
    
  3. Restart the AppDaemon App. Watch its log — it reinstalls these packages fresh on every startup (takes 30-60 seconds, this is normal), and you should see Successfully installed ... google-cloud-bigquery ... before AppDaemon itself starts.

    You’ll likely also see a pip dependency-resolver warning like appdaemon 4.5.13 requires requests<2.33.0 ... but you have requests 2.34.2 which is incompatible. This is a benign version-pin conflict, not an install failure — AppDaemon starts fine regardless. Worth knowing about so it doesn’t look alarming.

  4. Recommended App settings: Start on Boot = on, Watchdog = on (safe here — a crash-triggered restart just re-runs initialize(), there’s no state to lose), Auto-update = off. Auto-update is worth turning off specifically for this one: a new AppDaemon base image could change the Python version or how python_packages gets installed without warning, which defeats the whole point of moving off the fragile venv approach.

Getting file access to AppDaemon’s config folder

AppDaemon’s own /config (where appdaemon.yaml and the apps/ folder live) is a separate directory from Home Assistant’s /config — it’s mounted at addon_configs/<appdaemon-slug>/ on the host. The built-in “File editor” App only browses HA’s root config, so you’ll need something else to get files in there.

Easiest option: install the Studio Code Server App (same repository as AppDaemon). After installing:

  1. Settings → Apps → Studio Code Server → Configuration tab, set:

    config_path: /addon_configs/a0d7b954_appdaemon
    
    

    (substitute your actual AppDaemon slug if different)

  2. Restart Studio Code Server. Open it (sidebar icon, or Settings → Apps → Studio Code Server → Open Web UI) and it should load directly into AppDaemon’s folder. If it still shows the old location, do a hard refresh of the tab, or use the ☰ menu → File → Open Folder and type the path manually.

(A Samba share App works too if you’d rather drag-and-drop from your desktop — just make sure it’s configured to expose addon_configs, not just config.)

Part 4: MQTT — create a dedicated login

The original guide has you dig up the existing homeassistant MQTT password from Settings → Devices & Services → MQTT → Configure, or fall back to grepping .storage/core.config_entries. In practice that password often isn’t visible in the UI at all (it’s masked), and Home Assistant’s config API doesn’t expose it either. Skip the archaeology — create a fresh, dedicated login instead:

  1. Settings → Apps → Mosquitto broker → Configuration tab.
  2. Under Logins, add an entry: username geodrops, password — anything you like, it doesn’t need to be memorable.
  3. Save, then restart the Mosquitto broker App for the new login to take effect (a plain “Save” alone didn’t pick it up in testing — an explicit restart was needed).

Always wrap the password in quotes in apps.yaml, since an unquoted #, :, or a few other characters can get misparsed as YAML syntax rather than part of the string:

mqtt_password: "your-password-here"

Part 5: Install the script

  1. In Studio Code Server, create apps/geodrops_sync.py with the contents below. Also create/edit apps/apps.yaml with the config block that follows.
  2. Copy your geodrops-service-account.json (from Part 1) into the same apps/ folder.

A note on BigQuery schema drift: GeoDrops has changed their public dataset’s column names/availability at least twice since the original script was written. The version below is current as of August 2026 — it no longer selects moisturePctDepthSmooth1/2/3 (renamed to moisturePctDepth1/2/3), and it drops weatherPrecipMmHr, dateLastWatered, miscIsBattPoorQuality, and total7dSunExposureHour (all either removed from the schema or unused in the output anyway — the “7-Day Sun Exposure” sensor from v2 of the original script is gone as a result). If BigQuery changes things again, the AppDaemon log will show a 400 Unrecognized name: <field> error naming the exact broken column — sometimes even suggesting the corrected name — so treat that as your signal to edit the SELECT list, not a sign anything is fundamentally wrong.

A note on “Dominant Moisture”: the schema also has a moisturePct column (and a moistureRawPct counterpart) that’s separate from the three per-depth values — this is GeoDrops’ “Dominant Real Moisture,” a single whole-profile value from their AI Soil Physics Model, explicitly not just an average of the three depths (see this thread for GeoDrops staff explaining the distinction). It’s null until your sensor finishes its initial calibration/training period, same as the per-depth QCN flags — the script below only publishes a state for it once a real value shows up, confirmed working via a live sensor that had just come out of Training.

apps/geodrops_sync.py:

"""
GeoDrops BigQuery -> Home Assistant MQTT integration, running as a native
AppDaemon app (installed via Settings > Apps > AppDaemon). See the setup
guide for install steps.
"""

import json
import traceback

import appdaemon.plugins.hass.hassapi as hass
from google.cloud import bigquery
from google.oauth2 import service_account
import paho.mqtt.publish as publish

EXPIRE_AFTER_SECONDS = 45 * 60  # mark unavailable if no update in 45 min

QCN_STATE_MAP = {2: "Good", 1: "Poor", 0: "Bad", -1: "Training"}
QCN_ICON_MAP = {
    2: "mdi:check-circle",
    1: "mdi:alert",
    0: "mdi:close-circle",
    -1: "mdi:school",
}


class GeodropsSync(hass.Hass):
    def initialize(self):
        self.project_id = self.args["project_id"]
        self.service_account_file = self.args["service_account_file"]
        self.mqtt_broker = self.args.get("mqtt_broker", "core-mosquitto")
        self.mqtt_port = self.args.get("mqtt_port", 1883)
        self.mqtt_username = self.args.get("mqtt_username", "homeassistant")
        self.mqtt_password = self.args["mqtt_password"]
        self.device_mapping = {
            int(k): v for k, v in self.args["device_mapping"].items()
        }
        interval = self.args.get("interval_seconds", 900)

        credentials = service_account.Credentials.from_service_account_file(
            self.service_account_file
        )
        self.client = bigquery.Client(credentials=credentials, project=self.project_id)

        # Run once shortly after startup, then on a fixed interval.
        self.run_in(self.sync, 5)
        self.run_every(self.sync, "now+15", interval)

    def sync(self, kwargs):
        try:
            self._sync()
        except Exception:
            self.error(traceback.format_exc())

    def _sync(self):
        device_ids = tuple(self.device_mapping.keys())
        device_ids_str = ", ".join(str(i) for i in device_ids)

        query = f"""
            SELECT
                deviceId, mfgSn, date,
                moisturePct,
                moisturePctDepth1, moisturePctDepth2, moisturePctDepth3,
                temperatureCSurface, temperatureCDepth1, temperatureCDepth2, temperatureCDepth3,
                miscSensorSyncDelayHour,
                miscBattPercent,
                qcnDepth1, qcnDepth2, qcnDepth3
            FROM `geodrops-prod.db_public.p_sensor_unified`
            WHERE deviceId IN ({device_ids_str})
              AND createdAtOrigin > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 12 HOUR)
            QUALIFY ROW_NUMBER() OVER (PARTITION BY deviceId ORDER BY date DESC) = 1
        """

        self.log("Executing BigQuery query...")
        rows = list(self.client.query(query))
        self.log(f"Query returned {len(rows)} results")

        if not rows:
            self.log(
                "No results in the last 12h window - check device IDs / sensor reporting",
                level="WARNING",
            )
            return

        messages = []
        published_configs = 0
        published_states = 0

        for row in rows:
            info = self.device_mapping.get(row.deviceId)
            if not info:
                self.log(f"Unknown deviceId {row.deviceId}, skipping", level="WARNING")
                continue

            name = info["name"]
            mfg_sn = info["mfg_sn"]

            sync_delay = row.miscSensorSyncDelayHour or 0
            if sync_delay > 12:
                self.log(
                    f"{name} ({mfg_sn}) stale (sync delay {sync_delay}h) - SKIPPING",
                    level="WARNING",
                )
                continue
            elif sync_delay > 6:
                self.log(
                    f"{name} ({mfg_sn}) stale (sync delay {sync_delay}h) - publishing anyway",
                    level="WARNING",
                )
            else:
                self.log(
                    f"{name} ({mfg_sn}) last reading {row.date} (sync delay {sync_delay}h)"
                )

            device_info = {
                "identifiers": [mfg_sn],
                "name": f"{name.replace('_', ' ').title()} Moisture Sensor",
                "manufacturer": "GeoDrops",
                "model": "Soil Moisture Sensor",
                "sw_version": "A2.02r1",
            }

            qcn = {
                1: row.qcnDepth1 if getattr(row, "qcnDepth1", None) is not None else -1,
                2: row.qcnDepth2 if getattr(row, "qcnDepth2", None) is not None else -1,
                3: row.qcnDepth3 if getattr(row, "qcnDepth3", None) is not None else -1,
            }

            # Whole-profile calibrated moisture ("Dominant Real Moisture" in
            # GeoDrops' terminology) - a single value distinct from just
            # averaging the three depths. It's null until the sensor
            # finishes its initial calibration/training period, so we only
            # publish a state for it when a real value is present.
            dominant_moisture = getattr(row, "moisturePct", None)

            def sensor_config(key, friendly, unit=None, device_class=None, icon=None):
                payload = {
                    "name": friendly,
                    "state_topic": f"geodrops/{name}/{key}",
                    "unique_id": f"{mfg_sn}_{key}",
                    "device": device_info,
                    "expire_after": EXPIRE_AFTER_SECONDS,
                }
                if icon:
                    payload["icon"] = icon
                else:
                    payload["state_class"] = "measurement"
                if unit:
                    payload["unit_of_measurement"] = unit
                if device_class:
                    payload["device_class"] = device_class
                return {
                    "topic": f"homeassistant/sensor/{name}_{key}/config",
                    "payload": json.dumps(payload),
                    "retain": True,
                    "qos": 1,
                }

            discovery = [
                sensor_config("moisture_d1", "Moisture Depth 1", "%", "moisture"),
                sensor_config("moisture_d2", "Moisture Depth 2", "%", "moisture"),
                sensor_config("moisture_d3", "Moisture Depth 3", "%", "moisture"),
                sensor_config("moisture_dominant", "Dominant Moisture", "%", "moisture"),
                sensor_config("qcn_d1", "Quality Depth 1", icon=QCN_ICON_MAP.get(qcn[1], "mdi:help-circle")),
                sensor_config("qcn_d2", "Quality Depth 2", icon=QCN_ICON_MAP.get(qcn[2], "mdi:help-circle")),
                sensor_config("qcn_d3", "Quality Depth 3", icon=QCN_ICON_MAP.get(qcn[3], "mdi:help-circle")),
                sensor_config("battery", "Battery", "%", "battery"),
                sensor_config("sync_delay", "Sync Delay", "h"),
                sensor_config("temp_surface", "Surface Temperature", "°C", "temperature"),
                sensor_config("temp_d1", "Temperature Depth 1", "°C", "temperature"),
                sensor_config("temp_d2", "Temperature Depth 2", "°C", "temperature"),
                sensor_config("temp_d3", "Temperature Depth 3", "°C", "temperature"),
            ]

            states = [
                {"topic": f"geodrops/{name}/moisture_d1", "payload": str(row.moisturePctDepth1 or 0), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/moisture_d2", "payload": str(row.moisturePctDepth2 or 0), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/moisture_d3", "payload": str(row.moisturePctDepth3 or 0), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/qcn_d1", "payload": QCN_STATE_MAP.get(qcn[1], "Unknown"), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/qcn_d2", "payload": QCN_STATE_MAP.get(qcn[2], "Unknown"), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/qcn_d3", "payload": QCN_STATE_MAP.get(qcn[3], "Unknown"), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/battery", "payload": str(int(round(row.miscBattPercent or 0))), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/sync_delay", "payload": str(sync_delay), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/temp_surface", "payload": str(row.temperatureCSurface or 0), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/temp_d1", "payload": str(row.temperatureCDepth1 or 0), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/temp_d2", "payload": str(row.temperatureCDepth2 or 0), "retain": True, "qos": 0},
                {"topic": f"geodrops/{name}/temp_d3", "payload": str(row.temperatureCDepth3 or 0), "retain": True, "qos": 0},
            ]

            if dominant_moisture is not None:
                states.append({
                    "topic": f"geodrops/{name}/moisture_dominant",
                    "payload": str(dominant_moisture),
                    "retain": True,
                    "qos": 0,
                })

            messages.extend(discovery + states)
            published_configs += len(discovery)
            published_states += len(states)

        if messages:
            auth = {"username": self.mqtt_username, "password": self.mqtt_password}
            publish.multiple(messages, hostname=self.mqtt_broker, port=self.mqtt_port, auth=auth)
            self.log(f"Published {published_configs} discovery configs and {published_states} states")
        else:
            self.log("No messages to publish (all sensors stale)", level="WARNING")

apps/apps.yaml:

Every line you need to personalize is flagged # EDIT ME: — after pasting this in, search the file for that exact text (Ctrl+F / Cmd+F) to make sure you’ve caught all of them. Don’t rely on the line numbers below; they’ll shift as soon as you start editing.

geodrops_sync:
  module: geodrops_sync
  class: GeodropsSync

  # EDIT ME: your Google Cloud project ID from Part 1
  project_id: your-google-cloud-project-id

  # Path to the service account key AS SEEN FROM INSIDE THE APPDAEMON
  # CONTAINER. If you put the JSON file in the apps/ folder next to
  # geodrops_sync.py (as instructed above), this is the correct value -
  # note it's /config/apps/..., not the host-side /addon_configs/... path
  # you used to get the file there via Studio Code Server.
  service_account_file: /config/apps/geodrops-service-account.json

  # MQTT connection - use the dedicated login created in Part 4.
  # Quote the password - see the note above about special characters.
  mqtt_broker: core-mosquitto
  mqtt_port: 1883
  # EDIT ME: match the login username you created in Part 4
  mqtt_username: geodrops
  # EDIT ME: match the login password you created in Part 4
  mqtt_password: "your-mqtt-password-here"

  # How often to poll BigQuery, in seconds (900 = 15 minutes)
  interval_seconds: 900

  # EDIT ME: your sensor(s), from the Part 2 BigQuery lookup. "name"
  # becomes part of the MQTT topic and the HA entity/device name - call
  # it whatever you like (a bed name, a yard zone, etc.)
  device_mapping:
    YOUR_DEVICE_ID: {mfg_sn: "YOUR_SERIAL_NUMBER", name: "soil_sensor"}
    # Add more devices here as needed, one line per sensor:
    # deviceId: {mfg_sn: "SERIAL", name: "friendly_name"}

  1. Save both files. AppDaemon watches its apps/ folder and hot-reloads automatically — no restart needed after edits (though restarting is a reliable way to force an immediate run if you don’t want to wait for the next cycle).

Part 6: Nothing to do here

Unlike the original guide, there’s no shell_command: or automation to add to configuration.yaml. The AppDaemon app schedules itself (self.run_in fires 5 seconds after every reload, then self.run_every repeats on your configured interval). This whole part of the original guide is simply not needed with this approach.

Part 7: Verify sensors appear

  1. Settings → Apps → AppDaemon → Log. On success you’ll see a line like:

    Published 13 discovery configs and 12 states
    
    

    That’s one run, one sensor: 13 discovery configs (the 12 always-on sensors plus Dominant Moisture) but only 12 states, because Dominant Moisture doesn’t publish a state until it has a real value (see the Training note below). Once your sensor is calibrated, expect 13 discovery configs and 13 states instead. If you have N sensors, multiply accordingly.

  2. Settings → Devices & Services → MQTT → you should see your GeoDrops device(s) listed (e.g., “Soil Sensor Moisture Sensor”).

  3. Developer Tools → States → search your sensor name to confirm real values are populating.

One thing that’s normal, not a bug: a freshly added probe will likely show moisture readings of 0 with a quality flag of Training on all three depths for a while, and the Dominant Moisture sensor won’t appear as an entity with a state at all (its discovery config is published, but no state message is sent). That’s GeoDrops’ own model telling you it hasn’t built enough history on that specific sensor yet — not something wrong with this integration. Confirmed on a live sensor here: QCN flipped from Training to Good on all three depths, and Dominant Moisture started reporting a real percentage in the very next sync cycle after that.


Troubleshooting (AppDaemon-specific)

paho.mqtt.MQTTException: Not authorized — either the login doesn’t exist on the Mosquitto broker yet (add it under Configuration → Logins and restart the App, not just Save), or the password has an unquoted special character that YAML is mangling (see Part 4).

google.api_core.exceptions.BadRequest: 400 Unrecognized name: <field> — GeoDrops changed their BigQuery schema. Remove or rename the offending field in the SELECT list (and in the corresponding output line further down if it’s actually used). BigQuery’s error sometimes suggests the correct replacement name directly.

SyntaxError: unterminated string literal in the AppDaemon log after an edit — almost always a broken triple-quoted docstring at the top of the file. AppDaemon reports the exact line number; check that the """...""" block is still properly closed.

Script keeps reinstalling packages on every App restart — this is expected behavior for this App’s python_packages option, not a bug; it takes 30-60 seconds each time.

Can’t find addon_configs in your file browser — see the “Getting file access” section above; the built-in File Editor App won’t show it, and Studio Code Server needs its config_path option pointed there explicitly.

Entities show “Unknown” — same as the original guide: wait for the next 15-minute cycle, or restart the AppDaemon App to force an immediate run.


Everything else

Cost/usage, the free-tier math, and the long-term future-enhancements ideas (30-day trend data, precipitation integration, VPD calculations, dashboards) are all unchanged from the original guide linked at the top of this post — that post is still the right reference for Parts 1 and 2, and for the broader context on what this data is good (and not good) for.

Credit to Scythe for the original script and approach this builds on.

This is impressive and defiantly a lot of work. I use HA and considered this but it comes down to my goal for using HA. Being totally local. So pulling the data from the cloud might not be an option for me.

If they GeoDrops updates anything that would change the device or Entity IDs, would I have to change this setup?
If any of the drops have to recalibrate, or I move them and they go into training, how does this show up in HA?

Right now I have a group showing “Watering Disabled” because one of the sensors is is “Poor Sensor Calibration”. How will that change my sprinkler system?

Well, I would guess that anyone reading this thread would much prefer an official Home Assistant integration or via HACS. I believe I read that it may be on the long term plan for GeoDrops, but for now, this works for me. If it breaks, I will be happy to update the script. It is not as hard as it looks actually. Step by step.

1 Like

An official integration would be great, but baby steps! The availability of the API is the critical piece and that’s done.

Some updates from my side. I’ve spent the past few weeks building an automated watering system that ties together the data coming from GeoDrops, my Rachio 3 controller, and a local weather station, all orchestrated through Home Assistant. Instead of watering on a fixed schedule, it checks real soil moisture in each zone every night and only runs the zones that actually need it. Rachio has its own scheduler and can (sort of) incorporate moisture data from GeoDrops, but that system had enough flaws that I decided to write a replacement.

What it does (summary courtesy of Claude):

  • Reads soil moisture at multiple depths per zone and skips any zone that’s already sufficiently moist
  • Adjusts how much it waters based on regional drought conditions — waters less aggressively when things are dry across the area
  • Checks the overnight rain forecast and skips watering entirely when meaningful rain is expected
  • Always finishes watering before sunrise (or earlier still, on the driest weeks) — so grass doesn’t sit wet for any longer than necessary. Extended leaf wetness, not simply nighttime watering, is one of the biggest drivers of fungal turf disease, and ending right before sunrise lets morning sun start drying the grass as early as possible
  • Shortens the total watering window on warm, humid, still nights that favor fungal growth, on top of the sunrise cutoff
  • Groups zones into efficient back-to-back watering blocks instead of starting/stopping repeatedly, cutting down on controller notification spam while still supporting soak/cycle functionality.
  • Detects real rainfall starting mid-watering and stops itself rather than continuing into a storm
  • Integrates with the local Home Assistant calendar so you’ve got a historical activity record.

What it depends on:

  • Home Assistant — the platform that runs everything
  • pyscript — a Home Assistant add-on that runs the actual scheduling logic
  • GeoDrops soil moisture sensors — feed real moisture data in via their cloud API
  • Rachio 3 controller — does the actual valve control
  • A local weather station (in my case, a Tempest) — real-time temperature/humidity/wind/rain readings
  • An online weather forecast source — feeds the overnight rain and disease-pressure forecasting (some local weather stations, such as my Tempest, provide this functionality as well)

So If there’s interest, I can try to package it up and make it available for others.

If GeoDrops updates their API fields, then there’s work that needs to be done to pull that into Home Assistant. There’s been at least one revision to the API that I’ve needed to adjust to - not a big deal and my expectation is that revisions will slow/stop as they refine the product. The entityID’s on the Home Assistant side don’t need to change as they’re associated with the DeviceID (which never changes).

Recalibration and training aren’t a problem - the GeoDrops API shares that quality data which is pulled through and reflected in the Home Assistant device attributes, so you can adjust accordingly. In the scheduler I mentioned above, for example, I’ve chosen to water even if some levels are reporting poor quality data as there’s one “Good” rating to work against, but the system will not water zones that are in a “Training” state.

1 Like

i’d be very interested, you’re hardware setup is same as mine. I’ve already implemented your currently posted integration, but like you, am finding the flex daily schedule with geodrops moisture reporting less than satisfactory.

Can do - this is all hard-coded and in a private repo right now, so give me a bit. I just told Claude to figure out how to make it easier for others to adopt, so it’s chewing on it. I’ll post back here when I’ve got something to take a look at.

Many thanks in advance.