LinqConnect School Lunches

Hi there - I was wondering if anyone has had any success tapping into the LinqConnect school lunch API to display menus, similar to the thread below:

https://community.home-assistant.io/t/rest-nutrislice-json-values

I realize this is pretty old, but in case anyone is looking for this later, I built a rest sensor yaml for this exact purpose. I wanted my kid to see the main menu options for the day. If it’s past 10am, it will show the menu options for the next day. It could obviously be extended to include sides as well, but I just needed something simple. Here is my rest.yaml file - to use, just create rest.yaml and include it in your configuration.yaml like

rest: !include rest.yaml

rest.yaml content - BE SURE TO REPLACE CONFIGURATION_VARIABLE VALUES FOR YOUR_BUILDING_ID, YOUR_DISTRICT_ID, AND YOUR_ALLERGEN_ID

- resource_template: >
    {#--- CONFIGURATION VARIABLES ---#}
    {% set building_id = 'YOUR_BUILDING_ID' %}
    {% set district_id = 'YOUR_DISTRICT_ID' %}
    {#--------------------------------#}
    {% set target_date = now() if now().hour < 10 else now() + timedelta(days=1) %}
    https://api.linqconnect.com/api/FamilyMenu?buildingId={{ building_id }}&districtId={{ district_id }}&startDate={{ target_date.strftime('%m-%d-%Y') }}&endDate={{ target_date.strftime('%m-%d-%Y') }}
  scan_interval: 10800 # 3 hours
  sensor:
    - name: "Lunch Date"
      value_template: >
        {% if value_json.FamilyMenuSessions | length > 0 and value_json.FamilyMenuSessions[0].MenuPlans | length > 0 %}
          {{ value_json.FamilyMenuSessions[0].MenuPlans[0].Days[0].Date }}
        {% else %}
          {% set date_to_display = now() if now().hour < 10 else now() + timedelta(days=1) %}
          {{ date_to_display.isoformat() }}
        {% endif %}
      device_class: timestamp

    - name: "Lunch Entree 1"
      value_template: >
        {% if value_json.FamilyMenuSessions | length > 0 and value_json.FamilyMenuSessions[0].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[0].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {{ recipes[0].RecipeName if recipes | length > 0 else "No lunch today" }}
        {% else %}
          No lunch today
        {% endif %}

    - name: "Lunch Entree 1 Has Allergen"
      value_template: >
        {#--- CONFIGURATION VARIABLES ---#}
        {% set allergen_id_to_check = 'YOUR_ALLERGEN_ID' %}
        {#--------------------------------#}
        {% if value_json.FamilyMenuSessions | length > 0 and value_json.FamilyMenuSessions[0].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[0].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {% if recipes | length > 0 and recipes[0].Allergens %}
            {{ allergen_id_to_check in recipes[0].Allergens }}
          {% else %}
            false
          {% endif %}
        {% else %}
          false
        {% endif %}

    - name: "Lunch Entree 2"
      value_template: >
        {% if value_json.FamilyMenuSessions | length > 0 and value_json.FamilyMenuSessions[0].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[0].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {{ recipes[1].RecipeName if recipes | length > 1 else "No second lunch" }}
        {% else %}
          No second lunch
        {% endif %}

    - name: "Lunch Entree 2 Has Allergen"
      value_template: >
        {#--- CONFIGURATION VARIABLES ---#}
        {% set allergen_id_to_check = 'YOUR_ALLERGEN_ID' %}
        {#--------------------------------#}
        {% if value_json.FamilyMenuSessions | length > 0 and value_json.FamilyMenuSessions[0].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[0].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {% if recipes | length > 1 and recipes[1].Allergens %}
            {{ allergen_id_to_check in recipes[1].Allergens }}
          {% else %}
            false
          {% endif %}
        {% else %}
          false
        {% endif %}

    - name: "Breakfast Entree 1"
      value_template: >
        {% if value_json.FamilyMenuSessions | length > 1 and value_json.FamilyMenuSessions[1].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[1].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {{ recipes[0].RecipeName if recipes | length > 0 else "No breakfast today" }}
        {% else %}
          No breakfast today
        {% endif %}

    - name: "Breakfast Entree 1 Has Allergen"
      value_template: >
        {#--- CONFIGURATION VARIABLES ---#}
        {% set allergen_id_to_check = 'YOUR_ALLERGEN_ID' %}
        {#--------------------------------#}
        {% if value_json.FamilyMenuSessions | length > 1 and value_json.FamilyMenuSessions[1].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[1].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {% if recipes | length > 0 and recipes[0].Allergens %}
            {{ allergen_id_to_check in recipes[0].Allergens }}
          {% else %}
            false
          {% endif %}
        {% else %}
          false
        {% endif %}

    - name: "Breakfast Entree 2"
      value_template: >
        {% if value_json.FamilyMenuSessions | length > 1 and value_json.FamilyMenuSessions[1].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[1].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {{ recipes[1].RecipeName if recipes | length > 1 else "No second breakfast" }}
        {% else %}
          No second breakfast
        {% endif %}

    - name: "Breakfast Entree 2 Has Allergen"
      value_template: >
        {#--- CONFIGURATION VARIABLES ---#}
        {% set allergen_id_to_check = 'YOUR_ALLERGEN_ID' %}
        {#--------------------------------#}
        {% if value_json.FamilyMenuSessions | length > 1 and value_json.FamilyMenuSessions[1].MenuPlans | length > 0 %}
          {% set recipes = value_json.FamilyMenuSessions[1].MenuPlans[0].Days[0].MenuMeals[0].RecipeCategories[0].Recipes | default([]) %}
          {% if recipes | length > 1 and recipes[1].Allergens %}
            {{ allergen_id_to_check in recipes[1].Allergens }}
          {% else %}
            false
          {% endif %}
        {% else %}
          false
        {% endif %}

I also created a set of cards on the dashboard to display these:

- type: grid
        cards:
          - type: markdown
            content: >-
              ## <ha-icon icon=""></ha-icon> School Lunch for {{
              as_timestamp(states('sensor.lunch_date')) | timestamp_custom('%A,
              %B %-d') }}
          - type: tile
            entity: sensor.lunch_entree_1
            name: 'Lunch Choice #1'
            icon: mdi:help-circle-outline
            state_content: state
            vertical: false
            features_position: bottom
            card_mod:
              style: |
                ha-tile-icon {
                  --card-mod-icon: {% if is_state('sensor.lunch_entree_1_has_allergen', 'True') %}
                    mdi:cow
                  {% elif is_state('sensor.lunch_entree_1', 'No lunch today')%}
                    mdi:food-off-outline
                  {% else %}
                    mdi:cow-off
                  {% endif %};
                }
            grid_options:
              columns: 12
              rows: 1
          - type: tile
            entity: sensor.lunch_entree_2
            name: 'Lunch Choice #2'
            icon: mdi:help-circle-outline
            state_content: state
            vertical: false
            features_position: bottom
            card_mod:
              style: |
                ha-tile-icon {
                  --card-mod-icon: {% if is_state('sensor.lunch_entree_2_has_allergen', 'True') %}
                    mdi:cow
                  {% elif is_state('sensor.lunch_entree_2', 'No second lunch')%}
                    mdi:food-off-outline
                  {% else %}
                    mdi:cow-off
                  {% endif %};
                }
            grid_options:
              columns: 12
              rows: 1

2 Likes

Can you describe how you identified the {building id} and {allergen id}? I can find the {district id} through the schools website but unsure how to identify the others. Any help would be much appreciated. Thanks!

If you go to your school’s page on LinqConnect LINQ Connect - Making Schools Stronger, open the network tab and search for https://api.linqconnect.com/api/FamilyMenu?, and copy the buildingId

For allergenId list, you can search the network tab for 'allergy and look at the response tab for a mapping of allergen to Id.

I love this community! I just searched for this and you did me a wonderful service. I’m much more a hardware person, I wouldn’t have ever figured out how to do this with Linq on HA, thank you!!

1 Like

I finally made the switch to pyscript for this and it has been so much better. Now I can get all the lunch menu items instead of just the first two. My kids are much happier seeing everything on our home dashboard :slight_smile: .

If you’re interested, here’s what I did:

  1. Create 3 text helpers, and add the appropriate values:
  • input_text.school_building_id
  • input_text.school_district_id
  • input_text.school_allergen_dairy (change this to be your specific allergen if desired)
  1. Install pyscript via HACS

  2. Add the following script in /homeassistant/pyscript/school_menu.py. This will create a script to update the menu info every 3 hours, and put it into the sensor sensor.school_weekly_menu:

# /config/pyscript/school_menu.py

import requests
from datetime import datetime, timedelta

# --- 1. Scheduling and Main Function ---

@time_trigger("startup")
@time_trigger("cron(0 0,3,6,9,12,15,18,21 * * *)")
async def update_school_menu():
    """Fetches menu data, processes it, and sets the Home Assistant sensor state."""
    
    # 1. Get Configuration IDs from Home Assistant Helpers
    building_id = state.get('input_text.school_building_id')
    district_id = state.get('input_text.school_district_id')
    dairy_id = state.get('input_text.school_allergen_dairy')

    if not building_id or not district_id:
        log.error(f"Menu update failed: Missing config IDs (Building: {building_id}, District: {district_id})")
        return

    # 2. Build URL and Fetch Data
    start_date = datetime.now().strftime('%m-%d-%Y')
    end_date = (datetime.now() + timedelta(days=6)).strftime('%m-%d-%Y')

    url = (
        f"https://api.linqconnect.com/api/FamilyMenu?buildingId={building_id}&districtId={district_id}"
        f"&startDate={start_date}&endDate={end_date}"
    )

    try:
        # Run the blocking 'requests.get' call in HA's executor thread pool
        response = await task.executor(requests.get, url)
        
        response.raise_for_status()
        data = response.json()
    except requests.exceptions.RequestException as e:
        log.error(f"Menu update failed: Error fetching data from LINQ Connect API: {e}")
        return
    
    # 3. Process Data
    lunch_menu_data = _process_menu_session(data, 'Lunch', dairy_id)
    breakfast_menu_data = _process_menu_session(data, 'Breakfast', dairy_id)

    # 4. Set Home Assistant State (This creates or updates the sensor)
    state.set(
        'sensor.school_weekly_menu',
        value=len(lunch_menu_data), # State is the number of days with lunch
        new_attributes={
            'friendly_name': 'School Weekly Menu',
            'icon': 'mdi:silverware-fork-knife',
            'lunch_menu': lunch_menu_data,
            'breakfast_menu': breakfast_menu_data
        }
    )
    log.info("School Menu sensor successfully updated.")

def _process_menu_session(data, session_name, dairy_id):
    """Internal function to process menu data for a specific session."""
    final_menu_list = []
    
    # --- FIX: Replaced the generator expression with a standard 'for' loop ---
    session_data = None
    for s in data.get('FamilyMenuSessions', []):
        if s.get('ServingSession') == session_name:
            session_data = s
            break
    # --- End of Fix ---

    if session_data and session_data.get('MenuPlans'):
        for day in session_data['MenuPlans'][0].get('Days', []):
            try:
                # Use strptime to handle date format
                date_str = datetime.strptime(day.get('Date'), '%m/%d/%Y').strftime('%Y-%m-%d')
            except ValueError:
                continue # Skip day if date format is unexpected

            current_day_entrees = []
            
            menu_meals = day.get('MenuMeals', [])
            if menu_meals:
                recipe_categories = menu_meals[0].get('RecipeCategories', [])
                
                for category in recipe_categories:
                    if category.get('CategoryName', '').strip() == "Main Entree":
                        recipes_list = category.get('Recipes', [])
                        
                        for recipe in recipes_list:
                            has_dairy = dairy_id in recipe.get('Allergens', [])
                            
                            current_day_entrees.append({
                                "name": recipe.get('RecipeName'),
                                "has_dairy": has_dairy
                            })
                        break # Found the Main Entree, stop checking categories

            final_menu_list.append({
                "date": date_str,
                "entrees": current_day_entrees
            })
            
    return final_menu_list
  1. Restart Home Assistant and go to Developer Tools → Actions → Run pyscript.reload

  2. Add a card to use the school menu sensor:

type: vertical-stack
cards:
  - type: vertical-stack
    title: School Lunch
    cards:
      - type: horizontal-stack
        cards:
          - type: markdown
            content: >
              {% set day = now() %}

              {% set day_str = day.strftime('%Y-%m-%d') %}

              {% set menu_list = state_attr('sensor.school_weekly_menu',
              'lunch_menu') or [] %}

              {% set day_list = (menu_list | selectattr('date', '==', day_str) |
              list) %}

              {% set today_menu = day_list[0] if day_list | length > 0 else {}
              %}

              {% set entrees = today_menu.get('entrees', []) %}


              **{{ day.strftime('%A, %B %-d') }}**


              {% if entrees | length > 0 %}
                {% for entree in entrees %}
                 {% if entree.has_dairy %}<ha-icon icon="mdi:cow" title="Contains Dairy" style="color: #03A9F4; vertical-align: middle;"></ha-icon>{% else %}<ha-icon icon="mdi:cow-off" title="No Dairy" style="color: grey; vertical-align: middle;"></ha-icon>{% endif %}  {{ entree.name }}
                {% endfor %}
              {% else %}
                *No Main Entrees listed.*
              {% endif %}
          - type: markdown
            content: >
              {% set day = now() + timedelta(days=1) %}

              {% set day_str = day.strftime('%Y-%m-%d') %}

              {% set menu_list = state_attr('sensor.school_weekly_menu',
              'lunch_menu') or [] %}

              {% set day_list = (menu_list | selectattr('date', '==', day_str) |
              list) %}

              {% set today_menu = day_list[0] if day_list | length > 0 else {}
              %}

              {% set entrees = today_menu.get('entrees', []) %}

              **{{ day.strftime('%A, %B %-d') }}** {% if entrees | length > 0 %}
                {% for entree in entrees %}
                 {% if entree.has_dairy %}<ha-icon icon="mdi:cow" title="Contains Dairy" style="color: #03A9F4; vertical-align: middle;"></ha-icon>{% else %}<ha-icon icon="mdi:cow-off" title="No Dairy" style="color: grey; vertical-align: middle;"></ha-icon>{% endif %}  {{ entree.name }}
                {% endfor %}
              {% else %}
                *No Main Entrees listed.*
              {% endif %}
  - type: vertical-stack
    title: Internet
    cards:
      - type: horizontal-stack
        cards:
          - type: tile
            entity: automation.basement_apple_tv_turn_off_lights
            icon: mdi:robot-happy
            vertical: false
            tap_action:
              action: toggle
            features_position: bottom
            grid_options:
              columns: 12
              rows: 1
          - type: entities
            card_mod:
              style: |
                ha-card {
                  background: var(--card-background-color);
                }
            entities:
              - entity: media_player.master_bedroom
      - type: horizontal-stack
        cards:
          - type: entities
            card_mod:
              style: |
                ha-card {
                  background: var(--card-background-color);
                }
            entities:
              - entity: media_player.main_floor_apple_tv
          - type: entities
            card_mod:
              style: |
                ha-card {
                  background: var(--card-background-color);
                }
            entities:
              - entity: media_player.basement_apple_tv
      - type: todo-list
        entity: todo.google_keep_shopping_list
        title: Shopping List
        vertical: true
        hide_completed: true
        features_position: bottom
    card_mod:
      style: |
        background: white; 
        padding: var(--card-background-color)stack-padding, 0px);
view_layout:
  grid-area: main2

For my middle schoolers, there were potentially multiple MenuMeals, so i’ve updated the above script slightly to accommodate :slight_smile:

# /config/pyscript/school_menu.py

import requests
from datetime import datetime, timedelta

# --- 1. Scheduling and Main Function ---

@time_trigger("startup")
@time_trigger("cron(0 0,3,6,9,12,15,18,21 * * *)")
async def update_school_menu():
    """Fetches menu data, processes it, and sets the Home Assistant sensor state."""
    
    # 1. Get Configuration IDs from Home Assistant Helpers
    building_id = state.get('input_text.middle_school_building_id')
    district_id = state.get('input_text.school_district_id')
    dairy_id = state.get('input_text.school_allergen_dairy')

    if not building_id or not district_id:
        log.error(f"Menu update failed: Missing config IDs (Building: {building_id}, District: {district_id})")
        return

    # 2. Build URL and Fetch Data
    start_date = datetime.now().strftime('%m-%d-%Y')
    end_date = (datetime.now() + timedelta(days=6)).strftime('%m-%d-%Y')

    url = (
        f"https://api.linqconnect.com/api/FamilyMenu?buildingId={building_id}&districtId={district_id}"
        f"&startDate={start_date}&endDate={end_date}"
    )

    try:
        # Run the blocking 'requests.get' call in HA's executor thread pool
        response = await task.executor(requests.get, url)
        
        response.raise_for_status()
        data = response.json()
    except requests.exceptions.RequestException as e:
        log.error(f"Menu update failed: Error fetching data from LINQ Connect API: {e}")
        return
    
    # 3. Process Data
    lunch_menu_data = _process_menu_session(data, 'Lunch', dairy_id)
    breakfast_menu_data = _process_menu_session(data, 'Breakfast', dairy_id)

    # 4. Set Home Assistant State (This creates or updates the sensor)
    state.set(
        'sensor.middle_school_weekly_menu',
        value=len(lunch_menu_data), # State is the number of days with lunch
        new_attributes={
            'friendly_name': 'Middle School Weekly Menu',
            'icon': 'mdi:silverware-fork-knife',
            'lunch_menu': lunch_menu_data,
            'breakfast_menu': breakfast_menu_data
        }
    )
    log.info("School Menu sensor successfully updated.")

def _process_menu_session(data, session_name, dairy_id):
    """Internal function to process menu data for a specific session."""
    final_menu_list = []
    
    session_data = None
    for s in data.get('FamilyMenuSessions', []):
        if s.get('ServingSession') == session_name:
            session_data = s
            break

    if session_data and session_data.get('MenuPlans'):
        # Still looking at the first MenuPlan; adjust if multiple plans exist
        for day in session_data['MenuPlans'][0].get('Days', []):
            try:
                date_str = datetime.strptime(day.get('Date'), '%m/%d/%Y').strftime('%Y-%m-%d')
            except (ValueError, TypeError):
                continue

            current_day_entrees = []
            
            # Loop through ALL meals in the day
            for meal in day.get('MenuMeals', []):
                recipe_categories = meal.get('RecipeCategories', [])
                
                for category in recipe_categories:
                    if category.get('CategoryName', '').strip() == "Main Entree":
                        recipes_list = category.get('Recipes', [])
                        
                        for recipe in recipes_list:
                            has_dairy = dairy_id in recipe.get('Allergens', [])
                            
                            current_day_entrees.append({
                                "name": recipe.get('RecipeName'),
                                "has_dairy": has_dairy
                            })
                        # We found the Main Entree category for THIS meal, 
                        # so we move to the next meal.
                        break 

            final_menu_list.append({
                "date": date_str,
                "entrees": current_day_entrees
            })
            
    return final_menu_list