Hubitat - Maker Integration

All, I use Ignition regularly at work but wanted to try a Hubitat - Maker integration. There’s a lot of disparate information on the various forums about this but it all seems a bit convoluted and sometimes old.

I have a working integration and wanted to share, so I asked ChatGPT to assist with a guide. Here’s what I came up with…feel free to give it a try and share your own improvements.

Hubitat ↔ Ignition (Maker API Only) — End-to-End Setup

Goal: Control Hubitat devices from Ignition Perspective and get push status updates into Ignition without MQTT or polling.
Works with: Hubitat Maker API (LAN) → Ignition 8.1.x WebDev + Perspective.


Table of Contents


0) Prerequisites

  • Hubitat hub on your LAN with the Maker API app installed.

  • Ignition 8.1.x with WebDev + Perspective modules.

  • Hubitat and Ignition can reach each other over the network (same subnet is easiest).


1) Hubitat → Maker API

  1. In Apps → Maker API:

    • Select the devices you want to control/monitor.

    • Enable Allow Access via Local IP Address.

    • (Optional) Enable control of modes/HSM if you need it.

    • Click Create New Access Token (copy it).

  2. Note these values (we’ll use placeholders below):

HUB_IP = <HUB_IP>          # e.g. 192.168.1.238
APP_ID = <APP_ID>          # e.g. 307
TOKEN  = <TOKEN>           # e.g. c7720...6dee

Quick browser checks (sub in your values):

  • All devices

    http://<HUB_IP>/apps/api/<APP_ID>/devices?access_token=<TOKEN>
    
    
  • Turn a switch on/off

    http://<HUB_IP>/apps/api/<APP_ID>/devices/<DEVICE_ID>/on?access_token=<TOKEN>
    http://<HUB_IP>/apps/api/<APP_ID>/devices/<DEVICE_ID>/off?access_token=<TOKEN>
    
    
  • Set a dimmer level

    http://<HUB_IP>/apps/api/<APP_ID>/devices/<DEVICE_ID>/setLevel/55?access_token=<TOKEN>
    
    

2) Ignition → Project Library (hubitat.py)

Create Project → Scripting → Project Library → New Script named hubitat.py:

# hubitat.py  (Project Library)
HUB_IP = "<HUB_IP>"
APP_ID = "<APP_ID>"
TOKEN  = "<TOKEN>"

def _base():
    return "http://%s/apps/api/%s" % (HUB_IP, APP_ID)

def send_command(device_id, command, arg=None):
    """
    Examples:
      send_command("1", "on")
      send_command("1", "off")
      send_command("2", "setLevel", "55")
    """
    import system
    path = command if arg is None else "%s/%s" % (command, arg)
    url = "%s/devices/%s/%s?access_token=%s" % (_base(), str(device_id), path, TOKEN)
    try:
        resp = system.net.httpGet(url, connectTimeout=5000, readTimeout=7000)
        return {"ok": True, "response": resp}
    except Exception as ex:
        return {"ok": False, "error": str(ex), "url": url}

This is what your Perspective buttons/sliders will call.


3) Ignition → WebDev endpoint (events_post)

Why: Hubitat will POST each device event to Ignition. We decode and write tags under [default]Hubitat/<deviceId>/….

  1. In Web Dev, create a mount called hubitat.

  2. Inside it, create a New Python Resource named events_post.

    • Method: doPost

    • Enabled: :white_check_mark:

Paste this complete doPost:

def doPost(request, session):
    import system, re

    SHARED_KEY = "<STRONG_SHARED_KEY>"     # any strong random string; see step 4
    PROVIDER   = "default"                 # your writable tag provider
    baseProv   = u"[%s]" % PROVIDER

    # --- auth ---
    params = request.get("params", {}) or {}
    k = params.get("k")
    if isinstance(k, list): k = k[0] if k else ""
    if SHARED_KEY and (k or "") != SHARED_KEY:
        return {"status": 401, "type": "application/json",
                "json": {"ok": False, "err": "Unauthorized"}}

    # helpers
    def _s(x):
        try:    return x if isinstance(x, basestring) else unicode(x)
        except: return u""

    def _first(p, key):
        v = p.get(key)
        return v[0] if isinstance(v, list) and v else v

    def _lenient_decode(s):
        if not isinstance(s, basestring):
            return None
        t = s.strip()
        if not (t.startswith("{") and t.endswith("}")):
            return None
        t = re.sub(r"u'(.+?)'", r"'\1'", t)
        t = re.sub(r'(?<!\\)\'', '"', t)
        try:
            return system.util.jsonDecode(t)
        except:
            return None

    # parse body (strict → lenient) or fallback to params
    raw_json = request.get("json"); raw_pd = request.get("postData"); raw_dt = request.get("data")
    evt = None
    for cand in (raw_json, raw_pd, raw_dt):
        if evt is not None: break
        if cand is None:    continue
        if isinstance(cand, dict):
            evt = cand; break
        s = _s(cand)
        if s:
            try:
                evt = system.util.jsonDecode(s.strip())
            except:
                evt = _lenient_decode(s)

    if evt is None:
        if _first(params, "deviceId") and _first(params, "name") and _first(params, "value"):
            evt = {"deviceId": _first(params, "deviceId"),
                   "name": _first(params, "name"),
                   "value": _first(params, "value"),
                   "displayName": _first(params, "displayName") or ""}

    if not evt:
        # debug breadcrumbs if Hubitat isn't sending JSON
        dbg_folder = baseProv + "Hubitat/_debug"
        system.tag.configure(baseProv, [{"name":"Hubitat","tagType":"Folder"}], "m")
        system.tag.configure(baseProv+"Hubitat", [{"name":"_debug","tagType":"Folder"}], "m")
        system.tag.configure(dbg_folder, [
            {"name":"_lastParams","tagType":"AtomicTag","dataType":"String"},
            {"name":"_lastData",  "tagType":"AtomicTag","dataType":"String"},
            {"name":"_lastPD",    "tagType":"AtomicTag","dataType":"String"},
            {"name":"_lastTime",  "tagType":"AtomicTag","dataType":"String"},
        ], "m")
        system.tag.writeBlocking(
            [dbg_folder+"/_lastParams", dbg_folder+"/_lastData", dbg_folder+"/_lastPD", dbg_folder+"/_lastTime"],
            [system.util.jsonEncode(params),
             _s(raw_dt)[:1000] if raw_dt else u"",
             _s(raw_pd)[:1000] if raw_pd else u"",
             system.date.format(system.date.now(), "yyyy-MM-dd HH:mm:ss")]
        )
        return {"status": 200, "type": "application/json",
                "json": {"ok": False, "err": "Missing JSON"}}

    # unwrap common Hubitat shapes
    if isinstance(evt, dict):
        if isinstance(evt.get("content"), dict):
            evt = evt["content"]
        elif isinstance(evt.get("event"), dict):
            evt = evt["event"]
        elif isinstance(evt.get("events"), list) and evt["events"]:
            evt = evt["events"][0]

    deviceId    = _s(evt.get("deviceId") or evt.get("id") or evt.get("device") or "unknown")
    attr        = _s(evt.get("name") or evt.get("attribute") or "unknown")
    value       = _s(evt.get("value") or evt.get("val") or "")
    displayName = _s(evt.get("displayName") or evt.get("deviceName") or evt.get("label") or "")

    # canonical tree by deviceId
    hub_folder = baseProv + "Hubitat"
    dev_folder = hub_folder + "/" + deviceId
    system.tag.configure(baseProv,   [{"name":"Hubitat","tagType":"Folder"}], "m")
    system.tag.configure(hub_folder, [{"name":deviceId,"tagType":"Folder"}], "m")
    system.tag.configure(dev_folder, [
        {"name":"displayName",    "tagType":"AtomicTag","dataType":"String"},
        {"name":"_lastEventJson", "tagType":"AtomicTag","dataType":"String"},
        {"name":"_lastUpdate",    "tagType":"AtomicTag","dataType":"String"},
        {"name":attr,             "tagType":"AtomicTag","dataType":"String"},
    ], "m")

    system.tag.writeBlocking(
        [dev_folder+"/displayName",
         dev_folder+"/_lastEventJson",
         dev_folder+"/_lastUpdate",
         dev_folder+"/"+attr],
        [displayName,
         system.util.jsonEncode(evt),
         system.date.format(system.date.now(), "yyyy-MM-dd HH:mm:ss"),
         value]
    )

    # optional: keep a simple byId index for UI use
    idx_path = hub_folder + "/_deviceIndexJson"
    system.tag.configure(hub_folder, [{"name":"_deviceIndexJson","tagType":"AtomicTag","dataType":"String"}], "m")
    cur = system.tag.readBlocking([idx_path])[0].value
    try:
        idx = system.util.jsonDecode(cur) if (cur and isinstance(cur, basestring)) else {}
    except:
        idx = {}
    if not isinstance(idx, dict):
        idx = {}
    byId = idx.get("byId") if isinstance(idx.get("byId"), dict) else {}
    if byId.get(deviceId) != displayName:
        byId[deviceId] = displayName
        idx["byId"] = byId
        system.tag.writeBlocking([idx_path], [system.util.jsonEncode(idx)])

    return {"status": 200, "type": "application/json",
            "json": {"ok": True, "deviceId": deviceId, "attr": attr, "value": value}}


4) Point Hubitat at Ignition (Maker API URL)

In Maker API, set “URL to POST device events to” to:

http://<IGNITION_IP>:8088/system/webdev/<PROJECT_NAME>/hubitat/events_post?k=<STRONG_SHARED_KEY>

Example:

http://192.168.1.54:8088/system/webdev/HomeAutomation/hubitat/events_post?k=p7s9Q-2K1-ABCD

Save/publish the Ignition project and ensure the WebDev resource is Enabled.


5) Perspective: minimal control + status

Switch ON/OFF buttons (device 1)

Button → onActionPerformed:

import hubitat
hubitat.send_command("1", "on")   # or "off"

Dimmer slider (device 2)

Slider → props.min=0, max=100, step=1

  • Readback: bind props.value (Indirect Tag)
    [default]Hubitat/2/level → transform:

    try: return int(value)
    except: return 0
    
    
  • Write (send once on release): onActionPerformed:

    import hubitat
    val = int(self.props.value)
    hubitat.send_command("2", "setLevel", str(val))
    
    

Status labels

  • Switch state: [default]Hubitat/1/switch → transform:

    s = (value or "").lower()
    return "ON" if s=="on" else "OFF" if s=="off" else (value or "unknown")
    
    
  • Last event timestamp: [default]Hubitat/1/_lastUpdate


6) Quick test plan

  1. Health check (browser):
    http://<IGNITION_IP>:8088/system/webdev/<PROJECT_NAME>/hubitat/events_post
    (GET will return 405; that’s fine. We only accept POST.)

  2. Send a synthetic POST (from Ignition Script Console):

    import system
    url = "http://<IGNITION_IP>:8088/system/webdev/<PROJECT_NAME>/hubitat/events_post?k=<STRONG_SHARED_KEY>"
    body = '{"deviceId":"1","name":"switch","value":"on","displayName":"Living Room Ceiling Fan"}'
    print system.net.httpPost(url, "application/json", body)
    
    

    You should see tags appear under [default]Hubitat/1/....

  3. Flip a real device in Hubitat and watch the tags update in Ignition.

  4. Press Perspective buttons/slider; the device should respond.


7) Security notes

  • Treat TOKEN and SHARED_KEY like passwords.

  • Use a strong shared key and keep it in the URL’s ?k=....

  • Limit Maker API to the devices you actually need.

  • On your firewall, restrict Ignition’s port 8088 to the Hubitat IP if possible.

  • If you ever pasted a real TOKEN while testing, re-generate it in Maker API.


8) Troubleshooting

  • Unauthorized from WebDev: ?k=... missing or wrong key.

  • Missing JSON response: Hubitat isn’t hitting Ignition; double-check the URL/IP or Maker API isn’t configured to POST.

  • No tags: ensure events_post is doPost, Enabled, and your project is Saved/Published.

  • Perspective error currentValue undefined: In Perspective, use onActionPerformed and self.props.value (not currentValue).

  • Tag quality issues: confirm your provider name is default (or change PROVIDER in the script).

  • Device not updating: make sure it’s selected in Maker API.


Appendix A: DeviceCard (hides slider if level missing)

Reusable view: components/DeviceCard
Param: deviceId (String)
Layout: Flex (row; gap 8–12; align center)

Components & bindings:

  1. Name (Label → props.text)
    [default]Hubitat/{view.params.deviceId}/displayName
    Transform:
return value or ("Device " + str(self.view.params.deviceId))

  1. State text (Label → props.text)
    [default]Hubitat/{view.params.deviceId}/switch
    Transform:
s = (value or "").lower()
return "ON" if s=="on" else "OFF" if s=="off" else (value or "unknown")

  1. On/Off buttons (onActionPerformed):
# On
import hubitat
hubitat.send_command(self.view.params.deviceId, "on")

# Off
import hubitat
hubitat.send_command(self.view.params.deviceId, "off")

  1. Slider (min 0, max 100, step 1)
    Readback (props.value): [default]Hubitat/{view.params.deviceId}/level
    Transform:
try: return int(value)
except: return 0

Send (onActionPerformed):

import hubitat
val = int(self.props.value)
hubitat.send_command(self.view.params.deviceId, "setLevel", str(val))

Hide when device has no level (bind props.visible to same level tag, transform):

return value is not None

  1. Last update (Label → props.text)
    [default]Hubitat/{view.params.deviceId}/_lastUpdate
    Transform:
return "Last update: " + (value or "")

Usage: Drop an Embedded View and set:

{ "path": "components/DeviceCard", "params": { "deviceId": "2" } }

Duplicate for "3", "8", etc. The slider will auto-hide on devices without level.


Credits / Thanks: guide assembled with help from the community.
Replace all placeholders (<HUB_IP>, <APP_ID>, <TOKEN>, <PROJECT_NAME>, <STRONG_SHARED_KEY>) before use.

3 Likes