Dynamic Inventory query and results

As part of multiple hobbies, I have a large amount of inventory that needed organization. I decided to use the knowledge from working in excel during office hours, to perform a query for my listed inventory.

Over the years, the collection of Games Workshop miniatures and boxed games has grown past the point of quick list management. I made a spreadsheet with listed barcodes, scraped the web for inventory translation to provide the names, gathered url’s for pictures, and added some manual tags for each “bin” where the miniature is stored.

I then took this sheet, uploaded it to local storage for HASS, and created an appdaemon that would allow free-text query. Based off the query, it would feed results with the desired attributes and pictures, where available.

Here’s a view of the final product, with a list below it for brief steps. Additional details below!

  1. Create spreadsheet with required fields
  2. Upload spreadsheet to HASS
  3. Install and configure AppDaemon
  4. Create application to load spreadsheet into a database
  5. Create application to allow free-text query of database
  6. Create input_text to feed free-text query, and resulting items
  7. Create input_select to populate resulting items where multiples exist
  8. Create python scripts and scripts to allow for automation based of manual input
  9. Create the automation, referencing all aspects
  10. Create dashboard to render all options in a friendly format

Now for the fun bit. The spreadsheet itself is a tangential project in and of itself. If you have a desire to scrape the web, you’ll need to find a way to connect it with a query to load the details you’re after. In my example, I have a barcode list, and an api query to a barcode lookup. If you’re interested in the details, I can provide them.

Short net: Barcodes in column A, scraped details in remaining columns.

You’ll need to install AppDaemon add-on at this point, and configure it according to the instructions. You will generate a long-lived token and add it to the config file for appdaemon.yaml:

Summary
---
appdaemon:
  latitude: 52.3
  longitude: 4.89
  elevation: 2
  time_zone: !env_var TZ
  plugins:
    HASS:
      type: hass
      ha_url: http://homeassistant.local:8123
      token: [u]***YOUR TOKEN HERE***[/u]

admin:
api:
hadashboard:
  dash_url: http://homeassistant.local:5050
  dash_dir: /addon_configs/appdaemon/dashboards

Once the spreadsheet is ready, I uploaded it to the www/ folder within Appdaemon. This can be found under the root folder structure, * /addon_configs/a0d7b954_appdaemon/www/ in my case.

The next steps are specific prep to allow for the inventory sheet to be ingested. We need specific versions of packages to be loaded with AppDaemon. I added pandas, numpy, and openpyxl.

{002105D1-BA1B-4319-88EC-2DB0C184A348}

Next up: creating the inventory app.

Here’s the examlpe of my code. It reads the excel path, reads the absolute path, publishes that within the logs for appdaemon, and loads it into inventory.

Summary

`import appdaemon.plugins.hass.hassapi as hass
import pandas as pd
import os

class InventoryApp(hass.Hass):

def initialize(self):
    self.log("Starting Inventory App")

    # Path to your Excel file
    excel_file_path = '/config/www/inventory.xlsx'
    self.log(f"Expected path: {excel_file_path}")
    self.log(f"Absolute path: {os.path.abspath(excel_file_path)}")
    
    # List directory contents
    directory = '/config/www'
    self.log(f"Contents of {directory}: {os.listdir(directory)}")

    # Verify the file path exists
    if os.path.exists(excel_file_path):
        self.log("File exists. Attempting to load...")
        try:
            self.df = pd.read_excel(excel_file_path)
            self.log(f"Excel file loaded successfully with {len(self.df)} rows")
        except Exception as e:
            self.log(f"Error loading Excel file: {e}")
    else:
        self.log(f"File not found at path: {excel_file_path}")

def query_inventory(self, query):
    self.log(f"Querying inventory for: {query}")
    # Perform a basic query to search the Title column
    results = self.df[self.df['Title'].str.contains(query, case=False, na=False)]
    for result in results.to_dict(orient='records'):
        self.log(result)
    return results.to_dict(orient='records')`

{B0F76C8C-66B7-4687-AB13-13E5A0A7D192}

Cool! Now, we need a way to query it.

I created an input_text.query, input_text.selected_result, an input_select.query_result, template sensor.query_result, sensor.selected_result, and 2 scripts to reference in automations.

input_select

Summary
query_result:
    name: Query Result
    options: 
        - "Loading..."
    initial: "Loading..."

input_text

Summary
query:
  name: Query
  initial: ""
selected_result:
  name: Selected Result
  initial: ""

templates

Summary

`- sensor:
######################################

Section for Query Excel

######################################

  • name: “Query Result”
    unique_id: query_result
    state: “{{ states(‘sensor.query_result’) }}”
    attributes:
    Results: >-
    {% if state_attr(‘sensor.query_result’, ‘Results’) %}
    {{ state_attr(‘sensor.query_result’, ‘Results’) }}
    {% else %}
    ‘No data’
    {% endif %}
  • name: “Query Selected Result”
    unique_id: selected_result
    state: “{{ states(‘sensor.selected_result’) }}”
    attributes:
    EAN: “{{ state_attr(‘sensor.selected_result’, ‘EAN’) }}”
    BIN: “{{ state_attr(‘sensor.selected_result’, ‘BIN’) }}”
    Classification: “{{ state_attr(‘sensor.selected_result’, ‘Classification’) }}”
    Title: “{{ state_attr(‘sensor.selected_result’, ‘Title’) }}”
    Keywords: “{{ state_attr(‘sensor.selected_result’, ‘Keywords’) }}”
    Images1: “{{ state_attr(‘sensor.selected_result’, ‘Images1’) }}”
    Images2: “{{ state_attr(‘sensor.selected_result’, ‘Images2’) }}”
    Images3: “{{ state_attr(‘sensor.selected_result’, ‘Images3’) }}”
    Images4: “{{ state_attr(‘sensor.selected_result’, ‘Images4’) }}”
    Images5: “{{ state_attr(‘sensor.selected_result’, ‘Images5’) }}”
    Images6: “{{ state_attr(‘sensor.selected_result’, ‘Images6’) }}”
    Images7: “{{ state_attr(‘sensor.selected_result’, ‘Images7’) }}”
    Images8: “{{ state_attr(‘sensor.selected_result’, ‘Images8’) }}”
    Images9: “{{ state_attr(‘sensor.selected_result’, ‘Images9’) }}”
  • name: “Images1 Check”
    state: “{{states.sensor.selected_result.attributes.Images1}}”
  • name: “Images2 Check”
    state: “{{states.sensor.selected_result.attributes.Images2}}”
  • name: “Images3 Check”
    state: “{{states.sensor.selected_result.attributes.Images3}}”
  • name: “Images4 Check”
    state: “{{states.sensor.selected_result.attributes.Images4}}”
  • name: “Images5 Check”
    state: “{{states.sensor.selected_result.attributes.Images5}}”
  • name: “Images6 Check”
    state: “{{states.sensor.selected_result.attributes.Images6}}”
  • name: “Images7 Check”
    state: “{{states.sensor.selected_result.attributes.Images7}}”
  • name: “Images8 Check”
    state: “{{states.sensor.selected_result.attributes.Images8}}”
  • name: “Images9 Check”
    state: “{{states.sensor.selected_result.attributes.Images9}}”`

scripts

Summary

`query_inventory:
alias: Query Inventory
sequence:

  • action: python_script.query_inventory
    data_template:
    query: ‘{{ states(’‘input_text.query’‘) }}’`

python_scripts
query_inventory.py:

Summary

query = data.get('query') result = hass.services.call('inventory_app', 'query_inventory', {'query': query}) hass.states.set('sensor.query_result', result, {'friendly_name': 'Query Result'})

remove_duplicates.py:

Summary

`# remove_duplicates.py
options = data.get(‘options’, )
unique_options =
for option in options:
if option not in unique_options:
unique_options.append(option)

if not unique_options: # If the list is empty, set to default option
unique_options = [“Loading…”]

service_data = {
‘entity_id’: data.get(‘entity_id’, ‘input_select.query_result’), # Default to input_select.query_result if not provided
‘options’: unique_options
}

logger.info(f"Setting options for {service_data[‘entity_id’]}: {unique_options}")

hass.services.call(‘input_select’, ‘set_options’, service_data)
`

Now that all of that noise is completed, we get to the actual query app.

here’s the examlpe of what the query does. In my case, I query based off of keyword, referencing the Title and Keyword column. Based off what it finds, I make it throw everything into resulting sensors above.

Summary

`import appdaemon.plugins.hass.hassapi as hass
import pandas as pd
import json

class ExcelQueryApp(hass.Hass):

def initialize(self): 
    self.log("ExcelQueryApp initialized") 
    self.listen_event(self.query_excel, "QUERY_EXCEL") 
    self.listen_state(self.update_sensor, "input_select.query_result") 
    
def query_excel(self, event_name, data, kwargs):
    keyword = data.get("keyword", "").lower()
    self.log(f"Received keyword: {keyword}")

    try:
        df = pd.read_excel('/config/www/inventory.xlsx')
        self.log(f"DataFrame columns: {df.columns}")

        # Filter the DataFrame based on the keyword in specific columns
        result = df[df[['Title', 'Keywords']].apply(lambda row: row.astype(str).str.contains(keyword, case=False).any(), axis=1)]
        self.log(f"Search result for '{keyword}': {result}")

        if not result.empty:
            result = result[['EAN', 'BIN', 'Classification', 'Title', 'Keywords', 'Images1', 'Images2', 'Images3', 'Images4', 'Images5', 'Images6', 'Images7', 'Images8', 'Images9']].where(pd.notnull(result), None)
            
            # Prepare image attributes
            attributes = {"titles": result['Title'].tolist()}
            for i in range(1, 10):
                col_name = f"Images{i}"
                if col_name in result.columns:
                    attributes[col_name.lower()] = result[col_name].dropna().tolist()

            result_dict = result.to_dict(orient='records')
            count = len(result_dict)
            attributes.update({"Results": result_dict, "Count": count})
        else:
            result_dict = []
            count = 0
            attributes = {"Results": result_dict, "Count": count}

        self.log(f"Setting state for sensor.query_result with state: {count} and attributes: {attributes}")

        # Set the state and attributes of the sensor
        self.set_state("sensor.query_result", state=count, attributes=attributes)
    except Exception as e:
        self.log(f"Error: {e}")

def update_sensor(self, entity, attribute, old, new, kwargs): 
    query_result = self.get_state("sensor.query_result", attribute="Results") 
    selected_result = next((result for result in query_result if result["Title"] == new), {}) 
    self.set_state("sensor.selected_result", state=new, attributes=selected_result)

`

it’s a lot, I know. Once I got it all working, I plugged this into 2 automations.

the first will take the results, remove any duplicates (we have 2 or more of the same box), and only list the unique entries in the results.

automations:

Summary

`alias: Query Excel on Input Change
description: Query Fire
triggers:

  • trigger: state
    entity_id:
    • input_text.query
      actions:
  • event: QUERY_EXCEL
    event_data:
    keyword: “{{ states(‘input_text.query’) }}”
    mode: single

alias: Update Query Result Options
description: query updater
triggers:

  • trigger: state
    entity_id:
    • sensor.query_result
      conditions:
      actions:
  • action: python_script.remove_duplicates
    metadata: {}
    data: {}
  • action: input_select.set_options
    metadata: {}
    data:
    options: >
    {% set results = state_attr(‘sensor.query_result’, ‘Results’) %} {% if
    results %}
    {{ results | map(attribute=‘Title’) | list | unique | list }}
    {% else %}
    [“No results”]
    {% endif %}
    target:
    entity_id: input_select.query_result
    mode: single

`

Beauty. That gets us to the final product!

Dashboard was set with entities card, to allow input text for the query sensor. Then, another entities card, showing the dropdown of input_select for what we select as the results from query!

Based off the current select in the dropdown, we then get the details of each picture, attribute and location from a third entities card, and markdown/swipe card combo that allows swipe thru each image:

Summary

`type: vertical-stack
cards:

  • type: entities
    entities:
    • entity: input_text.query
      title: “Enter a keyword to begin:”
      show_header_toggle: false
      state_color: false
  • type: entities
    entities:
    • entity: input_select.query_result
  • type: conditional
    conditions:
    • condition: state
      entity: input_select.query_result
      state_not: Loading…
    • condition: or
      conditions:
      • condition: state
        entity: input_select.query_result
        state_not: unknown
      • condition: or
        conditions:
        • condition: state
          entity: input_select.query_result
          state_not: unavailable
          card:
          type: vertical-stack
          cards:
      • type: entities
        entities:
        • entity: sensor.query_selected_result
          name: Title
          type: attribute
          attribute: Title
          icon: mdi:text
        • entity: sensor.query_selected_result
          name: BIN
          type: attribute
          attribute: BIN
          icon: mdi:bucket-outline
        • entity: sensor.query_selected_result
          name: Classification
          type: attribute
          icon: mdi:magnify-expand
          attribute: Classification
        • entity: sensor.query_selected_result
          name: Keywords
          type: attribute
          attribute: Keywords
          icon: mdi:key-plus
          state_color: false
      • type: custom:swipe-card
        parameters:
        scrollbar:
        hide: false
        draggable: true
        pagination:
        type: bullets
        clickable: true
        loop: true
        cards:
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images1_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images1
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images2_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images2
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images3_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images3
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images4_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images4
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images5_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images5
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images6_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images6
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images7_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images7
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images8_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images8
            }})
        • type: conditional
          conditions:
          • condition: state
            entity: sensor.images9_check
            state_not: unknown
            card:
            type: markdown
            content: >-
            ![Image]({{ states.sensor.selected_result.attributes.Images9
            }})
            title: “Selection Details:”
            `

et voila! the final product:

2 Likes