Skip to content

Add source maiambiente_pt (Maia, Portugal) - #7230

Open
CargoTechSolutions wants to merge 4 commits into
mampfes:masterfrom
CargoTechSolutions:maiambiente-pt
Open

Add source maiambiente_pt (Maia, Portugal)#7230
CargoTechSolutions wants to merge 4 commits into
mampfes:masterfrom
CargoTechSolutions:maiambiente-pt

Conversation

@CargoTechSolutions

@CargoTechSolutions CargoTechSolutions commented Aug 25, 2026

Copy link
Copy Markdown

Service provider

Maiambiente, E.M., the municipal waste operator of Maia, Portugal (~140k inhabitants). Public lookup at https://servicos.maiambiente.pt/cal2026/, no login.

This is the second Portuguese source in the repo, after cm_lisboa_pt.

Files added

  • custom_components/waste_collection_schedule/waste_collection_schedule/source/maiambiente_pt.py
  • custom_components/waste_collection_schedule/waste_collection_schedule/service/Maiambiente.py
  • doc/source/maiambiente_pt.md

Also modified: custom_components/waste_collection_schedule/manifest.json and requirements.txt, to add Pillow. See below.

No changes to README.md, info.md, sources.json, source_metadata.json, translations/*.json or doc/ics/*.md.

How it works

The lookup chains street to house number to collection circuit to a yearly PDF. The body of that PDF is a bitmap, but it needs no OCR: every day is a flat-coloured circle on a regular grid, so

  • the date comes from the geometry, meaning month block, weekday row and week column;
  • the stream comes from the fill colour, taken from the calendar's own legend.

The numbers printed inside the circles are never read. The grid is anchored on the dark green month-name bars, which are always present, so circuits that skip a weekday do not break the alignment. A day can carry two streams, which is how holiday compensation is drawn, and both are returned.

The calendar belongs to the circuit rather than the address and covers a whole year, so it is fetched once per year and cached. Every Maiambiente path carries the year (cal2026/cal2026.php, type=arruamento2026, and so on), so the address is re-resolved per year, since one year's internal id is not necessarily valid in the next. Once the current calendar nears its end the next year's is picked up automatically, which covers the December to January transition.

Two things I would like reviewed explicitly

1. This adds Pillow to the requirements.

It decodes the PDF's embedded bitmap and nothing else. That bitmap arrives as /FlateDecode with /Predictor 15, which is exactly how a PNG IDAT is encoded, so the stream is wrapped in a valid PNG header and handed to Pillow. No poppler, no MuPDF, no OCR engine.

Home Assistant Core already ships Pillow as a base requirement, so this costs nothing at install time for HA users.

2. Maiambiente's servers send an incomplete TLS chain.

They present only the leaf certificate and omit the intermediate (GlobalSign GCC R6 AlphaSSL CA 2025) linking it to GlobalSign Root CA - R6. Browsers recover by fetching it from the certificate's AIA extension, Python does not, so requests fails with CERTIFICATE_VERIFY_FAILED and nothing works at all.

The service module supplies that intermediate itself through an SSLContext mounted on the session, the same shape as the existing service/SSLError.py workaround. Certificate verification stays fully enabled: the chain still has to terminate at the already trusted GlobalSign root and the leaf still has to be valid for the host. This is the opposite of verify=False. The pinned certificate is documented inline with its expiry (2027-05-21) and instructions for refreshing it. I am reporting the misconfiguration to Maiambiente; until they fix it, the source cannot connect without this.

Memory

Since this parse runs inside the Home Assistant process, I treated its footprint as a correctness concern rather than an optimisation, and the later commits in this PR are that work.

Three things were wrong and are fixed:

  • The signature helper decompressed every stream to look for the embedded PNG's name, the 6000x4161 bitmap included, then ran a text regex over 75 MB of binary noise and kept every false match. On a 960 KB PDF that peaked at 1234 MB and took 19.8 s. Decompression is now bounded to 2 MiB per stream and anything larger is skipped as an image.
  • The largest image in these PDFs is not even the calendar. Selection is now structural, smallest image first, with "did the 12 month bars turn up" as the test, so the 25 megapixel image is normally never decoded.
  • Colour classification built a full RGB image per palette entry. The bands are now split once, each colour is handled with a 256-entry lookup table, and the result is a one-byte-per-pixel label map, after which the RGB image and its bands are released.

A full read, signature plus calendar, now peaks at 61 MB over a 19.8 MB baseline and returns to baseline afterwards.

Test cases

TEST_CASES deliberately uses two public buildings, Maia city hall and Maia public library, rather than private addresses, since these run on every scheduled source test. They also exercise the non-numeric house number case, which Maiambiente uses for named buildings.

Checks

  • python -m pytest tests/ gives 39 passed
  • ruff check and ruff format --check are clean on all changed files
  • test_sources.py -s maiambiente_pt:
Testing source maiambiente_pt ...
  found 279 entries for Câmara Municipal da Maia
  found 227 entries for Biblioteca da Maia
    2026-01-05 : Papel e cartão [mdi:package-variant]
    2026-01-06 : Resíduos indiferenciados [mdi:trash-can]
    2026-01-07 : Embalagens de plástico e metal [mdi:recycle-variant]
    2026-01-14 : Vidro [mdi:bottle-soda]
    2026-01-14 : Embalagens de plástico e metal [mdi:recycle-variant]

14 January shows the two-streams-on-one-day case on live data.

Beyond the test cases, this has been running on a real Home Assistant OS installation (2 GB, HA Core 2025.12.1) since today, resolving a third address and producing correct next-collection dates.

Notes

SOURCE_CODEOWNERS is @CargoTechSolutions; I will maintain it.

The service module is generated from the library in CargoTechSolutions/ha-maiambiente, which also ships this as a standalone HACS integration. That repo has a CI job asserting the generated file stays in sync, and unit tests that read a synthetic calendar drawn with the same geometry, including two-stream days, suspended days and a circuit collecting every day, so the parser is covered without publishing a real address's PDF.

Miguel Sousa and others added 4 commits August 25, 2026 22:28
Maiambiente is the municipal waste operator of Maia, Portugal. No login is
required and no OCR is involved.

The public lookup resolves street -> house number -> collection circuit ->
yearly PDF. The body of that PDF is a bitmap, but it does not need OCR: every
day is a flat-coloured circle on a regular grid, so the date comes from the
geometry (month block, weekday row, week column) and the waste stream comes
from the fill colour, taken from the calendar's own legend. The numbers
printed inside the circles are never read. A day can carry two streams, which
is how holiday compensations are drawn; both are returned.

The calendar belongs to the circuit rather than to the address and covers a
whole year, so it is fetched once per year and cached. Every Maiambiente path
carries the year, so the address is re-resolved per year - one year's internal
id is not necessarily valid in the next - and the next year's calendar is
picked up automatically once the current one nears its end.

Two things worth flagging for review:

* This adds Pillow to the requirements. It is used only to decode the PDF's
  embedded bitmap, which arrives as /FlateDecode with /Predictor 15 - exactly
  a PNG IDAT - so the stream is wrapped in a valid PNG and handed to Pillow;
  no poppler, no MuPDF, no OCR engine. Home Assistant Core already ships
  Pillow as a base requirement, so this costs nothing at install time.

* Maiambiente's servers send an incomplete TLS chain: they omit the
  intermediate that links their leaf certificate to the GlobalSign root.
  Browsers recover via AIA fetching, Python does not, so requests fails with
  CERTIFICATE_VERIFY_FAILED. The service module supplies that intermediate
  itself. Certificate verification stays fully enabled - this is not
  verify=False - and the pinned certificate is documented with instructions
  for refreshing it.

TEST_CASES use two public buildings (Maia city hall and Maia public library)
rather than private addresses, since they run on every scheduled source test.

test_sources.py -s maiambiente_pt:
  found 279 entries for Câmara Municipal da Maia
  found 227 entries for Biblioteca da Maia

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The embedded bitmap is 6000x4161 (25 megapixels, 71 MB decompressed) and was
being copied twice for no reason: convert("RGB") was called on an image that
already decodes to RGB, since the PNG wrapper we build around the stream is
colour type 2, and every candidate image had all 25 million pixels walked by
getcolors() just to decide which one was the calendar.

Scoring now runs on a 1/8 subsample with NEAREST, which keeps the flat fill
colours exact, and rejected candidates are released instead of staying alive
for the rest of the loop.

Measured on a real calendar PDF with Pillow 12.3, peak process RSS drops from
214 MB to 134 MB over a 19.5 MB baseline, and RSS returns to baseline after
each read. This matters because the parse runs inside the Home Assistant
process: on a 2 GB installation the old peak was enough for the kernel to kill
Home Assistant Core outright.

Behaviour is unchanged. test_sources.py still returns 279 and 227 entries for
the two test cases, and the repository's own unit tests pass on Pillow 9.4 and
12.3.
…examples

Memory
------
The large bitmap in these PDFs is not the calendar. A Maiambiente PDF carries
two images, and the 6000x4161 one (71 MB decompressed) contains no month bars
at all; the calendar is the 2303x1547 one. The old selection decompressed the
large image purely to score it by colour and then discarded it.

Selection is now structural and runs smallest image first, with "did the 12
month bars turn up" as the test. Image.open is lazy, so enumerating candidates
costs only their headers and the large image is normally never decoded.

Colour classification no longer builds a full RGB image per palette entry
either. The bands are split once and each colour is handled with 256-entry
lookup tables, one at a time, releasing each mask before the next. The result
is a one-byte-per-pixel label map, after which the RGB image and its bands are
dropped. That also removes a colour cache that grew without bound.

Peak process RSS on a real calendar PDF with Pillow 12.3 goes from 134 MB to
62 MB over a 19.8 MB baseline, and returns to baseline after each read. This
matters because the parse runs inside the Home Assistant process.

Examples
--------
The documentation and HOW_TO_GET_ARGUMENTS_DESCRIPTION used a private
residential address as their example. They now use public buildings, the same
ones the test cases use, and the preposition example uses "Vieira Carvalho"
against "Vieira de Carvalho", verified live as 3 results against 0.

Behaviour is unchanged: test_sources.py still returns 279 and 227 entries, and
pytest tests/ passes.
…n it

The signature helper walks every stream in the PDF to find the embedded PNG's
name and the circuit codes. It decompressed each one, the 6000x4161 bitmap
included, which inflates to roughly 75 MB. The "does it contain Tj or TJ"
guard always passes on that much binary noise, so the parenthesised-text regex
then ran over it, matched millions of times, and kept every match as a Python
object.

On a real 960 KB calendar PDF that peaked at 1234 MB and took 19.8 seconds,
producing 22.8 million characters of garbage. Since this runs inside the Home
Assistant process, on a 2 GB installation it means the kernel OOM killer takes
Home Assistant down; the reporter's kernel journal shows the victim at 1.6 GB.

Decompression is now bounded to 2 MiB per stream with decompressobj, and any
stream that does not fit is skipped as an image. Content streams in these
calendars are a few kilobytes.

After the fix that helper peaks at 26.6 MB in 0.0 s and returns the same
signature. A full read, signature plus calendar, peaks at 61 MB.
@CargoTechSolutions

Copy link
Copy Markdown
Author

Updated and ready for review. Since opening this I found and fixed a serious memory problem in my own code, so the description above has been rewritten to describe the final state rather than the original one.

The short version: the signature helper was decompressing every stream in the PDF to look for the embedded PNG's name, the 25 megapixel bitmap included, and then running a text regex over 75 MB of binary noise. On a 960 KB PDF that peaked at 1234 MB and took 19.8 seconds. Since the parse runs inside the Home Assistant process, that was enough for the kernel OOM killer to take Home Assistant down repeatedly on a 2 GB installation.

Decompression is now bounded per stream, calendar selection is structural rather than colour-scored so the large image is normally never decoded at all, and colour classification no longer builds a full RGB image per palette entry. A full read now peaks at 61 MB over a 19.8 MB baseline.

I would rather have caught that before submitting. Flagging it explicitly because "runs inside the HA process" makes footprint a correctness question, not a tuning one, and a reviewer should not have to discover it.

pytest tests/ still passes at 39, ruff is clean, and test_sources.py -s maiambiente_pt returns the same 279 and 227 entries as before. It has also been running on a live Home Assistant OS install since today without issues.

@bbr111

bbr111 commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator

Since we are currently maintaining both v2 and the v3 beta, I think this might be a good candidate to target v3 only.

We are not generally freezing v2 yet, but this source introduces significantly more complexity than a typical source: a custom PDF/image parser, Pillow usage and the TLS certificate workaround.

Pillow is already part of the v3 dependencies, and avoiding this additional complexity in both branches would reduce the maintenance burden while we move towards v3.

So unless there is a strong reason to have this available in v2 as well, I would prefer targeting release/3.0.0.

@markvp What do you think?

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