Affald fra mail til stemmebesked – sådan husker vi skraldespanden

Hvem har ikke stået en morgen og hørt skraldebilen dreje om hjørnet – med spanden stadig inde i carporten? Hos os er det et løst problem. Forsyningen sender en mail dagen før tømning, og Home Assistant gør resten: push-besked, kalender, kort på tabletten, en stemmebesked om aftenen og til sidst et kamera, der tjekker, om spanden faktisk står ude.

Overblikket

  1. Ca. kl. 16 dagen før tømning kommer en mail fra forsyningen (via affaldonline.dk).
  2. Home Assistant læser mailen og sender en push-besked, gemmer teksten og sikrer, at tømningen står i affaldskalenderen.
  3. Tabletten i køkkenet viser et kort: “Mad/Rest – tømmes i morgen”.
  4. Kl. 19 siger Home Assistant Voice beskeden højt – medmindre nogen allerede har trykket “Kørt ud” på tabletten.
  5. Kl. 18 aftenen før – og når der sker noget ved spanden – tæller et kamera med AI beholderne på deres faste plads. Mangler der en, er den kørt ud, og stemmebeskeden kl. 19 springes over.

1. En mailkonto kun til Home Assistant

Mailen fra forsyningen går til en separat mailkonto, som kun Home Assistant læser. Den er koblet på med den indbyggede IMAP-integration, som affyrer et imap_content-event, hver gang der kommer en ny mail. Kontoen er kun til notifikationer – den kan ikke bruges til at styre noget.

Det lyder enkelt, men der var en del fælder undervejs:

  • Afsenderen er ikke forsyningen. Mailen kommer fra noreply@affaldonline.dk, mens Return-Path er no_reply@affaldonline.dk. Derfor matcher automationen på “affaldonline” som en del af afsenderen i stedet for en præcis adresse.
  • Emnefeltet kommer forkert ud. “Tømning” bliver til “T?mning”, mens selve mailteksten har korrekte danske tegn. Al tekst hentes derfor fra mailens indhold.
  • Teksten er HTML. Feltet text indeholder HTML-udgaven, ikke den rene tekst. Fjerner man bare HTML-tags, står hele CSS-blokken tilbage – så <style> skal fjernes først.
  • Standardgrænsen på 2048 tegn er for lille. Mailen bliver klippet af, før den egentlige besked kommer. Sæt max message size i integrationens indstillinger op – her er den 16384.
  • Datoen er ikke en tekst. trigger.event.data.date er et datetime-objekt. Prøver man at lave hele eventet om til JSON, fejler automationen stille: Den står som kørt, men der sker ingenting.
  • Marker mailen som læst. Ingen læser kontoen, så uden et kald til imap.seen bliver mailen ved med at være ulæst – og hver genstart af Home Assistant giver en ny notifikation.

Fra HTML-mail til en sætning man kan sige højt

Mailen ser sådan ud: “Du får tømt din mad- og restaffaldsbeholder – [adresse] – i morgen.” Automationen renser den i tre trin med variabler:

variables:
  # 1. Fjern <style>, så HTML-tags, og skær hilsenen fra
  body_clean: >-
    {% set c = trigger.event.data.text | default('') | string
       | regex_replace('(?s)<style.*?</style>', ' ')
       | regex_replace('<[^>]+>', ' ')
       | replace(' ', ' ')
       | regex_replace('\s+', ' ') | trim %}
    {{ c.split('Mvh.')[0] | trim }}
  # 2. Brug en standardtekst, hvis der ikke kom noget brugbart ud
  msg: "{{ body_clean if body_clean | length > 5 else 'Ny besked om affaldstømning' }}"
  # 3. Fjern adressen (det midterste led) til stemmebeskeden
  spoken: >-
    {% set p = msg.split(' - ') %}{% if p | length >= 3 %}{{ (p[0] ~ ' ' ~ (p[2:] | join(' '))) | regex_replace('\s+', ' ') | trim }}{% else %}{{ msg }}{% endif %}

Push-beskeden får hele teksten, men stemme beskeden får udgaven uden adresse. Det lyder meget mere naturligt at høre “Du får tømt din mad- og restaffaldsbeholder i morgen” end at få læst sin egen adresse op. Teksten gemmes i en input_text (klippet til 250 tegn, da en input_text højst kan rumme 255), og en input_boolean slås til som flag for, at der venter en besked.

2. Kalenderen er sandheden

Affaldskalenderen fyldes normalt udefra med alle årets tømninger. Mail-automationen bruger den som sikkerhedsnet: Hvis mailen siger “i morgen”, slår den op i kalenderen med calendar.get_events, og kun hvis datoen er tom, oprettes en heldagsbegivenhed med den rigtige affaldstype. Så bliver der aldrig dubletter, og der bliver aldrig gættet på en dato, hvis forsyningen ændrer ordlyden.

Tabletkortet læser fra kalenderen – ikke fra mailen – så det virker også, hvis en mail skulle gå tabt. Her lå endnu en fælde: En kalenders attributter beskriver den begivenhed, der er i gang nu, ikke den næste. På en tømningsdag kører heldagsbegivenheden fra 00 til 24 og skygger for morgendagens tømning. Da vi havde to tømninger i træk, forsvandt kortet præcis når det skulle bruges. Løsningen er en lille automation, der hvert kvarter kalder calendar.get_events for i dag og i morgen og skriver resultatet til en hjælper, f.eks. “🌿 Haveaffald — tømmes i morgen”.

3. Stemmebesked kl. 19

Mailen kommer om eftermiddagen, så kl. 19 samme aften er et godt tidspunkt – alle er hjemme, og der er stadig tid til at køre spanden ud. Beskeden siges kun, hvis flaget er sat, og flaget slås fra bagefter, så den kun siges én gang:

alias: "Affald: stemmeannoncering kl. 19"
mode: single
triggers:
  - trigger: time
    at: "19:00:00"
conditions:
  - condition: state
    entity_id: input_boolean.affald_afventer
    state: "on"
actions:
  - action: assist_satellite.announce
    target:
      entity_id: assist_satellite.home_assistant_voice_092cb5_assist_satellite
    data:
      message: "{{ states('input_text.affald_besked') }}"
      preannounce: true
    continue_on_error: true
  - action: input_boolean.turn_off
    target:
      entity_id: input_boolean.affald_afventer

preannounce spiller en lille lyd først, så man når at lytte efter. continue_on_error sørger for, at flaget stadig bliver slået fra, selv hvis højttaleren er offline – ellers ville den gamle besked blive læst op aftenen efter.

4. “Kørt ud”-knappen

Er spanden allerede kørt ud, er der ingen grund til at blive mindet om det. På tabletkortet er der derfor en knap, “Kørt ud”. Trykker man på den, slås flaget fra, og stemmebeskeden springes over. Når påmindelsen slukker efter tømningen, nulstilles knappen igen:

alias: "Affald: kvittering for kørt ud"
mode: queued
triggers:
  - trigger: state
    entity_id: input_boolean.skraldespand_kort_ud
    from: "off"
    to: "on"
    id: kvitteret
  - trigger: state
    entity_id: binary_sensor.affald_snart
    from: "on"
    to: "off"
    id: toemt
actions:
  - choose:
      - conditions:
          - condition: trigger
            id: kvitteret
        sequence:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.affald_afventer
      - conditions:
          - condition: trigger
            id: toemt
        sequence:
          - action: input_boolean.turn_off
            target:
              entity_id: input_boolean.skraldespand_kort_ud

5. Kameraet tjekker efter

Kl. 18 aftenen før hver tømning – seks timer før kalenderens heldagsbegivenhed starter – tager et af UniFi-kameraerne ved indkørslen et billede, og HACS-integrationen LLM Vision spørger en AI-model, om affaldsbeholderne er kørt ud. Det er også derfor, kalenderen skal være komplet – mangler en tømning, bliver tjekket aldrig kørt. Og tidspunktet er ikke tilfældigt: kl. 18 er en time før stemmebeskeden.

Tre fejl der gjorde kameraet blindt

Da jeg gravede i det for at skrive dette indlæg, viste det sig, at analysen aldrig var blevet kørt:

  1. Et tomt trin stoppede alt. Første trin var et ai_task.generate_data uden data – en rest fra en tidligere udgave. Home Assistant afviser kaldet, og automationen stopper, før analysen nås. Udefra så alt normalt ud: “sidst udløst kl. 18:00”, ingen fejlbesked.
  2. Udbyderen var slået fra. AI-udbyderen i LLM Vision var deaktiveret, og LLM Vision-hukommelsen pegede på et billede, der var slettet. Løsningen blev at oprette Google Gemini som ny udbyder og rydde op i hukommelsen.
  3. Kameraet der både virker og ikke virker. Det valgte kamera stod som unavailable, så mit første gæt var, at det var forkert. Men da jeg hentede billeder fra begge kameraer, var det netop det “utilgængelige”, der kunne se beholderne. Entiteten skifter til recording et splitsekund hvert tiende minut – men snapshot virker fint.

Tæl i stedet for at spørge

Første udgave spurgte modellen direkte: Er beholderne kørt ud? Svaret skulle starte med JA, NEJ eller USIKKER. Det gik galt på to måder. Modellen skrev “NEJ. Affaldsbeholderne står …” på én linje, så svaret måtte læses som første ord i stedet for første linje. Og værre: Dagen efter svarede den JA til præcis samme scene – beholderne stod på deres faste plads. Det var ikke kun modellens skyld. Spandene stilles ud ved vejen, og den kan kameraet slet ikke se. Spørgsmålet var umuligt at besvare.

Nu spørger automationen om noget, kameraet kan se: Hvor mange beholdere står på deres faste plads? Normalt er der tre – to ved garagegavlen og én ved buskene. Første linje skal kun være antallet med cifre, eller USIKKER, hvis det er for mørkt, eller noget dækker. At tælle er en langt enklere opgave end at gætte på noget uden for billedet. Svaret læses som første ord eller tal, efter at alt andet end bogstaver og cifre er fjernet:

Svar fra modellenAntalBetyder
3 + beskrivelse på ny linje3Alle står hjemme – intet sker
**2.** To ved gavlen2En er kørt ud – “Kørt ud” slås til
USIKKER, mørkt–Intet sker
tre eller Antal: 2–Ikke et rent tal – intet sker
(fejl / intet svar)–Intet sker

Koblet til “Kørt ud”

Mangler der en beholder, mens der er tømning på vej, slår automationen den samme “Kørt ud”-hjælper til som knappen på tabletten – og stemmebeskeden kl. 19 springes over. Alt andet ændrer ingenting. Det er bevidst asymmetrisk: Det værste, der kan ske, er en påmindelse for meget – aldrig en for lidt.

Kravet om, at der skal være tømning på vej, kom efter en testkørsel. Uden det kunne et forkert svar mellem to tømninger slå “Kørt ud” til – og så ville stemmebeskeden ved næste tømning blive sprunget over, fordi hjælperen først nulstilles efter en tømning.

Tjek når der sker noget ved spanden

Kl. 18 er sikkerhedsnettet. Derudover kalder en webhook Home Assistant, når der sker noget ved skraldespanden. Er der tømning på vej, og er der ikke allerede kvitteret, venter automationen 30 sekunder, så spanden er rullet væk, og starter så den samme optælling. Bagefter holder den 10 minutters pause, hvor nye webhooks ignoreres, så travlhed ved spanden ikke giver en strøm af AI-kald. Logikken findes kun ét sted – webhook-automationen kalder bare kameraautomationen.

alias: Analyze Trashcan Image
mode: single
variables:
  normalt_antal: 3   # beholdere på deres faste plads
triggers:
  # Heldagsbegivenheder starter 00:00, så -6 t = kl. 18 aftenen før
  - trigger: calendar
    entity_id: calendar.affalds_kalender
    event: start
    offset: "-6:0:0"
actions:
  - action: llmvision.image_analyzer
    continue_on_error: true
    response_variable: analyse
    data:
      provider: DIN_LLM_VISION_UDBYDER   # f.eks. Google Gemini
      image_entity:
        - camera.g6_turret_high_resolution_channel_2
      message: >-
        Billedet er fra et overvågningskamera ved en indkørsel med garager.
        Tæl affaldsbeholderne (sorte/grå containere på hjul) i billedet.
        Normalt står der {{ normalt_antal }}: to ved gavlen af garagelængen
        og én længere til venstre ved buskene.
        Første linje skal KUN være antallet som et tal med cifre.
        Kan du ikke se området tydeligt, så skriv USIKKER i stedet.
        Beskriv derefter kort på dansk, hvor beholderne står.
      store_in_timeline: true
      use_memory: false
      target_width: 1280
      max_tokens: 300
      response_format: text
  # Første ord/tal i svaret; antal = -1, hvis det ikke er et rent tal
  - variables:
      svar: >-
        {{ ((analyse.response_text if analyse is defined and analyse is mapping else '')
            | regex_replace('[^A-Za-zÆØÅæøå0-9]+', ' ') | trim | upper).split(' ')[0] }}
      antal: "{{ svar | int(-1) if svar is match('^[0-9]+$') else -1 }}"
  # Kun når der er tømning på vej, og en beholder mangler
  - if:
      - condition: state
        entity_id: binary_sensor.affald_snart
        state: "on"
      - condition: template
        value_template: "{{ 0 <= antal | int(-1) < normalt_antal }}"
    then:
      - action: input_boolean.turn_on
        target:
          entity_id: input_boolean.skraldespand_kort_ud

continue_on_error på analysen betyder, at automationen fortsætter, hvis kameraet eller AI-tjenesten er nede – antal bliver -1, og den normale påmindelse kører. Tjekket på trigger.platform gør, at automationen også kan testkøres manuelt, hvor der ingen kalenderbegivenhed er.

Det jeg har lært

  • Stol ikke på mailens overflade. Afsender, emnefelt og tekstformat var alle anderledes end forventet. Kig på det rå event i Udviklerværktøjer → Events, før du skriver automationen.
  • Brug én kilde til sandheden. Mailen er triggeren, men kalenderen er det, alt andet læser fra.
  • Spørg kun om det, kameraet kan se. “Er spanden kørt ud?” kunne ikke besvares, fordi vejen er uden for billedet. “Hvor mange står på deres plads?” kan.
  • Tænk på de stille fejl. Et JSON-kald der fejler, en højttaler der er offline, en mail der aldrig markeres som læst – det er dem, der giver mærkelig opførsel uger senere.

Resultatet er, at ingen i familien behøver at holde styr på tømmekalenderen længere. Huset siger til.