🌙 UniFi WLAN-AP (U6+) nachts aus per HA — mit Wochenend-Zeiten und Always-On-Override

Hallo HA-Freunde!

Vielleicht hilfts dem ein oder anderen — vor allem Eltern hier im Forum :wink:

Worum gehts?

Ich wollte unseren WLAN-AP im Kinderzimmer nachts wirklich aus haben. Nicht “SSID versteckt”, nicht “Bandsteering blockt 2.4 GHz” — sondern Radio off, Funkstille, kein Signal. Aus zwei Gruenden:

  1. Strahlung im Schlafzimmer — wir wollten den AP einfach lieber nicht durchballern lassen wenn die Kinder schlafen.

  2. “Bildschirmzeit zu Ende”-Funktion — die Kids haben mal Tablets in der Hand, mal nicht. WLAN um 21:00 wegnehmen ist eine sanfte aber wirksame Bremse. Am Wochenende darf es laenger gehen.

Und dann noch der Modus-Schalter: wenn Besuch da ist oder ich abends noch ne Folge auf dem Tablet zu Ende schaue → tap auf “immer_an” und der AP bleibt durch. Morgens wieder zurueck auf “auto”. Klingt simpel, ist aber genau das was mir bei reinen Zeitplaenen immer gefehlt hat.

:warning: DER WICHTIGSTE GRUND: UniFi OS macht das NICHT richtig!

Bevor wir loslegen — das ist der eigentliche Aufhaenger fuer diesen ganzen Post:

Die in UniFi OS eingebaute “WiFi Schedule”-Funktion schaltet das Radio NICHT aus! Sie unterdrueckt nur die SSID-Bekanntmachung — das Funkmodul sendet trotzdem weiter (Beacons, Probe-Responses, je nach Firmware mehr oder weniger). Wenn ihr also ueber UniFi → WiFi → Schedule “Off” zwischen 22:00 und 06:00 setzt:

  • :cross_mark: Radio sendet weiter (messt es selber mit Wifi Analyzer, ihr seht den AP)

  • :cross_mark: Stromverbrauch nahezu identisch

  • :cross_mark: Funkstille im Kinderzimmer? Fehlanzeige

Das hat mich erstmal ueberrascht. Ich dachte echt der Zeitplan macht den Job — tut er nicht. Erst als ich ueber den disabled-Flag der Device-API gestolpert bin, gings los: das deaktiviert das Radio wirklich physisch, der AP ist im Wifi-Scanner komplett weg.

Genau dafuer ist diese HA-Bastelloesung. Es gibt schlicht keinen “richtigen” Aus-Schalter in der UniFi-UI selber — den muss man sich ueber die API holen.

Das Endergebnis sieht so aus (Screenshot vom Dashboard):

Modus + Status oben, darunter die vier Zeitfelder — direkt im Dashboard editierbar. Keine YAML-Frickelei mehr fĂŒr Zeit-Änderungen.


Warum nicht die offizielle UniFi-Integration?

Hab ich auch erst versucht. Die unifi-Integration in HA bringt Switches fĂŒr Clients, fĂŒr Ports, fĂŒr Reconnect-Aktionen — aber nicht das AP-Radio. Das geht nur ĂŒber den device-Endpunkt der Controller-REST-API. Also: kleines Python-Skript drum bauen, fertig. Ist ĂŒberschaubar.

Wenn jemand einen Weg ĂŒber die offizielle Integration kennt → ich freu mich ĂŒber Hinweise! :folded_hands:


Was ihr braucht

  • Einen UniFi-Controller (Cloud Key, UDM, Dream Router oder selbst gehostete UniFi Network Application). Bei mir Self-Hosted auf Port 8443

  • Home Assistant (getestet auf 2025.10.1)

  • AP mit eindeutigem Namen im Controller (Devices → Einstellungen → Name): bei mir U6+_BZ und U6+_WZ

  • ~15 Minuten Zeit


Schritt 1: Lokalen UniFi-User anlegen

WICHTIG: kein SSO-Account! Wir brauchen einen “klassischen” lokalen Admin sonst klappt der API-Login nicht.

UniFi Network App → Settings → Admins & Users → Add New Admin → “Restrict to local access only”

  • Username: ha-integration

  • Passwort: irgendwas langes Random (NICHT euer Hauptpasswort)

  • Role: Site Admin


Schritt 2: Python-Skript

Das macht die eigentliche Arbeit. Ein Kommando, ein AP, on oder off. Wir packen’s nach /usr/local/bin/unifi-ap-radio.py auf den Server der HA ausfĂŒhrt:

#!/usr/bin/env python3
"""
unifi-ap-radio.py - UniFi-AP-Radios per API ein/aus

Aufruf:
  unifi-ap-radio.py off <ap_name>
  unifi-ap-radio.py on  <ap_name>

Config: ~/.config/unifi-ap-radio.ini  (chmod 600!)
  [controller]
  host = https://192.168.0.52:8443
  user = ha-integration
  password = STARK_UND_EINMALIG
  site = default
"""

import sys
import configparser
import pathlib
import requests
from urllib3 import disable_warnings

disable_warnings()

CONFIG = pathlib.Path.home() / ".config" / "unifi-ap-radio.ini"


def die(msg, code=1):
    print(f"FEHLER: {msg}", file=sys.stderr)
    sys.exit(code)


def main():
    if len(sys.argv) != 3 or sys.argv[1] not in ("on", "off"):
        die("Aufruf: unifi-ap-radio.py {on|off} <ap_name>", code=2)

    action, ap_name = sys.argv[1], sys.argv[2]

    if not CONFIG.exists():
        die(f"Config fehlt: {CONFIG}")

    cfg = configparser.ConfigParser()
    cfg.read(CONFIG)
    c = cfg["controller"]

    s = requests.Session()
    s.verify = False  # selbstsigniertes Cert vom Controller — passt

    # Login
    r = s.post(
        f"{c['host']}/api/login",
        json={"username": c["user"], "password": c["password"]},
        timeout=10,
    )
    if not r.ok:
        die(f"Login fehlgeschlagen: {r.status_code}")

    # AP per Namen suchen
    r = s.get(f"{c['host']}/api/s/{c['site']}/stat/device", timeout=10)
    devs = r.json().get("data", [])
    ap = next((d for d in devs if d.get("name") == ap_name), None)
    if not ap:
        existing = ", ".join(d.get("name", "?") for d in devs)
        die(f"AP nicht gefunden: '{ap_name}'. Vorhanden: {existing}")

    # Radio + LED zusammen schalten
    disabled = action == "off"
    payload = {
        "disabled": disabled,
        "led_override": "off" if disabled else "default",
    }
    r = s.put(
        f"{c['host']}/api/s/{c['site']}/rest/device/{ap['_id']}",
        json=payload,
        timeout=10,
    )
    if not r.ok:
        die(f"PUT fehlgeschlagen: {r.status_code}")

    print(f"OK: {ap_name} disabled={disabled}")


if __name__ == "__main__":
    main()

Drauf machen und Config setzen:

chmod 755 /usr/local/bin/unifi-ap-radio.py

mkdir -p /root/.config
cat > /root/.config/unifi-ap-radio.ini <<'EOF'
[controller]
host = https://192.168.0.52:8443
user = ha-integration
password = MEIN_LANGES_PASSWORT
site = default
EOF
chmod 600 /root/.config/unifi-ap-radio.ini

# Testen!
/usr/local/bin/unifi-ap-radio.py off U6+_BZ
# Bei dir sollte stehen: OK: U6+_BZ disabled=True
# AP sollte 2-3 Sek spÀter dunkel werden (auch die LED), kein WLAN mehr.

/usr/local/bin/unifi-ap-radio.py on U6+_BZ
# Und schon ist er wieder an.

Wenn das hier schon klappt → der schwierige Teil ist durch. Der Rest ist HA-Standard. :tada:

Heads-up fĂŒr UDM/UDR/Cloud Key Gen2+ User: Bei UniFi-OS-GerĂ€ten muss der Pfad anders aussehen: :443/proxy/network/api/login statt :8443/api/login, und CSRF-Token muss man auch mitschleppen. Habe ich selbst nicht ausprobiert weil ich auf der Self-Hosted-App bin. Gibt’s aber Beispiele im Netz. Falls hier jemand das fertig hat → bitte gerne posten, dann ergĂ€nze ich’s oben.


Schritt 3: Home Assistant Konfiguration

Jetzt die HA-Seite. Drei Blöcke in die configuration.yaml:

Shell-Commands (so ruft HA das Skript)

shell_command:
  ap_bz_off: "/usr/local/bin/unifi-ap-radio.py off U6+_BZ"
  ap_bz_on:  "/usr/local/bin/unifi-ap-radio.py on  U6+_BZ"

Helpers fĂŒr Modus + Zeiten

input_select:
  ap_bz_modus:
    name: AP BZ Modus
    options:
      - auto
      - immer_an
      - immer_aus
    icon: mdi:wifi-cog

input_datetime:
  ap_bz_start_weekday:
    name: AP BZ Start Mo-Fr
    has_date: false
    has_time: true
    icon: mdi:wifi-arrow-up
  ap_bz_end_weekday:
    name: AP BZ Ende Mo-Fr
    has_date: false
    has_time: true
    icon: mdi:wifi-arrow-down
  ap_bz_start_weekend:
    name: AP BZ Start Sa-So
    has_date: false
    has_time: true
    icon: mdi:wifi-arrow-up
  ap_bz_end_weekend:
    name: AP BZ Ende Sa-So
    has_date: false
    has_time: true
    icon: mdi:wifi-arrow-down

Der “Soll der gerade an sein?”-Sensor

Hier wird’s interessant. Das Template muss nĂ€mlich auch funktionieren wenn das Zeitfenster ĂŒber Mitternacht geht (z. B. 21:00 bis 06:00 — also “abends ausschalten, morgens wieder an”). Da muss man drei FĂ€lle abklappern:

template:
  - binary_sensor:
      - name: AP BZ Soll AN
        unique_id: ap_bz_soll_an
        icon: mdi:wifi
        state: >
          {% set today_we = now().weekday() >= 5 %}
          {% set yest_we = ((now().weekday() - 1) % 7) >= 5 %}
          {% set s_today = states('input_datetime.ap_bz_start_weekend' if today_we else 'input_datetime.ap_bz_start_weekday') %}
          {% set e_today = states('input_datetime.ap_bz_end_weekend' if today_we else 'input_datetime.ap_bz_end_weekday') %}
          {% set s_yest = states('input_datetime.ap_bz_start_weekend' if yest_we else 'input_datetime.ap_bz_start_weekday') %}
          {% set e_yest = states('input_datetime.ap_bz_end_weekend' if yest_we else 'input_datetime.ap_bz_end_weekday') %}
          {% set now_t = now().strftime('%H:%M:%S') %}
          {% set in_today_normal = s_today <= e_today and s_today <= now_t < e_today %}
          {% set in_today_wrap = s_today > e_today and now_t >= s_today %}
          {% set in_yest_tail = s_yest > e_yest and now_t < e_yest %}
          {{ in_today_normal or in_today_wrap or in_yest_tail }}

Was hier passiert in Kurzfassung:

  • Fall 1 — in_today_normal: “ganz normal” wie 06:30 bis 21:00 an einem Wochentag — heute Start, heute Ende, jetzt dazwischen

  • Fall 2 — in_today_wrap: Wenn Start > Ende (also z. B. 22:00 bis 06:00) und es ist jetzt nach 22:00 → an

  • Fall 3 — in_yest_tail: Gleiche Logik, aber wir sind im “Tail” — also es ist z. B. 05:00 morgens und das Fenster von gestern lĂ€uft noch

Bonus: Da gestern auch ein Wochenend-Tag gewesen sein kann, mĂŒssen wir bei s_yest/e_yest ein eigenes yest_we Flag bauen. Sonst wĂŒrde Montag um 02:00 die falsche End-Zeit gegen Sonntag-Nachmittag vergleichen :sweat_smile:

Automation

- id: "20260519_unifi_ap_bz_sync"
  alias: AP BZ folgt Modus und Zeitfenstern
  triggers:
    - trigger: state
      entity_id: binary_sensor.ap_bz_soll_an
    - trigger: state
      entity_id: input_select.ap_bz_modus
    - trigger: homeassistant
      event: start
  actions:
    - choose:
        - conditions: "{{ is_state('input_select.ap_bz_modus', 'immer_an') }}"
          sequence:
            - action: shell_command.ap_bz_on
        - conditions: "{{ is_state('input_select.ap_bz_modus', 'immer_aus') }}"
          sequence:
            - action: shell_command.ap_bz_off
        - conditions: "{{ is_state('input_select.ap_bz_modus', 'auto') and is_state('binary_sensor.ap_bz_soll_an', 'on') }}"
          sequence:
            - action: shell_command.ap_bz_on
        - conditions: "{{ is_state('input_select.ap_bz_modus', 'auto') and is_state('binary_sensor.ap_bz_soll_an', 'off') }}"
          sequence:
            - action: shell_command.ap_bz_off
  mode: single

Der homeassistant.start Trigger ist klein aber wichtig — nach HA-Restart wird der Zustand sofort wieder synchronisiert. Sonst könnte der AP in einem falschen Zustand “hĂ€ngen” bleiben.


Schritt 4: Dashboard

Tile-Cards, einfach gehalten. Erste Reihe Modus + Status (je 6 Cols), dann pro Zeitfeld eine Card volle Breite:

type: heading
heading: WLAN AP BZ
icon: mdi:wifi-cog

# Reihe 1
type: tile
entity: input_select.ap_bz_modus
name: Modus
icon: mdi:wifi-cog
color: blue
grid_options: { columns: 6, rows: 1 }

type: tile
entity: binary_sensor.ap_bz_soll_an
name: Status
icon: mdi:wifi
color: primary
grid_options: { columns: 6, rows: 1 }

# Reihe 2-5: Zeitfelder, volle Breite
type: tile
entity: input_datetime.ap_bz_start_weekday
name: Mo-Fr Start
icon: mdi:wifi-arrow-up
grid_options: { columns: 12, rows: 1 }

# ... entsprechend fĂŒr end_weekday, start_weekend, end_weekend

Tap auf 'ne Zeit öffnet den Picker direkt. Praktisch.


Mehrere APs?

Klar — der Stack ist pro AP geklont, alles mit einem anderen Suffix:

shell_command:
  ap_bz_off: "/usr/local/bin/unifi-ap-radio.py off U6+_BZ"
  ap_bz_on:  "/usr/local/bin/unifi-ap-radio.py on  U6+_BZ"
  ap_wz_off: "/usr/local/bin/unifi-ap-radio.py off U6+_WZ"
  ap_wz_on:  "/usr/local/bin/unifi-ap-radio.py on  U6+_WZ"

Bei mir lĂ€uft das fĂŒr Wohnzimmer (Eltern-Schedule, abends spĂ€ter aus) und Budlizimmer (Kinder, frĂŒher aus). Funktioniert beides parallel ohne Probleme.


Stolperfallen die ich hatte

“AP nicht gefunden” → Name im Controller checken, der muss byte-genau passen. Wenn U6+_BZ da steht, dann muss das Skript auch U6+_BZ aufrufen, nicht U6+ BZ oder U6+-BZ. Sonderzeichen sind erlaubt aber genau matchen.

“Login fehlgeschlagen” → fast immer der SSO-Account vs. Local-Admin-Account. Local-Only nochmal anlegen, andere Credentials in der ini.

AP wacht erst nach 30+ Sekunden auf → habe ich bei sehr alten AC-Pro mal gesehen. U6+ macht das in 5-7 Sek. Falls jemand das Problem hat: force_provision ĂŒber die Controller-UI löst das in der Regel.

Klienten hĂ€ngen “verbunden aber kein Internet” → kann passieren wenn der AP der einzige im Bereich ist. Logisch. Bei uns ist im EG noch ein U6+ aktiv wĂ€hrend der OG-AP nachts schlĂ€ft.

HA-Restart und AP bleibt im falschen Zustand → genau dafĂŒr ist der homeassistant.start Trigger im Automation. Falls ihr den vergesst, kann der AP nach HA-Restart “hĂ€ngen”. Bei mir hat das einmal zu 'nem Sonntag-Morgen “warum geht das Internet nicht?!” gefĂŒhrt :grimacing:


Sicherheit

  • INI mit Klartextpasswort: chmod 600. Wer das File lesen kann, hat Zugriff aufs Controller-Konto

  • Der lokale Admin hat keinen Cloud-Zugriff — Schaden bei Leak ist auf LAN begrenzt

  • Skript lĂ€uft als der User der HA startet. Bei meinem DietPi-Setup ist das root, daher Config in /root/.config/. Bei HA-OS oder HACS-Container mĂŒsst ihr pathlib.Path.home() evtl. an euren Pfad anpassen


Fazit

LĂ€uft bei mir seit ein paar Wochen ohne Probleme. Kinder haben sich an die “WLAN-Zeiten” gewöhnt, ich hab Funkruhe im Schlafzimmer und kann jederzeit ĂŒberbrĂŒcken wenn ich will. WĂ€re froh ĂŒber Feedback, VerbesserungsvorschlĂ€ge, andere Lösungen — vor allem fĂŒr UDM/UDR User wĂ€re ein Kommentar mit dem angepassten Login-Pfad Gold wert. :folded_hands:

Falls ihr Fragen habt, einfach reinhauen. Wenn’s sich lohnt baue ich auch ein eigenes Repo daraus mit 'ner README.

Liebe GrĂŒĂŸe,


Setup-Stack bei mir

  • DietPi auf Raspberry Pi 5 (HA 2025.10.1 als Docker-loses Setup)

  • UniFi Network Application 8.x (Self-Hosted)

  • 2× UniFi U6+ APs (Wohnzimmer + Kinderzimmer/Schlafzimmer)

  • Beide APs auf 2.4 GHz Channel 1 (Co-Channel-Deployment, falls jemand fragt — Zigbee+Thread sind die GrĂŒnde dafĂŒr, aber das ist ein anderer Thread :grinning_face_with_smiling_eyes:)