Skip to content

Instantly share code, notes, and snippets.

@k5njm
Created August 24, 2026 14:41
Show Gist options
  • Select an option

  • Save k5njm/31752e2220f9d529c7cc13a0fb983175 to your computer and use it in GitHub Desktop.

Select an option

Save k5njm/31752e2220f9d529c7cc13a0fb983175 to your computer and use it in GitHub Desktop.
Index 01 ring → Home Assistant shopping list: uncheck completed items instead of duplicating

Index 01 → Home Assistant shopping list (uncheck completed items, don't duplicate)

I wired my Index 01 ring so "add eggs to my shopping list" lands on Home Assistant's Shopping List. If Eggs is already there and checked off, it unchecks that row instead of adding a second Eggs. New items are added. Non-shopping recordings are ignored.

No custom Pebble build. Official Index webhook (transcription only) → HA webhook automation → a script that fuzzy-matches against todo.shopping_list.

Works on the stock iOS/Android Pebble app (coredevices/mobileapp, open source). The webhook fires alongside the Index agent, so the in-app list still gets the item too. One-way only; reverse sync is extra work.

What you get

You say HA does
"Add eggs to my shopping list" Uncheck existing Eggs if it was completed; no-op if it's already unchecked; else add Eggs
"Add shrimp and cornstarch to my shopping list" Two items
"Add fruit loops …" Unchecks Froot Loops (typo / branding)
"Remind me to call mom" Ignored (no shopping/grocery phrase)

Matching (score ≥ 80):

  • Case/punctuation: q tips = QTips
  • Plurals: egg = Eggs, paper towel = Paper Towels
  • Extra flavor words: pop tarts = Pop Tarts - Smore
  • Do not treat milk as Oat Milk
  • Do not treat cornstarch as Croissants (letter-bag similarity isn't enough; they must share a word)

Requirements

  • Home Assistant with the Shopping list integration (entity is todo.shopping_list; old shopping_list.* services still exist)
  • A public webhook URL the phone can hit off-LAN. Nabu Casa /api/webhook/<id> works. If ha.example.com is behind Cloudflare Access / SSO, the ring will get a login redirect and nothing will land — use Nabu Casa or an unauthenticated path.
  • HA 2024.11+ (2026.x is fine). Templates auto-parse JSON; don't | from_json a value that's already a list/dict.

No HACS, pyscript, or AppDaemon.

1. Enable Shopping list

Settings → Devices & services → Shopping list, if it isn't already there. Confirm Developer Tools → States shows todo.shopping_list.

2. Add the script

Paste into scripts.yaml (or Settings → Automations & scenes → Scripts → YAML). Then reload scripts (Developer Tools → YAML → Scripts). No core restart.

smart_add_shopping:
  alias: Smart add shopping item
  description: Uncheck a fuzzy-matched completed shopping-list item, or add a new one
  icon: mdi:cart-plus
  mode: queued
  max: 20
  fields:
    item:
      name: Item
      required: true
      selector:
        text:
  sequence:
    - if:
        - condition: template
          value_template: "{{ not (item | default('') | string | trim) }}"
      then:
        - stop: empty item
    - action: todo.get_items
      target:
        entity_id: todo.shopping_list
      data:
        status:
          - needs_action
          - completed
      response_variable: shopping
    - variables:
        raw: "{{ item | string | trim }}"
        decision: >-
          {%- set needle = item | string | trim -%}
          {%- set items = shopping['todo.shopping_list']['items'] | default([]) -%}
          {%- set nc = needle | lower | regex_replace('[^a-z0-9]+', '') -%}
          {%- set ntoks = namespace(x=[]) -%}
          {%- for t in (needle | lower | regex_findall('[a-z0-9]+')) -%}
            {%- if t | length > 3 and t[-1] == 's' -%}
              {%- set ntoks.x = ntoks.x + [t[:-1]] -%}
            {%- else -%}
              {%- set ntoks.x = ntoks.x + [t] -%}
            {%- endif -%}
          {%- endfor -%}
          {%- set ns = namespace(best=none, score=0, status=none) -%}
          {%- for i in items -%}
            {%- set hay = i.summary | default('') -%}
            {%- set hc = hay | lower | regex_replace('[^a-z0-9]+', '') -%}
            {%- set htoks = namespace(x=[]) -%}
            {%- for t in (hay | lower | regex_findall('[a-z0-9]+')) -%}
              {%- if t | length > 3 and t[-1] == 's' -%}
                {%- set htoks.x = htoks.x + [t[:-1]] -%}
              {%- else -%}
                {%- set htoks.x = htoks.x + [t] -%}
              {%- endif -%}
            {%- endfor -%}
            {%- set score = 0 -%}
            {%- if nc and hc and nc == hc -%}
              {%- set score = 100 -%}
            {%- elif ntoks.x and (ntoks.x | sort) == (htoks.x | sort) -%}
              {%- set score = 100 -%}
            {%- else -%}
              {%- set inter = ntoks.x | intersect(htoks.x) | list | unique | list -%}
              {%- set nset = ntoks.x | unique | list -%}
              {%- set hset = htoks.x | unique | list -%}
              {%- if nset and (inter | length) == (nset | length) and (hset | length) > 0 -%}
                {%- set cov = inter | length / hset | length -%}
                {%- if cov >= 0.6 -%}
                  {%- set score = (80 + 20 * cov) | int -%}
                {%- endif -%}
              {%- endif -%}
              {%- if hset and (inter | length) == (hset | length) and (nset | length) > 0 -%}
                {%- set cov2 = inter | length / nset | length -%}
                {%- if cov2 >= 0.6 -%}
                  {%- set score = [score, (80 + 20 * cov2) | int] | max -%}
                {%- endif -%}
              {%- endif -%}
              {%- if nc and hc -%}
                {%- if nc in hc -%}
                  {%- set score = [score, (100 * nc | length / hc | length) | int] | max -%}
                {%- elif hc in nc -%}
                  {%- set score = [score, (100 * hc | length / nc | length) | int] | max -%}
                {%- endif -%}
                {%- set dlen = (nc | length - hc | length) | abs -%}
                {%- set shared = ntoks.x | intersect(htoks.x) | list | length -%}
                {%- if dlen <= 2 and ([nc | length, hc | length] | min) >= 5 and shared > 0 -%}
                  {%- set ua = nc | list | unique | list -%}
                  {%- set ub = hc | list | unique | list -%}
                  {%- set ui = ua | intersect(ub) | list | length -%}
                  {%- if ua | length and ub | length -%}
                    {%- set dice = (2 * ui / (ua | length + ub | length) * 100) | int -%}
                    {%- set score = [score, dice] | max -%}
                  {%- endif -%}
                {%- endif -%}
              {%- endif -%}
            {%- endif -%}
            {%- if score > ns.score or (score == ns.score and ns.status == 'completed' and i.status == 'needs_action') -%}
              {%- set ns.score = score -%}
              {%- set ns.best = i.summary -%}
              {%- set ns.status = i.status -%}
            {%- endif -%}
          {%- endfor -%}
          {{- {'name': ns.best, 'score': ns.score, 'status': ns.status} | tojson -}}
    - variables:
        match_name: "{% if decision is mapping %}{{ decision.name }}{% else %}{{ (decision | from_json({'name': none, 'score': 0, 'status': none})).name }}{% endif %}"
        match_score: "{% if decision is mapping %}{{ decision.score | int }}{% else %}{{ (decision | from_json({'name': none, 'score': 0, 'status': none})).score | int }}{% endif %}"
        match_status: "{% if decision is mapping %}{{ decision.status }}{% else %}{{ (decision | from_json({'name': none, 'score': 0, 'status': none})).status }}{% endif %}"
    - choose:
        - conditions:
            - condition: template
              value_template: "{{ match_score | int >= 80 and match_status == 'completed' }}"
          sequence:
            - action: shopping_list.incomplete_item
              data:
                name: "{{ match_name }}"
        - conditions:
            - condition: template
              value_template: "{{ match_score | int >= 80 }}"
          sequence:
            - action: system_log.write
              data:
                level: info
                message: "smart_add_shopping: '{{ match_name }}' already on the list"
      default:
        - action: shopping_list.add_item
          data:
            name: "{{ raw | title }}"

Developer Tools → Actions → script.smart_add_shopping with item: eggs is enough to test the matcher without the ring.

3. Add the webhook automation

Generate an ID (python3 -c 'import secrets; print(secrets.token_hex(24))'). Put this in automations.yaml (or the UI). Reload automations.

- id: index_shopping_list_webhook
  alias: "Index: shopping list webhook"
  description: Index ring transcriptions → smart-add on the shopping list
  trigger:
    - platform: webhook
      webhook_id: YOUR_WEBHOOK_ID
      allowed_methods:
        - POST
      local_only: false
  condition: []
  action:
    - variables:
        transcription: "{% set data = trigger.data | default({}) %}{% set js = trigger.json | default({}) %}{{ data.transcription | default(js.transcription | default('')) | string | trim }}"
        direct_item: "{% set data = trigger.data | default({}) %}{% set js = trigger.json | default({}) %}{{ data.item | default(js.item | default('')) | string | trim }}"
        parsed_items: >-
          {%- set t = transcription | regex_replace('\s+', ' ') | trim -%}
          {%- set intent = t is search('shopping\s*list|grocery\s*list|groceries', ignorecase=True) -%}
          {%- set test = t | lower is search('index webhook test event') -%}
          {%- set ns = namespace(items=[]) -%}
          {%- if direct_item -%}
            {%- set ns.items = [direct_item] -%}
          {%- elif intent and not test -%}
            {%- set m1 = t | regex_findall('(?:add|put|get)\s+(.+?)\s+to\s+(?:my\s+|the\s+)?(?:shopping|grocery)\s*list', ignorecase=True) -%}
            {%- set m2 = t | regex_findall('(?:add|put)\s+(.+?)\s+on\s+(?:my\s+|the\s+)?(?:shopping|grocery)\s*list', ignorecase=True) -%}
            {%- set m3 = t | regex_findall('(?:shopping|grocery)\s*list[:\s]+(.+)', ignorecase=True) -%}
            {%- set captured = m1[0] if m1 else (m2[0] if m2 else (m3[0] if m3 else '')) -%}
            {%- set captured = captured | regex_replace('(?i)\b(?:to my|to the|on my|on the)?\s*(?:shopping|grocery)\s*list\b', '') | trim -%}
            {%- set normalized = captured | regex_replace('\s*(?:,|\band\b)\s*', '|', ignorecase=True) -%}
            {%- set ns.items = normalized | regex_findall('[^|]+') | map('trim') | reject('eq', '') | list -%}
          {%- endif -%}
          {{- ns.items | tojson -}}
    - if:
        - condition: template
          value_template: "{{ parsed_items | length == 0 }}"
      then:
        - stop: not a shopping-list phrase
    - repeat:
        for_each: "{{ parsed_items }}"
        sequence:
          - action: script.smart_add_shopping
            data:
              item: "{{ repeat.item }}"
  mode: queued

HA 2024.11+ auto-parses a template that is valid JSON, so parsed_items is already a list. Do not pipe it through from_json or you'll get from_json got invalid input '["eggs"]' … no default was specified.

Webhook URL:

  • Remote: https://<your-nabu-casa-or-external-host>/api/webhook/YOUR_WEBHOOK_ID
  • LAN: http://homeassistant.local:8123/api/webhook/YOUR_WEBHOOK_ID

Quick test (no ring):

curl -X POST -H 'Content-Type: application/json' \
  -d '{"transcription":"Add eggs to my shopping list"}' \
  https://YOUR_HA/api/webhook/YOUR_WEBHOOK_ID

# skip phrase parsing
curl -X POST -H 'Content-Type: application/json' \
  -d '{"item":"eggs"}' \
  https://YOUR_HA/api/webhook/YOUR_WEBHOOK_ID

Index itself sends multipart/form-data with a transcription field; trigger.data.transcription is that field.

4. Point Index at it

Pebble app → Index settings → Webhook:

Field Value
URL the webhook URL from step 3
Headers none required
Send Transcription only
Trigger the gesture you record with

Older app: one URL + a radio (Single click & hold or Double click & hold).
Newer app: per-gesture URL (Hold & talk vs Double click & hold). Same payload either way.

Leave the gesture on the Index agent (not webhook-only). Save. Optional: Send test event — the automation ignores Index webhook test event on purpose.

Then say "Add eggs to my shopping list" on the ring.

Notes / limitations

  • One-way. HA shopping_list_updated → Index would be a second automation and isn't in this setup.
  • Phrase filter requires shopping list, grocery list, or groceries. "Buy milk" with no list words is ignored on purpose so random recordings don't pollute the list.
  • New items are title-cased (diet cokeDiet Coke). If something else (Target cart, Bring, etc.) keys off exact names, that helps.
  • The Index agent still writes the in-app shopping list. This only copies shopping phrases into HA.
  • Auth-gated public URLs (Cloudflare Access, Authelia in front of /api/webhook) will break the phone. Nabu Casa's webhook path is the easy remote option.

Index webhook payload docs live in the app repo: INDEX_WEBHOOK_API.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment