Automatic Generational Backup of Every User-Configurable File (JSON & YAML) — now with selective restore, redaction, and encryption

[!NOTE]
Updated — the project now lives on GitHub: GitHub - FortranFour/ha-config-export: Generational YAML/JSON config backups for Home Assistant, with .storage converted to YAML and selective restore · GitHub

It has gained selective file-by-file restore, search and sorting, optional redaction and encryption, and several minor fixes. The code in the next two posts below is the original version and is kept for reference; install from the repository instead. Later posts in this thread announce what has changed since.

Home Assistant’s built-in backups restore a whole instance well. They are less good at answering the question I keep having:

What did my dashboard, automations, or scripts, etc. look like three weeks ago, before I broke it?

So I put together a script that quickly and compactly exports every user-configurable YAML and JSON file to a Samba share, browsable on your PC/Mac/Linux desktop, on a schedule, converts the UI-managed JSON in .storage into readable YAML, and keeps generational copies — 7 daily, 4 weekly, 12 monthly, 5 yearly. Plus a dashboard card to run it, schedule it, and see what’s on disk.


What it does

  • Copies every *.yaml, *.yml and *.json in your config directory
  • Copies all of .storage — helpers, entity/device/area/floor/label registries, energy config, exposed entities, config entries
  • Converts every JSON file to YAML alongside the original, so you can read and diff them
  • Extracts each UI dashboard from its .storage wrapper into a ready-to-use YAML-mode dashboard file
  • Fetches the Frigate add-on config over its API (add-on configs aren’t readable from the HA Core container)
  • Keeps GFS generations with hardlinks, so 28 generations cost roughly the size of one
  • Maintains an uncompressed latest/ mirror for browsing and diffing without extracting anything
  • Writes a MANIFEST.txt with SHA-256 per file, so diffing two generations tells you exactly what changed

Typical run here: ~190 files, ~120 converted to YAML, 4.3 MB compressed, 12 seconds.

Deliberately skipped: custom_components/, www/, deps/, the database, logs, .storage/core.restore_state, saved traces, SQLite scratch files, and anything over 25 MB. All tunable at the top of the script.

:warning: Nothing is redacted. secrets.yaml, .storage/auth*, API tokens, ADB keys, camera RTSP credentials and Frigate’s raw config all land in the share in plaintext. That is intentional — it’s what makes this restore-grade — but the destination deserves the same protection as your config directory. Don’t put it anywhere world-readable.


Requirements

  • Home Assistant OS / Supervised (tested on HA OS, bare metal)
  • The Samba share add-on
  • packages: !include_dir_named packages in configuration.yaml
  • For the card: stack-in-card, mushroom, button-card, card-mod, fold-entity-row (all HACS)

No Python dependencies — it uses PyYAML if present (it is, inside the HA Core container) and falls back to a built-in YAML emitter otherwise.


Step 1 — Set up the Samba share

On Home Assistant: Settings → Add-ons → Add-on Store → Samba share. Install, set a username and password in the Configuration tab, start it, enable “Start on boot”. The add-on exposes config, share, media, backup, ssl and addon_configs. This project lives in share.

Windows

File Explorer → This PC → Map network drive:

Folder:  \\homeassistant\share

Tick “Reconnect at sign-in” and “Connect using different credentials”, then enter the add-on username and password. If the hostname doesn’t resolve, use the IP: \\192.168.1.50\share. From the command line instead:

net use Z: \\homeassistant\share /persistent:yes

macOS

Finder → Go → Connect to Server (⌘K):

smb://homeassistant/share

Enter the add-on credentials. To remount at login, add it under System Settings → General → Login Items. It mounts at /Volumes/share.

Linux

One-off via your file manager: smb://homeassistant/share

Permanent via /etc/fstab (install cifs-utils first):

//homeassistant/share  /mnt/ha-share  cifs  credentials=/etc/ha-smb.creds,uid=1000,gid=1000,iocharset=utf8  0  0

with /etc/ha-smb.creds containing username= and password= lines. chmod 600 it, then sudo mount -a.


Step 2 — Install the script

Create a folder ha_config_backup in the share and drop ha_config_backup.py into it, so it lands at /share/ha_config_backup/ha_config_backup.py.

It lives in the share rather than in /config for two reasons: Home Assistant updates can never touch it, and the backups sit next to it where you can already reach them from your desktop. The script locates its own directory, so moving the whole folder elsewhere just works.

:arrow_down: The script is in post #2 below — at 750 lines it’s past the per-post limit.


Step 3 — Install the package

Save as /config/packages/config_yaml_export.yaml and reload. This creates:

Entity Purpose
input_datetime.config_export_time Run time, settable from the card
script.config_export_run_now The “Back up now” button
automation.config_export_daily The schedule
sensor.config_export_status Status plus per-tier generation inventory

Change the notify service in the failure branch to your own, or delete it — failures also raise a persistent notification, which always works.

config_yaml_export.yaml (click to expand)
# =============================================================================
#  config_yaml_export.yaml
#  Drop into /config/packages/ — picked up by `packages: !include_dir_named packages`
#
#  Backend for the "Configuration Export" dashboard card. Schedules and reports
#  on ha_config_backup.py, which lives in the Samba share at
#  /share/ha_config_backup/ so Home Assistant updates cannot touch it.
#
#  Entities created:
#    input_datetime.config_export_time   run time, settable from the card
#    script.config_export_run_now        "Back up now" button
#    automation.config_export_daily      the schedule
#    sensor.config_export_status         status + per-tier generation inventory
#
# -----------------------------------------------------------------------------
#  MAKING THE CARD'S FOLDER BUTTONS CLICKABLE (Edge / Chrome, Windows)
# -----------------------------------------------------------------------------
#  The Daily / Weekly / Monthly / Yearly buttons on the card point at
#  file://homeassistant/share/ha_config_backup/<tier>/. Browsers block file://
#  links opened from an http(s) page by default, so out of the box those
#  buttons do nothing. One registry policy re-enables them.
#
#  Run an ELEVATED Command Prompt (Win+X > Terminal (Admin)) and paste the
#  line for your browser:
#
#    Edge:
#      reg add "HKLM\SOFTWARE\Policies\Microsoft\Edge\URLAllowlist" /v 1 /t REG_SZ /d "file:///" /f
#
#    Chrome:
#      reg add "HKLM\SOFTWARE\Policies\Google\Chrome\URLAllowlist" /v 1 /t REG_SZ /d "file:///" /f
#
#  Fully close and reopen the browser (check Task Manager for leftover
#  processes). Confirm it took at edge://policy or chrome://policy — look for
#  URLAllowlist with the value file:///.
#
#  To undo:
#      reg delete "HKLM\SOFTWARE\Policies\Microsoft\Edge\URLAllowlist" /v 1 /f
#
#  Notes:
#    - This whitelists ALL file:// links, not just these. Any page you visit
#      can then offer clickable links to your local filesystem. Clicking is
#      still required, but it is a real loosening of a browser default.
#    - The policy affects the desktop browser only. The Home Assistant
#      companion app cannot open file:// under any configuration.
#    - Prefer a drive letter? Map the share to Z: in Explorer, then edit the
#      four url_path lines in the card to file:///Z:/daily/, file:///Z:/weekly/
#      and so on. Same policy requirement applies.
#    - Not comfortable with the policy? Leave it alone. The card prints the
#      UNC paths as selectable text underneath the table — copy one into
#      Explorer's address bar and it opens with no changes to your browser.
# =============================================================================

shell_command:
  config_yaml_export: "python3 /share/ha_config_backup/ha_config_backup.py"
  # Diagnostics. Run from Developer Tools > Actions and read the response:
  # it reports the resolved config directory, write access and file counts.
  config_yaml_export_check: "python3 /share/ha_config_backup/ha_config_backup.py --check"

input_datetime:
  config_export_time:
    name: Config export time
    icon: mdi:clock-outline
    has_date: false
    has_time: true

command_line:
  - sensor:
      name: "Config Export Status"
      unique_id: config_yaml_export_status
      command: "python3 /share/ha_config_backup/ha_config_backup.py --report"
      value_template: "{{ value_json.status }}"
      json_attributes:
        - root
        - generated
        - daily
        - weekly
        - monthly
        - yearly
        - total_mb
        - last_run
      scan_interval: 900
      command_timeout: 30

script:
  config_export_run_now:
    alias: Config export run now
    icon: mdi:play-circle-outline
    mode: single
    sequence:
      - action: shell_command.config_yaml_export
        response_variable: export
      - action: homeassistant.update_entity
        target:
          entity_id: sensor.config_export_status
      - if:
          - condition: template
            value_template: "{{ export.returncode | int(1) != 0 }}"
        then:
          # persistent_notification first: it always exists, so a wrong
          # notify service name can no longer swallow the failure.
          - action: persistent_notification.create
            data:
              notification_id: config_export_failed
              title: Config export failed
              message: >-
                Exit code {{ export.returncode }}.

                {{ (export.stderr or export.stdout or 'No output.')
                   | truncate(600) }}
          - action: notify.mobile_app_YOUR_PHONE
            continue_on_error: true
            data:
              title: Config export failed
              message: >-
                Exit {{ export.returncode }}. {{ (export.stderr or export.stdout)
                | replace('\n', ' ') | truncate(180) }}
        else:
          - action: persistent_notification.dismiss
            data:
              notification_id: config_export_failed
          - action: persistent_notification.create
            data:
              notification_id: config_export_ok
              title: Config export complete
              message: >-
                {{ state_attr('sensor.config_export_status', 'last_run').files }} files,
                {{ state_attr('sensor.config_export_status', 'last_run').converted }}
                converted to YAML,
                {{ state_attr('sensor.config_export_status', 'total_mb') }} MB on disk.

automation:
  - id: config_export_daily
    alias: Config export daily
    description: >-
      Runs the YAML/JSON configuration export at the time set on the dashboard
      card, keeping 7 daily / 4 weekly / 12 monthly / 5 yearly generations.
      A restart only triggers an export if the last one is over a day old.
    mode: single
    triggers:
      - trigger: time
        at: input_datetime.config_export_time
        id: scheduled
      - trigger: homeassistant
        event: start
        id: startup
    actions:
      - choose:
          # Restart path: settle, refresh the sensor, THEN judge staleness.
          # The staleness check has to come after the refresh — at startup the
          # command_line sensor has not polled yet and reads as unknown, which
          # would look like "never run" and export on every restart.
          - conditions:
              - condition: trigger
                id: startup
            sequence:
              - delay: "00:00:30"
              - action: homeassistant.update_entity
                target:
                  entity_id: sensor.config_export_status
              - if:
                  - condition: template
                    value_template: >-
                      {% set lr = state_attr('sensor.config_export_status', 'last_run') %}
                      {{ lr is not mapping or lr.timestamp is not defined
                         or (now() - (lr.timestamp | as_datetime | as_local)).total_seconds()
                            > 86400 }}
                then:
                  - action: script.turn_on
                    target:
                      entity_id: script.config_export_run_now
        # Scheduled runs and manual "Run actions" go straight through.
        default:
          - action: script.turn_on
            target:
              entity_id: script.config_export_run_now

The automation also has a restart trigger, but it’s gated: after a restart it waits 30 seconds, refreshes the sensor, and only exports if the last successful run is over 24 hours old. The order matters — the command_line sensor hasn’t polled yet at startup, so checking staleness before refreshing it fires an export on every single restart.


Step 4 — Add the card

:arrow_down: The card YAML is in post #3 below. Add it as a Manual card, then set a run time — the input_datetime starts empty and the schedule won’t fire until it has a value.


Step 5 — Test it

Developer Tools → Actions → shell_command.config_yaml_export_check, run in YAML mode and read the response. It reports the interpreter, whether the share is writable, which config directory it resolved, how many files it would copy, and whether Frigate’s API answered. That one command tells you what’s wrong before you schedule anything.

Then press Back up now and check /share/ha_config_backup/latest/. Logs land in logs/backup.log; logs/last_run.json feeds the sensor.


Making the folder buttons clickable

The Daily / Weekly / Monthly / Yearly buttons point at file:// URLs. Browsers block file:// links opened from an http(s) page, so out of the box those buttons do nothing. One policy re-enables them.

This whitelists all file:// links, not just these. Any page you visit can then offer clickable links into your local filesystem. Clicking is still required, but it is a real loosening of a browser default — decide knowingly. If you’d rather not, the card prints the path as selectable text; paste it into your file manager.

Windows (Edge / Chrome) — elevated Command Prompt:

reg add "HKLM\SOFTWARE\Policies\Microsoft\Edge\URLAllowlist" /v 1 /t REG_SZ /d "file:///" /f
reg add "HKLM\SOFTWARE\Policies\Google\Chrome\URLAllowlist" /v 1 /t REG_SZ /d "file:///" /f

Fully quit and reopen the browser, then confirm at edge://policy or chrome://policy. To undo, swap add for delete and drop the /t /d arguments. Prefer a drive letter? Map the share to Z: and use file:///Z:/daily/ in the card.

macOS:

defaults write com.google.Chrome URLAllowlist -array "file:///"
defaults write com.microsoft.Edge URLAllowlist -array "file:///"

Then point the four url_path lines at the mount: file:///Volumes/share/ha_config_backup/daily/

Linux:

sudo mkdir -p /etc/opt/chrome/policies/managed
echo '{"URLAllowlist": ["file:///"]}' | sudo tee /etc/opt/chrome/policies/managed/allow_file_links.json

(For Edge use /etc/opt/edge/policies/managed/.) Then use file:///mnt/ha-share/ha_config_backup/daily/.

The companion app cannot open file:// under any configuration. Desktop browser only.


Notes and gotchas

Add-on configs. Run as a shell_command, the script executes inside the HA Core container, which has no mount for /addon_configs. ESPHome YAML and other add-on configs aren’t reachable that way. Frigate is handled specially by pulling /api/config/raw from the add-on over HTTP — the script tries the Full Access slug first, then plain Frigate. If you want all add-on configs, run the script from the Advanced SSH & Web Terminal add-on on a cron instead; it can read /addon_configs directly and the script picks that up automatically.

Non-JSON files in .storage. Certificates, ADB keys, pickles and shell scripts live there too. They’re copied verbatim and counted separately rather than reported as conversion failures.

The backups are on the same disk as HA. This protects you from bad edits, botched updates and wrecked dashboards. It does not protect you from disk failure. A scheduled robocopy Z:\ha_config_backup C:\backups\ha_config_backup /MIR (or rsync) closes that gap.

Markdown card styling. If you fork the card: the markdown card strips class= and style= attributes, so all its CSS has to use element- or position-based selectors.


Credits

Written collaboratively with Claude (Anthropic) — design, script, package and card, plus a fair amount of debugging against a live instance. Posting it because the “readable, diffable, generational config history” gap seems like a common one.

Suggestions and improvements very welcome.

ha_config_backup.py

Save to /share/ha_config_backup/ha_config_backup.py. No dependencies beyond python3 — it uses PyYAML when available (it is, inside the HA Core container) and falls back to a built-in emitter otherwise.

Everything configurable sits in the settings block at the top: retention counts, compression, what to skip, the size cap, and the Frigate API URLs.

#!/usr/bin/env python3
"""
ha_config_backup.py
===================

Generational (GFS) export of every user-configurable Home Assistant
YAML/JSON configuration file, with UI-managed JSON (.storage) converted
to YAML.

Designed to live in the Samba share (e.g. /share/ha_config_backup/) so
that Home Assistant OS / Core updates can never touch it.  It writes its
generations next to itself, wherever it is placed.

Runtime requirements: python3 only.  PyYAML is used when available (it is,
inside the Home Assistant Core container); otherwise a built-in emitter
handles the JSON -> YAML conversion.

Retention (configurable below):
    daily   x 7
    weekly  x 4
    monthly x 12
    yearly  x 5

Output layout (BACKUP_ROOT):
    ha_config_backup.py
    latest/                       uncompressed mirror of the newest run
    daily/   ha-config-2026-08-16.tar.gz
    weekly/  ha-config-2026-W33.tar.gz
    monthly/ ha-config-2026-08.tar.gz
    yearly/  ha-config-2026.tar.gz
    logs/    backup.log, last_run.json

Snapshot layout (inside each archive / inside latest/):
    config/      verbatim copies of *.yaml, *.yml, *.json from the HA config dir
    storage/     verbatim copies of .storage/* (JSON, no extension)
    addon_configs/  add-on configs, when reachable (SSH add-on only)
    yaml/        YAML conversions of every JSON file above, same tree shape
    lovelace/    ready-to-use dashboard YAML, unwrapped from .storage/lovelace*
    MANIFEST.txt file inventory with sizes and SHA-256

NOTE: nothing is redacted, by design.  secrets.yaml, .storage/auth*,
API tokens and password hashes are all included, so the destination
folder deserves the same protection as the HA config directory itself.
"""

from __future__ import annotations

import hashlib
import json
import os
import shutil
import sys
import tarfile
import traceback
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path

# --------------------------------------------------------------------------
# Settings
# --------------------------------------------------------------------------

_env_root = os.environ.get("HA_YAML_BACKUP_ROOT")
BACKUP_ROOT = Path(_env_root) if _env_root else Path(__file__).resolve().parent

KEEP = {"daily": 7, "weekly": 4, "monthly": 12, "yearly": 5}

# tar.gz keeps 28 generations small; set False to store plain folders instead.
COMPRESS = True

# Keep an uncompressed copy of the newest run for browsing/diffing from Windows.
KEEP_LATEST_MIRROR = True

# Where the HA configuration directory might be mounted, in priority order.
CONFIG_CANDIDATES = ["/config", "/homeassistant", "/usr/share/hassio/homeassistant"]

# Reachable only from the Advanced SSH & Web Terminal add-on; skipped silently
# when running as a shell_command inside the HA Core container.
ADDON_CONFIG_DIRS = ["/addon_configs"]

# Add-on configs the HA Core container cannot read from disk, but which the
# add-on will hand over via HTTP. Add-ons are reachable at their slug with
# underscores turned into dashes. Frigate's raw config includes camera and
# go2rtc credentials; nothing is redacted here, same as everywhere else.
# Each entry tries its urls in order and stops at the first that answers.
HTTP_SOURCES = [
    {
        "name": "frigate/config.yml",
        "urls": [
            "http://ccab4aaf-frigate-fa:5000/api/config/raw",   # Frigate Full Access
            "http://ccab4aaf-frigate:5000/api/config/raw",      # Frigate
        ],
        # Set only if your Frigate requires a login for API calls. Either paste
        # the token here or leave it and export FRIGATE_TOKEN in the environment.
        "token": "",
        "token_env": "FRIGATE_TOKEN",
    },
]
HTTP_TIMEOUT = 20

# File extensions collected from the config tree.
CONFIG_EXTENSIONS = {".yaml", ".yml", ".json"}

# Directory names skipped anywhere in the tree (code, caches, media, DBs).
SKIP_DIRS = {
    ".git", ".github", ".venv", "__pycache__", "node_modules", "deps",
    "custom_components", "www", "tts", "image", "tmp", ".cloud",
    "backups", "home-assistant_v2.db", ".storage",  # .storage handled separately
}

# .storage entries that are machine state, not configuration.
SKIP_STORAGE = {
    "core.restore_state",
    "trace.saved_traces",
}

# .storage scratch files: SQLite databases and their journals, temp files.
SKIP_STORAGE_SUFFIXES = (".db", ".db-shm", ".db-wal", ".db-journal")
SKIP_STORAGE_PREFIXES = ("tmp",)

# Skip anything larger than this (regenerable caches such as hacs.data).
MAX_FILE_MB = 25

FILE_PREFIX = "ha-config-"

# --------------------------------------------------------------------------
# Logging
# --------------------------------------------------------------------------

LOG_DIR = BACKUP_ROOT / "logs"
LOG_FILE = LOG_DIR / "backup.log"
MAX_LOG_LINES = 2000

_log_lines: list[str] = []


def log(msg: str) -> None:
    line = f"{datetime.now():%Y-%m-%d %H:%M:%S}  {msg}"
    _log_lines.append(line)
    print(line, flush=True)


def flush_log() -> None:
    try:
        LOG_DIR.mkdir(parents=True, exist_ok=True)
        old: list[str] = []
        if LOG_FILE.exists():
            old = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
        lines = (old + _log_lines)[-MAX_LOG_LINES:]
        LOG_FILE.write_text("\n".join(lines) + "\n", encoding="utf-8")
    except Exception as err:
        # Never let logging hide the real failure.
        print(f"Could not write {LOG_FILE}: {err}", file=sys.stderr, flush=True)


# --------------------------------------------------------------------------
# JSON -> YAML
# --------------------------------------------------------------------------

try:
    import yaml  # type: ignore

    def to_yaml(obj) -> str:
        return yaml.safe_dump(
            obj,
            sort_keys=False,
            allow_unicode=True,
            default_flow_style=False,
            width=1000,
        )

    YAML_ENGINE = "PyYAML"

except ImportError:  # pragma: no cover - fallback for minimal environments

    def _plain_ok(s: str) -> bool:
        """True when a string can be written as an unquoted YAML scalar."""
        if s == "" or s.strip() != s:
            return False
        if s[0] in "-?:,[]{}#&*!|>'\"%@`":
            return False
        if ": " in s or " #" in s or "\n" in s or "\t" in s or s.endswith(":"):
            return False
        if s.lower() in ("true", "false", "null", "yes", "no", "on", "off", "~"):
            return False
        try:
            float(s)
            return False
        except ValueError:
            return True

    def _scalar(v) -> str:
        if v is None:
            return "null"
        if v is True:
            return "true"
        if v is False:
            return "false"
        if isinstance(v, (int, float)):
            return repr(v)
        s = str(v)
        # json.dumps produces a valid YAML double-quoted scalar.
        return s if _plain_ok(s) else json.dumps(s, ensure_ascii=False)

    def _emit(obj, indent: int, out: list[str]) -> None:
        pad = "  " * indent
        if isinstance(obj, dict):
            if not obj:
                if out:
                    out[-1] += " {}"
                else:
                    out.append("{}")
                return
            for key, val in obj.items():
                k = _scalar(str(key))
                if isinstance(val, (dict, list)) and val:
                    out.append(f"{pad}{k}:")
                    _emit(val, indent + 1, out)
                elif isinstance(val, dict):
                    out.append(f"{pad}{k}: {{}}")
                elif isinstance(val, list):
                    out.append(f"{pad}{k}: []")
                else:
                    out.append(f"{pad}{k}: {_scalar(val)}")
        elif isinstance(obj, list):
            if not obj:
                if out:
                    out[-1] += " []"
                else:
                    out.append("[]")
                return
            for item in obj:
                if isinstance(item, dict) and not item:
                    out.append(f"{pad}- {{}}")
                elif isinstance(item, list) and not item:
                    out.append(f"{pad}- []")
                elif isinstance(item, (dict, list)):
                    out.append(f"{pad}-")
                    _emit(item, indent + 1, out)
                else:
                    out.append(f"{pad}- {_scalar(item)}")
        else:
            out.append(f"{pad}{_scalar(obj)}")

    def to_yaml(obj) -> str:
        out: list[str] = []
        _emit(obj, 0, out)
        return "\n".join(out) + "\n"

    YAML_ENGINE = "builtin"


# --------------------------------------------------------------------------
# Source discovery
# --------------------------------------------------------------------------


def resolve_config_dir() -> Path:
    override = os.environ.get("HA_CONFIG_DIR")
    if override:
        return Path(override)
    for cand in CONFIG_CANDIDATES:
        p = Path(cand)
        if (p / "configuration.yaml").is_file():
            return p
    raise RuntimeError(
        "Could not locate the Home Assistant configuration directory. Looked for "
        "configuration.yaml in: " + ", ".join(CONFIG_CANDIDATES)
        + ". Set HA_CONFIG_DIR to override."
    )


def iter_config_files(root: Path):
    """Yield (absolute_path, relative_path) for config YAML/JSON files."""
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for name in sorted(filenames):
            src = Path(dirpath) / name
            if src.suffix.lower() not in CONFIG_EXTENSIONS:
                continue
            if src.is_symlink() or not src.is_file():
                continue
            yield src, src.relative_to(root)


def iter_storage_files(storage: Path):
    if not storage.is_dir():
        return
    for entry in sorted(storage.iterdir()):
        if not entry.is_file() or entry.is_symlink():
            continue
        if entry.name in SKIP_STORAGE:
            continue
        if entry.name.endswith(SKIP_STORAGE_SUFFIXES):
            continue
        if entry.name.startswith(SKIP_STORAGE_PREFIXES):
            continue
        if ".corrupt." in entry.name:
            continue
        yield entry, Path(entry.name)


def iter_addon_configs():
    for base in ADDON_CONFIG_DIRS:
        root = Path(base)
        if not root.is_dir():
            continue
        for dirpath, dirnames, filenames in os.walk(root):
            dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
            for name in sorted(filenames):
                src = Path(dirpath) / name
                if src.suffix.lower() not in CONFIG_EXTENSIONS:
                    continue
                if src.is_symlink() or not src.is_file():
                    continue
                yield src, src.relative_to(root)


# --------------------------------------------------------------------------
# Snapshot construction
# --------------------------------------------------------------------------


class Stats:
    def __init__(self) -> None:
        self.copied = 0
        self.converted = 0
        self.non_json = 0
        self.yaml_files = 0   # sources that were already YAML
        self.json_files = 0   # sources that were JSON (and got a YAML twin)
        self.skipped_large = 0
        self.fetched = 0
        self.errors: list[str] = []
        self.warnings: list[str] = []
        self.bytes = 0


def looks_like_json(path: Path) -> bool:
    """.storage holds certificates, pickles and keys alongside its JSON.

    Those are copied verbatim; attempting to convert them is not an error.
    """
    try:
        with path.open("rb") as handle:
            head = handle.read(64).lstrip()
    except OSError:
        return False
    return head[:1] in (b"{", b"[")


def copy_one(src: Path, dest: Path, stats: Stats) -> bool:
    try:
        size = src.stat().st_size
        if size > MAX_FILE_MB * 1024 * 1024:
            stats.skipped_large += 1
            log(f"  skip (>{MAX_FILE_MB}MB): {src}")
            return False
        dest.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dest)
        stats.copied += 1
        stats.bytes += size
        return True
    except Exception as err:
        stats.errors.append(f"copy {src}: {err}")
        return False


def convert_one(src: Path, yaml_dest: Path, stats: Stats):
    """Convert a JSON file to YAML. Returns the parsed object, or None."""
    try:
        raw = src.read_text(encoding="utf-8", errors="replace")
        data = json.loads(raw)
    except Exception as err:
        stats.errors.append(f"parse {src}: {err}")
        return None
    try:
        yaml_dest.parent.mkdir(parents=True, exist_ok=True)
        header = (
            f"# Converted from JSON: {src}\n"
            f"# {datetime.now():%Y-%m-%d %H:%M:%S} via {YAML_ENGINE}\n"
        )
        yaml_dest.write_text(header + to_yaml(data), encoding="utf-8")
        stats.converted += 1
    except Exception as err:
        stats.errors.append(f"convert {src}: {err}")
    return data


def extract_lovelace(name: str, data, snap: Path, stats: Stats) -> None:
    """Unwrap .storage/lovelace* into a directly usable dashboard YAML."""
    if not isinstance(data, dict):
        return
    config = data.get("data", {}).get("config") if isinstance(data.get("data"), dict) else None
    if config is None:
        return
    label = "default" if name == "lovelace" else name.split(".", 1)[-1]
    dest = snap / "lovelace" / f"{label}.yaml"
    try:
        dest.parent.mkdir(parents=True, exist_ok=True)
        header = (
            f"# Dashboard '{label}' unwrapped from .storage/{name}\n"
            "# This is the YAML-mode equivalent: paste under a lovelace:\n"
            "# dashboards: entry, or use directly as a dashboard file.\n"
        )
        dest.write_text(header + to_yaml(config), encoding="utf-8")
        log(f"  lovelace dashboard extracted: {label}.yaml")
    except Exception as err:
        stats.errors.append(f"lovelace {name}: {err}")


def fetch_http_source(source: dict) -> tuple[bytes | None, str]:
    """Return (content, note). Tries each url until one answers."""
    token = source.get("token") or os.environ.get(source.get("token_env", ""), "")
    headers = {"Authorization": f"Bearer {token}"} if token else {}
    attempts = []
    for url in source["urls"]:
        request = urllib.request.Request(url, headers=headers)
        try:
            with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT) as response:
                return response.read(), url
        except urllib.error.HTTPError as err:
            hint = " (needs a token — see HTTP_SOURCES)" if err.code in (401, 403) else ""
            attempts.append(f"{url} -> HTTP {err.code}{hint}")
        except Exception as err:
            attempts.append(f"{url} -> {type(err).__name__}: {err}")
    return None, "; ".join(attempts)


def fetch_http_sources(snap: Path, stats: Stats) -> None:
    for source in HTTP_SOURCES:
        content, note = fetch_http_source(source)
        if content is None:
            stats.warnings.append(f"{source['name']}: {note}")
            log(f"  could not fetch {source['name']} — {note}")
            continue
        dest = snap / "external" / source["name"]
        try:
            dest.parent.mkdir(parents=True, exist_ok=True)
            dest.write_bytes(content)
            stats.fetched += 1
            stats.bytes += len(content)
            log(f"  fetched {source['name']} from {note} ({len(content)} bytes)")
        except Exception as err:
            stats.warnings.append(f"{source['name']}: {err}")


def build_snapshot(snap: Path, config_dir: Path) -> Stats:
    stats = Stats()

    log(f"Config directory: {config_dir}")

    # 1. YAML/JSON from the config tree
    for src, rel in iter_config_files(config_dir):
        if not copy_one(src, snap / "config" / rel, stats):
            continue
        if src.suffix.lower() == ".json":
            stats.json_files += 1
            convert_one(src, snap / "yaml" / "config" / rel.with_suffix(".yaml"), stats)
        else:
            stats.yaml_files += 1

    # 2. .storage (UI-managed configuration, JSON without extensions)
    storage = config_dir / ".storage"
    for src, rel in iter_storage_files(storage):
        if not copy_one(src, snap / "storage" / rel, stats):
            continue
        if not looks_like_json(src):
            # Certificates, keys, pickles: kept verbatim, nothing to convert.
            stats.non_json += 1
            continue
        stats.json_files += 1
        data = convert_one(src, snap / "yaml" / "storage" / f"{rel.name}.yaml", stats)
        if data is not None and (rel.name == "lovelace" or rel.name.startswith("lovelace.")):
            extract_lovelace(rel.name, data, snap, stats)

    # 3. Add-on configs (ESPHome etc.) when this process can reach them
    addon_count = 0
    for src, rel in iter_addon_configs():
        if copy_one(src, snap / "addon_configs" / rel, stats):
            addon_count += 1
            if src.suffix.lower() == ".json":
                convert_one(
                    src, snap / "yaml" / "addon_configs" / rel.with_suffix(".yaml"), stats
                )
    if addon_count:
        log(f"Add-on config files: {addon_count}")
    else:
        log("Add-on configs not reachable from this context — trying HTTP instead")

    # 4. Configs only reachable over the add-on's own API (Frigate)
    fetch_http_sources(snap, stats)

    return stats


def write_manifest(snap: Path, config_dir: Path, stats: Stats) -> None:
    lines = [
        "Home Assistant configuration export",
        f"Generated : {datetime.now():%Y-%m-%d %H:%M:%S}",
        f"Host      : {os.uname().nodename}",
        f"Source    : {config_dir}",
        f"YAML via  : {YAML_ENGINE}",
        f"Files     : {stats.copied} copied "
        f"({stats.yaml_files} YAML, {stats.json_files} JSON, {stats.non_json} other), "
        f"{stats.converted} JSON converted to YAML, {stats.fetched} fetched over HTTP",
        f"Size      : {stats.bytes / 1024 / 1024:.1f} MiB (source bytes)",
        "",
        "SHA-256                                                           SIZE  PATH",
        "-" * 100,
    ]
    for path in sorted(snap.rglob("*")):
        if not path.is_file():
            continue
        digest = hashlib.sha256(path.read_bytes()).hexdigest()
        lines.append(f"{digest}  {path.stat().st_size:>9}  {path.relative_to(snap)}")
    if stats.warnings:
        lines += ["", "WARNINGS", "-" * 100] + stats.warnings
    if stats.errors:
        lines += ["", "PROBLEMS", "-" * 100] + stats.errors
    (snap / "MANIFEST.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")


# --------------------------------------------------------------------------
# Generations
# --------------------------------------------------------------------------


def tier_tags(now: datetime) -> dict[str, str]:
    return {
        "daily": now.strftime("%Y-%m-%d"),
        "weekly": now.strftime("%G-W%V"),
        "monthly": now.strftime("%Y-%m"),
        "yearly": now.strftime("%Y"),
    }


def suffix() -> str:
    return ".tar.gz" if COMPRESS else ""


def store(snap: Path, dest: Path) -> None:
    """Write the staged snapshot to dest as an archive or a folder."""
    dest.parent.mkdir(parents=True, exist_ok=True)
    if dest.exists():
        if dest.is_dir():
            shutil.rmtree(dest)
        else:
            dest.unlink()
    if COMPRESS:
        tmp = dest.with_suffix(dest.suffix + ".part")
        with tarfile.open(tmp, "w:gz") as tar:
            tar.add(snap, arcname=dest.name.replace(".tar.gz", ""))
        tmp.replace(dest)
    else:
        shutil.copytree(snap, dest)


def duplicate(source: Path, dest: Path) -> None:
    """Hardlink where possible so promoted generations cost no extra space."""
    dest.parent.mkdir(parents=True, exist_ok=True)
    if dest.exists():
        return
    try:
        if source.is_dir():
            shutil.copytree(source, dest, copy_function=os.link)
        else:
            os.link(source, dest)
    except OSError:
        if source.is_dir():
            shutil.copytree(source, dest)
        else:
            shutil.copy2(source, dest)


def prune(tier: str, keep: int) -> None:
    tier_dir = BACKUP_ROOT / tier
    if not tier_dir.is_dir():
        return
    entries = sorted(p for p in tier_dir.iterdir() if p.name.startswith(FILE_PREFIX))
    for old in entries[:-keep] if keep else entries:
        try:
            if old.is_dir():
                shutil.rmtree(old)
            else:
                old.unlink()
            log(f"  pruned {tier}/{old.name}")
        except Exception as err:
            log(f"  could not prune {old}: {err}")


# --------------------------------------------------------------------------
# Inventory report (--report), consumed by the dashboard sensor
# --------------------------------------------------------------------------


def _dir_size(path: Path) -> int:
    total = 0
    for p in path.rglob("*"):
        if p.is_file():
            try:
                total += p.stat().st_size
            except OSError:
                pass
    return total


def build_report() -> dict:
    """Inventory every generation. Printed as JSON for a command_line sensor."""
    report: dict = {
        "root": str(BACKUP_ROOT),
        "generated": datetime.now().isoformat(timespec="seconds"),
    }
    seen_inodes: set[int] = set()
    unique_bytes = 0

    for tier, keep in KEEP.items():
        tier_dir = BACKUP_ROOT / tier
        entries = []
        if tier_dir.is_dir():
            entries = sorted(
                p for p in tier_dir.iterdir() if p.name.startswith(FILE_PREFIX)
            )
        files = []
        tier_bytes = 0
        for p in entries:
            try:
                st = p.stat()
            except OSError:
                continue
            size = st.st_size if p.is_file() else _dir_size(p)
            tier_bytes += size
            # Promoted generations are hardlinks; count each inode once.
            if p.is_file():
                if st.st_ino not in seen_inodes:
                    seen_inodes.add(st.st_ino)
                    unique_bytes += size
            else:
                unique_bytes += size
            files.append(
                {
                    "name": p.name,
                    "mb": round(size / 1048576, 2),
                    "modified": datetime.fromtimestamp(st.st_mtime).strftime(
                        "%Y-%m-%d %H:%M"
                    ),
                }
            )
        report[tier] = {
            "count": len(files),
            "keep": keep,
            "mb": round(tier_bytes / 1048576, 2),
            "path": str(tier_dir),
            "newest": files[-1]["name"] if files else None,
            "newest_modified": files[-1]["modified"] if files else None,
            "oldest": files[0]["name"] if files else None,
            "files": files,
        }

    report["total_mb"] = round(unique_bytes / 1048576, 2)

    last_run_file = LOG_DIR / "last_run.json"
    if last_run_file.is_file():
        try:
            report["last_run"] = json.loads(last_run_file.read_text(encoding="utf-8"))
        except Exception:
            report["last_run"] = {}
    else:
        report["last_run"] = {}

    report["status"] = report["last_run"].get("status", "never")
    return report


# --------------------------------------------------------------------------
# Main
# --------------------------------------------------------------------------


def main() -> int:
    started = datetime.now()
    # Created first so that even an immediate failure leaves a log behind.
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    log("=" * 68)
    log(f"Starting configuration export -> {BACKUP_ROOT}")

    config_dir = resolve_config_dir()
    staging = BACKUP_ROOT / ".staging"
    if staging.exists():
        shutil.rmtree(staging)
    staging.mkdir(parents=True, exist_ok=True)

    try:
        stats = build_snapshot(staging, config_dir)
        write_manifest(staging, config_dir, stats)

        tags = tier_tags(started)
        daily_dest = BACKUP_ROOT / "daily" / f"{FILE_PREFIX}{tags['daily']}{suffix()}"
        store(staging, daily_dest)
        log(f"Wrote {daily_dest.relative_to(BACKUP_ROOT)}")

        for tier in ("weekly", "monthly", "yearly"):
            dest = BACKUP_ROOT / tier / f"{FILE_PREFIX}{tags[tier]}{suffix()}"
            if dest.exists():
                continue
            duplicate(daily_dest, dest)
            log(f"Promoted to {tier}/{dest.name}")

        for tier, keep in KEEP.items():
            prune(tier, keep)

        if KEEP_LATEST_MIRROR:
            latest = BACKUP_ROOT / "latest"
            tmp_latest = BACKUP_ROOT / ".latest.new"
            if tmp_latest.exists():
                shutil.rmtree(tmp_latest)
            shutil.copytree(staging, tmp_latest)
            if latest.exists():
                shutil.rmtree(latest)
            tmp_latest.replace(latest)
            log("Refreshed latest/ mirror")

        elapsed = (datetime.now() - started).total_seconds()
        archived = daily_dest.stat().st_size if daily_dest.is_file() else 0
        log(
            f"Done in {elapsed:.1f}s — {stats.copied} files "
            f"({stats.yaml_files} YAML, {stats.json_files} JSON, {stats.non_json} other), "
            f"{stats.converted} converted, "
            f"{stats.fetched} fetched, {len(stats.warnings)} warning(s), "
            f"{len(stats.errors)} problem(s), "
            f"archive {archived / 1024 / 1024:.1f} MiB"
        )

        LOG_DIR.mkdir(parents=True, exist_ok=True)
        (LOG_DIR / "last_run.json").write_text(
            json.dumps(
                {
                    "timestamp": started.isoformat(timespec="seconds"),
                    "status": "error" if stats.errors else "ok",
                    "files": stats.copied,
                    "converted": stats.converted,
                    "yaml_files": stats.yaml_files,
                    "json_files": stats.json_files,
                    "non_json": stats.non_json,
                    "fetched": stats.fetched,
                    "skipped_large": stats.skipped_large,
                    "warnings": len(stats.warnings),
                    "warning_detail": stats.warnings[:10],
                    "errors": len(stats.errors),
                    "error_detail": stats.errors[:20],
                    "archive_bytes": archived,
                    "duration_seconds": round(elapsed, 1),
                    "yaml_engine": YAML_ENGINE,
                },
                indent=2,
            ),
            encoding="utf-8",
        )
        return 1 if stats.errors else 0

    finally:
        shutil.rmtree(staging, ignore_errors=True)


def diagnostics() -> str:
    """Environment check. Prints to stdout so it shows up in the HA action response."""
    lines = [
        f"python      : {sys.version.split()[0]} at {sys.executable}",
        f"yaml engine : {YAML_ENGINE}",
        f"script path : {Path(__file__).resolve()}",
        f"backup root : {BACKUP_ROOT}  (exists={BACKUP_ROOT.is_dir()})",
    ]

    probe = BACKUP_ROOT / ".write_test"
    try:
        probe.write_text("ok", encoding="utf-8")
        probe.unlink()
        lines.append("writable    : yes")
    except Exception as err:
        lines.append(f"writable    : NO — {err}")

    lines.append("config dir candidates:")
    found = None
    for cand in CONFIG_CANDIDATES:
        p = Path(cand)
        marker = p / "configuration.yaml"
        state = (
            "configuration.yaml found" if marker.is_file()
            else ("directory exists, no configuration.yaml" if p.is_dir() else "missing")
        )
        lines.append(f"  {cand:<36} {state}")
        if found is None and marker.is_file():
            found = p
    lines.append(f"HA_CONFIG_DIR env: {os.environ.get('HA_CONFIG_DIR') or '(unset)'}")

    if found:
        storage = found / ".storage"
        n_storage = len(list(storage.iterdir())) if storage.is_dir() else 0
        n_yaml = sum(1 for _ in iter_config_files(found))
        lines.append(f"resolved    : {found}")
        lines.append(f"  YAML/JSON files that would be copied: {n_yaml}")
        lines.append(f"  .storage entries visible: {n_storage}")
    else:
        lines.append("resolved    : NONE — this is why the export produces nothing")

    for extra in ADDON_CONFIG_DIRS:
        lines.append(f"{extra:<12}: {'reachable' if Path(extra).is_dir() else 'not reachable (normal from HA Core)'}")

    for source in HTTP_SOURCES:
        content, note = fetch_http_source(source)
        if content is None:
            lines.append(f"http source : {source['name']} UNREACHABLE — {note}")
        else:
            lines.append(f"http source : {source['name']} ok ({len(content)} bytes from {note})")

    try:
        usage = shutil.disk_usage(BACKUP_ROOT if BACKUP_ROOT.is_dir() else Path("/"))
        lines.append(f"free space  : {usage.free / 1024 / 1024 / 1024:.1f} GiB")
    except Exception as err:
        lines.append(f"free space  : unknown — {err}")

    return "\n".join(lines)


if __name__ == "__main__":
    if "--check" in sys.argv:
        print(diagnostics())
        sys.exit(0)
    if "--report" in sys.argv:
        # Read-only inventory: no logging, no side effects.
        print(json.dumps(build_report()))
        sys.exit(0)
    try:
        code = main()
    except KeyboardInterrupt:
        log("Interrupted")
        code = 130
    except BaseException:
        # BaseException, not Exception: SystemExit used to escape and skip the log.
        log("FATAL\n" + traceback.format_exc())
        code = 2
    flush_log()
    sys.exit(code)

Run it with --check for a diagnostic report, or --report for the JSON inventory the sensor consumes.

Dashboard card

Add via “Add card” → “Manual”. Requires (all HACS): stack-in-card, mushroom, button-card, card-mod, fold-entity-row.

Change the four url_path lines if your share isn’t \\homeassistant\share, and see the browser policy section in the first post to make those buttons clickable.

# =============================================================================
#  Configuration Export — dashboard card
#  Add via "Add card" -> "Manual".
#
#  Requires (all HACS): stack-in-card, mushroom, button-card, card-mod,
#  fold-entity-row.
#
#  Adjust the UNC host in the four url_path lines if your share is not
#  \\homeassistant\share (e.g. use the HA IP address instead). Those buttons
#  need a one-time browser policy to be clickable — see the header comment in
#  config_yaml_export.yaml.
# =============================================================================

type: custom:stack-in-card
mode: vertical
cards:
  # --- Title: styled to match a native ha-card header (Rodents, System, ...) --
  - type: custom:mushroom-title-card
    title: Configuration Export
    subtitle: >-
      {%- set s = 'sensor.config_export_status' -%} {%- set n = namespace(t=0)
      -%} {%- for k in ['daily','weekly','monthly','yearly'] -%} {%- set n.t =
      n.t + (state_attr(s, k) or {}).get('count', 0) -%} {%- endfor -%} {{ n.t ~
      ' generations · ' ~ (state_attr(s, 'total_mb') or 0) ~ ' MB on disk' }}
    card_mod:
      style: |
        :host, .header {
          padding: 12px 16px 4px 16px !important;
        }
        .title {
          font-family: var(--ha-card-header-font-family, inherit) !important;
          font-size: var(--ha-card-header-font-size, 24px) !important;
          font-weight: 400 !important;
          letter-spacing: -0.012em !important;
          line-height: 32px !important;
          color: var(--ha-card-header-color, var(--primary-text-color)) !important;
          margin: 0 !important;
        }
        .subtitle {
          font-size: 14px !important;
          font-weight: 400 !important;
          line-height: 20px !important;
          color: var(--secondary-text-color) !important;
          margin: 2px 0 0 0 !important;
        }

  # --- Status + run button ----------------------------------------------------
  - type: horizontal-stack
    cards:
      - type: custom:mushroom-template-card
        entity: sensor.config_export_status
        primary: >-
          {% set s = states('sensor.config_export_status') %} {{ 'Healthy' if s
          == 'ok' else ('Problems' if s == 'error' else 'Never run') }}
        secondary: >-
          {%- set lr = state_attr('sensor.config_export_status','last_run') -%}
          {%- if lr and lr.timestamp is defined -%}
          {%- set d = (now() - (lr.timestamp | as_datetime | as_local)).total_seconds() -%}
          {%- if d < 3600 -%}{%- set ago = (d // 60) | int ~ 'm' -%}
          {%- elif d < 86400 -%}{%- set ago = (d // 3600) | int ~ 'h' -%}
          {%- else -%}{%- set ago = (d // 86400) | int ~ 'd' -%}{%- endif -%}
          {{ ago ~ ' ago · YAML ' ~ (lr.yaml_files | default(0)) ~ ' · JSON '
             ~ (lr.json_files | default(0)) }}
          {%- else -%}Waiting for the first run{%- endif -%}
        multiline_secondary: true
        icon: >-
          {% set s = states('sensor.config_export_status') %} {{
          'mdi:content-save-check' if s == 'ok'
             else ('mdi:content-save-alert' if s == 'error' else 'mdi:content-save-off') }}
        icon_color: >-
          {% set s = states('sensor.config_export_status') %} {{ 'green' if s ==
          'ok' else ('red' if s == 'error' else 'grey') }}
        tap_action:
          action: more-info
        # Wrap onto a second line instead of truncating with an ellipsis
        card_mod:
          style:
            mushroom-state-info$: |
              .container {
                white-space: normal !important;
                overflow: visible !important;
              }
              .primary, .secondary {
                white-space: normal !important;
                overflow: visible !important;
                text-overflow: clip !important;
                line-height: 1.25 !important;
              }
            .: |
              ha-card { --ha-card-border-width: 0; }

      - type: custom:mushroom-template-card
        entity: script.config_export_run_now
        primary: Back up now
        secondary: |-
          {% if is_state('script.config_export_run_now','on') %}
            Running…
          {% else %}
            Export and rotate
          {% endif %}
        multiline_secondary: true
        icon: >-
          {{ 'mdi:progress-download' if
          is_state('script.config_export_run_now','on')
             else 'mdi:play-circle-outline' }}
        icon_color: >-
          {{ 'orange' if is_state('script.config_export_run_now','on') else
          'blue' }}
        tap_action:
          action: perform-action
          perform_action: script.config_export_run_now
        card_mod:
          style:
            mushroom-state-info$: |
              .container {
                white-space: normal !important;
                overflow: visible !important;
              }
              .primary, .secondary {
                white-space: normal !important;
                overflow: visible !important;
                text-overflow: clip !important;
                line-height: 1.25 !important;
              }
            .: |
              ha-card { --ha-card-border-width: 0; }

  # --- Generation tiers: moved up, directly under the status row --------------
  - type: grid
    columns: 4
    square: false
    cards:
      - type: custom:button-card
        entity: sensor.config_export_status
        name: Daily
        icon: mdi:calendar-today
        show_state: false
        show_label: true
        label: |
          [[[
            const t = entity.attributes.daily || {};
            return `${t.count ?? 0}/${t.keep ?? 0} · ${t.mb ?? 0} MB`;
          ]]]
        tap_action:
          action: url
          url_path: file://homeassistant/share/ha_config_backup/daily/
        hold_action:
          action: more-info
        styles:
          card:
            - padding: 10px 4px
            - border: none
            - background: none
          name:
            - font-size: 13px
          label:
            - font-size: 11px
            - color: var(--secondary-text-color)
          icon:
            - color: var(--state-icon-color)
      - type: custom:button-card
        entity: sensor.config_export_status
        name: Weekly
        icon: mdi:calendar-week
        show_state: false
        show_label: true
        label: |
          [[[
            const t = entity.attributes.weekly || {};
            return `${t.count ?? 0}/${t.keep ?? 0} · ${t.mb ?? 0} MB`;
          ]]]
        tap_action:
          action: url
          url_path: file://homeassistant/share/ha_config_backup/weekly/
        hold_action:
          action: more-info
        styles:
          card:
            - padding: 10px 4px
            - border: none
            - background: none
          name:
            - font-size: 13px
          label:
            - font-size: 11px
            - color: var(--secondary-text-color)
      - type: custom:button-card
        entity: sensor.config_export_status
        name: Monthly
        icon: mdi:calendar-month
        show_state: false
        show_label: true
        label: |
          [[[
            const t = entity.attributes.monthly || {};
            return `${t.count ?? 0}/${t.keep ?? 0} · ${t.mb ?? 0} MB`;
          ]]]
        tap_action:
          action: url
          url_path: file://homeassistant/share/ha_config_backup/monthly/
        hold_action:
          action: more-info
        styles:
          card:
            - padding: 10px 4px
            - border: none
            - background: none
          name:
            - font-size: 13px
          label:
            - font-size: 11px
            - color: var(--secondary-text-color)
      - type: custom:button-card
        entity: sensor.config_export_status
        name: Yearly
        icon: mdi:calendar-star
        show_state: false
        show_label: true
        label: |
          [[[
            const t = entity.attributes.yearly || {};
            return `${t.count ?? 0}/${t.keep ?? 0} · ${t.mb ?? 0} MB`;
          ]]]
        tap_action:
          action: url
          url_path: file://homeassistant/share/ha_config_backup/yearly/
        hold_action:
          action: more-info
        styles:
          card:
            - padding: 10px 4px
            - border: none
            - background: none
          name:
            - font-size: 13px
          label:
            - font-size: 11px
            - color: var(--secondary-text-color)

  # --- Schedule: fold-entity-row, since these rows are interactive ------------
  - type: entities
    show_header_toggle: false
    card_mod:
      style: |
        ha-card {
          box-shadow: none;
          background: transparent;
          padding: 0 !important;
        }
        /* Same 16px inset as the markdown card below, so both rules match */
        #states {
          padding: 0 16px 8px 16px !important;
        }
    entities:
      - type: custom:fold-entity-row
        open: false
        padding: 0
        # Children of a fold row don't inherit the entities card's settings,
        # so state colouring is handed down explicitly.
        group_config:
          state_color: true
        # The rule goes on the fold row itself, not on the section row. The
        # section row sits left of the toggle button, so its own divider stops
        # ~48px short of the edge; this one spans the whole content box and so
        # matches the <details> border-top below exactly.
        card_mod:
          style: |
            :host {
              display: block;
              border-top: 1px solid var(--divider-color);
              padding-top: 12px;
            }
        head:
          type: section
          label: Schedule
          # A section row indents its label 8px and bleeds its divider 16px
          # past the content box. Label goes flush left; divider is dropped in
          # favour of the full-width one above.
          card_mod:
            style: |
              .label {
                margin-left: 0 !important;
                margin-inline-start: 0 !important;
                padding-left: 0 !important;
              }
              .divider {
                display: none !important;
              }
        entities:
          - entity: input_datetime.config_export_time
            name: Daily run time
            icon: mdi:clock-outline
          - entity: automation.config_export_daily
            name: Schedule enabled
            secondary_info: last-triggered
            state_color: true

  # --- Generations: native <details>, styled to match the Schedule fold -------
  - type: markdown
    card_mod:
      style:
        .: |
          ha-card {
            box-shadow: none;
            background: transparent;
            padding: 0 !important;
          }
          /* Matches #states on the entities card above */
          ha-markdown {
            padding: 0 16px 12px 16px !important;
          }
        ha-markdown$: |
          details {
            border-top: 1px solid var(--divider-color);
            padding: 12px 0 0 0;
          }
          /* Match the fold-entity-row header: label left, chevron right */
          summary {
            cursor: pointer;
            margin-left: 0;
            padding-left: 0;
            display: flex;
            align-items: center;
            justify-content: space-between;
            font-size: 15px;
            font-weight: 500;
            color: var(--primary-text-color);
            padding: 4px 0;
            list-style: none;
          }
          summary::-webkit-details-marker {
            display: none;
          }
          summary::marker {
            content: "";
          }
          summary::after {
            content: "";
            width: 9px;
            height: 9px;
            /* Aligns with fold-entity-row's toggle glyph, which sits 12px
               inside its 48px icon button. Nudge this one number if needed. */
            margin-right: 12px;
            border-right: 2.8px solid var(--primary-text-color);
            border-bottom: 2.8px solid var(--primary-text-color);
            transform: rotate(45deg) translate(-2px, -2px);
            transition: transform 180ms ease-in-out;
          }
          details[open] summary::after {
            transform: rotate(-135deg) translate(-2px, -2px);
          }
          /* NOTE: the markdown card strips class= and style= attributes, so
             everything below has to be element/position based. */
          table {
            width: 100%;
            border-collapse: collapse;
            font-size: 13px;
            margin-top: 10px;
          }
          td, th {
            padding: 5px 6px;
            vertical-align: top;
            text-align: left;
          }
          th {
            border-bottom: 1px solid var(--divider-color);
            font-weight: 500;
          }
          /* Kept + Size columns right-aligned */
          th:nth-child(2), th:nth-child(3),
          td:nth-child(2), td:nth-child(3) {
            text-align: right;
            white-space: nowrap;
          }
          /* Tier name */
          td:first-child {
            font-weight: 600;
          }
          /* Share path line: the div that follows the table */
          details > div {
            padding-top: 22px;
            font-size: 12px;
            opacity: 0.8;
          }
    content: |
      {% set s = 'sensor.config_export_status' %}
      <details>
      <summary>Generations</summary>
      <table>
      <tr><th>Tier</th><th>Kept</th><th>Size</th><th>Newest</th></tr>
      {% for label, key in [('Daily','daily'), ('Weekly','weekly'), ('Monthly','monthly'), ('Yearly','yearly')] %}{% set t = state_attr(s, key) or {} %}<tr><td>{{ label }}</td><td>{{ t.get('count', 0) }} / {{ t.get('keep', 0) }}</td><td>{{ t.get('mb', 0) }} MB</td><td><code>{{ t.get('newest', '—') }}</code></td></tr>
      {% endfor %}</table>
      <div>Share path: <code>\\homeassistant\share\ha_config_backup</code></div>
      </details>

Update: selective restore, and the project has moved to GitHub

:package: GitHub - FortranFour/ha-config-export: Generational YAML/JSON config backups for Home Assistant, with .storage converted to YAML and selective restore · GitHub

The code was getting long for forum posts, so it now lives in a repository with proper docs.
Install instructions: docs/INSTALL.md

The big addition is restore. Browse any generation, and every candidate file is hashed
against your live copy so the list shows only what actually differs — 3 of 190 differ from
live
is a far better starting point than 190 filenames. Pick files from a paged list with
search and sortable Name/Size columns, or type a path with wildcards. Everything you
overwrite is copied to a timestamped rollback folder first, and an export runs immediately
before any restore.

.storage restores are gated separately, since restoring core.entity_registry or auth
from three weeks ago can rename half your entities or lock you out. Those show red with a
warning triangle.

Also since the original posts:

  • Distinct-snapshot count, so a fresh install no longer claims “4 generations” when one export is hardlinked into four tiers
  • YAML vs JSON source counts on the status line
  • Non-JSON files in .storage (certs, keys, pickles) copied verbatim instead of reported as errors
  • Frigate’s config fetched over its add-on API, since /addon_configs is unreachable from the Core container
  • --check diagnostics, and failures can no longer vanish silently
  • Card rebuilt: consistent fold headers, and a chevron that works in the iOS app

If you installed the original, replace all three files — script, package, card — and note the
package gained a couple of new helpers.




Wow, does it support file compression for space savings? Differential only?

Compression yes, differential no — but the numbers end up close to differential anyway.

Compression: each generation is a .tar.gz. Config files are text: my instance is ~190 files that come out around 4.3 MB compressed (vs. ~ 5 GB for full backup without media, i.e. 1000-fold smaller). There’s also an uncompressed latest/ mirror alongside the archives, so you can browse, grep and diff the newest export without extracting anything. Set COMPRESS = False at the top of the script if you’d rather have plain folders throughout.

Differential: no, every generation is a full snapshot. That’s deliberate because a full snapshot restores on its own, with no chain to walk back and nothing that breaks if an intermediate generation is pruned or corrupted. At a few MB per config, the storage argument for differentials doesn’t really apply.

If you’re asking if you can see which files are changed between now (live version) and various states, that’s addressable through the various options for selecting files in the Restore card. Only files that actually differ are worth restoring:

Additionally, every tarball contains a hashed manifest:

Though these files reside on HA, you can browse and view them equivalently in the terminal or on your PC’s desktop:

Where the space saving actually comes from: hardlinks between tiers. Promotion doesn’t copy. When a daily is promoted to weekly, monthly and yearly, all four names point at the same inode. So one snapshot living in four tiers occupies disk once, and a full 28-generation set costs closer to the number of genuinely distinct exports than 28 × 4 MB. The card shows both numbers for that reason: 28 generations (9 unique).

One caveat: hardlinks are a filesystem feature, so they hold on the HA machine’s ext4 but not if you copy the tree to Windows over SMB, there each tier entry becomes a real file. Still only a couple of hundred MB at steady state, but it explains why the PC copy looks bigger than the share. Look in the Extras directory for a Windows script to copy files over to your PC. There’s also a script to encrypt and upload to OnePoint.

If you did want true deduplication across generations, borg or restic pointed at the latest/ mirror would do it properly with block-level dedup and encryption. I went with plain tarballs because they’re readable with no tooling in ten years, which felt like the right trade for config files.

Update — startup input_select warning fixed

I have pushed a small fix for the Configuration Restore package.

Some installations logged the following harmless warning during Home Assistant startup:

Current option: Loading… no longer valid

This was not a failed startup or a backup/restore failure. The restore-generation dropdown initially contains the placeholder Loading…, then dynamically replaces its options with the available daily, weekly, monthly, and yearly generations. Home Assistant logged a warning because the placeholder was removed while it was still selected.

The revised package now updates the dropdown in three stages:

  1. Temporarily retains the current selection while loading the new generation list.
  2. Preserves the selected generation if it still exists; otherwise selects Latest.
  3. Removes Loading… or an expired generation only after a valid option has been selected.

This also prevents the same warning when a previously selected generation eventually ages out under the retention schedule.

Existing users only need to update:

packages/config_restore.yaml

Copy it to:

/config/packages/config_restore.yaml

Then run Developer Tools → YAML → Check configuration and restart Home Assistant.

No changes are required to the export package, Python scripts, or dashboard cards.

Update — optional redaction and encryption

:package: GitHub - FortranFour/ha-config-export: Generational YAML/JSON config backups for Home Assistant, with .storage converted to YAML and selective restore · GitHub

Everything below is optional and off by default. If you update and tick nothing, the export behaves exactly as it did before.


Privacy & encryption

A new section on the export card:

  • Encrypt the backup — AES via cryptography (already ships with Home Assistant), keyed by scrypt from a passphrase. Archives become .tar.gz.enc and show a padlock in the restore picker; restore decrypts in memory, never to a plaintext file in the share.
  • Redact personal info — replaces credential-shaped values with tokens: keys named like password / token / api_key / secret, email addresses, credentials inside URLs such as rtsp://user:pass@camera/stream, and latitude/longitude.
  • Keep sidecar of redacted values — records what was removed to sidecars/, outside the archive, so restore can put the values back automatically. Outside is the point: a sidecar inside the archive would be obfuscation with the key taped to the box.
  • Encrypt the sidecar separately, plus a passphrase box with a show/hide toggle and an indicator showing whether one is stored.

File restoration automatically decrypts and restores redacted info

Three things to note about security:

Redaction is best-effort, not a security boundary. It matches key names and value shapes, so a secret stored under an unusual key survives it. Use it to make an export shareable — posting a config excerpt, or handing a snapshot to someone helping you debug. Encryption is what makes an export safe.

The passphrase is a key file, not a prompt. Typed once on the card, written to /share/ha_config_backup/.passphrase, and both text boxes cleared immediately. It is deliberately not kept in an entity: entity state lands in .storage and the recorder database, both of which this export copies, and a key stored inside the backup is not a key. The package adds a recorder: exclusion so it never reaches the database in transit. The practical consequence is that the boundary is filesystem access — this protects archives that leave the machine, not the machine itself.

Encryption removes the latest/ mirror. An uncompressed copy of the same content sitting beside an encrypted archive is not encrypted, so the script deletes it and says so in the log. You lose easy browsing and diffing from the desktop; that is the trade. If encryption is off, then the latest/ returns.

And the one that will bite someone eventually: write the passphrase down somewhere physical. The key file is the only copy. If the disk dies, every encrypted archive becomes permanently unreadable, and you find out on the day you need a restore.

Details, including a table of what to turn on for which situation: docs/PRIVACY.md

Opening an encrypted archive without Home Assistant

If the server is gone and you have an archive from a PC copy or a cloud sync, extras/decrypt_export.py opens it on any machine with Python 3 — no add-on, no share, no config, Windows or macOS or Linux. Nothing is tied to the machine that wrote the archive: the passphrase is the only input, and each file carries its own salt.

There is also a route using nothing but openssl, base64 and tar, which does not depend on my script still existing. Both routes and the file format are in docs/RECOVERY.md.


What to update

File Why
scripts/ha_config_backup.py Redaction, sidecar and encryption
scripts/ha_config_restore.py Decryption, sidecar lookup, un-redaction
packages/config_yaml_export.yaml The new entities and passphrase handling
packages/config_restore.yaml Housekeeping — updated comments, and a small automation that refreshes the generation list after an export so the dropdown does not sit stale.
cards/export_card.yaml The Privacy & encryption section
cards/restore_card.yaml Unchanged

Reload the packages before pasting the card, or the new rows show as missing entities.

If you have no interest in encryption, the only file you need is the restore script — it is what lets the restore card read an encrypted archive should you ever turn it on.

I am keeping code out of the thread from here on.

Update — v1.2.0: old rollbacks and sidecars no longer pile up

Two things in the share used to grow without limit. That is now fixed.

_restore_rollback/ gets a timestamped folder every time you restore a file, holding a copy of whatever was overwritten. Restore a registry a few times and you are keeping several 9 MB copies indefinitely. sidecars/ collects one file per redacted export, and those outlive the generation they belong to, so you end up with sidecars for dailies that aged out weeks ago.

Automatic pruning

Both are now pruned on a deliberately cautious rule: an item is kept while it is either newer than a year or among the twelve most recent. Only when both tests fail does it go.

That means a quiet year of restores keeps everything, and a busy week does not evict last month’s safety net. The rollback folder is the only copy of a file a restore replaced, so I would rather it err toward keeping. Both numbers are constants at the top of the scripts if a year is too long or twelve too few.

The Cleanup section

New fold on the export card, showing how many rollbacks and sidecars are on disk, how much space they take, and how many could go. One button clears them, behind a confirmation dialog.

It is deliberately more aggressive than the automatic rule — it keeps the twelve most recent regardless of age. Automatic pruning is the unattended backstop, and the button is there when you want the space back today.

An example. Sixteen rollback folders, all a month old: automatic pruning takes none, because none are a year old. The button offers four, keeping the newest twelve.

A notification appears when there is something to clear, and dismisses itself when there is not. Ignoring it is fine — the folder stays bounded either way.

Also in this release

restore.log is now size-bounded, as backup.log already was. It had no cap and grew a line per restored file.

What to update

File Why
scripts/ha_config_backup.py Sidecar pruning, and the engine behind the Cleanup section
scripts/ha_config_restore.py Rollback pruning after each restore; bounded restore.log
packages/config_yaml_export.yaml Cleanup sensor, script and notification
cards/export_card.yaml Adds Cleanup section to dashboard export card
packages/config_restore.yaml Unchanged
cards/restore_card.yaml Unchanged

Reload the export package before pasting the card, or the new rows show as missing entities.

Nothing on disk changes when you update — existing generations, archives, rollbacks and sidecars are untouched. Pruning begins with your next restore or export.

:package: GitHub - FortranFour/ha-config-export: Generational YAML/JSON config backups for Home Assistant, with .storage converted to YAML and selective restore · GitHub