About making inexpensive models smarter by providing tools and context. (local models, gpt-5-mini, gpt-4.1-mini, gpt-4o-mini ...)

But I’m already working on a new tool that is looking up short term history, which would also help with entities without statistics.
But this is limited to 10 days as long as you don’t increase the recorder purge days setting. Excluded high-frequent data that you don’t need and then increasing this to 1 or 2 months might be a good idea then.

Also not sure yet what I should allow the AI to request, as I don’t want it to receive thousands of results killing the token window. :wink:

Maybe something like

  • first value in requested timespan with old_value/new_value
  • last value in requested timespan with old_value/new_value
  • count per state that occured in the time range
  • count of all state changes
  • an array of the newest N raw state changes
  • limit that the AI can set to control how N in the line above should be (but allow a maximum of maybe 50 or so)

That way the AI can still request more than the initial 50 values by moving the time window in case it really is looking for a single value that wasn’t in the truncated window (and it knows there is more data based on the returned count values).

If anyone has additional thoughts about that, feel free to comment. :slight_smile:

2 Likes

Provide very reasonable most used defaults and good examples on how to get what it wants and available ranges… It’ll be fine.

So here we go: Short term history access.
To be able to request the recorder values of all entities (as long as you didn’t exclude them manually).
Default persistence duration until this values get purged is 10 days.

Some examples:

  • When was the bathroom window opened the last time
  • How long was it opened
  • Has there been motion in the front yard today?
  • When did <name> leave the house?
  • How long did I watch TV today?

This one was a little bit more difficult, as there is no action in HA to retrieve this data.
So, you have to use the HA API with external tools like Python or Node-Red to retrieve the history data and get it back into HA.

As I use Node-RED for all my automations anyway, the setup looks like this:

  • The “tool” (HA script for the LLM) calls a generic script I posted here to send data to a Node-RED flow and wait for the returned data
  • This generic script sends an event to Node-RED and waits for a response with a different event.
  • Node-RED processes the parameters it received, calls the history API and sends the answer with the event type that the script is waiting for.
  • The generic script hands the answer back to the LLM script

This allows questions about a lot history states that aren’t in the aggregated statistics we accessed in the other history script.

The tool provides a list of state changes for the given time range.
It also provides the total count of state changes, the count and duration for each state that the entity had in the timespan as well as the first and last state value.

Here are the steps to add it to your installation:

  • First add my generic “Call a Node-RED flow and wait for the response” script from here
    Only the base script, not the samples (Node-RED flow and sample caller script).
    Do not expose this helper script to assist.

  • Import this Node-Red flow, which does the real work by collecting the history data and calculating the durations and counts for the single states:

    [{"id":"81cd8bd1866eb2a7","type":"group","z":"bfa4729855e61f82","style":{"label":false,"stroke":"none","fill":"#d1d1d1","fill-opacity":"0.5"},"nodes":["17a1219784662b39","d1864a3f26d6e6e0","72dafcbec8fb0f5f","2848b06d0cd60a3c","d6158a71fb770c01","eef83e5afa4e8a59","258eb4dd654d0e0a","20232be9215a10be","0d3f6bf42798bc6f","90b0d467703f69f5","044047795e2600eb"],"x":14,"y":19,"w":1052,"h":262},{"id":"17a1219784662b39","type":"api-get-history","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Read history","server":"ef6aa0b.3fe4a6","version":1,"startDate":"","endDate":"","entityId":"","entityIdType":"regex","useRelativeTime":false,"relativeTime":"","flatten":false,"outputType":"array","outputLocationType":"msg","outputLocation":"payload","x":730,"y":60,"wires":[["eef83e5afa4e8a59"]]},{"id":"d1864a3f26d6e6e0","type":"function","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Format Incoming Data","func":"const event = msg.payload.event;\nmsg.channel= event.channel;\nmsg.request_id= event.request_id;\n\nconst startDate = msg.start_time = event.payload.start_time;\nconst endDate = msg.end_time = event.payload.end_time || new Date().toLocaleString('sv-SE').replace('T', ' ');\n\nconst entities = event.payload.entity_ids.split(',').map(item => item.trim());\nconst escapedSpecialChars = entities.map(str => str.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'));\nconst regexString = `^(?:${escapedSpecialChars.join('|')})$`;\n\nmsg.limit = event.payload.limit;\n\nmsg.payload = {\n    entityId: regexString,\n    startDate,\n    endDate\n}\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":520,"y":60,"wires":[["17a1219784662b39"]]},{"id":"72dafcbec8fb0f5f","type":"server-events","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"","server":"ef6aa0b.3fe4a6","version":3,"exposeAsEntityConfig":"","eventType":"nodered_request.trigger","eventData":"","waitForRunning":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"$outputData(\"eventData\").event_type","valueType":"jsonata"}],"x":170,"y":120,"wires":[["2848b06d0cd60a3c"]]},{"id":"2848b06d0cd60a3c","type":"switch","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Are we the receiver for the event?","property":"payload.event.channel","propertyType":"msg","rules":[{"t":"eq","v":"entity-history-raw-state-log","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":280,"y":180,"wires":[["0d3f6bf42798bc6f"]]},{"id":"d6158a71fb770c01","type":"ha-fire-event","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"","server":"ef6aa0b.3fe4a6","version":0,"event":"nodered_request.response","data":"","dataType":"jsonata","x":900,"y":180,"wires":[[]]},{"id":"eef83e5afa4e8a59","type":"function","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Create output Mapping","func":"// Used below for converting from 2025-07-30T22:00:00+00:00 \n// to an local time converted string in the format 2025-07-30 22:00:00\nfunction formatLocalTime(isoString) {\n    const d = new Date(isoString);\n    const y = d.getFullYear();\n    const m = String(d.getMonth() + 1).padStart(2, '0');\n    const day = String(d.getDate()).padStart(2, '0');\n    const h = String(d.getHours()).padStart(2, '0');\n    const min = String(d.getMinutes()).padStart(2, '0');\n    const s = String(d.getSeconds()).padStart(2, '0');\n    return `${y}-${m}-${day} ${h}:${min}:${s}`;\n}\n\n// Parse local time strings like 2025-07-30 22:00:00 as LOCAL time to epoch ms\nfunction parseLocalDateTime(str) {\n    const [datePart, timePart] = str.split(' ');\n    const [Y, M, D] = datePart.split('-').map(Number);\n    const [h, m, s] = timePart.split(':').map(Number);\n    return new Date(Y, M - 1, D, h, m, s || 0).getTime();\n}\n\nconst windowStart = parseLocalDateTime(msg.start_time);\nconst windowEnd = parseLocalDateTime(msg.end_time);\nconst result = {};\n\nfor (const entityLog of msg.payload) {\n    const entityId = entityLog[0].entity_id;\n    result[entityId] = {\n        friendly_name: entityLog[0].attributes.friendly_name || \"\",\n        newest_log_entries_truncated_by_limit: [],\n        complete_log_count_within_timespan: entityLog.length,\n        state_values_within_timespan: {\n            state_at_start: entityLog[0].state,\n            state_at_end: entityLog[entityLog.length - 1].state,\n            states: {}\n        }\n    };\n\n    for (const logEntry of entityLog.slice().reverse()) {\n        const state = logEntry.state;\n\n        if (result[entityId].newest_log_entries_truncated_by_limit.length < msg.limit) {\n            result[entityId].newest_log_entries_truncated_by_limit.push({ [formatLocalTime(logEntry.last_changed)]: logEntry.state });\n        }\n\n        if (!result[entityId].state_values_within_timespan.states[state]) {\n            result[entityId].state_values_within_timespan.states[state] = { count: 1 };\n        } else {\n            result[entityId].state_values_within_timespan.states[state].count += 1;\n        }\n    }\n\n    const statesMap = result[entityId].state_values_within_timespan.states;\n\n    if (windowEnd > windowStart) {\n        let prevTimestamp = windowStart;\n        let prevState = entityLog[0].state;\n\n        for (let i = 0; i < entityLog.length; i++) {\n            const stateChangeTimestamp = new Date(entityLog[i].last_changed).getTime();\n            const segmentEnd = Math.min(stateChangeTimestamp, windowEnd);\n\n            if (segmentEnd > prevTimestamp) {\n                if (!statesMap[prevState]) statesMap[prevState] = { count: 0, duration_sum_in_seconds: 0 };\n                if (typeof statesMap[prevState].duration_sum_in_seconds !== 'number') statesMap[prevState].duration_sum_in_seconds = 0;\n                statesMap[prevState].duration_sum_in_seconds += Math.floor((segmentEnd - prevTimestamp) / 1000); // seconds\n            }\n\n            if (stateChangeTimestamp >= windowEnd) {\n                prevTimestamp = windowEnd;\n                break;\n            }\n\n            prevTimestamp = Math.max(stateChangeTimestamp, windowStart);\n            prevState = entityLog[i].state;\n        }\n\n        if (windowEnd > prevTimestamp) {\n            if (!statesMap[prevState]) statesMap[prevState] = { count: 0, duration_sum_in_seconds: 0 };\n            if (typeof statesMap[prevState].duration_sum_in_seconds !== 'number') statesMap[prevState].duration_sum_in_seconds = 0;\n            statesMap[prevState].duration_sum_in_seconds += Math.floor((windowEnd - prevTimestamp) / 1000);\n        }\n    }\n}\n\nmsg.payload = result;\ndelete msg.start_time;\ndelete msg.end_time;\ndelete msg.limit;\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":630,"y":120,"wires":[["20232be9215a10be"]]},{"id":"258eb4dd654d0e0a","type":"comment","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Entity History - Raw State Log","info":"","x":160,"y":60,"wires":[]},{"id":"20232be9215a10be","type":"function","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Format Response","func":"msg = { \n    payload : {\n        data: {\n            channel: msg.channel,\n            request_id: msg.request_id,\n            result: msg.payload,\n            hint: \"Remember you are bad at calculating or determining of min / max values. Use the calculator if you can't directly use the provided values!\"\n        } \n    }\n};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":850,"y":120,"wires":[["044047795e2600eb"]]},{"id":"0d3f6bf42798bc6f","type":"function","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Do the Provided Entity IDs exist?","func":"const event = msg.payload.event;\nconst entities = event.payload.entity_ids.split(',').map(item => item.trim());\n\nconst states = global.get('homeassistant').homeAssistant.states;\n\nconst missing = entities.filter(id => !(id in states));\n\nif (missing.length > 0) {\n    // Du brauchst keine Details – optional kannst du missing dranhängen\n    msg.error = 'Mindestens eine entity_id existiert nicht.';\n    // msg.missing = missing; // falls du doch debuggen willst\n    return [null, msg];\n}\n\nreturn [msg, null];","outputs":2,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":240,"wires":[["d1864a3f26d6e6e0"],["90b0d467703f69f5"]],"outputLabels":["Real","Fake"]},{"id":"90b0d467703f69f5","type":"function","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","name":"Format Error","func":"const event = msg.payload.event;\nmsg.channel = event.channel;\nmsg.request_id = event.request_id;\n\nmsg = {\n    payload: {\n        data: {\n            channel: msg.channel,\n            request_id: msg.request_id,\n            result: null,\n            error: {\n                code: 'entity_not_found',\n                message: 'You provided some not existing entity ids. Use only names returned from \"Entitiy Index\" or that you looked up in the data provided to you.'\n            }\n        }\n    }\n};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":610,"y":240,"wires":[["044047795e2600eb"]]},{"id":"044047795e2600eb","type":"junction","z":"bfa4729855e61f82","g":"81cd8bd1866eb2a7","x":720,"y":180,"wires":[["d6158a71fb770c01"]]},{"id":"ef6aa0b.3fe4a6","type":"server","name":"Home Assistant","addon":true}]
    

  • Add the final script that connect the parts. This one has to be exposed to assist:
alias: Entity History - RAW State Log
icon: mdi:chart-timeline
description: >
  Access historical state values.

  The return value for a timespan is an object with:  - first state  - last
  state - count of state entries - count per state value like "on" or "off" -
  summed up duration for each state value like "on" or "off" where the entity
  was in this state - Raw state values as time -> state-value mapping log. Use
  limit parameter for lot of state changes or long periods


  About the tool an the correct way to use it:


  Supply exact and existing entity_ids. Find them with the tool "Entity Index".

  Required:
    - entity_ids: String with an entity_id name, or a comma seperated list like "sensor.temp_1,
  sensor.temp_2"
    - start_time: Time string in local time like '2025-07-30 14:00:00'
    - limit: limit the output of raw log entries to avoid too large database queries and context size

  Optional:
    - end_time: Time string in local time like '2025-07-30 14:00:00'. If not provided, current time is used.

  Output on success:
   - result: 
     - <entity_name_1>: 
       - friendly_name: string
       - newest_log_entries_truncated_by_limit: array of { date-string: new-state-string }
       - complete_log_count_within_timespan: number
       - state_values_within_timespan:
         - state_at_start: state value
         - state_at_end: state value
         - states:
           - state_value_1:
             - count: number
             - duration_sum_in_seconds: number
           - ...
      - ...
  - error: null

  Output on error:
    - result: null
    - error: 
      - code: string
      - message: string


  Attention: 

  Always provide dates in local time like '2025-07-30 14:00:00'.  Returned dates
  and times are in same format and local time.

  Hint:
    - To only search for the state at a specific moment, set start and end date to same value.

  Examples. Use this tool to get information like:
    - When was bath window opened last time
    - How long is front door already open
    - Last time motion was detected in garden
    - How often was front door opened today
    - How long (summed up) was TV turned on today
mode: parallel
fields:
  entity_ids:
    name: Entity IDs (comma separated)
    description: >-
      comma-separated string of a single or multiple entity_id names (e.g.
      "sensor.roomname_temperature". MANDATORY!
    required: true
    selector:
      text: {}
  start_time:
    name: Start time
    description: >-
      Set the start date / time of your search. Always provide dates in
      localtime like '2025-07-30 14:00:00'
    required: true
    selector:
      text: {}
  end_time:
    name: End time
    description: >-
      Set the end date / time of your search. If not provided, the current time
      is used. Always provide dates in localtime like '2025-07-30 14:00:00'
    required: false
    selector:
      text: {}
  limit:
    name: Limit
    description: >-
      Set the maximum RAW state values you want to receive. If you only need the
      last state change, set limit to 2. Otherwise, set the limit as needed (but
      not lower than 2).
    required: true
    selector:
      number:
        min: 0
        max: 50
        mode: slider
sequence:
  - action: logbook.log
    data:
      name: "ENTITY HISTORY - RAW STATE LOG:"
      message: >-
        ids={{ entity_ids }}, start={{ start_time }}, end={{ end_time }},
        limit={{ limit }}
      entity_id: "{{ this.entity_id }}"
  - action: script.node_red_request
    data:
      channel: entity-history-raw-state-log
      payload:
        entity_ids: "{{\_entity_ids }}"
        start_time: "{{ start_time }}"
        end_time: "{{ end_time }}"
        limit: "{{ limit }}"
      timeout: 5
    response_variable: response
  - variables:
      result: "{{ response | default(None) }}"
  - stop: ""
    response_variable: result

If you use this script, you might want to add also some information to the prompt of your LLM.

Here’s my combined prompt for both history tools (which means you can delete the prompt part from the aggregated history statistics tool and replace it with this one):

There are 2 tools for checking history values of the entities:

  • “Entity History - Aggregated Statistics”
    This tool can retreive aggregated values but no RAW sate values.
    The aggregation has the benefit that the stored data is smaller and can be kept around for years.
    Also searching for results is way faster.
    Another benefit is, that you can request mean, min, max values e.g. of a temperature sensor without the need to calculate them yourself.
    The option “change” for the aggregation type parameter allows you to get the increment within a timespan of a monotonic increasing counter.
    Prefer this tool whenever you need to find a min or max value of a numeric entity like power (W) or temperature within a timespan, or the change/increment of monotonic increasing counters like energy (kWh) sensors.
  • “Entity History - RAW State Log”
    This tool has access to the RAW state values of entities from the past and can present them as a list of timestamp → state mappings.
    But it has only a limited time of persistance as the RAW data need a lot storage and search queries take more time.
    The Benefit is that you have access to the history of a lot more entities and not just numeric ones.
    Like “on” or “off” states of lights or opened windows.
    The exact timestamps of state changes returned from this tool also allow you to check how long an entity is already in a given state.
    It also returns the count of state changes per state and the summed up duration that the entity was in this state in the requested timespan.

Hints for both history tools:

  • You really need the EXACT and REAL entity_id of the devices to get the historical values.
  • The tools allow you to request values for multiple entities as once, so you need less tool calls.
  • Energy (kWh) sensors are cumulative, monotonic increasing counter.
    NEVER reply with their current state when asked about the energy used/produced in a specific timespan.
    Use the “Entitiy History - Aggregated Statistics” tool with the aggregation type “change” to get the deviation between start-time and end-time
1 Like

Haha, no I didn’t really plug in anything to the recorder config. Its basically plug and play. I think its refering to on off for that speakers i am trying to get history on. the speakers are also having assist on and I am using as well your entity index script.

Yes, I been using Gemini 2.5 flash for HA. It’s smart and it would be difficult for most HA user to spend more than a dollar or two a month. I expect the openAI API is the most expensive choice. o3 is my go to reasoning model, but I’ve been impressed at how much Gemini continues to improve. I did find Gemini 2.5 flash lite to be too dumb.

I’m interested in this topic as I’m interested in running a local AI.

The history script for aggregated statistics uses the recorder.get_statistics action.
You could try yourself if you get any data out of it when providing the media player as an entity.
For me it doesn’t show any data for this type of entities.

That’s exactly why I wrote the last script posted here, as the short term history has all these values that are missing in the long term statistics.

Another hint, that the LLM was maybe simply lying (hallucinating) to you:
The response of my script uses the key result not results.

So I guess it just made up some text it thought would sound reasonable.
The response of the script ("No history values found. Either the entity_ids have no long-term statistics enabled, or there are no datapoints in the requested timespan.") helps to avoid this for the LLMs from OpenAI.
But maybe with smaller local LLMs this might not be enough …

1 Like

Makes complete sense Thyraz!

I noticed that some of the ‘smarter’ but still cheap models (hosted gtp-oss-120b, gpt-5-mini) think they are too clever and don’t need the Entity Index tool for most calls.

And they are indeed better in finding the needed entities, which is quite nice. But then they fail at a lot questions because they don’t have the information I added as tags (which is only accessible for assist through the Entity Index tool).

So I changed the prompt part for the Entity Index tool a little bit and told it to always prefer ‘Entity Index’ over ‘GetLiveContext’ (which is the internal HA tool of assist to get entity data).

The edited version is in the post for the Entity Index tool (see first post for link).

1 Like

Yep. Also helps if you give a working example of why the llm is apparently trying to determine best path.

It understands well contextualized box o stuff but never saw a indexer in a home automation platform so we need to show it why it matters. Mine has an example of the output. (which is also the expabded ent view.) Ever since I put it there it has never looked back.

1 Like

Phew, this one took longer than I thought.

Date Calculator Tool

combines multiple functions:

  • provide a list of date → weekday entries between 2 dates
  • get duration between 2 dates (in different units (as floats if needed) and split up as time segment object like 3 days, 2 hours, 5 minutes
  • add time segments like days, hours, … to a date
  • get weekday for a given date
  • get date for a given weekday shiftet by N weeks
  • get a date for a given day of month, shiftet by N months
  • convert epoch times ↔ date-time strings

This was the second big shortcoming of LLMs next to numeric calculations. (Btw. why doesn’t OpenAI provide tools like this internally to their models, so they don’t have to create the result based on linguistic probability instead?)

Example questions:

  • How long was at least one window opened in <roomname> today (Only gpt-5-mini of the small OpenAI models will be able to see the problem of intersecting timespans with multiple windows in one room on its own. gpt-4.1-mini will be able to solve it on its own once giving the hint. gpt-4o-mini will need a step by step calculation instruction to get the correct result.)
  • How long is it until Christmas eve (yes this makes the kids happy :stuck_out_tongue:)
  • Whose birthday is next?

This one is also a Node-RED based solution like the ‘Short term history access’ script.

Here are the steps to add it to your installation:

  • First add my generic “Call a Node-RED flow and wait for the response” script from here
    Only the base script, not the samples (Node-RED flow and sample caller script).
    Do not expose this helper script to assist.
[{"id":"096745da26d1cde7","type":"group","z":"64f4dc3ef013c268","name":"Date Calculator","style":{"label":false,"stroke":"none","fill":"#d1d1d1","fill-opacity":"0.5"},"nodes":["305dfadf3c7d393b","e1b80314e9d30514","22430ad99fb5b08b","b42821cc7aa98550","f8173a43e943921c","f831aee8394153d7","c7d58db3c15313f0","2af6a2c1c40a9ca4","3a63c07faed94d13"],"x":14,"y":799,"w":1052,"h":242},{"id":"305dfadf3c7d393b","type":"ha-fire-event","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"","server":"ef6aa0b.3fe4a6","version":0,"event":"nodered_request.response","data":"","dataType":"jsonata","x":880,"y":960,"wires":[[]]},{"id":"e1b80314e9d30514","type":"function","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Date Calculator","func":"// @ts-nocheck\n/**\n * Node-RED Function: Date Calculator (local timezone)\n *\n * Input envelope:\n *   msg.payload.parameters = { function: \"<one of below>\", ...params }\n *\n * Output on success:\n *   msg.payload.result = { ... }    // function-specific fields\n *   msg.payload.error  = null\n *\n * Output on error:\n *   msg.payload.result = {}\n *   msg.payload.error  = { error_code: string, message: string }\n *\n * Date format everywhere:\n *   \"YYYY-MM-DD HH:mm:SS\" (e.g., \"2025-08-13 11:39:00\"), parsed/output in LOCAL TIME.\n *\n * Supported functions (with required parameters):\n *\n * 1) duration_between_dates\n *    - Required:\n *        - date:  string \"YYYY-MM-DD HH:mm:SS\"\n *        - date2: string \"YYYY-MM-DD HH:mm:SS\"\n *    - Returns:\n *        - duration_in_s, duration_in_minutes, duration_in_hours, duration_in_days, duration_in_weeks\n *        - duration_in_months (calendar-based float), duration_in_years (calendar-based float)\n *        - duration_in_segments { sign:\"+|-\", years, months, weeks, days, hours, minutes, seconds }\n *\n * 2) date_by_adding_segments\n *    - Required:\n *        - date:     string \"YYYY-MM-DD HH:mm:SS\"\n *        - segments: object with optional { years, months, days, hours, minutes, seconds } (numbers; ±/0)\n *    - Returns:\n *        - new_date: string \"YYYY-MM-DD HH:mm:SS\" (local time)\n *        - weekday:  string (\"Sunday\"...\"Saturday\")\n *        - epoch_time_s: number (Unix epoch seconds of new_date)\n *\n * 3) weekday_for_date\n *    - Required:\n *        - date: string \"YYYY-MM-DD HH:mm:SS\"\n *    - Returns:\n *        - date: string (normalized)\n *        - weekday: string (\"Sunday\"...\"Saturday\")\n *\n * 4) date_for_weekday_in_n_weeks\n *    - Required:\n *        - weekday: \"sunday\"...\"saturday\" (case-insensitive)\n *        - n: integer >= 0\n *    - Returns:\n *        - date: \"YYYY-MM-DD 00:00:00\", weekday, days_from_today\n *\n * 5) date_for_day_of_month_in_n_months\n *    - Required:\n *        - day_of_month: integer 1..31\n *        - n: integer >= 0\n *    - Returns:\n *        - date: \"YYYY-MM-DD 00:00:00\", day_of_month, days_from_today, weekday\n *\n * 6) list_calendar_days\n *    - Required:\n *        - date:  \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:mm:SS\" (local)\n *        - date2: \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:mm:SS\" (local)\n *    - Returns:\n *        - days: array of { date: \"YYYY-MM-DD\", weekday: \"Sunday\"...\"Saturday\" }\n *\n * 7) epoch_to_date\n *    - Required:\n *        - epoch_time_s: integer (Unix epoch seconds; NOT ms)\n *    - Returns:\n *        - date: \"YYYY-MM-DD HH:mm:SS\" (local time)\n *        - weekday: \"Sunday\"...\"Saturday\"\n *        - epoch_time_s: number (echoed)\n *\n * 8) date_to_epoch\n *    - Required:\n *        - date: \"YYYY-MM-DD HH:mm:SS\" (local time)\n *    - Returns:\n *        - epoch_time_s: number (Unix epoch seconds)\n *        - date: string (normalized)\n *        - weekday: \"Sunday\"...\"Saturday\"\n */\n\n/////////////////////////////// Helpers ///////////////////////////////\n\nfunction pad(n) { return String(n).padStart(2, \"0\"); }\n\n// Format in local time: \"YYYY-MM-DD HH:mm:SS\"\nfunction formatLocal(d) {\n    return (\n        d.getFullYear() + \"-\" +\n        pad(d.getMonth() + 1) + \"-\" +\n        pad(d.getDate()) + \" \" +\n        pad(d.getHours()) + \":\" +\n        pad(d.getMinutes()) + \":\" +\n        pad(d.getSeconds())\n    );\n}\n\n// Parse \"YYYY-MM-DD HH:mm:SS\" as local time with validation\nfunction parseLocalDate(str) {\n    if (typeof str !== \"string\") return { error: \"invalid_type\" };\n    const m = str.match(/^(\\d{4})-(\\d{2})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2})$/);\n    if (!m) return { error: \"invalid_format\" };\n    const [_, y, mo, d, hh, mm, ss] = m;\n    const year = +y, month = +mo, day = +d, hour = +hh, minute = +mm, second = +ss;\n    if (month < 1 || month > 12) return { error: \"invalid_date\" };\n    if (day < 1 || day > 31) return { error: \"invalid_date\" };\n    if (hour < 0 || hour > 23) return { error: \"invalid_date\" };\n    if (minute < 0 || minute > 59) return { error: \"invalid_date\" };\n    if (second < 0 || second > 59) return { error: \"invalid_date\" };\n    const dObj = new Date(year, month - 1, day, hour, minute, second, 0); // local\n    if (\n        dObj.getFullYear() !== year ||\n        dObj.getMonth() + 1 !== month ||\n        dObj.getDate() !== day ||\n        dObj.getHours() !== hour ||\n        dObj.getMinutes() !== minute ||\n        dObj.getSeconds() !== second\n    ) {\n        return { error: \"invalid_date\" };\n    }\n    return { date: dObj };\n}\n\nfunction todayStart() {\n    const now = new Date();\n    return new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);\n}\n\nfunction diffSeconds(dateA, dateB) {\n    return (dateB.getTime() - dateA.getTime()) / 1000;\n}\n\n// English weekdays (0=Sunday ... 6=Saturday)\nconst DOW_NAMES_EN = [\"Sunday\", \"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\"];\nconst DOW_MAP_EN = {\n    \"sunday\": 0, \"monday\": 1, \"tuesday\": 2,\n    \"wednesday\": 3, \"thursday\": 4, \"friday\": 5, \"saturday\": 6\n};\n\nfunction normalizeWeekdayEn(input) {\n    if (typeof input !== \"string\") return { error: \"invalid_weekday\" };\n    const key = input.trim().toLowerCase();\n    if (!key) return { error: \"invalid_weekday\" };\n    if (!(key in DOW_MAP_EN)) return { error: \"invalid_weekday\" };\n    const dow = DOW_MAP_EN[key];\n    return { dow, name: DOW_NAMES_EN[dow] };\n}\n\nfunction weekdayNameEn(dow) {\n    return DOW_NAMES_EN[dow];\n}\n\n// DST-safe, type-checker-friendly difference in whole days (UTC midnight)\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\nfunction daysBetweenDates(a, b) {\n    const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());\n    const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());\n    return Math.round((utcB - utcA) / MS_PER_DAY);\n}\n\nfunction addSegments(base, segs) {\n    const years = +segs.years || 0;\n    const months = +segs.months || 0;\n    const days = +segs.days || 0;\n    const hours = +segs.hours || 0;\n    const minutes = +segs.minutes || 0;\n    const seconds = +segs.seconds || 0;\n\n    const d = new Date(base.getTime());\n\n    // years/months first (JS handles overflow across months)\n    if (years || months) {\n        const y = d.getFullYear() + years;\n        const m = d.getMonth() + months;\n        d.setFullYear(y);\n        d.setMonth(m);\n    }\n    if (days) d.setDate(d.getDate() + days);\n    if (hours) d.setHours(d.getHours() + hours);\n    if (minutes) d.setMinutes(d.getMinutes() + minutes);\n    if (seconds) d.setSeconds(d.getSeconds() + seconds);\n\n    return d;\n}\n\nfunction fail(error_code, message) {\n    msg.payload = msg.payload || {};\n    msg.payload.result = {};\n    msg.payload.error = { error_code, message };\n    return msg;\n}\n\nfunction ok(resultObj) {\n    msg.payload = msg.payload || {};\n    msg.payload.result = resultObj || {};\n    msg.payload.error = null;\n    return msg;\n}\n\nfunction validateParams(actual, requiredKeys, optionalKeys) {\n    const req = new Set(requiredKeys || []);\n    const opt = new Set(optionalKeys || []);\n    const allowed = new Set([...req, ...opt, \"function\"]);\n\n    const missing = [];\n    req.forEach(k => { if (!(k in actual)) missing.push(k); });\n    if (missing.length) return { ok: false, type: \"missing\", keys: missing };\n\n    const unexpected = [];\n    Object.keys(actual || {}).forEach(k => {\n        if (!allowed.has(k)) unexpected.push(k);\n    });\n    if (unexpected.length) return { ok: false, type: \"unexpected\", keys: unexpected };\n\n    return { ok: true };\n}\n\n// Calendar-based greedy split: years -> months -> weeks -> days -> hours -> minutes -> seconds\n// Also returns \"anchor\" (cursor after applying full years+months) and the remaining seconds.\nfunction diffToCalendarSegments(dA, dB) {\n    const sign = dB >= dA ? \"+\" : \"-\";\n    const start = (sign === \"+\") ? dA : dB;\n    const end = (sign === \"+\") ? dB : dA;\n\n    let cursor = new Date(start.getTime());\n\n    // Years\n    let years = end.getFullYear() - cursor.getFullYear();\n    let candidate = new Date(cursor.getTime());\n    candidate.setFullYear(candidate.getFullYear() + years);\n    if (candidate > end) {\n        years--;\n        candidate = new Date(cursor.getTime());\n        candidate.setFullYear(candidate.getFullYear() + years);\n    }\n    cursor = candidate;\n\n    // Months\n    let months = (end.getFullYear() - cursor.getFullYear()) * 12 + (end.getMonth() - cursor.getMonth());\n    candidate = new Date(cursor.getTime());\n    candidate.setMonth(candidate.getMonth() + months);\n    if (candidate > end) {\n        months--;\n        candidate = new Date(cursor.getTime());\n        candidate.setMonth(candidate.getMonth() + months);\n    }\n    cursor = candidate;\n\n    // Remaining seconds to split into weeks..seconds\n    let remSec = Math.floor((end.getTime() - cursor.getTime()) / 1000); // integer seconds\n    const WEEK = 7 * 24 * 3600;\n    const DAY = 24 * 3600;\n    const HOUR = 3600;\n    const MIN = 60;\n\n    const weeks = Math.floor(remSec / WEEK); remSec -= weeks * WEEK;\n    const days = Math.floor(remSec / DAY); remSec -= days * DAY;\n    const hours = Math.floor(remSec / HOUR); remSec -= hours * HOUR;\n    const minutes = Math.floor(remSec / MIN); remSec -= minutes * MIN;\n    const seconds = remSec;\n\n    return { sign, years, months, weeks, days, hours, minutes, seconds, anchor: new Date(cursor.getTime()), remainder_seconds: (end.getTime() - cursor.getTime()) / 1000 };\n}\n\n// Duration (in seconds) of the calendar month starting at \"anchor\" (anchor to anchor+1 month)\nfunction secondsOfAnchorMonth(anchor) {\n    const next = new Date(anchor.getTime());\n    next.setMonth(next.getMonth() + 1);\n    return (next.getTime() - anchor.getTime()) / 1000;\n}\n\n/////////////////////////////// Input & Routing ///////////////////////////////\n\nconst p = (msg && msg.payload && msg.payload.parameters) || null;\nif (!p || typeof p !== \"object\") {\n    return fail(\"param_missing\", \"No valid object provided under parameters.\");\n}\n\nconst fn = p.function;\nif (typeof fn !== \"string\") {\n    return fail(\"param_missing\", 'The parameter \"function\" (string) is missing.');\n}\n\n/////////////////////////////// Functions ///////////////////////////////\n\nif (fn === \"duration_between_dates\") {\n    // Required: date, date2\n    const check = validateParams(p, [\"date\", \"date2\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=duration_between_dates: ${check.keys.join(\", \")}`);\n        }\n    }\n\n    const d1 = parseLocalDate(p.date);\n    const d2 = parseLocalDate(p.date2);\n    if (d1.error || d2.error) {\n        return fail(\"invalid_date_format\", \"date or date2 is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n\n    // Continuous differences\n    const seconds = diffSeconds(d1.date, d2.date); // may be negative\n    const minutes = seconds / 60;\n    const hours = seconds / 3600;\n    const days = seconds / (3600 * 24);\n    const weeks = seconds / (7 * 24 * 3600);\n\n    // Calendar-based split\n    const seg = diffToCalendarSegments(d1.date, d2.date);\n\n    // Calendar-based floating months/years\n    let monthsFloatAbs = seg.years * 12 + seg.months;\n    if (seg.remainder_seconds > 0) {\n        const monthLen = secondsOfAnchorMonth(seg.anchor);\n        monthsFloatAbs += seg.remainder_seconds / monthLen;\n    }\n    const signFactor = seg.sign === \"-\" ? -1 : 1;\n    const durationInMonths = signFactor * monthsFloatAbs;\n    const durationInYears = durationInMonths / 12;\n\n    return ok({\n        duration_in_s: seconds,\n        duration_in_minutes: minutes,\n        duration_in_hours: hours,\n        duration_in_days: days,\n        duration_in_weeks: weeks,\n        duration_in_months: durationInMonths,\n        duration_in_years: durationInYears,\n        duration_in_segments: {\n            sign: seg.sign,\n            years: seg.years,\n            months: seg.months,\n            weeks: seg.weeks,\n            days: seg.days,\n            hours: seg.hours,\n            minutes: seg.minutes,\n            seconds: seg.seconds\n        }\n    });\n}\n\nif (fn === \"date_by_adding_segments\") {\n    // Required: date, segments\n    const check = validateParams(p, [\"date\", \"segments\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_by_adding_segments: ${check.keys.join(\", \")}`);\n        }\n    }\n    const base = parseLocalDate(p.date);\n    if (base.error) {\n        return fail(\"invalid_date_format\", \"date is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n    if (typeof p.segments !== \"object\" || p.segments === null) {\n        return fail(\"param_missing\", \"segments must be an object with optional fields: years, months, days, hours, minutes, seconds.\");\n    }\n    const allowedSeg = new Set([\"years\", \"months\", \"days\", \"hours\", \"minutes\", \"seconds\"]);\n    const extraSeg = Object.keys(p.segments).filter(k => !allowedSeg.has(k));\n    if (extraSeg.length) {\n        return fail(\"param_unexpected\", `Unexpected fields in segments: ${extraSeg.join(\", \")}`);\n    }\n\n    const newDate = addSegments(base.date, p.segments);\n    return ok({\n        new_date: formatLocal(newDate),\n        weekday: weekdayNameEn(newDate.getDay()),\n        epoch_time_s: Math.floor(newDate.getTime() / 1000)\n    });\n}\n\nif (fn === \"weekday_for_date\") {\n    // Required: date\n    const check = validateParams(p, [\"date\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=weekday_for_date: ${check.keys.join(\", \")}`);\n        }\n    }\n    const d = parseLocalDate(p.date);\n    if (d.error) {\n        return fail(\"invalid_date_format\", \"date is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n    const dow = d.date.getDay();\n    return ok({\n        date: formatLocal(d.date),\n        weekday: weekdayNameEn(dow)\n    });\n}\n\nif (fn === \"date_for_weekday_in_n_weeks\") {\n    // Required: weekday, n\n    const check = validateParams(p, [\"weekday\", \"n\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_for_weekday_in_n_weeks: ${check.keys.join(\", \")}`);\n        }\n    }\n    const n = Number(p.n);\n    if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {\n        return fail(\"invalid_n\", \"n must be an integer >= 0.\");\n    }\n    const norm = normalizeWeekdayEn(p.weekday);\n    if (norm.error) {\n        return fail(\"invalid_weekday\", \"weekday must be one of: sunday, monday, tuesday, wednesday, thursday, friday, saturday (case-insensitive).\");\n    }\n    const targetDow = norm.dow;\n\n    const base = todayStart(); // today 00:00 local\n    const baseDow = base.getDay();\n    let delta = (targetDow - baseDow + 7) % 7; // 0..6 (0 = today)\n    delta += n * 7; // n=0 includes today, each +1 adds 7 days\n\n    const target = new Date(base.getFullYear(), base.getMonth(), base.getDate() + delta, 0, 0, 0);\n    const daysFromToday = daysBetweenDates(base, target);\n\n    return ok({\n        date: formatLocal(target),   // \"YYYY-MM-DD 00:00:00\"\n        weekday: weekdayNameEn(target.getDay()),\n        days_from_today: daysFromToday\n    });\n}\n\nif (fn === \"date_for_day_of_month_in_n_months\") {\n    // Required: day_of_month, n\n    const check = validateParams(p, [\"day_of_month\", \"n\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_for_day_of_month_in_n_months: ${check.keys.join(\", \")}`);\n        }\n    }\n    const n = Number(p.n);\n    const dom = Number(p.day_of_month);\n    if (!Number.isFinite(n) || n < 0 || !Number.isInteger(n)) {\n        return fail(\"invalid_n\", \"n must be an integer >= 0.\");\n    }\n    if (!Number.isFinite(dom) || dom < 1 || dom > 31 || !Number.isInteger(dom)) {\n        return fail(\"invalid_day_of_month\", \"day_of_month must be an integer between 1 and 31.\");\n    }\n\n    function daysInMonth(y, m) { return new Date(y, m + 1, 0).getDate(); }\n\n    const base = todayStart(); // 00:00\n    let firstY = base.getFullYear();\n    let firstM = base.getMonth();\n\n    let dim = daysInMonth(firstY, firstM);\n    let firstDate;\n\n    if (dom <= dim) {\n        firstDate = new Date(firstY, firstM, dom, 0, 0, 0);\n        if (firstDate < base) {\n            firstM += 1;\n            if (firstM > 11) { firstM = 0; firstY += 1; }\n            dim = daysInMonth(firstY, firstM);\n            if (dom > dim) {\n                return fail(\"invalid_day_of_month\", `day_of_month (${dom}) does not exist in the start month ${firstM + 1}/${firstY}.`);\n            }\n            firstDate = new Date(firstY, firstM, dom, 0, 0, 0);\n        }\n    } else {\n        firstM += 1;\n        if (firstM > 11) { firstM = 0; firstY += 1; }\n        dim = daysInMonth(firstY, firstM);\n        if (dom > dim) {\n            return fail(\"invalid_day_of_month\", `day_of_month (${dom}) does not exist in the start month ${firstM + 1}/${firstY}.`);\n        }\n        firstDate = new Date(firstY, firstM, dom, 0, 0, 0);\n    }\n\n    const target = new Date(firstDate.getFullYear(), firstDate.getMonth() + n, dom, 0, 0, 0);\n    if (target.getDate() !== dom) {\n        return fail(\"invalid_day_of_month\", `day_of_month (${dom}) does not exist in the target month.`);\n    }\n\n    const daysFromToday = daysBetweenDates(base, target);\n    return ok({\n        date: formatLocal(target),   // \"YYYY-MM-DD 00:00:00\"\n        day_of_month: dom,\n        days_from_today: daysFromToday,\n        weekday: weekdayNameEn(target.getDay())\n    });\n}\n\nif (fn === \"list_calendar_days\") {\n    // Required: date, date2\n    const check = validateParams(p, [\"date\", \"date2\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=list_calendar_days: ${check.keys.join(\", \")}`);\n        }\n    }\n\n    // Local helpers\n    function formatLocalYMD(d) {\n        return d.getFullYear() + \"-\" + pad(d.getMonth() + 1) + \"-\" + pad(d.getDate());\n    }\n    // Accepts \"YYYY-MM-DD\" or \"YYYY-MM-DD HH:mm:SS\", returns Date in local time\n    function parseLocalDateFlexible(str) {\n        if (typeof str !== \"string\") return { error: \"invalid_type\" };\n        const m = str.match(/^(\\d{4})-(\\d{2})-(\\d{2})(?: (\\d{2}):(\\d{2}):(\\d{2}))?$/);\n        if (!m) return { error: \"invalid_format\" };\n        const year = +m[1], month = +m[2], day = +m[3];\n        const hour = m[4] ? +m[4] : 0;\n        const minute = m[5] ? +m[5] : 0;\n        const second = m[6] ? +m[6] : 0;\n        if (month < 1 || month > 12) return { error: \"invalid_date\" };\n        if (day < 1 || day > 31) return { error: \"invalid_date\" };\n        if (hour < 0 || hour > 23) return { error: \"invalid_date\" };\n        if (minute < 0 || minute > 59) return { error: \"invalid_date\" };\n        if (second < 0 || second > 59) return { error: \"invalid_date\" };\n        const dObj = new Date(year, month - 1, day, hour, minute, second, 0);\n        if (\n            dObj.getFullYear() !== year ||\n            dObj.getMonth() + 1 !== month ||\n            dObj.getDate() !== day ||\n            dObj.getHours() !== hour ||\n            dObj.getMinutes() !== minute ||\n            dObj.getSeconds() !== second\n        ) {\n            return { error: \"invalid_date\" };\n        }\n        return { date: dObj };\n    }\n    function toLocalMidnight(d) {\n        return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);\n    }\n\n    const s = parseLocalDateFlexible(p.date);\n    const e = parseLocalDateFlexible(p.date2);\n    if (s.error || e.error) {\n        return fail(\"invalid_date_format\", \"date or date2 is not a valid date (accepted: 'YYYY-MM-DD' or 'YYYY-MM-DD HH:mm:SS').\");\n    }\n\n    let start = toLocalMidnight(s.date);\n    let end = toLocalMidnight(e.date);\n    if (start > end) {\n        return fail(\"invalid_range\", \"date must be less than or equal to date2.\");\n    }\n\n    const days = [];\n    while (start.getTime() <= end.getTime()) {\n        days.push({\n            date: formatLocalYMD(start),\n            weekday: DOW_NAMES_EN[start.getDay()]\n        });\n        start = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1, 0, 0, 0, 0);\n    }\n\n    return ok({ days });\n}\n\nif (fn === \"epoch_to_date\") {\n    // Required: epoch_time_s (seconds, NOT ms)\n    const check = validateParams(p, [\"epoch_time_s\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=epoch_to_date: ${check.keys.join(\", \")}`);\n        }\n    }\n    const s = Number(p.epoch_time_s);\n    if (!Number.isFinite(s) || !Number.isInteger(s)) {\n        return fail(\"invalid_epoch_time\", \"epoch_time_s must be an integer number of seconds since 1970-01-01T00:00:00Z.\");\n    }\n    const d = new Date(s * 1000);\n    if (isNaN(d.getTime())) {\n        return fail(\"invalid_epoch_time\", \"epoch_time_s results in an invalid Date.\");\n    }\n    return ok({\n        date: formatLocal(d),\n        weekday: weekdayNameEn(d.getDay()),\n        epoch_time_s: s\n    });\n}\n\nif (fn === \"date_to_epoch\") {\n    // Required: date\n    const check = validateParams(p, [\"date\"], []);\n    if (!check.ok) {\n        if (check.type === \"missing\") {\n            return fail(\"param_missing\", `Missing required parameters: ${check.keys.join(\", \")}`);\n        } else {\n            return fail(\"param_unexpected\", `Unexpected parameters for function=date_to_epoch: ${check.keys.join(\", \")}`);\n        }\n    }\n    const d = parseLocalDate(p.date);\n    if (d.error) {\n        return fail(\"invalid_date_format\", \"date is not a valid date in format 'YYYY-MM-DD HH:mm:SS'.\");\n    }\n    const epoch = Math.floor(d.date.getTime() / 1000);\n    return ok({\n        epoch_time_s: epoch,\n        date: formatLocal(d.date),\n        weekday: weekdayNameEn(d.date.getDay())\n    });\n}\n\nreturn fail(\n    \"unknown_function\",\n    \"Unknown function. Allowed: duration_between_dates, date_by_adding_segments, weekday_for_date, date_for_weekday_in_n_weeks, date_for_day_of_month_in_n_months, list_calendar_days, epoch_to_date, date_to_epoch.\"\n);\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":280,"y":960,"wires":[["c7d58db3c15313f0"]]},{"id":"22430ad99fb5b08b","type":"debug","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Parameters","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":950,"y":900,"wires":[]},{"id":"b42821cc7aa98550","type":"function","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Format Incoming Data","func":"const event = msg.payload.event;\nmsg.channel = event.channel;\nmsg.request_id= event.request_id;\n\nmsg.payload = {\n    parameters: event.payload\n}\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":720,"y":900,"wires":[["e1b80314e9d30514","22430ad99fb5b08b"]]},{"id":"f8173a43e943921c","type":"server-events","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"","server":"ef6aa0b.3fe4a6","version":3,"exposeAsEntityConfig":"","eventType":"nodered_request.trigger","eventData":"","waitForRunning":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"$outputData(\"eventData\").event_type","valueType":"jsonata"}],"x":170,"y":900,"wires":[["f831aee8394153d7"]]},{"id":"f831aee8394153d7","type":"switch","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Are we the receiver for the event?","property":"payload.event.channel","propertyType":"msg","rules":[{"t":"eq","v":"date-calculator","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":460,"y":900,"wires":[["b42821cc7aa98550"]]},{"id":"c7d58db3c15313f0","type":"function","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Format Response","func":"msg = { \n    payload : {\n        data: {\n            channel: msg.channel,\n            request_id: msg.request_id,\n            result: msg.payload.result,\n            error: msg.payload.error\n        } \n    }\n};\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":470,"y":960,"wires":[["305dfadf3c7d393b","2af6a2c1c40a9ca4"]]},{"id":"2af6a2c1c40a9ca4","type":"debug","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Result","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":650,"y":1000,"wires":[]},{"id":"3a63c07faed94d13","type":"comment","z":"64f4dc3ef013c268","g":"096745da26d1cde7","name":"Date Calculator","info":"","x":120,"y":840,"wires":[]},{"id":"ef6aa0b.3fe4a6","type":"server","name":"Home Assistant","addon":true}]
  • The final script follows in the next post, as I reached the max. char size. :smile:

ok, here’s the second part of the Date Calculator Tool:

  • Add the final script that connect the parts. This one has to be exposed to assist:
    alias: Date Calculator
    icon: mdi:calendar-start
    description: >-
      This is your tool for requesting a list of date <-> weekday mappings or to do
      date calculations. 
    
      Output on success:
        result = { ... }    // function-specific fields
        error  = null
    
      Output on error:
        result = null
        error  = { error_code: string, message: string }
    
      Date format everywhere:
        "YYYY-MM-DD HH:mm:SS" in LOCAL TIME.
    
      Supported functions (with required parameters):
    
      duration_between_dates
         - Required:
             - date
             - date2
         - Returns:
             - duration values (floats), converted in various units for your convenience: seconds, minutes, hours, days, weeks, months, years. Pick the one you prefer.
             - duration_in_segments: duration splitted into unsigned integer value segments and a sign string ('+' or '-'). object with keys: { sign, years, months, weeks, days, hours, minutes, seconds }
    
      date_by_adding_segments
         - Required:
             - date
             - segments: object with optional { years, months, days, hours, minutes, seconds } (numbers, can be negative when needed)
         - Returns:
             - new_date
             - weekday:  string ("Sunday"..."Saturday")
             - epoch_time_s: number (Unix epoch seconds) of new_date
    
      weekday_for_date
         - Required:
             - date
         - Returns:
             - weekday: string ("Sunday"..."Saturday")
    
      date_for_weekday_in_n_weeks
         - Required:
             - weekday: "Sunday"..."Saturday"
             - n: integer >= 0. For weeks to add. 0 means next occurrence for the weekday. Can be in the current week or the next week depending of the current weekday.
         - Returns:
             - date: "YYYY-MM-DD 00:00:00"
             - weekday: "Sunday"..."Saturday"
             - days_from_today: 0=today, 1=tomorrow, ...
    
      date_for_day_of_month_in_n_months
         - Required:
             - day_of_month: integer 1..31
             - n: integer >= 0. For months to add. 0 means next occurrence for the day-of-month. Can be in the current month or the next month depending of the current date.
         - Returns:
             - date: "YYYY-MM-DD 00:00:00"
             - day_of_month
             - days_from_today
             - weekday
    
      list_calendar_days
         - Required:
             - date
             - date2
         - Returns:
             - days: array of { date: "YYYY-MM-DD", weekday: "Sunday"..."Saturday" }
    
      epoch_to_date
         - Required:
             - epoch_time_s: integer (Unix epoch seconds)
         - Returns:
             - date
             - weekday: "Sunday"..."Saturday"
             - epoch_time_s: number (Unix epoch seconds)
    
      date_to_epoch
         - Required:
             - date: "YYYY-MM-DD HH:mm:SS" (local time)
         - Returns:
             - epoch_time_s: number (Unix epoch seconds)
             - date
             - weekday: "Sunday"..."Saturday"
    mode: parallel
    fields:
      function:
        name: Function
        required: true
        selector:
          select:
            options:
              - list_calendar_days
              - weekday_for_date
              - duration_between_dates
              - date_by_adding_segments
              - date_for_weekday_in_n_weeks
              - date_for_day_of_month_in_n_months
      date:
        name: Date or start-date, depending on the used function
        description: >-
          Set the date / start-date. Always provide dates in localtime like
          '2025-07-30 14:00:00'
        required: false
        selector:
          text: {}
      date2:
        name: Second date or end-date, depending on the used function
        description: >-
          Set the second date / end-date. Always provide dates in localtime like
          '2025-07-30 14:00:00'
        required: false
        selector:
          text: {}
      "n":
        name: "N"
        description: >-
          Only used for the functions 'date_for_weekday_in_n_weeks' and
          'date_for_day_of_month_in_n_months'
        required: false
        selector:
          number:
            min: 0
            max: 3650
            mode: slider
      weekday:
        name: Weekday
        required: false
        selector:
          select:
            options:
              - Monday
              - Tuesday
              - Wednesday
              - Thursday
              - Friday
              - Saturday
              - Sunday
      day_of_month:
        name: Day of Month
        required: false
        selector:
          number:
            min: 1
            max: 31
            mode: slider
      segments:
        name: Segments
        description: >-
          Date / Time Segments to be added to a date with the
          date_by_adding_segments function
        required: false
        selector:
          object:
            label_field: name
            fields:
              seconds:
                label: Seconds
                required: false
                selector:
                  number:
                    min: -600
                    max: 600
              minutes:
                label: Minutes
                required: false
                selector:
                  number:
                    min: -600
                    max: 600
              hours:
                label: Hours
                required: false
                selector:
                  number:
                    min: -240
                    max: 240
              days:
                label: Days
                required: false
                selector:
                  number:
                    min: -620
                    max: 620
              months:
                label: Months
                required: false
                selector:
                  number:
                    min: -240
                    max: 240
              years:
                label: Years
                required: false
                selector:
                  number:
                    min: -3000
                    max: 3000
    sequence:
      - action: logbook.log
        data:
          name: "DATE CALCULATOR - RAW STATE LOG:"
          message: >-
            function={{ function }}, date={{ date }}, date2={{ date2 }}, n={{ n }},
            weekday={{ weekday }}, day_of_month={{ day_of_month }}, segments={{
            segments }}
          entity_id: "{{ this.entity_id }}"
      - action: script.node_red_request
        data:
          channel: date-calculator
          payload: >
            {% set p = {'function': function} %} {% if date is defined and date %}
              {% set p = p | combine({'date': date}) %}
            {% endif %} {% if date2 is defined and date2 %}
              {% set p = p | combine({'date2': date2}) %}
            {% endif %} {% if (n | default(none)) is not none %}
              {% set p = p | combine({'n': n}) %}
            {% endif %} {% if weekday is defined and weekday %}
              {% set p = p | combine({'weekday': weekday}) %}
            {% endif %} {% if (day_of_month | default(none)) is not none %}
              {% set p = p | combine({'day_of_month': day_of_month}) %}
            {% endif %} {% set ns = namespace(seg={}) %} {% if segments is defined
            and segments %}
              {% for k, v in segments | dictsort %}
                {% if (v | default(none)) not in [none, '', 0] %}
                  {% set ns.seg = ns.seg | combine({k: v}) %}
                {% endif %}
              {% endfor %}
              {% if ns.seg %}
                {% set p = p | combine({'segments': ns.seg}) %}
              {% endif %}
            {% endif %} {{ p }}
          timeout: 5
        response_variable: response
      - variables:
          result: "{{ response | default(None) }}"
      - stop: ""
        response_variable: result
    

For the prompt, I adjusted my prompt for the ‘Calculator’ tool I posted above to a combined description:

You are VERY bad at calculations, finding min/max values, comparing numbers or date calculations like

  • which is the coolest room
  • which is the hottest room
  • what date might be tomorrow, or wednesday next week
  • how many days it is until a given date.
    These are only a few examples and this applies to all other calcuations too.

Always, really always use the ‘Calculator’ or ‘Date Calculator’ tool provided when possible to get the solution. Do NOT try to calculate yourself.
The ‘Calculator’ tool does

  • simple calculation
  • find the minimum or maximum value in a list of numbers
  • calculate the average of a list of numbers

The ‘Date Calculator’ tool does

  • provide a list of date → weekday mappings for the requested time range
  • calculations like durations between timestamps, epoch time convertion, adding time segments to a date and other handy stuff. Read its description when you need to handle dates.
1 Like

Good idea I’ll do a ha native version

1 Like

I updated the long term statistics history tool.

HA provides the following per aggregation period (like each hour or day):

  • start date
  • end date
  • the aggregated values like min, max, mean, …

The end-date of an daily result was so far exactly the same date as the start date of the next day.

  • start-date: 2025-08-14 00:00:00
  • end-date: 2025-08-15 00:00:00

The end date string, which included already the date of the next day confused the LLMs often and they mapped the values to the wrong day.

So I changed the script to return all end dates shiftet back in time by 1 second.
Which then looks like this:

  • start-date: 2025-08-14 00:00:00
  • end-date: 2025-08-14 23:59:59

This fixed these problems for me.

1 Like

Did this resolve the issue for you? I have modified your script (great, by the way) so that I have four locations - “rooms”, “outside”, “storage” and “everywhere”. This works well, except for temperatures - “rooms”, “outside” and “storage” all have temperature sensors.

When I ask “Which is the coolest room in the house?” it includes storage areas and sometimes outside areas. Pretty hard to test too - sometimes it does respond with a room but that’s only because that room is the coolest spot.

You could take a look at the logs and select the script as entity to filter.
The tool logs the parameter used.
That way you will see if it simply ignores the parameter or always sets “everywhere”.

If it ignores it, I could add an error in this case (as it should be mandatory from the description), so it has a chance to “think” about it again and start a new try.

1 Like

Ok, I edited the Entity Index script above to return an error when the location parameter isn’t set.
Hopefully that helps in your case.

1 Like

Thanks for taking the time.

Without making any changes to the script, I ask “Which is the warmest room in the house?” and I get the answer “The warmest room in the house is the IT cupboard in the study, with a temperature of 27 degrees.”

The log shows…

LLM ENTITY INDEX: ('get entities by tag', 'room', 'temperature', Undefined, Undefined) triggered by state of Home Assistant changed to 18 August 2025 at 00:38

The question “Which is the warmest storage area in the house?” gets the same answer. The log shows…

LLM ENTITY INDEX: ('get entities by tag', 'storage', 'temperature', Undefined, Undefined) triggered by state of Home Assistant changed to 18 August 2025 at 00:45

So it does seem to be getting the location - just ignoring it and answering the question “What’s the highest temperature in the house?” I had the same issue when I was using

{{ labels() }}

Tomorrow I’ll try your edit.

Edit: If I ask for the lowest temperature, I get the fridge! The log says:

LLM ENTITY INDEX: ('get entities by tag', 'room', 'temperature', Undefined, Undefined) triggered by state of Home Assistant changed to 18 August 2025 at 01:08
1 Like

Couldn’t wait. :grin:

Same results with the new version of the script:

“Which is the warmest room in the house?”

“The warmest room in the house is the IT cupboard in the study, with a temperature of 27 degrees.”

LLM ENTITY INDEX: ('get entities by tag', 'room', 'temperature', Undefined, Undefined) triggered by state of Home Assistant changed to 18 August 2025 at 01:54

Ok, that’s irritating :stuck_out_tongue:

Just to be sure, let’s walk through what you most likely already did:
The script searches for entities that have all the tags provided (basically an AND instead an OR).

And the locations like room, storage, outside, everywhere also need to be tags.
What’s selected as location simply gets added to the tags list inside the script before starting the search.
The reason why the location is a seperate parameter now (instead that the LLM should add it to the tags itseld), is simply because otherwise the LLMs often ignored these tags and only search e.g. for temperature sensors.
Now they are forced to “think” about that part.

So, to adjust this for your location tags you have to:

  • Add these tags like room, storage, … to the matching entities
  • Adjust the script description where it talks about outside areas and maybe the examples
  • Adjust the possible values and the description of the location parameter in the scripts ‘field’ section

As a final step, to see if the LLM really receives the correct values or if something goes wrong and it has to rely only on the data provided by HA by default:

Developer Tools → Actions → Select the Entity Index script from the dropdown.

Call it e.g. with the parameter location set to storage and tag to TemperatureSensor.
You should then only get the correct entities returned.

Example response from my system with location outside for temperature sensors and the details parameter also turned on:

entities:
  sensor.multisensor_garten_temperature:
    friendly_name: Temperatur Garten
    state: "17.48"
    unit: °C
    tags:
      - Everywhere
      - Outside
      - Garten
      - TemperatureSensor
  sensor.multisensor_zufahrt_temperature:
    friendly_name: Temperatur Zufahrt
    state: "17.71"
    unit: °C
    tags:
      - Everywhere
      - Zufahrt
      - Outside
      - TemperatureSensor
count: 2
1 Like

Well… I’m afraid I’ve given up on this. :unamused:

Not on your excellent script, which is working well, but on the question “Which is the warmest/coolest room in the house?”

I’ve rebuilt the index several times, with and without the extra “storage” location and carried out all the checks, but the problem is still there, and I notice that if I replace the index script with…

{{ labels() }}

…it’s still there as well. My conclusion is that while gpt-4o-mini is capable of comparing temperatures successfully (using your calculator script), it finds comparing temperatures in a subset of all the temperatures available more difficult.

“What’s the hottest/coolest area in…” does appear to work better with outside temperatures, but I suspect that this is an illusion - they are usually significantly higher/lower than temperatures inside - and (in my case) there are only two sensors.

How often do you need to know the hottest area in the house anyway? :grin:

1 Like