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
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
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!!
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
.
If you’re interested, here’s what I did:
Install pyscript via HACS
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
Restart Home Assistant and go to Developer Tools → Actions → Run pyscript.reload
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 ![]()
# /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