├── .gitignore ├── attach ├── .vscode ├── extensions.json ├── settings.json ├── settings.default.json ├── launch.json └── tasks.json ├── setup-repo.sh ├── .gitmodules ├── .devcontainer ├── ha_config │ ├── configuration.yaml.append │ ├── logger.yaml │ ├── lovelace.yaml │ ├── homeassistant.yaml │ └── sensors.yaml ├── container.env ├── setup-project ├── devcontainer.json ├── update_bootstrap.sh └── ha_config_bootstrap │ └── .storage │ ├── core.config_entries │ ├── core.device_registry │ └── core.entity_registry ├── hacs.json ├── custom_components └── skolmat │ ├── const.py │ ├── manifest.json │ ├── translations │ └── en.json │ ├── __init__.py │ ├── sensor.py │ ├── config_flow.py │ ├── calendar.py │ └── menu.py ├── workspace.code-workspace ├── .github └── workflows │ ├── hassfest.yaml │ └── validate.yaml ├── LICENSE ├── test └── test.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .homeassistant/ -------------------------------------------------------------------------------- /attach: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker exec -it hass-skolmat-dev /bin/bash 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "ms-python.python"] 3 | } 4 | -------------------------------------------------------------------------------- /setup-repo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Clone the repo and initialize submodules 3 | git submodule update --init --recursive 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "skolmat-card"] 2 | path = skolmat-card 3 | url = https://github.com/Kaptensanders/skolmat-card.git 4 | -------------------------------------------------------------------------------- /.devcontainer/ha_config/configuration.yaml.append: -------------------------------------------------------------------------------- 1 | # project specific config 2 | 3 | #sensor: !include sensors.yaml 4 | lovelace: !include lovelace.yaml 5 | -------------------------------------------------------------------------------- /.devcontainer/container.env: -------------------------------------------------------------------------------- 1 | HA_CUSTOM_COMPONENTS= 2 | #HA_LOVELACE_PLUGINS=Kaptensanders/skolmat-card 3 | HA_LOVELACE_PLUGINS= 4 | WORKSPACE_DIR=/workspaces/skolmat -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Skolmat Integration", 3 | "content_in_root": false, 4 | "country": ["SE"], 5 | "render_readme": true, 6 | "homeassistant": "2025.10.0" 7 | } -------------------------------------------------------------------------------- /.devcontainer/ha_config/logger.yaml: -------------------------------------------------------------------------------- 1 | # override default logger.yaml 2 | default: info 3 | logs: 4 | homeassistant.components.websocket_api.http.connection: error 5 | custom_components.skolmat: debug 6 | -------------------------------------------------------------------------------- /custom_components/skolmat/const.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "skolmat" 2 | 3 | CONF_NAME = "name" 4 | CONF_URL = "url" 5 | CONF_LUNCH_BEGIN = "lunch_begin" 6 | CONF_LUNCH_END = "lunch_end" 7 | 8 | CALENDAR_HISTORY_DAYS = 90 9 | -------------------------------------------------------------------------------- /.devcontainer/ha_config/lovelace.yaml: -------------------------------------------------------------------------------- 1 | mode: storage 2 | dashboards: 3 | lovelace-padpanel: 4 | mode: yaml 5 | title: Skolmat 6 | icon: mdi:home 7 | show_in_sidebar: true 8 | filename: panel.yaml -------------------------------------------------------------------------------- /.devcontainer/ha_config/homeassistant.yaml: -------------------------------------------------------------------------------- 1 | name: Home 2 | latitude: 57.688219 3 | longitude: 11.755695 4 | elevation: 5 5 | unit_system: metric 6 | currency: SEK 7 | country: SE 8 | time_zone: "Europe/Stockholm" 9 | legacy_templates: false 10 | -------------------------------------------------------------------------------- /workspace.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | 4 | { 5 | "path": "." 6 | }, 7 | { 8 | "path": "/home/vscode/ha_core" 9 | }, 10 | { 11 | "path": "/home/vscode/ha_config" 12 | } 13 | ], 14 | "settings": { 15 | 16 | } 17 | } -------------------------------------------------------------------------------- /.github/workflows/hassfest.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with hassfest 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * *' 8 | 9 | jobs: 10 | validate: 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: "actions/checkout@v4" 14 | - uses: "home-assistant/actions/hassfest@master" 15 | -------------------------------------------------------------------------------- /.github/workflows/validate.yaml: -------------------------------------------------------------------------------- 1 | name: Validate with HACS 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 0 * * *" 8 | workflow_dispatch: 9 | 10 | jobs: 11 | validate-hacs: 12 | runs-on: "ubuntu-latest" 13 | steps: 14 | - name: HACS validation 15 | uses: "hacs/action@main" 16 | with: 17 | category: "integration" 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json 3 | "python.formatting.provider": "black", 4 | // Added --no-cov to work around TypeError: message must be set 5 | // https://github.com/microsoft/vscode-python/issues/14067 6 | "python.testing.pytestArgs": ["--no-cov"], 7 | // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings 8 | "python.testing.pytestEnabled": false 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.default.json: -------------------------------------------------------------------------------- 1 | { 2 | // Please keep this file in sync with settings in home-assistant/.devcontainer/devcontainer.json 3 | "python.formatting.provider": "black", 4 | // Added --no-cov to work around TypeError: message must be set 5 | // https://github.com/microsoft/vscode-python/issues/14067 6 | "python.testing.pytestArgs": ["--no-cov"], 7 | // https://code.visualstudio.com/docs/python/testing#_pytest-configuration-settings 8 | "python.testing.pytestEnabled": false 9 | } 10 | -------------------------------------------------------------------------------- /custom_components/skolmat/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "skolmat", 3 | "name": "Skolmat", 4 | "codeowners": ["@kaptensanders"], 5 | "config_flow": true, 6 | "dependencies": [], 7 | "documentation": "https://github.com/Kaptensanders/skolmat", 8 | "integration_type": "service", 9 | "iot_class": "cloud_polling", 10 | "issue_tracker": "https://github.com/Kaptensanders/skolmat/issues", 11 | "requirements": [ 12 | "feedparser>=6.0.0", 13 | "beautifulsoup4>=4.12.2", 14 | "python-dateutil>=2.8.2" 15 | ], 16 | "version": "2.0.0" 17 | } 18 | -------------------------------------------------------------------------------- /custom_components/skolmat/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "config": { 3 | "step": { 4 | "user": { 5 | "title": "Add Skolmat", 6 | "data": { 7 | "name": "School name", 8 | "url": "Menu URL", 9 | "lunch_begin": "Lunch start time (HH:MM)", 10 | "lunch_end": "Lunch end time (HH:MM)" 11 | } 12 | }, 13 | "init": { 14 | "title": "Skolmat options", 15 | "data": { 16 | "name": "School name", 17 | "url": "Menu URL", 18 | "lunch_begin": "Lunch start time (HH:MM)", 19 | "lunch_end": "Lunch end time (HH:MM)" 20 | } 21 | } 22 | }, 23 | "error": { 24 | "invalid_url": "Invalid URL", 25 | "invalid_lunch_interval": "Lunch end time must be after lunch start time" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.devcontainer/ha_config/sensors.yaml: -------------------------------------------------------------------------------- 1 | - platform: skolmat 2 | name: "skolmaten.se - Skutehagen" 3 | url: "https://skolmaten.se/skutehagens-skolan" 4 | 5 | - platform: skolmat 6 | name: "foodit.se - Bäckaskolan" 7 | url: "https://webmenu.foodit.se/?r=6&m=617&p=883&c=10023&w=0&v=Week" 8 | 9 | - platform: skolmat 10 | name: "menu.matildaplatform.com - Älvkarleby" 11 | url: "https://menu.matildaplatform.com/meals/week/63fc6e2dccb95f5ce56d8ada_skolor" 12 | 13 | - platform: skolmat 14 | name: mpi.mashie.com - Scheeleskolan 15 | url: "https://mpi.mashie.com/public/app/K%C3%B6pings%20kommun/d0767b9e" 16 | 17 | - platform: skolmat 18 | name: sodexo.mashie.com - Västra skolan 19 | url: "https://sodexo.mashie.com/public/menu/Akademikrogen%20skolor/d47bc6bf" 20 | 21 | - platform: skolmat 22 | name: mateo.se - Kävlinge utbildning 23 | url: "https://meny.mateo.se/kavlinge-utbildning/106" 24 | 25 | - platform: skolmat 26 | name: "menu.matildaplatform.com - Dalajärs förskola" 27 | url: "https://menu.matildaplatform.com/meals/week/6682a34d6337e8ced9340214_dalajars-forskola" 28 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Home Assistant", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "homeassistant", 12 | "justMyCode": false, 13 | "args": ["--debug", "-c", "ha_config"], 14 | }, 15 | 16 | { 17 | "name": "Attach to Home Assistant", 18 | "type": "debugpy", 19 | "request": "attach", 20 | "connect": { 21 | "port": 5678, 22 | "host": "localhost" 23 | }, 24 | "pathMappings": [ 25 | { 26 | "localRoot": "${workspaceFolder}/custom_components/skolmat", 27 | "remoteRoot": "/workspaces/skolmat/custom_components/skolmat" 28 | }, 29 | { 30 | "localRoot": "${workspaceFolder}", 31 | "remoteRoot": "/workspaces/skolmat" 32 | } 33 | ] 34 | } 35 | 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 @Kaptensanders 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | sys.path.append(os.path.join(os.path.dirname(sys.path[0]),'custom_components','skolmat')) 3 | 4 | from menu import Menu 5 | import json 6 | import aiohttp, asyncio 7 | import logging 8 | 9 | logging.basicConfig(level=logging.INFO) 10 | 11 | conf = { 12 | "foodit": "https://webmenu.foodit.se/?r=6&m=617&p=883&c=10023&w=0&v=Week&l=undefined", 13 | "skolmaten": "https://skolmaten.se/skutehagens-skolan", 14 | "matilda1": "https://menu.matildaplatform.com/meals/week/63fc6e2dccb95f5ce56d8ada_skolor", 15 | "matilda2": "https://menu.matildaplatform.com/meals/week/63fc8f84ccb95f5ce570a0d4_parkskolan-restaurang?startDate=2023-05-22&endDate=2023-05-28", 16 | "mashie": "https://mpi.mashie.com/public/app/Laholms%20kommun/a326a379", 17 | "skolmaten2": "https://skolmaten.se/annerstaskolan", 18 | "mateo": "https://meny.mateo.se/kavlinge-utbildning/31", 19 | "matilda3":"https://menu.matildaplatform.com/meals/week/6682a34d6337e8ced9340214_dalajars-forskola" 20 | } 21 | 22 | menu = Menu.createMenu(asyncio.to_thread, url=conf["matilda2"]) 23 | async def main (): 24 | async with aiohttp.ClientSession() as session: 25 | await menu.loadMenu(session) 26 | print (json.dumps(menu.menu, indent=4)) 27 | print ("Today:" + "\n".join(menu.menuToday)) 28 | 29 | asyncio.run(main()) -------------------------------------------------------------------------------- /custom_components/skolmat/__init__.py: -------------------------------------------------------------------------------- 1 | """The Skolmat integration.""" 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from hashlib import sha1 7 | from typing import Any 8 | 9 | from homeassistant.config_entries import ConfigEntry 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.const import Platform 12 | 13 | from .const import DOMAIN, CONF_URL 14 | from .menu import Menu 15 | 16 | _LOGGER = logging.getLogger(__name__) 17 | 18 | PLATFORMS = [Platform.SENSOR, Platform.CALENDAR] 19 | 20 | 21 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 22 | hass.data.setdefault(DOMAIN, {}) 23 | 24 | url: str = entry.data[CONF_URL].rstrip(" /") 25 | url_hash = sha1(url.encode("utf-8")).hexdigest() 26 | 27 | menu = Menu.createMenu(hass.async_add_executor_job, url) 28 | 29 | hass.data[DOMAIN][entry.entry_id] = { 30 | "menu": menu, 31 | "url_hash": url_hash, 32 | } 33 | 34 | await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 35 | return True 36 | 37 | 38 | async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 39 | unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 40 | 41 | if unload_ok: 42 | hass.data[DOMAIN].pop(entry.entry_id, None) 43 | 44 | return unload_ok 45 | -------------------------------------------------------------------------------- /.devcontainer/setup-project: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "--- begin project specific setup for Skolmat --" 4 | 5 | # TODO 6 | # Add project specific setup directives to this file 7 | # It will be called by "container setup-project" (see devcontainer.json postCreateCommand) 8 | 9 | add_ha_resource /local/skolmat-card/skolmat-card.js 10 | 11 | # link lovelace panel for the skolmat-card (sub module repo) 12 | echo "Linking $WORKSPACE_DIR/skolmat-card/lovelace-panel.yaml to $HA_CONFIG_DIR/panel.yaml" 13 | ln -sf $WORKSPACE_DIR/skolmat-card/lovelace-panel.yaml $HA_CONFIG_DIR/panel.yaml 14 | 15 | # --------------------------------------------------------------- 16 | # Bootstrap Home Assistant .storage from repo snapshot 17 | 18 | BOOTSTRAP_STORAGE="$WORKSPACE_DIR/.devcontainer/ha_config_bootstrap/.storage" 19 | HA_STORAGE="$HA_CONFIG_DIR/.storage" 20 | 21 | if [ -d "$BOOTSTRAP_STORAGE" ]; then 22 | echo "Bootstrapping Home Assistant .storage from repo snapshot" 23 | 24 | mkdir -p "$HA_STORAGE" 25 | 26 | cp -v \ 27 | "$BOOTSTRAP_STORAGE/core.config_entries" \ 28 | "$BOOTSTRAP_STORAGE/core.device_registry" \ 29 | "$BOOTSTRAP_STORAGE/core.entity_registry" \ 30 | "$HA_STORAGE/" 31 | 32 | else 33 | echo "No HA bootstrap storage found, skipping" 34 | fi 35 | 36 | 37 | # --------------------------------------------------------------- 38 | # Bootstrap Home Assistant .storage from repo snapshot 39 | 40 | 41 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Run Home Assistant Core", 6 | "type": "shell", 7 | "command": "hass -c ./ha_config -v", 8 | "group": { 9 | "kind": "build", 10 | "isDefault": true 11 | }, 12 | "presentation": { 13 | "reveal": "always", 14 | "panel": "new" 15 | }, 16 | "problemMatcher": [] 17 | }, 18 | { 19 | "label": "Run Home Assistant Core - DEBUG", 20 | "type": "shell", 21 | "command": "hass -c ./ha_config -v --debug", 22 | "group": { 23 | "kind": "build", 24 | "isDefault": true 25 | }, 26 | "presentation": { 27 | "reveal": "always", 28 | "panel": "new" 29 | }, 30 | "problemMatcher": [] 31 | }, 32 | { 33 | "label": "Pytest", 34 | "type": "shell", 35 | "command": "pytest --timeout=10 tests", 36 | "dependsOn": [ 37 | "Install all Test Requirements" 38 | ], 39 | "group": { 40 | "kind": "test", 41 | "isDefault": true 42 | }, 43 | "presentation": { 44 | "reveal": "always", 45 | "panel": "new" 46 | }, 47 | "problemMatcher": [] 48 | }, 49 | { 50 | "label": "Pytest (changed tests only)", 51 | "type": "shell", 52 | "command": "pytest --timeout=10 --picked", 53 | "group": { 54 | "kind": "test", 55 | "isDefault": true 56 | }, 57 | "presentation": { 58 | "reveal": "always", 59 | "panel": "new" 60 | }, 61 | "problemMatcher": [] 62 | } 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skolmat-dev", 3 | "//image": "kaptensanders/hass_dev_image", 4 | "///image": "hass_dev_image_2025.1.0", 5 | "image": "hass_dev_image_2025.12.2", 6 | "postCreateCommand": "container setup-project", 7 | "containerEnv": {}, 8 | "forwardPorts": [5678], 9 | "appPort": ["8123:8123"], 10 | "remoteUser": "vscode", 11 | "workspaceFolder": "/workspaces/skolmat", 12 | "workspaceMount": "source=${localWorkspaceFolder},target=/workspaces/skolmat,type=bind", 13 | "mounts": [ 14 | "source=${localWorkspaceFolder}/skolmat-card,target=/home/vscode/ha_config/www/skolmat-card,type=bind" 15 | ], 16 | "runArgs": [ 17 | "--name", "hass-skolmat-dev", 18 | "--env-file",".devcontainer/container.env" 19 | ], 20 | "customizations":{ 21 | "vscode": { 22 | "settings": { 23 | "python.defaultInterpreterPath": "/workspaces/ha_core/venv/bin/python", 24 | "python.analysis.extraPaths": [ 25 | "/workspaces/ha_core", 26 | "/workspaces/ha_core/venv/lib/python3.13/site-packages" 27 | ], 28 | "files.exclude": { 29 | "**/.git": true, 30 | "**/.svn": true, 31 | "**/.hg": true, 32 | "**/CVS": true, 33 | "**/.DS_Store": true, 34 | "**/Thumbs.db": true, 35 | "**/__pycache__": true 36 | } 37 | }, 38 | "extensions": [ 39 | "ms-python.python", 40 | "ms-python.vscode-pylance", 41 | "esbenp.prettier-vscode", 42 | "ms-python.debugpy", 43 | "charliermarsh.ruff", 44 | "dbaeumer.vscode-eslint", 45 | "ms-python.autopep8" 46 | ] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.devcontainer/update_bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # ------------------------------------------------------------------ 5 | # Update Home Assistant dev bootstrap storage 6 | # 7 | # Copies a curated subset of HA .storage into: 8 | # /.devcontainer/ha_config_bootstrap/.storage 9 | # 10 | # SAFETY: 11 | # - Must be run INSIDE the devcontainer 12 | # - Home Assistant MUST NOT be running 13 | # ------------------------------------------------------------------ 14 | 15 | # ---- Ensure running inside container ----------------------------- 16 | 17 | if ! grep -qa docker /proc/1/cgroup; then 18 | echo "ERROR: This script must be run from inside the devcontainer." 19 | echo "Aborting." 20 | exit 1 21 | fi 22 | 23 | # ---- Ensure Home Assistant is NOT running ------------------------- 24 | 25 | if pgrep -f "homeassistant" >/dev/null; then 26 | echo "ERROR: Home Assistant appears to be running." 27 | echo "Stop HA before updating bootstrap storage." 28 | exit 1 29 | fi 30 | 31 | # ---- Paths -------------------------------------------------------- 32 | 33 | SRC_STORAGE="${HOME}/ha_config/.storage" 34 | DST_STORAGE="$(dirname "$0")/ha_config_bootstrap/.storage" 35 | 36 | echo "Updating HA bootstrap storage..." 37 | echo "Source: ${SRC_STORAGE}" 38 | echo "Destination: ${DST_STORAGE}" 39 | 40 | mkdir -p "${DST_STORAGE}" 41 | 42 | # ---- Core registries (REQUIRED) ---------------------------------- 43 | 44 | cp -v "${SRC_STORAGE}/core.config_entries" \ 45 | "${DST_STORAGE}/" 46 | 47 | cp -v "${SRC_STORAGE}/core.device_registry" \ 48 | "${DST_STORAGE}/" 49 | 50 | cp -v "${SRC_STORAGE}/core.entity_registry" \ 51 | "${DST_STORAGE}/" 52 | 53 | echo 54 | echo "Bootstrap storage updated successfully." 55 | echo "NOTE: Home Assistant was not running during export (good)." 56 | -------------------------------------------------------------------------------- /custom_components/skolmat/sensor.py: -------------------------------------------------------------------------------- 1 | """Sensor platform for Skolmat.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | import logging 7 | from typing import Any 8 | 9 | from homeassistant.components.sensor import SensorEntity 10 | from homeassistant.config_entries import ConfigEntry 11 | from homeassistant.core import HomeAssistant 12 | from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 13 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 14 | from homeassistant.helpers.restore_state import RestoreEntity 15 | from homeassistant.helpers.entity import DeviceInfo 16 | 17 | from .const import DOMAIN, CONF_NAME, CONF_URL 18 | from .menu import Menu 19 | 20 | _LOGGER = logging.getLogger(__name__) 21 | 22 | 23 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, add_entities): 24 | data = hass.data[DOMAIN][entry.entry_id] 25 | menu: Menu = data["menu"] 26 | url_hash: str = data["url_hash"] 27 | 28 | add_entities( 29 | [ 30 | SkolmatSensor( 31 | hass=hass, 32 | entry=entry, 33 | menu=menu, 34 | url_hash=url_hash, 35 | ) 36 | ], 37 | update_before_add=True, 38 | ) 39 | 40 | class SkolmatSensor(RestoreEntity, SensorEntity): 41 | 42 | _attr_icon = "mdi:food" 43 | 44 | def __init__(self, hass, entry, menu, url_hash): 45 | self.hass = hass 46 | self._entry = entry 47 | self._menu = menu 48 | 49 | self._name = entry.data[CONF_NAME] 50 | self._url = entry.data[CONF_URL] 51 | 52 | self._attr_unique_id = f"skolmat_sensor_{url_hash}" 53 | 54 | self._state: str | None = None 55 | self._attrs: dict[str, Any] = {} 56 | 57 | @property 58 | def name(self): 59 | return self._name 60 | 61 | @property 62 | def native_value(self): 63 | return self._state 64 | 65 | @property 66 | def extra_state_attributes(self): 67 | return self._attrs 68 | 69 | @property 70 | def device_info(self): 71 | return DeviceInfo( 72 | identifiers={(DOMAIN, self._entry.entry_id)}, 73 | name=self._name, 74 | manufacturer="Skolmat", 75 | ) 76 | 77 | async def async_added_to_hass(self): 78 | # Restore state 79 | await super().async_added_to_hass() 80 | 81 | last = await self.async_get_last_state() 82 | if last is not None: 83 | self._state = last.state 84 | self._attrs = dict(last.attributes) 85 | 86 | async def async_update(self) -> None: 87 | 88 | session = async_get_clientsession(self.hass) 89 | await self._menu.loadMenu(session) 90 | 91 | today_courses = self._menu.menuToday or [] 92 | 93 | if not today_courses: 94 | state = "Ingen meny idag" 95 | else: 96 | state = ", ".join(today_courses) 97 | 98 | if len(state) > 255: 99 | state = state[:252] + "..." 100 | 101 | self._state = state 102 | 103 | self._attrs = { 104 | "url": self._url, 105 | "updated": datetime.now().isoformat(), 106 | "calendar": self._menu.menu, 107 | "name": self._name, 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![hacs_badge](https://img.shields.io/badge/HACS-Default-41BDF5.svg?style=) 2 | ![Version](https://img.shields.io/github/v/release/Kaptensanders/skolmat) 3 | 4 |

5 | ⚠️ Version 2.0 is a breaking release. YAML configuration is no longer supported. 6 |

7 | 8 |

9 | Note: skolmaten.se api key issue still not resolved, still only one week menu available via rss, sorry: 10 |

11 | 12 | # skolmat custom component for Home Assistant 13 | Skolmat custom component for the food menu in Swedish schools (and some other places) 14 | 15 | ## Description 16 | This component is most probably only valid in Sweden. It leverages data from skolmaten.se, webmenu.foodit.se, menu.matildaplatform.com, mpi.mashie.com or meny.mateo.se to create entities from a configured data source (url). 17 | 18 | From version 2.0, the integration exposes **two entities per configured school**: 19 | - a **sensor entity** showing today's lunch 20 | - a **calendar entity** showing lunch events for past and upcoming days (90 day event history kept) 21 | 22 | The sensor attributes contain the full parsed menu data and are used by the calendar entity and the corresponding lovelace card. 23 | 24 | Use the entities as you please or install the companion lovelace custom card to display the menu for today or for the week: 25 | https://github.com/Kaptensanders/skolmat-card 26 | 27 | ![image](https://user-images.githubusercontent.com/24979195/154963878-013bb9c0-80df-4449-9a8e-dc54ef0a3271.png) 28 | 29 | --- 30 | 31 | ## Installation (version 2.0+) 32 | 33 | > **YAML configuration is no longer supported as of version 2.0.** 34 | 35 | 1. Install the integration with **HACS** 36 | 2. Restart Home Assistant 37 | 3. Go to **Settings → Devices & Services** 38 | 4. Click **Add integration** 39 | 5. Search for **Skolmat** 40 | 6. Enter: 41 | - Name of the school 42 | - Menu URL 43 | - Optional lunch begin / end time for calendar events (or it will be a full day event) 44 | 45 | For each configured school, the integration will create: 46 | - One sensor entity 47 | - One calendar entity 48 | 49 | Both entities belong to the same device and share the same underlying menu data. 50 | 51 | --- 52 | 53 | ## Entities 54 | 55 | ### Sensor 56 | - Entity ID: `sensor.skolmat_` 57 | - State: today's available course(es) 58 | - Attributes contain the full parsed menu data (used by skolmat-card) 59 | 60 | ### Calendar 61 | - Entity ID: `calendar.skolmat_` 62 | - One event per day (lunch) 63 | - All-day events by default 64 | - Optional lunch begin / end time if configured 65 | - Past events are kept for a limited time window 66 | 67 | --- 68 | 69 | ## Find the menu url 70 | 71 | ### skolmaten.se 72 | 1. Open https://skolmaten.se/ and follow the links to find your school. 73 | 2. When you arrive at the page with this week's menu, copy the url 74 | Example: 75 | `https://skolmaten.se/skutehagens-skolan` 76 | 77 | --- 78 | 79 | ### webmenu.foodit.se 80 | 1. Open https://webmenu.foodit.se/ and follow the links to find your school. 81 | 2. When you arrive at the page with this week's menu, copy the url 82 | Example: 83 | `https://webmenu.foodit.se/?r=6&m=617&p=883&c=10023&w=0&v=Week&l=undefined` 84 | 85 | --- 86 | 87 | ### menu.matildaplatform.com 88 | 1. Open https://menu.matildaplatform.com/ and find your school using the search box. 89 | 2. When you arrive at the page with this week's menu, copy the url 90 | Example: 91 | `https://menu.matildaplatform.com/meals/week/63fc93fcccb95f5ce5711276_indianberget` 92 | 93 | --- 94 | 95 | ### mashie.com 96 | NOTE: mashie.com has different subdomains like mpi, sodexo, and possibly more. 97 | Example below is for mpi.mashie.com. 98 | 99 | If the url to your weekly menu contains `/public/app/` you should be fine. Otherwise, let me know. 100 | 101 | 1. Open https://mpi.mashie.com/public/app and find your school using the search box 102 | 2. When you arrive at the page where you can see the menu, copy the url 103 | Example: 104 | `https://mpi.mashie.com/public/app/Laholms%20kommun/a326a379` 105 | 106 | --- 107 | 108 | ### mateo.se 109 | 1. Open https://meny.mateo.se and find your school using the search box 110 | 2. When you arrive at the page where you can see the menu, copy the url 111 | Example: 112 | `https://meny.mateo.se/kavlinge-utbildning/91` 113 | 114 | --- 115 | -------------------------------------------------------------------------------- /.devcontainer/ha_config_bootstrap/.storage/core.config_entries: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 5, 4 | "key": "core.config_entries", 5 | "data": { 6 | "entries": [ 7 | {"created_at":"2025-12-09T09:42:33.887487+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"sun","entry_id":"01KC17VFMZRSAB5PSDM2AXVRPY","minor_version":1,"modified_at":"2025-12-09T09:42:33.887489+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"import","subentries":[],"title":"Sun","unique_id":null,"version":1}, 8 | {"created_at":"2025-12-09T09:42:33.917270+00:00","data":{},"disabled_by":null,"discovery_keys":{},"domain":"backup","entry_id":"01KC17VFNXEDK449ZD2897C3NC","minor_version":1,"modified_at":"2025-12-09T09:42:33.917272+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"system","subentries":[],"title":"Backup","unique_id":null,"version":1}, 9 | {"created_at":"2025-12-16T16:18:33.744314+00:00","data":{"lunch_begin":"11:30","lunch_end":"12:30","name":"skolmaten.se - Skutehagen","url":"https://skolmaten.se/skutehagens-skolan"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZ9KMGA3J5BJ3EG531F7DX","minor_version":1,"modified_at":"2025-12-16T16:18:33.744318+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"skolmaten.se - Skutehagen","unique_id":null,"version":1}, 10 | {"created_at":"2025-12-16T16:19:05.787449+00:00","data":{"lunch_begin":"12:00","lunch_end":"13:00","name":"foodit.se - Bäckaskolan","url":"https://webmenu.foodit.se/?r=6&m=617&p=883&c=10023&w=0&v=Week"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZAJXVNV5ZMKKERRDFX246","minor_version":1,"modified_at":"2025-12-16T16:19:05.787453+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"foodit.se - Bäckaskolan","unique_id":null,"version":1}, 11 | {"created_at":"2025-12-16T16:19:30.655909+00:00","data":{"name":"menu.matildaplatform.com - Älvkarleby","url":"https://menu.matildaplatform.com/meals/week/63fc6e2dccb95f5ce56d8ada_skolor"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZBB6ZDPF74ZVGS1QD60GA","minor_version":1,"modified_at":"2025-12-16T16:19:30.655911+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"menu.matildaplatform.com - Älvkarleby","unique_id":null,"version":1}, 12 | {"created_at":"2025-12-16T16:19:58.121348+00:00","data":{"lunch_begin":"11:00","lunch_end":"13:00","name":"mpi.mashie.com - Scheeleskolan","url":"https://mpi.mashie.com/public/app/K%C3%B6pings%20kommun/d0767b9e"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZC61911RRQ9AKRMN6MAYP","minor_version":1,"modified_at":"2025-12-16T16:19:58.121350+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"mpi.mashie.com - Scheeleskolan","unique_id":null,"version":1}, 13 | {"created_at":"2025-12-16T16:20:18.449480+00:00","data":{"name":"sodexo.mashie.com - Västra skolan","url":"https://sodexo.mashie.com/public/menu/Akademikrogen%20skolor/d47bc6bf"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZCSWHK5CJEWQZHV9X4KWX","minor_version":1,"modified_at":"2025-12-16T16:20:18.449486+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"sodexo.mashie.com - Västra skolan","unique_id":null,"version":1}, 14 | {"created_at":"2025-12-16T16:20:42.036099+00:00","data":{"lunch_begin":"11:30","lunch_end":"12:30","name":"mateo.se - Kävlinge utbildning","url":"https://meny.mateo.se/kavlinge-utbildning/106"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZDGXM3Y7693ZFB6A7X9EE","minor_version":1,"modified_at":"2025-12-16T16:20:42.036102+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"mateo.se - Kävlinge utbildning","unique_id":null,"version":1}, 15 | {"created_at":"2025-12-16T16:24:55.529909+00:00","data":{"name":"menu.matildaplatform.com - Dalajärs förskola","url":"https://menu.matildaplatform.com/meals/week/6682a34d6337e8ced9340214_dalajars-forskola"},"disabled_by":null,"discovery_keys":{},"domain":"skolmat","entry_id":"01KCKZN8F9TJA8VY77ZR31B1NH","minor_version":1,"modified_at":"2025-12-16T16:24:55.529913+00:00","options":{},"pref_disable_new_entities":false,"pref_disable_polling":false,"source":"user","subentries":[],"title":"menu.matildaplatform.com - Dalajärs förskola","unique_id":null,"version":1} 16 | ] 17 | } 18 | } -------------------------------------------------------------------------------- /custom_components/skolmat/config_flow.py: -------------------------------------------------------------------------------- 1 | """Config flow for Skolmat.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | from typing import Any 7 | 8 | import voluptuous as vol 9 | from yarl import URL 10 | 11 | from homeassistant import config_entries 12 | from homeassistant.core import callback 13 | 14 | from .const import ( 15 | DOMAIN, 16 | CONF_NAME, 17 | CONF_URL, 18 | CONF_LUNCH_BEGIN, 19 | CONF_LUNCH_END, 20 | ) 21 | 22 | 23 | def _is_valid_url(url: str) -> bool: 24 | """Validate URL using yarl.""" 25 | try: 26 | URL(url) 27 | return True 28 | except Exception: 29 | return False 30 | 31 | 32 | def _parse_time(value: str | None): 33 | """Convert HH:MM string to time object, or None.""" 34 | if not value: 35 | return None 36 | return datetime.strptime(value, "%H:%M").time() 37 | 38 | 39 | class SkolmatConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): 40 | """Config flow for Skolmat.""" 41 | 42 | VERSION = 1 43 | 44 | async def async_step_user(self, user_input=None): 45 | errors: dict[str, str] = {} 46 | 47 | if user_input is not None: 48 | name = user_input[CONF_NAME].strip() 49 | url = user_input[CONF_URL].rstrip(" /") 50 | 51 | # Validate URL manually 52 | if not _is_valid_url(url): 53 | errors["base"] = "invalid_url" 54 | else: 55 | begin = _parse_time(user_input.get(CONF_LUNCH_BEGIN)) 56 | end = _parse_time(user_input.get(CONF_LUNCH_END)) 57 | 58 | # Validate time range 59 | if begin and end and end <= begin: 60 | errors["base"] = "invalid_lunch_interval" 61 | else: 62 | data = { 63 | CONF_NAME: name, 64 | CONF_URL: url, 65 | } 66 | 67 | # Optional times - add only when provided 68 | if begin: 69 | data[CONF_LUNCH_BEGIN] = begin.strftime("%H:%M") 70 | if end: 71 | data[CONF_LUNCH_END] = end.strftime("%H:%M") 72 | 73 | return self.async_create_entry(title=name, data=data) 74 | 75 | # Schema must use only serializable types (2025.x requirement) 76 | schema = vol.Schema( 77 | { 78 | vol.Required(CONF_NAME): str, 79 | vol.Required(CONF_URL): str, 80 | vol.Optional(CONF_LUNCH_BEGIN): str, 81 | vol.Optional(CONF_LUNCH_END): str, 82 | } 83 | ) 84 | 85 | return self.async_show_form( 86 | step_id="user", 87 | data_schema=schema, 88 | errors=errors, 89 | ) 90 | 91 | @staticmethod 92 | @callback 93 | def async_get_options_flow(entry): 94 | return SkolmatOptionsFlowHandler(entry) 95 | 96 | 97 | class SkolmatOptionsFlowHandler(config_entries.OptionsFlow): 98 | """Handle Skolmat options.""" 99 | 100 | def __init__(self, entry): 101 | self.entry = entry 102 | 103 | async def async_step_init(self, user_input=None): 104 | errors: dict[str, str] = {} 105 | current = dict(self.entry.data) 106 | 107 | if user_input is not None: 108 | name = user_input[CONF_NAME].strip() 109 | url = user_input[CONF_URL].rstrip(" /") 110 | 111 | if not _is_valid_url(url): 112 | errors["base"] = "invalid_url" 113 | else: 114 | begin = _parse_time(user_input.get(CONF_LUNCH_BEGIN)) 115 | end = _parse_time(user_input.get(CONF_LUNCH_END)) 116 | 117 | if begin and end and end <= begin: 118 | errors["base"] = "invalid_lunch_interval" 119 | else: 120 | data = { 121 | CONF_NAME: name, 122 | CONF_URL: url, 123 | } 124 | 125 | # Add times only when provided 126 | if begin: 127 | data[CONF_LUNCH_BEGIN] = begin.strftime("%H:%M") 128 | if end: 129 | data[CONF_LUNCH_END] = end.strftime("%H:%M") 130 | 131 | # Update entry and reload 132 | self.hass.config_entries.async_update_entry(self.entry, data=data) 133 | await self.hass.config_entries.async_reload(self.entry.entry_id) 134 | 135 | return self.async_create_entry(title="", data={}) 136 | 137 | # Pre-fill defaults (None → empty field) 138 | defaults = { 139 | CONF_NAME: current.get(CONF_NAME), 140 | CONF_URL: current.get(CONF_URL), 141 | CONF_LUNCH_BEGIN: current.get(CONF_LUNCH_BEGIN, ""), 142 | CONF_LUNCH_END: current.get(CONF_LUNCH_END, ""), 143 | } 144 | 145 | schema = vol.Schema( 146 | { 147 | vol.Required(CONF_NAME, default=defaults[CONF_NAME]): str, 148 | vol.Required(CONF_URL, default=defaults[CONF_URL]): str, 149 | vol.Optional(CONF_LUNCH_BEGIN, default=defaults[CONF_LUNCH_BEGIN]): str, 150 | vol.Optional(CONF_LUNCH_END, default=defaults[CONF_LUNCH_END]): str, 151 | } 152 | ) 153 | 154 | return self.async_show_form( 155 | step_id="init", 156 | data_schema=schema, 157 | errors=errors, 158 | ) 159 | -------------------------------------------------------------------------------- /.devcontainer/ha_config_bootstrap/.storage/core.device_registry: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "minor_version": 12, 4 | "key": "core.device_registry", 5 | "data": { 6 | "devices": [ 7 | {"area_id":null,"config_entries":["01KC17VFMZRSAB5PSDM2AXVRPY"],"config_entries_subentries":{"01KC17VFMZRSAB5PSDM2AXVRPY":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-09T09:42:33.889723+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"e94c6521d4d996df099c877b81844e66","identifiers":[["sun","01KC17VFMZRSAB5PSDM2AXVRPY"]],"labels":[],"manufacturer":null,"model":null,"model_id":null,"modified_at":"2025-12-09T09:42:33.889749+00:00","name_by_user":null,"name":"Sun","primary_config_entry":"01KC17VFMZRSAB5PSDM2AXVRPY","serial_number":null,"sw_version":null,"via_device_id":null}, 8 | {"area_id":null,"config_entries":["01KC17VFNXEDK449ZD2897C3NC"],"config_entries_subentries":{"01KC17VFNXEDK449ZD2897C3NC":[null]},"configuration_url":"homeassistant://config/backup","connections":[],"created_at":"2025-12-09T09:42:33.922006+00:00","disabled_by":null,"entry_type":"service","hw_version":null,"id":"f2bf965170f97c6144e4ce85c9ff6e5a","identifiers":[["backup","backup_manager"]],"labels":[],"manufacturer":"Home Assistant","model":"Home Assistant Backup","model_id":null,"modified_at":"2025-12-09T09:42:33.922033+00:00","name_by_user":null,"name":"Backup","primary_config_entry":"01KC17VFNXEDK449ZD2897C3NC","serial_number":null,"sw_version":"2025.12.2","via_device_id":null}, 9 | {"area_id":null,"config_entries":["01KCKZ9KMGA3J5BJ3EG531F7DX"],"config_entries_subentries":{"01KCKZ9KMGA3J5BJ3EG531F7DX":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:18:34.345965+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"4f90a629bdb996f1411d43b026ee8222","identifiers":[["skolmat","01KCKZ9KMGA3J5BJ3EG531F7DX"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:18:34.346095+00:00","name_by_user":null,"name":"skolmaten.se - Skutehagen","primary_config_entry":"01KCKZ9KMGA3J5BJ3EG531F7DX","serial_number":null,"sw_version":null,"via_device_id":null}, 10 | {"area_id":null,"config_entries":["01KCKZAJXVNV5ZMKKERRDFX246"],"config_entries_subentries":{"01KCKZAJXVNV5ZMKKERRDFX246":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:19:06.129950+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"071b9341859191c82b730fa0e71b765e","identifiers":[["skolmat","01KCKZAJXVNV5ZMKKERRDFX246"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:19:06.130026+00:00","name_by_user":null,"name":"foodit.se - Bäckaskolan","primary_config_entry":"01KCKZAJXVNV5ZMKKERRDFX246","serial_number":null,"sw_version":null,"via_device_id":null}, 11 | {"area_id":null,"config_entries":["01KCKZBB6ZDPF74ZVGS1QD60GA"],"config_entries_subentries":{"01KCKZBB6ZDPF74ZVGS1QD60GA":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:19:31.162946+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"d5eba04c0521def72e993be1fb952341","identifiers":[["skolmat","01KCKZBB6ZDPF74ZVGS1QD60GA"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:19:31.163009+00:00","name_by_user":null,"name":"menu.matildaplatform.com - Älvkarleby","primary_config_entry":"01KCKZBB6ZDPF74ZVGS1QD60GA","serial_number":null,"sw_version":null,"via_device_id":null}, 12 | {"area_id":null,"config_entries":["01KCKZC61911RRQ9AKRMN6MAYP"],"config_entries_subentries":{"01KCKZC61911RRQ9AKRMN6MAYP":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:19:58.550742+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"5f5a9bd4ab7cf013247b8d62fec62f01","identifiers":[["skolmat","01KCKZC61911RRQ9AKRMN6MAYP"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:19:58.550825+00:00","name_by_user":null,"name":"mpi.mashie.com - Scheeleskolan","primary_config_entry":"01KCKZC61911RRQ9AKRMN6MAYP","serial_number":null,"sw_version":null,"via_device_id":null}, 13 | {"area_id":null,"config_entries":["01KCKZCSWHK5CJEWQZHV9X4KWX"],"config_entries_subentries":{"01KCKZCSWHK5CJEWQZHV9X4KWX":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:20:18.650377+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"fe8e0b2d4e3f82538d31997fb2707424","identifiers":[["skolmat","01KCKZCSWHK5CJEWQZHV9X4KWX"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:20:18.650443+00:00","name_by_user":null,"name":"sodexo.mashie.com - Västra skolan","primary_config_entry":"01KCKZCSWHK5CJEWQZHV9X4KWX","serial_number":null,"sw_version":null,"via_device_id":null}, 14 | {"area_id":null,"config_entries":["01KCKZDGXM3Y7693ZFB6A7X9EE"],"config_entries_subentries":{"01KCKZDGXM3Y7693ZFB6A7X9EE":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:20:43.355272+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"ec6976cd2a2def9b58e4b0b03db09807","identifiers":[["skolmat","01KCKZDGXM3Y7693ZFB6A7X9EE"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:20:43.355337+00:00","name_by_user":null,"name":"mateo.se - Kävlinge utbildning","primary_config_entry":"01KCKZDGXM3Y7693ZFB6A7X9EE","serial_number":null,"sw_version":null,"via_device_id":null}, 15 | {"area_id":null,"config_entries":["01KCKZN8F9TJA8VY77ZR31B1NH"],"config_entries_subentries":{"01KCKZN8F9TJA8VY77ZR31B1NH":[null]},"configuration_url":null,"connections":[],"created_at":"2025-12-16T16:24:55.985742+00:00","disabled_by":null,"entry_type":null,"hw_version":null,"id":"e326c66f247dc91e1df5175d4f8590a0","identifiers":[["skolmat","01KCKZN8F9TJA8VY77ZR31B1NH"]],"labels":[],"manufacturer":"Skolmat","model":null,"model_id":null,"modified_at":"2025-12-16T16:24:55.985979+00:00","name_by_user":null,"name":"menu.matildaplatform.com - Dalajärs förskola","primary_config_entry":"01KCKZN8F9TJA8VY77ZR31B1NH","serial_number":null,"sw_version":null,"via_device_id":null} 16 | ], 17 | "deleted_devices": [] 18 | } 19 | } -------------------------------------------------------------------------------- /custom_components/skolmat/calendar.py: -------------------------------------------------------------------------------- 1 | """Calendar platform for Skolmat.""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime, timedelta, date 6 | import logging 7 | from typing import Any 8 | 9 | from homeassistant.components.calendar import CalendarEntity, CalendarEvent 10 | from homeassistant.core import HomeAssistant 11 | from homeassistant.config_entries import ConfigEntry 12 | from homeassistant.helpers.entity import DeviceInfo 13 | from homeassistant.helpers.aiohttp_client import async_get_clientsession 14 | from homeassistant.helpers.storage import Store 15 | from homeassistant.util import dt as dt_util 16 | 17 | from .const import ( 18 | DOMAIN, 19 | CONF_NAME, 20 | CONF_URL, 21 | CONF_LUNCH_BEGIN, 22 | CONF_LUNCH_END, 23 | CALENDAR_HISTORY_DAYS, 24 | ) 25 | from .menu import Menu 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | 30 | async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, add_entities): 31 | data = hass.data[DOMAIN][entry.entry_id] 32 | menu: Menu = data["menu"] 33 | url_hash: str = data["url_hash"] 34 | 35 | add_entities( 36 | [ 37 | SkolmatCalendarEntity( 38 | hass=hass, 39 | entry=entry, 40 | menu=menu, 41 | url_hash=url_hash, 42 | ) 43 | ], 44 | update_before_add=True, 45 | ) 46 | 47 | 48 | class SkolmatCalendarEntity(CalendarEntity): 49 | 50 | _attr_icon = "mdi:calendar" 51 | 52 | def __init__(self, hass, entry, menu, url_hash): 53 | self.hass = hass 54 | self._entry = entry 55 | self._menu = menu 56 | 57 | self._name = entry.data[CONF_NAME] 58 | self._url = entry.data[CONF_URL] 59 | 60 | self._attr_unique_id = f"skolmat_calendar_{url_hash}" 61 | 62 | self._lunch_begin = self._parse_time(entry.data.get(CONF_LUNCH_BEGIN)) 63 | self._lunch_end = self._parse_time(entry.data.get(CONF_LUNCH_END)) 64 | 65 | self._events = [] 66 | self._current_or_next = None 67 | 68 | self._store = Store(hass, 1, f"{DOMAIN}_{entry.entry_id}_calendar") 69 | self._history = {} 70 | self._history_dirty = False 71 | 72 | @staticmethod 73 | def _parse_time(v): 74 | if not v: 75 | return None 76 | return datetime.strptime(v, "%H:%M").time() 77 | 78 | @property 79 | def name(self): 80 | return self._name 81 | 82 | @property 83 | def device_info(self): 84 | return DeviceInfo( 85 | identifiers={(DOMAIN, self._entry.entry_id)}, 86 | name=self._name, 87 | manufacturer="Skolmat", 88 | ) 89 | 90 | @property 91 | def event(self) -> CalendarEvent | None: 92 | return self._current_or_next 93 | 94 | async def async_added_to_hass(self): 95 | await super().async_added_to_hass() 96 | await self._async_load_history() 97 | 98 | async def _async_load_history(self): 99 | data = await self._store.async_load() 100 | if not data: 101 | self._history = {} 102 | return 103 | 104 | hist = {} 105 | for item in data.get("events", []): 106 | hist[item["date"]] = item["course"] 107 | 108 | self._history = hist 109 | 110 | async def _async_save_history(self): 111 | if not self._history_dirty: 112 | return 113 | 114 | events = [{"date": d, "course": c} for d, c in sorted(self._history.items())] 115 | await self._store.async_save({"events": events}) 116 | self._history_dirty = False 117 | 118 | async def async_update(self): 119 | session = async_get_clientsession(self.hass) 120 | await self._menu.loadMenu(session) 121 | 122 | today = dt_util.now().date() 123 | today_str = today.isoformat() 124 | 125 | # Add today's menu to history if needed 126 | courses = self._menu.menuToday or [] 127 | if courses: 128 | course = courses[0] 129 | if self._history.get(today_str) != course: 130 | self._history[today_str] = course 131 | self._history_dirty = True 132 | 133 | # Prune history older than N days 134 | cutoff = today - timedelta(days=CALENDAR_HISTORY_DAYS) 135 | to_remove = [ 136 | d for d in self._history if date.fromisoformat(d) < cutoff 137 | ] 138 | for d in to_remove: 139 | self._history.pop(d, None) 140 | self._history_dirty = True 141 | 142 | await self._async_save_history() 143 | 144 | # Build event list 145 | events = [] 146 | 147 | # -------------------------------------------------------------- 148 | # FIX: Only add *past* events from history. 149 | # Do NOT include today's event here, otherwise it is duplicated, 150 | # because the present/future loop below already includes today. 151 | # -------------------------------------------------------------- 152 | for d, course in self._history.items(): 153 | day_date = date.fromisoformat(d) 154 | if day_date < today: 155 | events.append(self._build_event(day_date, course)) 156 | 157 | # Present + future events (today included) 158 | for week in self._menu.menu.values(): 159 | for day in week: 160 | d = date.fromisoformat(day["date"]) 161 | if d < today: 162 | continue 163 | c = day["courses"] 164 | if not c: 165 | continue 166 | events.append(self._build_event(d, c[0])) 167 | 168 | events.sort(key=lambda e: self._normalize(e.start)) 169 | 170 | self._events = events 171 | self._current_or_next = self._find_current_or_next(events) 172 | 173 | def _build_event(self, d: date, course: str) -> CalendarEvent: 174 | start = dt_util.start_of_local_day(d) 175 | end = start + timedelta(days=1) 176 | 177 | if self._lunch_begin and self._lunch_end: 178 | start = start.replace( 179 | hour=self._lunch_begin.hour, minute=self._lunch_begin.minute 180 | ) 181 | end = start.replace( 182 | hour=self._lunch_end.hour, minute=self._lunch_end.minute 183 | ) 184 | 185 | return CalendarEvent( 186 | summary=course, 187 | description=course, 188 | start=start, 189 | end=end, 190 | ) 191 | 192 | @staticmethod 193 | def _normalize(value: Any) -> datetime: 194 | if isinstance(value, datetime): 195 | return dt_util.as_local(value) 196 | if isinstance(value, date): 197 | return dt_util.start_of_local_day(value) 198 | return dt_util.now() 199 | 200 | def _find_current_or_next(self, events): 201 | now = dt_util.now() 202 | for e in events: 203 | start = self._normalize(e.start) 204 | end = self._normalize(e.end) 205 | if start <= now < end: 206 | return e 207 | if start > now: 208 | return e 209 | return None 210 | 211 | async def async_get_events(self, hass, start_date, end_date): 212 | await self.async_update() 213 | result = [] 214 | 215 | for e in self._events: 216 | s = self._normalize(e.start) 217 | t = self._normalize(e.end) 218 | 219 | if t <= start_date: 220 | continue 221 | if s >= end_date: 222 | continue 223 | 224 | result.append(e) 225 | 226 | return result 227 | -------------------------------------------------------------------------------- /custom_components/skolmat/menu.py: -------------------------------------------------------------------------------- 1 | import feedparser, re, asyncio 2 | from abc import ABC, abstractmethod 3 | from datetime import datetime, date, timezone, timedelta 4 | from dateutil import tz 5 | from logging import getLogger 6 | from bs4 import BeautifulSoup 7 | import json 8 | from urllib.parse import urlparse 9 | 10 | log = getLogger(__name__) 11 | 12 | class Menu(ABC): 13 | 14 | @staticmethod 15 | def createMenu (asyncExecutor, url:str): 16 | url = url.rstrip(" /") 17 | 18 | if SkolmatenMenu.provider in url: 19 | return SkolmatenMenu(asyncExecutor, url) 20 | elif FoodItMenu.provider in url: 21 | return FoodItMenu(asyncExecutor, url) 22 | elif MatildaMenu.provider in url: 23 | return MatildaMenu(asyncExecutor, url) 24 | elif MashieMenu.provider in url: 25 | return MashieMenu(asyncExecutor, url) 26 | elif MateoMenu.provider in url: 27 | return MateoMenu(asyncExecutor, url) 28 | else: 29 | raise Exception(f"URL not recognized as {SkolmatenMenu.provider}, {FoodItMenu.provider}, {MatildaMenu.provider}, {MashieMenu.provider} or {MateoMenu.provider}") 30 | 31 | 32 | def __init__(self, asyncExecutor, url:str, menuValidHours:int = 4): 33 | self.asyncExecutor = asyncExecutor 34 | self.menu = {} 35 | self.url = self._fixUrl(url) 36 | self.menuToday = [] 37 | self.last_menu_fetch = None 38 | self._weeks = 2 39 | self._weekDays = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag', 'Söndag'] 40 | self._menuValidHours = menuValidHours 41 | self._lock = asyncio.Lock() 42 | 43 | def getWeek(self, nextWeek=False): 44 | # if sunday, return next week 45 | today = date.today() 46 | if nextWeek: 47 | today = today + timedelta(weeks=1) 48 | 49 | if today.weekday() > 5: 50 | today = today + timedelta(days=1) 51 | 52 | year, week, day = today.isocalendar() 53 | return year, week 54 | 55 | 56 | @abstractmethod 57 | async def _fixUrl (self, url:str): 58 | return url 59 | 60 | @abstractmethod 61 | async def _loadMenu (self, aiohttp_session): 62 | return 63 | 64 | def isMenuValid (self) -> bool: 65 | 66 | if not isinstance(self.last_menu_fetch, datetime): 67 | return False 68 | 69 | now = datetime.now() 70 | if now.date() != self.last_menu_fetch.date(): 71 | return False 72 | 73 | if now - self.last_menu_fetch >= timedelta(hours=self._menuValidHours): 74 | return False 75 | 76 | return True 77 | 78 | async def loadMenu(self, aiohttp_session, force:bool=False): 79 | 80 | async with self._lock: 81 | 82 | if not force and self.isMenuValid(): 83 | return True 84 | 85 | cur_menu = self.menu 86 | cur_menuToday = self.menuToday 87 | 88 | self.menu = {} 89 | self.menuToday = [] 90 | 91 | try: 92 | await self._loadMenu(aiohttp_session) 93 | self.last_menu_fetch = datetime.now() 94 | return True 95 | 96 | except Exception as err: 97 | self.menu = cur_menu 98 | self.menuToday = cur_menuToday 99 | log.error(f"Failed to load {self.provider} menu from {self.url} - {str(err)}") 100 | return False 101 | 102 | def appendEntry(self, entryDate:date, courses:list): 103 | 104 | if type(entryDate) is not date: 105 | raise TypeError("entryDate must be date type") 106 | 107 | week = entryDate.isocalendar().week 108 | 109 | if not week in self.menu: 110 | self.menu[week] = [] 111 | 112 | dayEntry = { 113 | "weekday": self._weekDays[entryDate.weekday()], 114 | "date": entryDate.isoformat(), 115 | "week": week, 116 | "courses": courses 117 | } 118 | 119 | if entryDate == date.today(): 120 | self.menuToday = courses 121 | 122 | 123 | self.menu[week].append(dayEntry) 124 | 125 | def updateEntry(self, entryDate: date, courses: list): 126 | if type(entryDate) is not date: 127 | raise TypeError("entryDate must be date type") 128 | 129 | week = entryDate.isocalendar().week 130 | 131 | if week not in self.menu: 132 | raise KeyError(f"No entries found for week {week}") 133 | 134 | entry_iso = entryDate.isoformat() 135 | 136 | for dayEntry in self.menu[week]: 137 | if dayEntry["date"] == entry_iso: 138 | dayEntry["courses"] = courses 139 | 140 | if entryDate == date.today(): 141 | self.menuToday = courses 142 | 143 | return 144 | 145 | raise KeyError(f"No entry exists for date {entry_iso}") 146 | 147 | 148 | def entryExists(self, entryDate: date) -> bool: 149 | 150 | week = entryDate.isocalendar().week 151 | # No entries at all for this week 152 | if week not in self.menu: 153 | return False 154 | 155 | entry_iso = entryDate.isoformat() 156 | 157 | return any(day["date"] == entry_iso for day in self.menu[week]) 158 | 159 | 160 | async def parse_feed(self, raw_feed): 161 | 162 | def parse_helper(raw_feed): 163 | return feedparser.parse(raw_feed) 164 | 165 | return await self.asyncExecutor(parse_helper, raw_feed) 166 | 167 | class FoodItMenu(Menu): 168 | 169 | provider = "foodit.se" 170 | 171 | def __init__(self, asyncExecutor, url:str): 172 | 173 | super().__init__(asyncExecutor, url) 174 | 175 | def _fixUrl(self, url:str): 176 | 177 | if "foodit.se/rss" not in url: 178 | url = url.replace("foodit.se", "foodit.se/rss") 179 | return url 180 | 181 | async def _getFeed(self, aiohttp_session): 182 | 183 | # returns only one week at the time 184 | weekMenus = [] 185 | for week in range(self._weeks): 186 | rss = re.sub(r'\&w=[0-9]*\&', f"&w={week}&", self.url) 187 | async with aiohttp_session.get(rss) as response: 188 | 189 | # Offload feedparser.parse to an executor 190 | raw_feed = await response.text() 191 | parsed_feed = await self.parse_feed(raw_feed) 192 | weekMenus.append(parsed_feed) 193 | 194 | feed = weekMenus.pop(0) 195 | for f in weekMenus: 196 | feed["entries"].extend(f["entries"]) 197 | 198 | return feed 199 | 200 | async def _loadMenu(self, aiohttp_session): 201 | 202 | menuFeed = await self._getFeed(aiohttp_session) 203 | for day in menuFeed["entries"]: 204 | 205 | entryDate = datetime.strptime(day["title"].split()[1], "%Y%m%d").date() 206 | courses = [s.strip() for s in day['summary'].split(':') if s] 207 | self.appendEntry(entryDate, courses) 208 | 209 | 210 | class SkolmatenMenu(Menu): 211 | 212 | provider = "skolmaten.se" 213 | 214 | def __init__(self, asyncExecutor, url:str): 215 | super().__init__(asyncExecutor, url) 216 | 217 | def _fixUrl(self, url:str): 218 | # https://skolmaten.se/skutehagens-skolan 219 | 220 | parsed = urlparse(url) 221 | schoolName = parsed.path.lstrip("/") 222 | 223 | if schoolName is None: 224 | raise ValueError("school name could not be extracted from url") 225 | 226 | newUrl = "https://skolmaten.se/api/4/rss/week/" + schoolName + "?locale=en" 227 | return newUrl 228 | 229 | async def _getFeed(self, aiohttp_session): 230 | 231 | async with aiohttp_session.get(f"{self.url}?limit={self._weeks}") as response: 232 | raw_feed = await response.text() 233 | return await self.parse_feed(raw_feed) 234 | 235 | 236 | async def _loadMenu(self, aiohttp_session): 237 | 238 | menuFeed = await self._getFeed(aiohttp_session) 239 | 240 | for day in menuFeed["entries"]: 241 | entryDate = date(day['published_parsed'].tm_year, day['published_parsed'].tm_mon, day['published_parsed'].tm_mday) 242 | courses = re.sub(r"\s*\([^)]*\)", "", day["summary"]) 243 | self.appendEntry(entryDate, courses.split("
")) 244 | 245 | 246 | # class SkolmatenMenu(Menu): 247 | 248 | # provider = "skolmaten.se" 249 | 250 | # def __init__(self, asyncExecutor, url:str): 251 | # # https://skolmaten.se/skutehagens-skolan 252 | 253 | # super().__init__(asyncExecutor, url) 254 | # self.headers = {"Content-Type": "application/json", "Accept": "application/json", "Referer": f"https://{self.provider}/"} 255 | 256 | # def _fixUrl(self, url: str): 257 | 258 | # parsed = urlparse(url) 259 | # schoolName = parsed.path.lstrip("/") 260 | 261 | # if schoolName is None: 262 | # raise ValueError("school name could not be extracted from url") 263 | 264 | 265 | # newUrl = "https://skolmaten.se/api/4/menu/school/" + schoolName 266 | # return newUrl 267 | 268 | # async def _getWeek(self, aiohttp_session, url): 269 | 270 | # def remove_images(obj): 271 | # if isinstance(obj, dict): 272 | # return {k: remove_images(v) for k, v in obj.items() if k != "image"} 273 | # elif isinstance(obj, list): 274 | # return [remove_images(i) for i in obj] 275 | # return obj 276 | 277 | # try: 278 | # async with aiohttp_session.get(url, headers=self.headers, raise_for_status=True) as response: 279 | # html = await response.text() 280 | # return json.loads(html, object_hook=remove_images) 281 | # except Exception as err: 282 | 283 | # log.exception(f"Failed to retrieve {url}") 284 | # raise 285 | 286 | # async def _loadMenu(self, aiohttp_session): 287 | 288 | # try: 289 | # shoolName = "Skutehagens skolan F-3, 4-6" 290 | # thisWeek = self.getWeek() 291 | # nextWeek = self.getWeek(nextWeek=True) 292 | 293 | # w1Url = f"{self.url}?year={thisWeek[0]}&week={thisWeek[1]}" 294 | # w2Url = f"{self.url}?year={nextWeek[0]}&week={nextWeek[1]}" 295 | 296 | # w1 = await self._getWeek(aiohttp_session, w1Url) 297 | # w2 = await self._getWeek(aiohttp_session, w2Url) 298 | 299 | # dayEntries = [ 300 | # *(w1["WeekState"]["Days"] if isinstance(w1.get("WeekState"), dict) else []), 301 | # *(w2["WeekState"]["Days"] if isinstance(w2.get("WeekState"), dict) else []) 302 | # ] 303 | 304 | # for day in dayEntries: 305 | # entryDate = parser.isoparse(day["date"]).date() 306 | # courses = [] 307 | # for course in day["Meals"]: 308 | # courses.append(course["name"]) 309 | # self.appendEntry(entryDate, courses) 310 | 311 | # except Exception as err: 312 | # log.exception(f"Failed to process:\n{w1Url}\nor\n{w2Url} ", exc_info=err) 313 | # raise 314 | 315 | class MatildaMenu (Menu): 316 | provider = "matildaplatform.com" 317 | 318 | def __init__(self, asyncExecutor, url:str): 319 | # https://menu.matildaplatform.com/meals/week/63fc93fcccb95f5ce5711276_indianberget 320 | super().__init__(asyncExecutor, url) 321 | self.headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36"} 322 | 323 | def _fixUrl(self, url: str): 324 | return url 325 | 326 | async def _getWeek(self, aiohttp_session, url): 327 | 328 | try: 329 | async with aiohttp_session.get(url, headers=self.headers, raise_for_status=True) as response: 330 | html = await response.text() 331 | soup = BeautifulSoup(html, 'html.parser') 332 | jsonData = soup.select("#__NEXT_DATA__")[0].string 333 | return json.loads(jsonData)["props"]["pageProps"] 334 | except Exception as err: 335 | log.exception(f"Failed to retrieve {url}") 336 | raise 337 | 338 | 339 | async def _loadMenu(self, aiohttp_session): 340 | 341 | w1 = await self._getWeek(aiohttp_session, self.url) 342 | w2 = await self._getWeek(aiohttp_session, "https://menu.matildaplatform.com" + w1["nextURL"]) 343 | 344 | dayEntries = [*w1["meals"], *w2["meals"]] 345 | 346 | for day in dayEntries: 347 | entryDate = datetime.strptime(day["date"], "%Y-%m-%dT%H:%M:%S").date() # 2023-06-02T00:00:00 348 | courses = [] 349 | 350 | for course in day["courses"]: 351 | courses.append(course["name"]) 352 | 353 | # some schools have several entries for the same day, frukost, lunch, mellanmål, etc 354 | if self.entryExists (entryDate): 355 | # owerwrite if name=Lunch, sketchy approach, "Lunch" as key may be set by the schools, we'll see 356 | if day["name"] == "Lunch": 357 | self.updateEntry(entryDate, courses) 358 | else: 359 | self.appendEntry(entryDate, courses) 360 | 361 | class MashieMenu(Menu): 362 | 363 | provider = "mashie.com" 364 | 365 | def __init__(self, asyncExecutor, url:str): 366 | 367 | super().__init__(asyncExecutor, url) 368 | self.headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36", 369 | "cookie": "cookieLanguage=sv-SE"} # set page lang to Swe 370 | 371 | def _fixUrl(self, url): 372 | # observed variants: 373 | # mpi.mashie.com/public/app/Laholms%20kommun/a326a379 374 | # sodexo.mashie.com/public/app/Akademikrogen%20skolor/d47bc6bf 375 | # 376 | # all subdomains seem to have a corresponding ../menu/.. url to the ../app/.. 377 | # the ../menu/.. page contains json data for the menu, so use that instead of scraping the page 378 | 379 | if "/app/" in url: 380 | url = url.replace("/app/", "/menu/") 381 | 382 | return url 383 | 384 | async def _loadMenu(self, aiohttp_session): 385 | 386 | def preserveTs(match_obj): 387 | if match_obj.group() is not None: 388 | return re.sub(r"[^0-9]", "", match_obj.group()) 389 | 390 | #se = tz.gettz("Europe/Stockholm") 391 | # se = await run_in_executor(None, gettz, "Europe/Stockholm") 392 | se = await self.asyncExecutor(tz.gettz, "Europe/Stockholm") 393 | 394 | try: 395 | async with aiohttp_session.get(self.url, headers=self.headers, raise_for_status=True) as response: 396 | html = await response.text() 397 | 398 | log.info(f"Parsing html from {self.url}") 399 | soup = BeautifulSoup(html, 'html.parser') 400 | scriptTag = soup.select_one("script") 401 | if scriptTag is None: 402 | raise ValueError(f"Malformatted/unexpected data") 403 | 404 | jsonData = scriptTag.string 405 | # discard javascript variable assignment, weekMenues = {... 406 | jsonData = jsonData[jsonData.find("{") - 1:] 407 | # replace javascipt dates (new Date(1234567...) with only the ts 408 | jsonData = re.sub(r"new Date\([0-9]+\)", preserveTs, jsonData) 409 | # json should be fine now 410 | data = json.loads(jsonData) 411 | 412 | w = 1 413 | for week in data["Weeks"]: 414 | for day in week["Days"]: 415 | entryDate = datetime.fromtimestamp(day["DayMenuDate"] / 1000, timezone.utc) 416 | entryDate = entryDate.astimezone(tz=se).date() 417 | courses = [] 418 | for course in day["DayMenus"]: 419 | courses.append(course["DayMenuName"].strip()) 420 | 421 | self.appendEntry(entryDate, courses) 422 | 423 | w = w + 1 424 | if w > 2: 425 | break 426 | 427 | except Exception: 428 | raise 429 | 430 | class MateoMenu(Menu): 431 | provider = "mateo.se" 432 | 433 | def __init__(self, asyncExecutor, url:str): 434 | # https://meny.mateo.se/kavlinge-utbildning/31 435 | super().__init__(asyncExecutor, url) 436 | self.headers = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.82 Safari/537.36", 437 | "cookie": "cookieLanguage=sv-SE"} # set page lang to Swe 438 | self.jsUrl = "https://meny.mateo.se/" 439 | self.municipalities = "/mateo-menu/municipalities.json" 440 | self.mateo_menu_shared_path = "/mateo.shared" 441 | 442 | def _fixUrl(self, url): 443 | return url 444 | 445 | async def _constructJsUrl(self, url:str, aiohttp_session): 446 | try: 447 | async with aiohttp_session.get(self.url, headers=self.headers, raise_for_status=True) as response: 448 | html = await response.text() 449 | soup = BeautifulSoup(html, 'html.parser') 450 | 451 | # Find all