Skip to main content

Nutrislice Menu Plugin

Overview

This is my journal of creating a new MagicMirror^2 plugin that retrieves the daily breakfast and lunch menu from their school and displays it on our home information system like this:

MagicMirror is a popular smart mirror platform that displays various information such as time, date, weather, and custom modules. This screenshot effectively showcases the integration of different modules in a MagicMirror setup, including a custom module for displaying school menus alongside standard time and weather modules.

What's Nutrislice?

Nutrislice is a digital platform designed to help schools, healthcare facilities, and other organizations manage and distribute their menus and nutrition information. The version used by my school district allows students, parents, and staff to easily view what is being served in cafeterias.

Parents and students are instructed to go to https://soudertonsd.nutrislice.com/menu/salford-hills-elementary-school where a web front end queries an API endpoint behind the scenes and displays menu information.

What's Magic Mirror?

MagicMirror² (or MagicMirror2) is an open-source modular smart mirror platform that allows you to transform a regular mirror into an interactive information hub. The software runs on a Raspberry Pi or similar devices and displays customizable modules on the mirror, such as the time, date, weather forecast, calendar events, news feeds, and more.

Understanding the URL structure

The URL that the public facing site uses to populate the web page for school students is off the api.nutrislice.com domain and for my kid's school is:

https://soudertonsd.api.nutrislice.com/menu/api/weeks/school/salford-hills-elementary-school/menu-type/breakfast/2024/08/27/

So as far as I can tell the structure will be:

https://{district}.api.nutrislice.com/menu/api/weeks/school/{school-name}/menu-type/{menu-type}/{year}/{month}/{day}/

I want it to be flexible for other people so I'll design around this modular URL structure when abstracting out configuration options for other users.

Has anyone else done this before?

Yes! One user of the Reddit dakboard subreddit said he was "devastated" when his school moved off the platform, and it gets mentioned from time to time in the Home Assistant community, but there are no existing plugins for MagicMirror that match my criteria.

Long after I finished this project in October 2024, I discovered kblankenship1989/MMM-nutrislice-menu which seems to do a lot of the things I wanted and has commits going back to 2020.

Pull a copy of the file down for testing

We don't want to thrash the API more than we need to during testing so initially I'll substitute a text file instead:

placid:~ rob$ curl https://soudertonsd.api.nutrislice.com/menu/api/weeks/school/salford-hills-elementary-school/menu-type/breakfast/2024/08/27/ > menu.json

Analyze the structure with ChatGPT

I uploaded this menu.json to ChatGPT and asked "Determine the structure of this JSON and highlight the most important parts," to which it responded:

The JSON structure in the file appears to be organized as follows:

  1. Overall Structure:

    • The root object contains a start_date, menu_type_id, and an array days.
    • Each object in the days array represents a day and includes details like date, has_unpublished_menus, menu_info, and menu_items.
  2. Day Object:

    • date: The date for which the menu is applicable.
    • has_unpublished_menus: A boolean indicating if there are unpublished menus for the day.
    • menu_info: Contains metadata about the menu, such as section options.
    • menu_items: An array of menu items for that day.
  3. Menu Items:

    • Each menu item contains detailed information about a food item or a section title.
    • Important Fields:
      • id: Unique identifier for the menu item.
      • text: The title or description of the menu item.
      • food: Contains detailed information about the food, including name, description, image_url, nutrition_info, and icons.
      • position: The order in which the item appears in the menu.
      • station_id: Identifies the station where the food is served.
      • serving_size_info: Information about the serving size.
  4. Food Information:

    • name: The name of the food item.
    • description: A brief description of the food.
    • image_url: URL of the image representing the food.
    • nutrition_info: Nutritional information like calories, fat, carbohydrates, etc.
    • icons: Icons representing food attributes (e.g., contains milk, gluten-free).
  5. Station Information:

    • The file also includes details about stations (station_id) and whether a station's menu section can expand or collapse (section_title_can_expand_collapse).

Key Takeaways

  • Menu Structure: The JSON is designed to represent daily menus with detailed information about each food item and the associated stations.
  • Nutritional Information: Each food item includes detailed nutritional information, which is critical for menu planning.
  • Expandable Sections: The section_title_can_expand_collapse field in menu_info suggests that menu sections may be designed to be expandable or collapsible in a UI.

ChatGPT writes some test code

Having ChatGPT verify my assumptions required three queries in a row:

  • "Extract breakfast and lunch menus for each day and display them in a bulleted list"
  • "Combine each meal into a single bullet under the date"
  • "Show me the code for this"
# Function to extract breakfast and lunch menus and combine them
def extract_combined_menus(menu_data):
combined_menus = []
for day in menu_data.get("days", []):
date = day.get("date")
menu_items = day.get("menu_items", [])
breakfast_items = []
lunch_items = []
current_menu = None

for item in menu_items:
section_title = item.get("is_section_title")
text = item.get("text")
if section_title and text == "Breakfast":
current_menu = breakfast_items
elif section_title and text == "Lunch":
current_menu = lunch_items
elif not section_title and current_menu is not None:
food = item.get("food")
if food:
food_name = food.get("name")
if food_name:
current_menu.append(food_name)

combined_menu = f"**Breakfast**: {', '.join(breakfast_items) if breakfast_items else 'None'}, " \
f"**Lunch**: {', '.join(lunch_items) if lunch_items else 'None'}"

combined_menus.append(f"- **{date}**: {combined_menu}")

return combined_menus

# Extract the combined menus
combined_menus = extract_combined_menus(menu_data)

# Display the combined menus
for menu in combined_menus:
print(menu)

Gave the following abbreviated output:

  • 2024-08-27
    • Breakfast:
      • Mini Maple Waffles
      • Lucky Charms
      • WG Banana Muffin
      • Honey Graham Crackers
      • Low Fat Mozzarella String Cheese
      • Assorted Chilled Fruit
      • Fresh Whole Fruit
      • Craisins
      • Assorted Fruit Juice
      • 1% Low-fat Milk
      • Chocolate Fat Free Milk
      • Lactose-Free Skim Milk
    • Lunch: None

That was a little long so I asked it to combine the bullets into a single bullet for each meal.

# Function to extract breakfast and lunch menus and combine them
def extract_combined_menus(menu_data):
combined_menus = []
for day in menu_data.get("days", []):
date = day.get("date")
menu_items = day.get("menu_items", [])
breakfast_items = []
lunch_items = []
current_menu = None

for item in menu_items:
section_title = item.get("is_section_title")
text = item.get("text")
if section_title and text == "Breakfast":
current_menu = breakfast_items
elif section_title and text == "Lunch":
current_menu = lunch_items
elif not section_title and current_menu is not None:
food = item.get("food")
if food:
food_name = food.get("name")
if food_name:
current_menu.append(food_name)

combined_menu = f"**Breakfast**: {', '.join(breakfast_items) if breakfast_items else 'None'}, " \
f"**Lunch**: {', '.join(lunch_items) if lunch_items else 'None'}"

combined_menus.append(f"- **{date}**: {combined_menu}")

return combined_menus

# Extract the combined menus
combined_menus = extract_combined_menus(menu_data)

# Display the combined menus
for menu in combined_menus:
print(menu)

Interestingly, it output the following for the first week of school:

  • 2024-08-27:

    • Breakfast: Mini Maple Waffles, Lucky Charms, WG Banana Muffin, Honey Graham Crackers, Low Fat Mozzarella String Cheese, Assorted Chilled Fruit, Fresh Whole Fruit, Craisins, Assorted Fruit Juice, 1% Low-fat Milk, Chocolate Fat Free Milk, Lactose-Free Skim Milk
    • Lunch: None
  • 2024-08-28:

    • Breakfast: Strawberry Cream Cheese Stuffed Bagel, Cocoa Puffs, Whole Grain Blueberry Muffin, Honey Graham Crackers, Low Fat Mozzarella String Cheese, Assorted Chilled Fruit, Fresh Whole Fruit, Craisins, Assorted Fruit Juice, 1% Low-fat Milk, Chocolate Fat Free Milk, Lactose-Free Skim Milk
    • Lunch: None
  • 2024-08-29:

    • Breakfast: Mini Cinnis, Cheerios, WG Chocolate Chocolate Chip Muffin, Honey Graham Crackers, Low Fat Mozzarella String Cheese, Assorted Chilled Fruit, Fresh Whole Fruit, Craisins, Assorted Fruit Juice, 1% Low-fat Milk, Chocolate Fat Free Milk, Lactose-Free Skim Milk
    • Lunch: None

Now, I know that there must be lunch, so there must be some issue with how ChatGPT started interpreting the file. And then I realized, each menu had its own URL for breakfast and lunch, so there wouldn't be any lunch information in what I was provided.

Since I only pulled the one, I need either pull both breakfast and lunch or figure out some way to combine the two into a single JSON output.

Investigate the REST API further

Up to this point I've been treating the Nutrislice API as hostile, in that I can see it as a public user because its part of their platform. But with this beautiful Django page exposed they way it is, is it possible that they condone or even encourage the use of their API with documentation?

The image shows a web page from the Django REST framework, specifically displaying an API endpoint that returns data for a "Menu Week" from a school menu system. The API endpoint is used to retrieve serialized data about the weekly menu for a particular school and menu type. The API is part of a school menu management system, allowing retrieval of menus by date, school, and meal type (e.g., breakfast or lunch). The page is styled using the Django REST framework's default interface, which is used to interact with and test API endpoints.

In some respects this should have been one of my first questions. Turns out there wasn't any obvious documentation available to the public. Digging in a little and searching for "Nutrislice API" didn't return anything, but playing around with the obvious combinations of the URL endpoint yielded some insight:

  • lunch returned the lunch menu for the week
  • all resulted in a Django error { "detail": "No menu type found to match the 'all' slug." }
  • Removing the particular combination resulted in an HTTP 404 due to no pattern match

Knowing that the API is slug-based and having a little knowledge of how Django arranges things within the data access layer and the URL structure is really helpful here. Plugging in and removing items from the URL is unlikely to help and keeping to the strict structure with different known items is probably the best way to proceed.

So without wasting too much time on this we can get a whole week worth of menus with two calls, combine them into a single payload that can be parsed out into two messages on the MagicMirror screen.

I had ChatGPT mock out one last file that used requests to grab the breakfast_url and lunch_url and ran the code from my local machine to prove it could be done.

Starting a MagicMirror module

So at this point I have a plan for the data retrieval and parsing and need to refresh myself on the structure (and language) of a Magic Mirror module.

The Module Creators encourages developers to add the following items to their README, so we'll come back to that later:

  • A high quality screenshot of your working module.
  • A short, one sentence, clear description what it does.
  • What external APIs it depend on, including web links to those
  • Whether the API/request require a key and the user limitations of those. (Is it free?)
  • What is the current development status (WIP, searching for new maintainer or abandoned).

Started a new repo and added that README to complement this documentation as I go along.

Just so I didn't lose it, I copied the test Python code as prototype.py into the repository ran it to make sure everything was working in theory:

python prototype.py 
- **2024-08-25**: **Breakfast**: None, **Lunch**: None
- **2024-08-26**: **Breakfast**: None, **Lunch**: None
- **2024-08-27**: **Breakfast**: Mini Maple Waffles, Lucky Charms, WG Banana Muffin, Honey Graham Crackers, Low Fat Mozzarella String Cheese, Assorted Chilled Fruit, Fresh Whole Fruit, Craisins, Assorted Fruit Juice, 1% Low-fat Milk, Chocolate Fat Free Milk, Lactose-Free Skim Milk, **Lunch**: Chicken Nuggets, Whole Grain Dinner Roll, Corn, Cheese Stuffed Breadsticks, Sunbutter & Grape Jelly Sandwich, Fresh Carrots, Fresh Celery Sticks, Fresh Whole Fruit, Assorted Chilled Fruit, Assorted Fruit Juice, Craisins, 1% Low-fat Milk, Chocolate Fat Free Milk, Lactose-Free Skim Milk, Ketchup Packet, Mustard Packet, Mayonnaise Light, 0.437 oz, Fat Free Ranch, 0.4375 oz
[...]

Final Module Code Breakdown

The module code is divided into two essential JS files, a configuration block described in the README and one non-essential but pretty important CSS.

Configuration block

Since people who use this module could be from a variety of Nutrislice-supported schools and position the application in many places on their screen, the block is described as an example so that they can put their own information in the blocks and use it accordingly.

{
module: "MMM-Nutrislice",
position: "top_left",
config: {
district: "soudertonsd", // Your district here
schoolName: "salford-hills-elementary-school", // Your school name here
updateInterval: 3600000 // 1 hour
}
}

Module File

Inside the file MMM-Nutrislice.js named for the module, everything is wrapped in a Module.register block like so:

Module.register("MMM-Nutrislice", {
});

Within that we have a defaults block and a series of lifecycle methods:

  • start: Initializes the module, setting up data fetching and periodic updates.
  • getStyles: Defines the CSS files used by the module.
  • getMenuData: Fetches menu data using dynamically constructed URLs.
  • buildUrl: Helper to build API URLs.
  • socketNotificationReceived: Processes data received from the node helper.
  • getDom: Generates and returns the HTML content for the module, including the heading and the menu for today.

The defaults profile a fall-through if configuration options are not specified, and follow the same naming convention and style as the config.js block, with the exception of module and location information.

MMM-Nutrislice/MMM-Nutrislice.js at master · vees/MMM-Nutrislice

NodeHelper file

The node_helper.js runs on the server context and provides fetching from the API and transformation functions inside of the following block:

module.exports = NodeHelper.create({
});
  • start: Initializes the helper when it starts.
  • socketNotificationReceived: Listens for incoming requests from the frontend and triggers data fetching.
  • getMenuData: Coordinates fetching and processing of menu data.
  • fetchData: Handles the actual HTTP requests to the Nutrislice API.
  • combineMenus: Merges breakfast and lunch menus for each date.
  • parseMenuItems: Extracts and organizes menu items from the API response.
  • sendSocketNotification: Sends the processed menu data back to the frontend module.

MMM-Nutrislice/node_helper.js at master · vees/MMM-Nutrislice

CSS File

And then a CSS file to structure things like other modules on my Magic Mirror, like so:

.menu-date {
font-size: 16px;
margin-bottom: 10px;
}

.MMM-Nutrislice {
max-width: 400px;
width: 100%;
margin: 0 auto;
}

Final Commits

Final code is available at vees/MMM-Nutrislice: Magic Mirror Module for Nutrislice Menus