Skip to content

Instantly share code, notes, and snippets.

@nuvious
Created April 26, 2025 19:58
Show Gist options
  • Save nuvious/d466ba4536e57689fbb19597a3d59a1b to your computer and use it in GitHub Desktop.
Save nuvious/d466ba4536e57689fbb19597a3d59a1b to your computer and use it in GitHub Desktop.
NFC Driven Chore List for Home Assistant and Node Red

NFC Chores for Home Assistant

This is a node red flow that integrates with home assistant to allow one to create chores using the native HA todo list and complete those chores by scanning NFC tags. The flow is written as generally as possibl

Quick Setup

Create Tags

Create one or more nfc tags and place them near where a chore is needed to be done. Examples are:

  • On a trash can for a "take out the trash" chore
  • On the clothes washer for "load and run laundry"
  • On a vaccuum for "vaccuum the living room"
  • etc.

Keep note of the NAMES of the tags as they're used later when making chores.

Create Lists

As-written the flow needs two chore lists "Chores - Daily" and "Chores - Weekend", so create those in Home Assistant in the Todo List section.

Create Chores

For each chore the following fields are required

  • Task Name
    • This is the descriptive name used as a key when tracking chores
  • Description
    • This is the NAME OF THE TAG used to clear the chore. Write only on one line with no return character.
  • Due Date:
    • Set the due date hour, minute, and AM/PM.
    • You can set the date to whatever you want, the node red flow will update that to the current date.

Import the Flow

Go to the menu in Node Red and select the Import option. Set it to import to new flow (bottom right of the dialog) and paste the flow in or import it if you downloaded the json to your computer.

Adjust the following to your desires:

  • The time intervals for the Weekday Trigger and Weekend Trigger nodes.
  • The time the Reset Chores trigger works.

Integrate into Other Flows

To keep this flow as general as possible, not events are triggered when chores are completed. Instead, an output node called Updated Chores is provided that gives the list of updated chores eevery time an NFC tag is scanned, the previous_chore_count and remaining_chore_count.

As an example of how I used this is I created a Link In node that then connected to two switch statements that would see if the remaining_chore_count was 0 and that it wasn't equal to the previous_chore_count. This meant that I just completed the last chore on the list and if those switch statements were satisfied it would flash the lights blue in my house and play a celebratory video on my Roku.

Additionally chores and their status are stored in the chores variable at the global scope in the file storage method. This means any flow can simply run global.get('chores', 'file') || [] to get the chore list or an empty list if no chores are in the list. I use this to display the chores on my smart-mirror via an HTTP endpoint I have in a different flow.

Automatic Update of Todo List

The true state of chores is managed in node-red. In the todo list tasks will have their statuses and due dates updated automatically. For example:

  • If a tag is scanned to complete a chore, the chore will be cheked off in the todo list within 1 minute
  • If a tag is checked but hasn't had its NFC scanned, it will be un-checked within 1 minute
  • When chores are cleared and re-added, the due dates are automatically updated to the current date so the todo-list reflects accurate relative-to-today times

Limitations

  • Multiple chores can be mapped to the same NFC tag, in some ways by design. This means multiple chores could be cleared at once, so the user needs to be disciplined about ensuring all chores for a tag are completed before scanning.
  • Chores are only added at the time specified in their due date in the backend, so there's currently no support for completing tasks ahead of schedule. This hasn't impacted me personally because I can just acknowledge the chore is done already and walk to the NFC and scan it.
[
{
"id": "7823c4cbf9ad1cb7",
"type": "tab",
"label": "NFC Chores",
"disabled": false,
"info": "",
"env": []
},
{
"id": "47e41aab3a2c5d1b",
"type": "inject",
"z": "7823c4cbf9ad1cb7",
"name": "Daily Trigger",
"props": [{ "p": "payload" }, { "p": "topic", "vt": "str" }],
"repeat": "",
"crontab": "*/1 6-22 * * *",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "[\t \"todo.chores_daily_morning\",\t \"todo.chores_daily_evening\"\t]",
"payloadType": "jsonata",
"x": 140,
"y": 100,
"wires": [["09820fbcdda6d672"]]
},
{
"id": "09820fbcdda6d672",
"type": "ha-api",
"z": "7823c4cbf9ad1cb7",
"name": "Daily Todo List",
"server": "5021ce22.33335",
"version": 1,
"debugenabled": false,
"protocol": "websocket",
"method": "get",
"path": "",
"data": "{\t \"type\": \"call_service\",\t \"domain\": \"todo\",\t \"service\": \"get_items\",\t \"target\": { \t \"entity_id\": \"todo.chores_daily\"\t },\t \"id\": 1,\t \"return_response\": true\t }",
"dataType": "jsonata",
"responseType": "json",
"outputProperties": [
{
"property": "payload",
"propertyType": "msg",
"value": "",
"valueType": "results"
}
],
"x": 340,
"y": 100,
"wires": [["ba983682a8709d45"]]
},
{
"id": "ba983682a8709d45",
"type": "function",
"z": "7823c4cbf9ad1cb7",
"name": "Transform Chores",
"func": "// Deep copy the raw response\nmsg.raw_response = JSON.parse(JSON.stringify(msg.payload));\n\n// Formats a date for updating todo items in home assistant\nfunction getCurrentDate() {\n const today = new Date();\n const year = today.getFullYear();\n const month = String(today.getMonth() + 1).padStart(2, '0');\n const day = String(today.getDate()).padStart(2, '0');\n return `${year}-${month}-${day}`;\n}\n\n// Filters out for chores that are scheduled to be added\nfunction transformAllChores(apiResponse) {\n const currentHour = new Date().getHours();\n msg.current_hour = currentHour;\n const items = [];\n\n for (const key in apiResponse) {\n const listItems = apiResponse[key]?.items || [];\n\n const transformedItems = listItems\n .map(item => {\n const dueDate = new Date(item.due);\n const hour = dueDate.getHours();\n const minutes = dueDate.getMinutes();\n const seconds = dueDate.getSeconds();\n const formattedTime = `${String(hour).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;\n\n return {\n ...item,\n dueHour: hour,\n status: \"needs_action\",\n tag: item.description,\n todo_list: key,\n complete: false,\n due_datetime: `${getCurrentDate()} ${formattedTime}`\n };\n })\n .filter(item => item.dueHour <= currentHour)\n .map(({ description, dueHour, ...rest }) => rest);\n\n items.push(...transformedItems);\n }\n\n return items;\n}\n\n// Adds chores if they're not in the global chore list\nfunction syncUpcomingChores(apiResponse) {\n const currentChores = global.get('chores', 'file') || [];\n const upcomingChores = transformAllChores(apiResponse);\n const existingSummaries = new Set(currentChores.map(chore => chore.summary));\n\n const newChores = upcomingChores.filter(chore => !existingSummaries.has(chore.summary));\n const updatedChores = [...currentChores, ...newChores];\n\n global.set('chores', updatedChores, 'file');\n return updatedChores;\n}\n\n// Grabs the current chores and sends them out asynchronously\nconst updatedChores = syncUpcomingChores(msg.payload.response);\nupdatedChores.forEach(chore => {\n node.send({ payload: chore });\n});\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 610,
"y": 100,
"wires": [["61073a4d6ef2d0ee"]]
},
{
"id": "ad727d0a918726fa",
"type": "inject",
"z": "7823c4cbf9ad1cb7",
"name": "Reset Chores",
"props": [{ "p": "payload" }, { "p": "topic", "vt": "str" }],
"repeat": "",
"crontab": "00 05 * * *",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 140,
"y": 360,
"wires": [["4a69482a43321501"]]
},
{
"id": "4a69482a43321501",
"type": "change",
"z": "7823c4cbf9ad1cb7",
"name": "Clear Chores List",
"rules": [
{
"t": "set",
"p": "#:(file)::chores",
"pt": "global",
"to": "[]",
"tot": "jsonata"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 330,
"y": 360,
"wires": [[]]
},
{
"id": "bcc03a66c9718547",
"type": "inject",
"z": "7823c4cbf9ad1cb7",
"name": "Get Chores",
"props": [{ "p": "payload" }, { "p": "topic", "vt": "str" }],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 130,
"y": 440,
"wires": [["099c55965980d15e"]]
},
{
"id": "099c55965980d15e",
"type": "change",
"z": "7823c4cbf9ad1cb7",
"name": "Get global.chores",
"rules": [
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "#:(file)::chores",
"tot": "global"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 330,
"y": 440,
"wires": [["eb3f9e221ae85d25"]]
},
{
"id": "9f0a25079af5273b",
"type": "ha-tag",
"z": "7823c4cbf9ad1cb7",
"name": "",
"server": "5021ce22.33335",
"version": 2,
"exposeAsEntityConfig": "",
"tags": ["__ALL_TAGS__"],
"devices": [],
"outputProperties": [
{
"property": "payload",
"propertyType": "msg",
"value": "",
"valueType": "eventData"
},
{
"property": "topic",
"propertyType": "msg",
"value": "",
"valueType": "triggerId"
}
],
"x": 110,
"y": 260,
"wires": [["baaec6d9a0e23c9f"]]
},
{
"id": "baaec6d9a0e23c9f",
"type": "function",
"z": "7823c4cbf9ad1cb7",
"name": "Update Chores",
"func": "// Get existing chores and count incomplete ones\nconst chores = global.get('chores', 'file') || [];\nconst previousChoreCount = chores.filter(chore => !chore.complete).length;\n\n// Mark chores as complete based on matching tag\nconst updatedChores = chores.map(chore => {\n if (chore.tag === msg.payload.name) {\n return { ...chore, complete: true, status: \"completed\" };\n }\n return chore;\n});\n\n// Save updated chore list\nglobal.set('chores', updatedChores, 'file');\n\n// Compute remaining chores\nconst remainingChores = updatedChores.filter(chore => !chore.complete);\n\n// Set message payload and metadata\nmsg.payload = updatedChores;\nmsg.previous_chore_count = previousChoreCount;\nmsg.remaining_chore_count = remainingChores.length;\n\nreturn msg;\n",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 280,
"y": 260,
"wires": [["9b9d42cdd758029e"]]
},
{
"id": "4892caf26b3627b3",
"type": "inject",
"z": "7823c4cbf9ad1cb7",
"name": "Weekend Trigger",
"props": [{ "p": "payload" }, { "p": "topic", "vt": "str" }],
"repeat": "",
"crontab": "*/1 6-22 * * 6",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "[\t \"todo.chores_daily_morning\",\t \"todo.chores_daily_evening\"\t]",
"payloadType": "jsonata",
"x": 150,
"y": 160,
"wires": [["ca0cda6f48b85966"]]
},
{
"id": "ca0cda6f48b85966",
"type": "ha-api",
"z": "7823c4cbf9ad1cb7",
"name": "Weekend Todo List",
"server": "5021ce22.33335",
"version": 1,
"debugenabled": false,
"protocol": "websocket",
"method": "get",
"path": "",
"data": "{\t \"type\": \"call_service\",\t \"domain\": \"todo\",\t \"service\": \"get_items\",\t \"target\": { \t \"entity_id\": \"todo.chores_weekend\"\t },\t \"id\": 1,\t \"return_response\": true\t }",
"dataType": "jsonata",
"responseType": "json",
"outputProperties": [
{
"property": "payload",
"propertyType": "msg",
"value": "",
"valueType": "results"
}
],
"x": 350,
"y": 160,
"wires": [["ba983682a8709d45"]]
},
{
"id": "61073a4d6ef2d0ee",
"type": "ha-api",
"z": "7823c4cbf9ad1cb7",
"name": "Update Checklist",
"server": "5021ce22.33335",
"version": 1,
"debugenabled": false,
"protocol": "websocket",
"method": "get",
"path": "",
"data": "{\t \"type\": \"execute_script\",\t \"sequence\": [\t {\t \"service\" : \"todo.update_item\",\t \"target\" : {\t \"entity_id\" : payload.todo_list\t },\t \"data\" : {\t \"status\" : payload.status,\t \"item\": payload.summary,\t \"due_datetime\": payload.due_datetime\t }\t },\t {\t \"stop\" : \"done\",\t \"response_variable\": \"service_result\"\t }\t ]\t}",
"dataType": "jsonata",
"responseType": "json",
"outputProperties": [
{
"property": "payload",
"propertyType": "msg",
"value": "",
"valueType": "results"
}
],
"x": 610,
"y": 140,
"wires": [[]]
},
{
"id": "9b9d42cdd758029e",
"type": "link out",
"z": "7823c4cbf9ad1cb7",
"name": "Updated Chores",
"mode": "link",
"links": [],
"x": 395,
"y": 260,
"wires": []
},
{
"id": "eb3f9e221ae85d25",
"type": "debug",
"z": "7823c4cbf9ad1cb7",
"name": "Get Chores Debug Output",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 550,
"y": 440,
"wires": []
},
{
"id": "4174d3798d930140",
"type": "comment",
"z": "7823c4cbf9ad1cb7",
"name": "Logic to Update HA Chore Lists",
"info": "",
"x": 190,
"y": 60,
"wires": []
},
{
"id": "06694b6363513540",
"type": "comment",
"z": "7823c4cbf9ad1cb7",
"name": "Logic to Mark Chores Complete",
"info": "",
"x": 190,
"y": 220,
"wires": []
},
{
"id": "7bf5b12db4869094",
"type": "comment",
"z": "7823c4cbf9ad1cb7",
"name": "Nightly Chore List Reset",
"info": "",
"x": 170,
"y": 320,
"wires": []
},
{
"id": "17353b6e4bcd5b55",
"type": "comment",
"z": "7823c4cbf9ad1cb7",
"name": "Debug",
"info": "",
"x": 110,
"y": 400,
"wires": []
},
{
"id": "5021ce22.33335",
"type": "server",
"name": "Home Assistant",
"addon": true
}
]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment