Skip to content

Add EasyIQ SkolePortal support (widget 0128)#352

Open
MartinSaaby wants to merge 2 commits into
scaarup:mainfrom
MartinSaaby:add-easyiq-skoleportal-widget-0128
Open

Add EasyIQ SkolePortal support (widget 0128)#352
MartinSaaby wants to merge 2 commits into
scaarup:mainfrom
MartinSaaby:add-easyiq-skoleportal-widget-0128

Conversation

@MartinSaaby
Copy link
Copy Markdown

Summary

Some Danish schools license EasyIQ SkolePortal instead of the classic EasyIQ Ugeplan widget. These schools expose widget 0128 in Aula instead of the existing 0001. They look related (same vendor) but use a completely different SaaS backend — skoleportal.easyiqcloud.dk vs api.easyiqcloud.dk — with a different auth flow and event JSON shape.

Without this PR, families with widget 0128 see the warning "You have enabled ugeplaner, but we cannot find any supported widgets (0029,0004,0001) in Aula" and no homework/weekplan data is fetched.

Refs #245.

What changed

  • New SkolePortal flow added to ugeplan() — only runs when widget 0128 is detected, fully additive (no changes to existing 0001/0029/0004/0062 paths).
  • Module-level helpers (_skoleportal_authenticate, _skoleportal_fetch_weekplan, _render_skoleportal_ugeplan, etc.) keep the new logic readable and testable.
  • Per-child iteration so multi-child families with SkolePortal schools each get their own ugeplan attribute.
  • Output uses native HTML <details>/<summary> so each weekday is individually collapsible in HA's markdown card without any frontend dependencies. Today's section auto-expands in the current week.
  • html.unescape() on text fields so Danish characters (æ, ø, å) render correctly instead of &aelig;/&oslash;/&aring;.

How SkolePortal works (notes for reviewers)

  1. Get an Aula bearer token for widgetId=0128 (existing get_token helper)
  2. POST to https://skoleportal.easyiqcloud.dk/Aula/AuthenticateAulaUser with x-childfilter/x-institutionfilter/x-login headers → returns {loginId, child, childName, schoolName, schoolId}
  3. GET https://skoleportal.easyiqcloud.dk/Calendar/CalendarGetWeekplanEvents?loginId=...&date=... → returns array of events
  4. Events use PascalCase fields: StartTime (YYYY/MM/DD HH:MM Danish format), StartTimeISO, EndTime, CoursesDisplay (subject like "Matematik"), ActivitiesDisplay (class like "4D"), ChapterTitle, Description

Testing

Tested with a single child at a SkolePortal-licensed school (4. klasse, Skt. Klemensskolen). Multi-child path implemented but untested — feedback from anyone with multiple children at SkolePortal schools welcome.

The existing 0001/0029/0004/0062 paths are unchanged, so users without widget 0128 are unaffected.

Sample output

The ugeplan attribute now contains:

​```html

Uge 18

Mandag Matematik (4D)
Tid: 08:00 - 08:45
Vi har arbejdet med decimaltal på s. 85.

Engelsk (4D)
Tid: 08:45 - 09:30
Eleverne er gået i gang med tegneserier på Pixton...

Tirsdag ...
​```

Renders nicely in a standard HA markdown card with browser-native expand/collapse per day.

Schools licensed for EasyIQ SkolePortal expose widget 0128 instead of
the classic 0001 widget. SkolePortal uses skoleportal.easyiqcloud.dk
with a different auth flow (AuthenticateAulaUser → loginId →
CalendarGetWeekplanEvents) and PascalCase event fields.

This adds a new ugeplan flow for widget 0128 alongside the existing
0001/0029/0004/0062 implementations. Per-child iteration supports
families with multiple children at SkolePortal schools. Output uses
native HTML <details>/<summary> for browser-collapsible per-day view.

Refs scaarup#245
Events from CalendarGetWeekplanEvents are returned in API order, which
is not necessarily chronological. Within a single day this caused
lessons to render out of order (e.g. a 09:50 lesson appearing after a
12:50 lesson). Sort events by StartTime within each day, and iterate
days in Mon-Sun order regardless of insertion order in the by_day dict.
Casperjuel added a commit to Casperjuel/aula-mcp that referenced this pull request May 4, 2026
)

Adds EasyIqSkoleportalClient — a separate plugin from the existing
EasyIqClient (widget 0001). Same vendor, distinct SaaS backend at
skoleportal.easyiqcloud.dk with a different auth handshake and a
PascalCase event JSON shape.

Per-call flow (matches the upstream PR):
  1. Get widget token for 0128 via WidgetTokenManager.
  2. POST /Aula/AuthenticateAulaUser per child with
     x-childfilter / x-institutionfilter / x-login headers; receive
     { loginId, child, childName, schoolName, schoolId }.
  3. GET /Calendar/CalendarGetWeekplanEvents?loginId=…&date=YYYY-MM-DD;
     receive an array of events (StartTime, StartTimeISO, EndTime,
     CoursesDisplay, ActivitiesDisplay, ChapterTitle, Description).

Multi-child: iterate per child (each child has its own loginId).
Per-child failures collected as warnings; partial results return.

Helpers added in integrations/types.ts:
  - isoWeekToMonday(isoWeek): inverse of isoWeekString — returns the
    Monday 00:00 UTC of the given ISO week string.
  - isoDate(d): YYYY-MM-DD UTC formatter.
  - decodeHtmlEntities(s): decodes the Danish-specific entities
    (æ/ø/å + uppercase) plus the standard five and numeric refs.
    SkolePortal in particular sends un-decoded entities in titles.

Wired into MCP:
  - aula-context.ts: AulaContext.getEasyIqSkoleportal()
  - tools.ts: aula.ugeplan.easyiq_skoleportal tool
  - discover.ts already maps widget 0128 → easyiq_skoleportal — agents
    that see '0128' in detectedWidgets get the right tool listed first.
Casperjuel added a commit to Casperjuel/aula-mcp that referenced this pull request May 4, 2026
Five real bugs fixed against live MitID + live Aula traffic; everything
from MitID app approval through Aula token exchange and EasyIQ
SkolePortal weekplan now works end-to-end.

MitID
- Drop the speculative /complete path entirely; APP completion runs
  /init → /prove → /verify → /next exactly like Python's reference.
- Pass realistic frontEndProcessingTime (wall-clock ms over the SRP +
  AES window) instead of the placeholder 100.
- Stop PKCS#7-padding the AES-GCM plaintext before /verify. Python's
  pad() is a no-op (b64decode silently strips the chars); GCM doesn't
  need block padding. Sending padded bytes was passing /verify-204 but
  failing the next /next with a generic "Try again."

SAML / Aula
- Follow redirects on POST /login/mitid (was leaving us on a 302 stub
  and trying to parse "Object moved" HTML as a SAML form).
- POST the final SAMLResponse to the form's own action URL (e.g.
  …/saml2-acs.php/app-level3-sp), not a hardcoded /uni-sp. Wrong SP
  endpoint produced SimpleSAMLphp's generic "Unhandled exception."

EasyIQ SkolePortal (widget 0128)
- Match upstream PR scaarup/aula#352: per-child opaque userId in
  x-childfilter, skoleportal.easyiqcloud.dk origin/referer, desktop
  user-agent + x-requested-with + x-userprofile, accept */*, and the
  YYYY-MM-DDT00:00:00.000Z date format. Authorization stays Bearer
  <jwt> (Python's get_token prepends "Bearer " before storing, so
  PR #352's `authorization: token` is the same wire shape).
- Plumb childUserIds[] alongside childIds[] through the integration
  context so SkolePortal can resolve the right child without burning
  a numeric-vs-opaque guess.

MCP server / discover
- Read widgetConfigurations[].widget.widgetId (nested), with a
  fallback to the legacy flat shape. We were reading the flat path,
  so detectedWidgets came back empty and Claude fanned out across
  every ugeplan tool.
- Treat profileContext userId / child userId as opaque (string |
  number) — Aula returns alphanumeric tokens for some accounts.
- Tighten capabilities so detected vendors are the only listed tool;
  no alternates to invite fan-out.
- Add usage block + workflow instructions (cache discover, resolve
  names, pick one tool, default ranges, language) so Claude needs
  fewer round-trips to behave well.
- Server reads the same KeychainTokenStore the CLI writes to, and
  bumps Bun.serve idleTimeout to 240s for SSE.
@spraot
Copy link
Copy Markdown

spraot commented May 11, 2026

Drive-by suggestion while you're in the EasyIQ neighborhood: the legacy widget 0001 block above this PR's new code has a silent-failure mode that cost me a chunk of diagnostic time before I realised what was happening. The renderer wraps the iteration in:

try:
    for i in ugeplaner.json()["Events"]:
        ...
except KeyError:
    _LOGGER.debug("None")

When the EasyIQ API returns an error envelope (e.g. {"ErrorCode": "1", "ErrorDescription": "..."} — which it now does aggressively for schools that have moved to SkolePortal but still have 0001 in their widget list for some reason), the KeyError on Events is caught and logged as the string "None" at DEBUG level. The sensor ends up rendering only <h2> Uge NN</h2> with no log at default verbosity to indicate why.

Two-line fix to surface it loudly:

_LOGGER.debug("EasyIQ Opgaver response " + str(ugeplaner.json()))
easyiq_payload = ugeplaner.json()
if isinstance(easyiq_payload, dict) and easyiq_payload.get("ErrorCode"):
    _LOGGER.warning(
        "EasyIQ %s rejected weekplan request for %s "
        "(institution header %s, week %s): %s %s",
        "0001", first_name, self._institutionProfiles[0], week,
        easyiq_payload.get("ErrorCode"), easyiq_payload.get("ErrorDescription"),
    )
# ... existing _ugep build, findDay, is_correct_format ...

try:
    for i in easyiq_payload["Events"]:
        ...
        else:
            _LOGGER.debug(
                "EasyIQ event for %s skipped: unrecognised start format %r",
                first_name, i.get("start"),
            )
except KeyError as exc:
    _LOGGER.warning(
        "EasyIQ weekplan for %s missing expected key %s; response keys=%s",
        first_name, exc,
        list(easyiq_payload.keys()) if isinstance(easyiq_payload, dict)
            else type(easyiq_payload).__name__,
    )

Same UX for genuine empty weeks; just makes EasyIQ license/auth errors and future schema changes visible at default log level instead of behind a debug("None") haystack. Happy to send as a separate small PR if you'd prefer to keep this one focused on the SkolePortal addition.

Independent of this — I opened #354 yesterday with a SkolePortal port that I now realise duplicates this PR. Closing mine in favour of yours, since the additive shape, helpers, <details>-per-day rendering, and html.unescape() are all the right calls and cleaner than what I shipped.

@MartinSaaby
Copy link
Copy Markdown
Author

Thanks @spraot — much appreciated, both for closing #354 and for the kind words on the structure. 🙏

The drive-by suggestion is a good catch. I ran into exactly that silent failure during the SkolePortal reverse-engineering earlier this month — the bare except KeyError swallowing the ErrorCode envelope from EasyIQ cost me a chunk of time before I enabled DEBUG logging and saw {"ErrorCode":"1","ErrorDescription":"Institutionen har ikke licens til EasyIQ Ugeplan widget."} in the response.

I'd love to keep this PR focused on the additive 0128 path, so a separate small PR from you for the 0001 error-surfacing fix would be perfect. Happy to test it on my end before/after to confirm the WARNING fires correctly when a SkolePortal-only school still has 0001 in their widget list.

@spraot
Copy link
Copy Markdown

spraot commented May 11, 2026

Followed up in #355 with the small surfacing fix as discussed. Tagged you in the test-plan checklist for the SkolePortal-only-school-with-0001-still-listed verification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants