```
├── .github/
├── FUNDING.yml
├── ISSUE_TEMPLATE/
├── bug_report.md (200 tokens)
├── config.yml
├── custom.md
├── feature_request.md (100 tokens)
├── question.md (100 tokens)
├── workflows/
├── docker-publish.yml (300 tokens)
├── .gitignore
├── CHANGELOG.md (500 tokens)
├── CONTRIBUTING.md (200 tokens)
├── LICENSE (omitted)
├── README.md (3.4k tokens)
├── compose.yaml (100 tokens)
├── custom_components/
├── time_machine/
├── __init__.py (700 tokens)
├── __pycache__/
├── sensor.cpython-314.pyc (omitted)
├── brand/
├── icon.png
├── logo.png
├── brands/
├── icon.png
├── logo.png
├── config_flow.py (1100 tokens)
├── const.py (100 tokens)
├── hacs.json
├── manifest.json (100 tokens)
├── sensor.py (700 tokens)
├── services.yaml (300 tokens)
├── translations/
├── en.json (200 tokens)
├── hacs.json
├── homeassistant-time-machine/
├── .dockerignore
├── .github/
├── FUNDING.yml
├── workflows/
├── docker-publish.yml (400 tokens)
├── .gitignore
├── .next/
├── trace (200 tokens)
├── CHANGELOG.md (500 tokens)
├── Dockerfile (100 tokens)
├── README.md (3.4k tokens)
├── app.js (25.8k tokens)
├── compose.yaml (100 tokens)
├── config.yaml (200 tokens)
├── data/
├── docker-app-settings.json
├── scheduled-jobs.json (100 tokens)
├── icon.png
├── logo.png
├── package-lock.json (7.1k tokens)
├── package.json (100 tokens)
├── public/
├── css/
├── style.css (13.4k tokens)
├── images/
├── favicon.ico
├── icon.png
├── js/
├── strings-language.js (7.4k tokens)
├── run.sh (100 tokens)
├── views/
├── index.ejs (28.8k tokens)
├── icon.png
├── images/
├── 1.1.png
├── 1.png
├── 2.png
├── 3.png
├── 4.png
├── 5.png
├── 6.png
├── history.svg (100 tokens)
├── icon.png
├── integration.png
├── palettes/
├── Screenshot 2025-12-12 at 1.28.53â¯PM.png
├── Screenshot 2025-12-12 at 1.28.56â¯PM.png
├── Screenshot 2025-12-12 at 1.28.59â¯PM.png
├── Screenshot 2025-12-12 at 1.29.02â¯PM.png
├── Screenshot 2025-12-12 at 1.29.07â¯PM.png
├── Screenshot 2025-12-12 at 1.29.10â¯PM.png
├── Screenshot 2025-12-12 at 1.29.13â¯PM.png
├── Screenshot 2025-12-12 at 1.29.45â¯PM.png
├── logo.png
├── repository.json
```
## /.github/FUNDING.yml
```yml path="/.github/FUNDING.yml"
ko_fi: saihgupr
```
## /.github/ISSUE_TEMPLATE/bug_report.md
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Open the Home Assistant Time Machine UI.
2. Click on 'Refres Backups'.
3. See error.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment Info (please complete the following information):**
- Home Assistant Version: [e.g. 2024.2.1]
- Add-on Version: [e.g. 2.3.0]
- Node.js Version: (if known)
- Browser: [e.g. chrome, safari]
- Docker Environment: [e.g. OS, Hardware]
**Additional context**
Add any other context about the problem here (e.g. relevant logs from the add-on configuration tab).
## /.github/ISSUE_TEMPLATE/config.yml
```yml path="/.github/ISSUE_TEMPLATE/config.yml"
blank_issues_enabled: false
```
## /.github/ISSUE_TEMPLATE/custom.md
---
name: Custom issue template
about: Describe this issue template's purpose here.
title: ''
labels: ''
assignees: ''
---
## /.github/ISSUE_TEMPLATE/feature_request.md
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Context**
Is this a UI/UX improvement or a backend backup/restore enhancement?
**Additional context**
Add any other context or screenshots about the feature request here.
## /.github/ISSUE_TEMPLATE/question.md
---
name: Question / Support
about: Ask a question or request support
title: ''
labels: question
assignees: ''
---
**Question**
A clear and concise description of what you would like to know.
**Background context**
Please provide relevant information such as:
- Home Assistant Version
- Add-on Version
- Relevant logs from the add-on
**Additional context**
Add any other context about your question here.
## /.github/workflows/docker-publish.yml
```yml path="/.github/workflows/docker-publish.yml"
name: Docker
on:
release:
types: [published]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: saihgupr/homeassistanttimemachine
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }}
type=raw,value=beta,enable=${{ github.event_name == 'release' && github.event.release.prerelease }}
type=sha,prefix=sha-,enable=${{ github.event_name == 'workflow_dispatch' }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: ./homeassistant-time-machine
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
```
## /.gitignore
```gitignore path="/.gitignore"
.DS_Store
config.js
node_modules/
*.log
.env
deploy.sh
data/
bump_version.sh
```
## /CHANGELOG.md
# v2.3.1
- **Integration Updates:** You can now configure the integration directly from the Home Assistant UI, and the `time_machine.backup_now` service call now supports all available parameters for granular control.
- **Fixed Scope Bug:** Fixed a `ReferenceError: findFullRange is not defined` bug that occurred when restoring an individual automation or script.
# v2.3.0
- **Context Menu:** Introduced a right-click context menu for backups to easily Lock, Unlock, Export, or Delete them.
- **Backup Lock:** Added a backup lock feature to prevent accidental deletion of backups. Protect your most important snapshots from being rotated out by auto-cleanup.
- **HACS Integration:** Introduced the Home Assistant companion integration, enabling native sensors and service calls.
- **Enhanced Sensors:** New sensor attributes for disk usage (total, free, used percentage), backup count, and version tracking.
- **Backup Status Tracking:** Real-time tracking of the last backup status (`success`, `failed`, `no_changes`) with persistence across restarts.
- **Service Improved:** `time_machine.backup_now` service call is now available with full parameter support for flexible automation.
- **Keyboard Navigation:** Navigate backups and items using arrow keys! Use Up/Down to change selection and Left/Right to switch between panels. Press Enter on an item to view its diff.
- **Docker Env Var:** Added `ESPHOME_CONFIG_PATH` environment variable support for Docker installations, allowing custom locations for ESPHome configuration files.
- **Split Config Support:** Advanced support for Home Assistant configurations using `!include`, `!include_dir_list`, and other split configuration methods.
- **Manifest-Driven Backups & Restoration:** Every backup now includes a detailed file manifest, ensuring that restores and change detection are perfectly aware of where your files live and are automatically placed back exactly where they belong in your YAML structure.
# v2.2.0
- **Smart Backup:** Incremental snapshots only save files that changed since your last backup. It looks complete in the UI but uses significantly less storage.
- **Show Changes Only:** Filter snapshots and files to just what has changed or deleted compared to your live config. This works per tab in both the snapshot list and file view.
- **Automation Triggers:** Backups can now be triggered from automations or scripts via `hassio.addon_stdin`. This is useful for scheduled, conditional, or event-driven backups.
- **Diff Color Palettes:** Eight new color palettes in the diff viewer which are switchable directly by clicking the header bar.
## /CONTRIBUTING.md
# Contributing
Thanks for wanting to help out! To keep things organized and make sure stable versions don't break, we use two main branches:
### Where should your code go?
* **New Features:** Please target the **`develop`** branch. This is where we test everything before it goes live.
* **Bug Fixes:** If it's a quick fix for the current version, **`main`** is fine. If it's for something currently in beta, use **`develop`**.
* **Docs & Typos:** Either branch is totally fine.
**Tip:** When you open a Pull Request on GitHub, look for the "base" dropdown and switch it to `develop` if you're adding something new.
---
### Getting Started
1. **Fork it:** Create your own copy of the repo.
2. **Branch it:** Create a new branch starting from `develop`.
3. **Code it:** Do your thing! Keep changes focused and clean.
4. **PR it:** Send over a Pull Request targeting `develop`.
We'll take a look, test it out in the beta versions, and eventually merge it into `main` for the next stable release.
### Not sure?
If you have a question or a cool idea but don't know where to start, just [open an issue](https://github.com/saihgupr/HomeAssistantTimeMachine/issues) and we can chat about it!
## /README.md
# Home Assistant Time Machine
Home Assistant Time Machine is a web-based tool that acts as a "Time Machine" for your Home Assistant configuration. Browse YAML backups across automations, scripts, Lovelace dashboards, ESPHome files, and packages, then restore individual items back to your live setup with confidence.
## What's New!
* **Backup Lock, Deletion & Export:** Added a new backup lock feature to prevent accidental deletion of snapshots. You can now also manually delete or export individual backups as .tar.gz archives directly from the web UI using the new right-click context menu.
* **HACS Integration:** Now available as a companion integration via HACS! Track backup status with a native sensor and trigger backups using the `time_machine.backup_now` service.
* **Keyboard Navigation:** Navigate backups and items using arrow keys! Use Up/Down to change selection and Left/Right to switch between panels. Press Enter on an item to view its diff.
* **Manifest-Driven Backups & Restoration:** Every backup now includes a detailed file manifest, ensuring that restores and change detection are perfectly aware of where your files live and are automatically placed back exactly where they belong in your YAML structure.
* **Docker Env Var:** Added `ESPHOME_CONFIG_PATH` environment variable support for Docker installations, allowing custom locations for ESPHome configuration files.
* **Split Config Support:** Optimized for advanced Home Assistant setups using `!include`, `!include_dir_list`, and other split configuration methods.






## Features
* **Browse Backups:** Easily browse through your Home Assistant backup YAML files.
* **View Changes & Diff Palettes:** See side-by-side diffs with 8 vibrant color palettes to choose from.
* **Restore Individual Items:** Restore individual automations or scripts without having to restore an entire backup.
* **Smart Backup:** Incremental backup mode that only saves changed files, significantly reducing storage usage.
* **Show Changes Only:** Filter backups to only show snapshots that contain changed or deleted items compared to live config.
* **Safety First:** Automatically creates a backup before restoring anything.
* **Reload Home Assistant:** Reload automations or scripts directly from the UI after a restore.
* **Scheduled Backups:** Configure automatic backups on a schedule.
* **Service Call Support:** Trigger backups from Home Assistant automations or scripts using the `hassio.addon_stdin` service.
* **Multi-language Support:** Available in English, Spanish, German, French, Dutch, and Italian.
* **Ingress Support:** Access through the Home Assistant UI without port forwarding.
* **Lovelace, ESPHome & Packages:** Full support for backing up and restoring dashboards, ESPHome files, and package configurations.
* **Max Backups & Flexible Locations:** Control retention limits and store backups in `/share`, `/backup`, `/media`, or remote shares.
* **Backup Lock & Context Menu:** Prevent accidental deletion by locking your favorite backups. Right-click any backup to Lock, Unlock, Export, or Delete it instantly.
* **REST API:** Full API for programmatic backup management.
## Installation
There are two ways to install Home Assistant Time Machine: as a Home Assistant add-on or as a standalone Docker container.
### 1. Home Assistant add-on (Recommended for most users)
1. **Add Repository:**
Click the button below to add the repository to your Home Assistant instance:
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https://github.com/saihgupr/ha-addons)
**Or manually add it:**
- Navigate to **Settings** → **Add-ons** → **Add-on Store**
- Click the three dots (⋮) in the top right corner and select **Repositories**
- Add the repository URL:
```
https://github.com/saihgupr/ha-addons
```
2. **Install the Add-on:**
The "Home Assistant Time Machine" add-on will now appear in the store. Click on it and then click "Install".
<details>
<summary><h3>2. Standalone Docker Installation</h3></summary>
For Docker users who aren't using the Home Assistant add-on, you have three deployment options:
**Option A: Docker Compose (recommended):**
1. Download the compose.yaml file:
```bash
curl -o compose.yaml https://github.com/saihgupr/HomeAssistantTimeMachine/raw/branch/main/compose.yaml
```
2. Edit the file to set your paths and credentials:
```bash
nano compose.yaml
```
3. Start the service:
```bash
docker compose up -d
```
**Option B: Docker Run (pre-built image):**
```bash
docker run -d \
-p 54000:54000 \
-e HOME_ASSISTANT_URL="http://your-ha-instance:8123" \
-e LONG_LIVED_ACCESS_TOKEN="your-long-lived-access-token" \
-e ESPHOME_CONFIG_PATH="/path/to/esphome/config" \
-e THEME="dark" \
-e DEBUG_LOGS="false" \
-v /path/to/your/ha/config:/config \
-v /path/to/your/backups:/media \
-v ha-time-machine-data:/data \
--name ha-time-machine \
ghcr.io/saihgupr/homeassistanttimemachine:latest
```
**Option C: Build locally:**
```bash
git clone https://github.com/saihgupr/HomeAssistantTimeMachine.git
cd HomeAssistantTimeMachine/homeassistant-time-machine
docker build -t ha-time-machine .
docker run -d \
-p 54000:54000 \
-e HOME_ASSISTANT_URL="http://your-ha-instance:8123" \
-e LONG_LIVED_ACCESS_TOKEN="your-long-lived-access-token" \
-e ESPHOME_CONFIG_PATH="/path/to/esphome/config" \
-e THEME="dark" \
-e DEBUG_LOGS="false" \
-v /path/to/your/ha/config:/config \
-v /path/to/your/backups:/media \
-v ha-time-machine-data:/data \
--name ha-time-machine \
ha-time-machine
```
Supplying the URL and token keeps credentials out of the UI. These environment variables are optional—if you set them, the settings fields are read-only; if you omit them, you can enter credentials in the web UI instead.
**Alternative:** omit the environment variables, start the container with the same volumes, then visit `http://localhost:54000` to enter credentials in the settings modal. They are stored in `/data/docker-ha-credentials.json`.
#### Changing Options in Docker
After the container is running, you can toggle ESPHome support, adjust text style, and switch light/dark modes by POSTing to the app settings API. This persists the value in `/data/homeassistant-time-machine/docker-app-settings.json` so the UI reflects it on reload:
```bash
curl -X POST http://localhost:54000/api/app-settings \
-H 'Content-Type: application/json' \
-d '{
"theme": "light",
"esphomeEnabled": true,
"packagesEnabled": true,
"language": "de"
}'
```
Adjust the payload if you need different paths, theme, or want to enable/disable features (`"esphomeEnabled": true|false`, `"packagesEnabled": true|false`, `"theme": light|dark`, `"language": en|es|de|fr|nl|it`).
#### Accessing the Web Interface
After starting the container, access the web interface at `http://localhost:54000` (or your server's IP/port).
> [!NOTE]
> The HA URL and token fields in settings will be read-only if configured via environment variables, or editable if configured through the web UI.
</details>
<details>
<summary><h3>HACS Companion Integration</h3></summary>
Enhance your Home Assistant experience by adding the Time Machine companion integration via HACS. This provides:
- **Sensors:** Track backup status and health directly in Home Assistant.
- **Services:** Trigger backups using native `time_machine.backup_now` service calls in your automations.
#### Installation & Setup:
<a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=saihgupr&repository=HomeAssistantTimeMachine&category=integration">
<img src="https://my.home-assistant.io/badges/hacs_repository.svg" alt="Open your Home Assistant instance and open a repository inside the Home Assistant Community Store." />
</a>
**Or manually add the custom repository:**
1. Ensure [HACS](https://hacs.xyz/) is installed.
2. In Home Assistant, go to **HACS** → **Integrations**.
3. Click the three dots (⋮) in the top right and select **Custom repositories**.
4. Add `https://github.com/saihgupr/HomeAssistantTimeMachine` as an **Integration**.
5. Find **Home Assistant Time Machine** in HACS and click **Download**.
6. Go to **Settings** → **Devices & Services**.
7. Click **Add Integration** in the bottom right and search for **Home Assistant Time Machine**.
8. Follow the UI prompts.
* If installed via the official Home Assistant Add-on, it will automatically discover the instance!
* If installed via Docker (or if auto-discovery fails), you will be prompted to enter the instance URL.
> [!IMPORTANT]
> **Docker Users:** Use the internal container name (e.g., `http://ha-time-machine:54000`) if they share a network, or your server's LAN IP if they are on separate hosts.
> - **Note:** If `sensor.time_machine_status` shows as `Offline`, it usually means Home Assistant cannot reach the Time Machine API at that address.
#### Sensor: `sensor.time_machine_status`
Monitor your backup system health directly in Home Assistant.
| Attribute | Description | Example |
| :--- | :--- | :--- |
| `state` | Current status of the instance | `Online` |
| `version` | Running version | `2.3.1` |
| `backup_count` | Total number of backups stored | `764` |
| `last_backup` | Timestamp of the last backup | `2026-02-17-000000` |
| `disk_total_gb` | Total storage space | `111.73` |
| `disk_free_gb` | Available storage space | `13.68` |
| `disk_used_pct` | Storage usage percentage | `87.8%` |
| `last_backup_status` | Status of the most recent run | `success` |
#### Action: `time_machine.backup_now`
Trigger backups via service calls in your automations or scripts.
| Parameter | Description | Example |
| :--- | :--- | :--- |
| `url` | (Optional) The URL of your Time Machine instance. Uses the integration's configured URL if left blank. | `http://192.168.1.4:54000` |
| `smart_backup_enabled` | Only backup if changes are detected compared to the last snapshot. | `true` |
| `max_backups_enabled` | Whether to enforce the maximum number of backups to keep. | `true` |
| `max_backups_count` | The number of backups to keep before removing oldest ones. | `100` |
| `live_config_path` | The source path in the container to backup (default is `/config`). | `/config` |
| `backup_folder_path` | The destination path in the container for backups (default is `/media/timemachine`). | `/media/timemachine` |
| `timezone` | The timezone to use for the backup folder name (e.g., `America/New_York`). | `America/New_York` |
**Example Automation:**
```yaml
action: time_machine.backup_now
data:
smart_backup_enabled: true
max_backups_enabled: true
max_backups_count: 100
timezone: "America/New_York"
```
</details>
## Usage
> [!TIP]
> If you expose port `54000/tcp` (for example, via the add-on's Configuration tab), you can open the UI directly at `http://your-host:54000` without relying on ingress.
### Home Assistant add-on
1. **Configure the add-on:** In the add-on's configuration tab, set theme, language, esphome/packages toggle, and port.
2. **Start the add-on.**
3. **Open the Web UI:**
* Use **Open Web UI** from the add-on panel to launch ingress (default recommended when the external port is disabled).
* Or, if you've enabled port `54000/tcp` in the add-on configuration, browse to `http://homeassistant.local:54000` (or your configured host/port).
4. **In-app setup:**
* In the web UI, go to the settings menu.
* **Live Home Assistant Folder Path:** Set the path to your Home Assistant configuration directory (e.g., `/config`).
* **Backup Folder Path:** Set the path to the directory where your backups are stored (e.g., `/media/timemachine`).
### Docker Container
1. **Start the container** with the required volume mounts (see Docker installation above).
2. **Open the Web UI** at `http://localhost:54000` (or your server's IP/port).
3. **In-app setup:**
* In the web UI, go to the settings menu.
* **Live Home Assistant Folder Path:** Set to `/config` (this is the mounted volume).
* **Backup Folder Path:** Set to `/media/timemachine` (this is the mounted volume).
### Triggering Backups from Automations
**Basic Method (Add-on built-in):**
You can trigger a backup from Home Assistant automations or scripts using the `hassio.addon_stdin` service:
```yaml
service: hassio.addon_stdin
data:
addon: 0f6ec05b_homeassistant-time-machine
input: backup
```
> [!NOTE]
> Replace `0f6ec05b_homeassistant-time-machine` with your addon's slug if different.
For more control over your backups (like setting a custom timezone, limiting max backups, or only backing up when changes occur), install the [HACS Companion Integration](#hacs-companion-integration) and use the `time_machine.backup_now` service instead.
## Backup to Remote Share
To configure backups to a remote share, first set up network storage within Home Assistant (Settings > System > Storage > 'Add network storage'). Name the share 'backups' and set its usage to 'Media'. Once configured, you can then specify the backup path in Home Assistant Time Machine settings as '/media/backups', which will direct backups to your remote share.
<details>
<summary><h2>API Endpoints</h2></summary>
- **POST /api/backup-now**: Trigger an immediate backup. Requires `liveFolderPath` and `backupFolderPath`. Optional parameters (`smartBackupEnabled`, `maxBackupsEnabled`, `maxBackupsCount`, `timezone`) fall back to saved settings when not provided.
- **POST /api/restore-automation** / **POST /api/restore-script**: Restore a single automation or script after creating a safety backup.
- **POST /api/restore-lovelace-file** / **POST /api/restore-esphome-file** / **POST /api/restore-packages-file**: Restore Lovelace, ESPHome, or package files with automatic pre-restore backups.
- **POST /api/get-backup-* ** & **/api/get-live-* ** families: Fetch specific items from backups or the live config (automations, scripts, Lovelace, ESPHome, packages).
- **GET /api/schedule-backup** / **POST /api/schedule-backup**: Inspect or update scheduled backup jobs.
- **POST /api/scan-backups**: Scan the backup directory tree and list discovered backups.
- **POST /api/validate-path** / **POST /api/validate-backup-path**: Verify that provided directories exist and contain Home Assistant data/backups.
- **POST /api/test-home-assistant-connection**: Confirm stored Home Assistant credentials work before saving.
- **POST /api/reload-home-assistant**: Invoke a Home Assistant reload service (e.g., `automation.reload`).
- **GET /api/health**: Simple status endpoint exposing version, ingress state, and timestamp.
Example usage:
```bash
# Trigger backup
curl -X POST http://localhost:54000/api/backup-now \
-H "Content-Type: application/json" \
-d '{"liveFolderPath": "/config", "backupFolderPath": "/media/timemachine"}'
# Get scheduled jobs
curl http://localhost:54000/api/schedule-backup
# Scan backups
curl -X POST http://localhost:54000/api/scan-backups \
-H "Content-Type: application/json" \
-d '{"backupRootPath": "/media/timemachine"}'
```
</details>
## Alternative Options
For detailed history tracking powered by a local Git backend, check out [Home Assistant Version Control](https://github.com/saihgupr/HomeAssistantVersionControl/). It provides complete version history for your setup by automatically tracking every change to your YAML files.
## Press & Community
Thank you to everyone who has written about or featured Home Assistant Time Machine!
- [XDA Developers – "Home Assistant Time Machine tool is amazing"](https://www.xda-developers.com/home-assistant-time-machine-tool-is-amazing/)
- [Glooob Domo – YouTube Video](https://www.youtube.com/watch?v=aWZ0ON8b8io)
- [smarterkram | Olli – YouTube Video](https://www.youtube.com/watch?v=zyTExP_ebAE)
## Contributing & Support
If you encounter a bug or have a feature request, feel free to [open an issue](https://github.com/saihgupr/HomeAssistantTimeMachine/issues). If you'd like to contribute, check out the [contribution guidelines](CONTRIBUTING.md).
If you find this add-on useful, consider giving it a ⭐ star or making a [donation](https://ko-fi.com/saihgupr) to support development.
## /compose.yaml
```yaml path="/compose.yaml"
services:
ha-time-machine:
image: ghcr.io/saihgupr/homeassistanttimemachine:latest
container_name: ha-time-machine
ports:
- "54000:54000"
environment:
- HOME_ASSISTANT_URL=http://ha-ip-address:8123
- LONG_LIVED_ACCESS_TOKEN=your-long-lived-access-token
volumes:
- /mnt/homeassistant/config:/config
- /mnt/homeassistant/timemachine:/media/timemachine
- ha-time-machine-data:/data
restart: unless-stopped
volumes:
ha-time-machine-data:
```
## /custom_components/time_machine/__init__.py
```py path="/custom_components/time_machine/__init__.py"
"""The Home Assistant Time Machine integration."""
import asyncio
import logging
import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, API_BACKUP_NOW, CONF_URL
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["sensor"]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Home Assistant Time Machine component (YAML legacy)."""
if DOMAIN in config:
conf = config[DOMAIN]
if conf and CONF_URL in conf:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": "import"},
data=conf,
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Time Machine from a config entry."""
url = entry.options.get(CONF_URL) or entry.data.get(CONF_URL, "")
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {"url": url}
async def handle_backup_now(call):
"""Handle the backup_now service call."""
service_url = call.data.get("url") or url
_LOGGER.info("Triggering Time Machine backup at %s", service_url)
payload = {}
if "smart_backup_enabled" in call.data:
payload["smartBackupEnabled"] = call.data["smart_backup_enabled"]
if "max_backups_enabled" in call.data:
payload["maxBackupsEnabled"] = call.data["max_backups_enabled"]
if "max_backups_count" in call.data:
payload["maxBackupsCount"] = call.data["max_backups_count"]
if "live_config_path" in call.data:
payload["liveFolderPath"] = call.data["live_config_path"]
if "backup_folder_path" in call.data:
payload["backupFolderPath"] = call.data["backup_folder_path"]
if "timezone" in call.data:
payload["timezone"] = call.data["timezone"]
try:
async with aiohttp.ClientSession() as session:
async with asyncio.timeout(10):
async with session.post(f"{service_url}{API_BACKUP_NOW}", json=payload) as response:
if response.status == 200:
_LOGGER.info("Backup triggered successfully")
else:
_LOGGER.error("Failed to trigger backup: %s", response.status)
except Exception as err:
_LOGGER.error("Error triggering backup: %s", err)
hass.services.async_register(DOMAIN, "backup_now", handle_backup_now)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
return True
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok
```
## /custom_components/time_machine/brand/icon.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/custom_components/time_machine/brand/icon.png
## /custom_components/time_machine/brand/logo.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/custom_components/time_machine/brand/logo.png
## /custom_components/time_machine/brands/icon.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/custom_components/time_machine/brands/icon.png
## /custom_components/time_machine/brands/logo.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/custom_components/time_machine/brands/logo.png
## /custom_components/time_machine/config_flow.py
```py path="/custom_components/time_machine/config_flow.py"
"""Config flow for Home Assistant Time Machine integration."""
import asyncio
import logging
import aiohttp
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.core import callback
from .const import DOMAIN, CONF_URL, DEFAULT_PORT
_LOGGER = logging.getLogger(__name__)
DEFAULT_URL = f"http://homeassistant.local:{DEFAULT_PORT}"
import os
async def _async_test_connection(url: str) -> bool:
"""Return True if the Time Machine server is reachable."""
try:
async with aiohttp.ClientSession() as session:
async with asyncio.timeout(5):
async with session.get(f"{url}/api/health") as resp:
return resp.status == 200
except Exception:
return False
async def _async_discover_addon_url() -> str | None:
"""Discover the Add-on URL via the Supervisor API."""
token = os.environ.get("SUPERVISOR_TOKEN")
if not token:
return None
try:
async with aiohttp.ClientSession() as session:
headers = {"Authorization": f"Bearer {token}"}
async with asyncio.timeout(5):
async with session.get("http://supervisor/addons", headers=headers) as resp:
if resp.status == 200:
data = await resp.json()
addons = data.get("data", {}).get("addons", [])
for addon in addons:
if addon.get("name") == "Home Assistant Time Machine" or "time_machine" in addon.get("slug", ""):
slug = addon["slug"]
async with session.get(f"http://supervisor/addons/{slug}/info", headers=headers) as info_resp:
if info_resp.status == 200:
info_data = await info_resp.json()
hostname = info_data.get("data", {}).get("hostname")
if hostname:
url = f"http://{hostname}:54000"
if await _async_test_connection(url):
return url
except Exception:
pass
return None
class TimeMachineConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Home Assistant Time Machine."""
VERSION = 1
def __init__(self):
"""Initialize the config flow."""
self.discovered_url = None
async def async_step_import(self, user_input=None):
"""Handle import from configuration.yaml."""
if not user_input or CONF_URL not in user_input:
return self.async_abort(reason="unknown")
url = user_input[CONF_URL].strip().rstrip("/")
await self.async_set_unique_id(url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Time Machine (Imported)",
data={CONF_URL: url},
)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
if self.discovered_url is None:
self.discovered_url = await _async_discover_addon_url()
errors = {}
if user_input is not None:
url = user_input[CONF_URL].strip().rstrip("/")
reachable = await _async_test_connection(url)
if reachable:
await self.async_set_unique_id(url)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title="Time Machine",
data={CONF_URL: url},
)
errors["base"] = "cannot_connect"
default_url = self.discovered_url or DEFAULT_URL
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_URL,
default=user_input.get(CONF_URL, default_url) if user_input else default_url,
): str,
}
),
errors=errors,
)
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Return the options flow."""
return TimeMachineOptionsFlow(config_entry)
class TimeMachineOptionsFlow(config_entries.OptionsFlow):
"""Handle options for Time Machine."""
def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage the options."""
errors = {}
if user_input is not None:
url = user_input[CONF_URL].strip().rstrip("/")
reachable = await _async_test_connection(url)
if reachable:
return self.async_create_entry(title="", data={CONF_URL: url})
errors["base"] = "cannot_connect"
current_url = self.config_entry.options.get(
CONF_URL, self.config_entry.data.get(CONF_URL, DEFAULT_URL)
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_URL, default=current_url): str,
}
),
errors=errors,
)
```
## /custom_components/time_machine/const.py
```py path="/custom_components/time_machine/const.py"
"""Constants for the Home Assistant Time Machine integration."""
DOMAIN = "time_machine"
# API Endpoints
API_HEALTH = "/api/health"
API_BACKUP_NOW = "/api/backup-now"
# Configuration keys
CONF_URL = "url"
CONF_TOKEN = "token"
# DEFAULT VALUES
DEFAULT_NAME = "Time Machine"
DEFAULT_PORT = 54000
```
## /custom_components/time_machine/hacs.json
```json path="/custom_components/time_machine/hacs.json"
{
"name": "Home Assistant Time Machine",
"content_in_root": false,
"zip_release": false,
"render_readme": true,
"homeassistant": "2024.4.1"
}
```
## /custom_components/time_machine/manifest.json
```json path="/custom_components/time_machine/manifest.json"
{
"domain": "time_machine",
"name": "Home Assistant Time Machine",
"documentation": "https://github.com/saihgupr/HomeAssistantTimeMachine",
"issue_tracker": "https://github.com/saihgupr/HomeAssistantTimeMachine/issues",
"dependencies": [],
"codeowners": [
"@saihgupr"
],
"requirements": [],
"version": "1.2.1",
"iot_class": "local_polling",
"icon": "mdi:history",
"config_flow": true
}
```
## /custom_components/time_machine/sensor.py
```py path="/custom_components/time_machine/sensor.py"
"""Sensor platform for Home Assistant Time Machine."""
import asyncio
import logging
from datetime import timedelta
import aiohttp
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, API_HEALTH, CONF_URL
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=30)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up Time Machine sensor from a config entry."""
url = entry.options.get(CONF_URL) or entry.data.get(CONF_URL, "")
async_add_entities([TimeMachineHealthSensor(url, entry.entry_id)], True)
class TimeMachineHealthSensor(SensorEntity):
"""Representation of a Time Machine Health sensor."""
_attr_has_entity_name = True
_attr_name = "Status"
def __init__(self, url: str, entry_id: str) -> None:
"""Initialize the sensor."""
self._url = url
self._state = None
self._attr_unique_id = f"time_machine_{entry_id}_status"
self._attr_device_info = {
"identifiers": {(DOMAIN, entry_id)},
"name": "Time Machine",
"manufacturer": "Home Assistant Time Machine",
}
@property
def state(self):
"""Return the state of the sensor."""
return self._state
async def async_update(self) -> None:
"""Fetch new state data for the sensor."""
try:
async with aiohttp.ClientSession() as session:
async with asyncio.timeout(5):
async with session.get(f"{self._url}{API_HEALTH}") as response:
if response.status == 200:
data = await response.json()
if self._state != "Online":
_LOGGER.info("Time Machine at %s is back online", self._url)
self._state = "Online"
_LOGGER.debug("Time Machine health data: %s", data)
self._attr_extra_state_attributes = {
"version": data.get("version"),
"backup_count": data.get("backup_count"),
"last_backup": data.get("last_backup"),
"active_schedules": data.get("active_schedules"),
"disk_total_gb": data.get("disk_usage", {}).get("total_gb"),
"disk_free_gb": data.get("disk_usage", {}).get("free_gb"),
"disk_used_pct": data.get("disk_usage", {}).get("used_pct"),
"last_backup_status": data.get("last_backup_status"),
}
else:
if self._state != "Error":
_LOGGER.warning(
"Error fetching Time Machine health: %s. If you have uninstalled the Time Machine add-on, please also remove this integration.",
response.status
)
self._state = "Error"
except Exception as err:
if self._state != "Offline":
_LOGGER.warning(
"Failed to connect to Time Machine at %s: %s. If you have uninstalled the Time Machine add-on, please also remove this integration to stop these logs.",
self._url, err
)
self._state = "Offline"
```
## /custom_components/time_machine/services.yaml
```yaml path="/custom_components/time_machine/services.yaml"
backup_now:
name: Backup Now
description: Triggers an immediate backup in Home Assistant Time Machine.
fields:
url:
name: URL
description: (Optional) The URL of your Time Machine instance. Uses the integration's configured URL if left blank.
example: "http://ccab4aaf-homeassistant-time-machine:54000"
selector:
text:
smart_backup_enabled:
name: Smart Backup
description: Only backup if changes are detected compared to the last snapshot.
example: true
selector:
boolean:
max_backups_enabled:
name: Limit Max Backups
description: Whether to enforce a maximum number of backups to keep.
example: true
selector:
boolean:
max_backups_count:
name: Max Backups Count
description: The number of backups to keep before removing the oldest ones.
example: 100
selector:
number:
min: 1
max: 1000
live_config_path:
name: Live Config Path
description: The source path in the container to backup (default is /config).
example: "/config"
selector:
text:
backup_folder_path:
name: Backup Folder Path
description: The destination path in the container for backups (default is /media/timemachine).
example: "/media/timemachine"
selector:
text:
timezone:
name: Timezone
description: The timezone to use for the backup folder name (e.g., America/New_York).
example: "America/New_York"
selector:
text:
```
## /custom_components/time_machine/translations/en.json
```json path="/custom_components/time_machine/translations/en.json"
{
"config": {
"step": {
"user": {
"title": "Connect to Time Machine",
"description": "Enter the URL of your Home Assistant Time Machine add-on.",
"data": {
"url": "Time Machine URL"
}
}
},
"error": {
"cannot_connect": "Cannot connect to the Time Machine server. Check the URL and make sure the add-on is running."
},
"abort": {
"already_configured": "This Time Machine instance is already configured."
}
},
"options": {
"step": {
"init": {
"title": "Time Machine Options",
"data": {
"url": "Time Machine URL"
}
}
},
"error": {
"cannot_connect": "Cannot connect to the Time Machine server. Check the URL and make sure the add-on is running."
}
}
}
```
## /hacs.json
```json path="/hacs.json"
{
"name": "Home Assistant Time Machine",
"content_in_root": false,
"zip_release": false,
"render_readme": true,
"homeassistant": "2024.4.1"
}
```
## /homeassistant-time-machine/.dockerignore
```dockerignore path="/homeassistant-time-machine/.dockerignore"
# Docker
Dockerfile
.dockerignore
# Git
.git
.gitignore
# Node
node_modules
.next
npm-debug.log
yarn-error.log
# Editor
.vscode
.idea
# OS
.DS_Store
```
## /homeassistant-time-machine/.github/FUNDING.yml
```yml path="/homeassistant-time-machine/.github/FUNDING.yml"
ko_fi: diggingfordinos
```
## /homeassistant-time-machine/.github/workflows/docker-publish.yml
```yml path="/homeassistant-time-machine/.github/workflows/docker-publish.yml"
name: Docker
on:
release:
types: [published]
workflow_dispatch:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# Semver tags for releases
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# Branch tags
type=ref,event=branch
# SHA tag (always generated)
type=sha
# Manual run: force 'latest' tag
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' }}
# Default branch: 'latest' tag
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }}
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
## /homeassistant-time-machine/.gitignore
```gitignore path="/homeassistant-time-machine/.gitignore"
.DS_Store
config.js
node_modules/
*.log
.env
```
## /homeassistant-time-machine/.next/trace
```next/trace path="/homeassistant-time-machine/.next/trace"
[{"name":"next-dev","duration":327612680289,"timestamp":1644962542667,"id":1,"tags":{},"startTime":1760817843467,"traceId":"017ffdea5726e56f"}]
[{"name":"next-dev","duration":327539982664,"timestamp":1645035232662,"id":1,"tags":{},"startTime":1760817916157,"traceId":"8cfe63141797c3af"}]
[{"name":"next-dev","duration":327306173002,"timestamp":1645269053558,"id":1,"tags":{},"startTime":1760818149979,"traceId":"d22f2ca124d29bce"}]
[{"name":"next-dev","duration":446700295200,"timestamp":1525875316787,"id":1,"tags":{},"startTime":1760698755890,"traceId":"17a5b14f75b2e68a"}]
[{"name":"next-dev","duration":446571601022,"timestamp":1526004193204,"id":1,"tags":{},"startTime":1760698884767,"traceId":"45a828e3de82984e"}]
[{"name":"next-dev","duration":446746520395,"timestamp":1525829365245,"id":1,"tags":{},"startTime":1760698709939,"traceId":"7ee9876db406d498"}]
```
## /homeassistant-time-machine/CHANGELOG.md
# v2.3.1
- **Integration Updates:** You can now configure the integration directly from the Home Assistant UI, and the `time_machine.backup_now` service call now supports all available parameters for granular control.
- **Fixed Scope Bug:** Fixed a `ReferenceError: findFullRange is not defined` bug that occurred when restoring an individual automation or script.
# v2.3.0
- **Context Menu:** Introduced a right-click context menu for backups to easily Lock, Unlock, Export, or Delete them.
- **Backup Lock:** Added a backup lock feature to prevent accidental deletion of backups. Protect your most important snapshots from being rotated out by auto-cleanup.
- **HACS Integration:** Introduced the Home Assistant companion integration, enabling native sensors and service calls.
- **Enhanced Sensors:** New sensor attributes for disk usage (total, free, used percentage), backup count, and version tracking.
- **Backup Status Tracking:** Real-time tracking of the last backup status (`success`, `failed`, `no_changes`) with persistence across restarts.
- **Service Improved:** `time_machine.backup_now` service call is now available with full parameter support for flexible automation.
- **Keyboard Navigation:** Navigate backups and items using arrow keys! Use Up/Down to change selection and Left/Right to switch between panels. Press Enter on an item to view its diff.
- **Docker Env Var:** Added `ESPHOME_CONFIG_PATH` environment variable support for Docker installations, allowing custom locations for ESPHome configuration files.
- **Split Config Support:** Advanced support for Home Assistant configurations using `!include`, `!include_dir_list`, and other split configuration methods.
- **Manifest-Driven Backups & Restoration:** Every backup now includes a detailed file manifest, ensuring that restores and change detection are perfectly aware of where your files live and are automatically placed back exactly where they belong in your YAML structure.
# v2.2.0
- **Smart Backup:** Incremental snapshots only save files that changed since your last backup. It looks complete in the UI but uses significantly less storage.
- **Show Changes Only:** Filter snapshots and files to just what has changed or deleted compared to your live config. This works per tab in both the snapshot list and file view.
- **Automation Triggers:** Backups can now be triggered from automations or scripts via `hassio.addon_stdin`. This is useful for scheduled, conditional, or event-driven backups.
- **Diff Color Palettes:** Eight new color palettes in the diff viewer which are switchable directly by clicking the header bar.
## /homeassistant-time-machine/Dockerfile
``` path="/homeassistant-time-machine/Dockerfile"
FROM node:20-alpine
# Install git, curl and other dependencies
RUN apk add --no-cache git curl
# Create app directory
WORKDIR /app
# Copy package files and install dependencies
COPY package*.json ./
RUN npm install
# Copy the rest of the application
COPY . .
# Make scripts executable
RUN chmod +x run.sh
# Expose the port
EXPOSE 54000
# Set environment variables
ENV NODE_ENV=development
# Start the application
CMD ["/bin/sh", "run.sh"]
```
## /homeassistant-time-machine/README.md
# Home Assistant Time Machine
Home Assistant Time Machine is a web-based tool that acts as a "Time Machine" for your Home Assistant configuration. Browse YAML backups across automations, scripts, Lovelace dashboards, ESPHome files, and packages, then restore individual items back to your live setup with confidence.
## What's New!
* **Backup Lock, Deletion & Export:** Added a new backup lock feature to prevent accidental deletion of snapshots. You can now also manually delete or export individual backups as .tar.gz archives directly from the web UI using the new right-click context menu.
* **HACS Integration:** Now available as a companion integration via HACS! Track backup status with a native sensor and trigger backups using the `time_machine.backup_now` service.
* **Keyboard Navigation:** Navigate backups and items using arrow keys! Use Up/Down to change selection and Left/Right to switch between panels. Press Enter on an item to view its diff.
* **Manifest-Driven Backups & Restoration:** Every backup now includes a detailed file manifest, ensuring that restores and change detection are perfectly aware of where your files live and are automatically placed back exactly where they belong in your YAML structure.
* **Docker Env Var:** Added `ESPHOME_CONFIG_PATH` environment variable support for Docker installations, allowing custom locations for ESPHome configuration files.
* **Split Config Support:** Optimized for advanced Home Assistant setups using `!include`, `!include_dir_list`, and other split configuration methods.






## Features
* **Browse Backups:** Easily browse through your Home Assistant backup YAML files.
* **View Changes & Diff Palettes:** See side-by-side diffs with 8 vibrant color palettes to choose from.
* **Restore Individual Items:** Restore individual automations or scripts without having to restore an entire backup.
* **Smart Backup:** Incremental backup mode that only saves changed files, significantly reducing storage usage.
* **Show Changes Only:** Filter backups to only show snapshots that contain changed or deleted items compared to live config.
* **Safety First:** Automatically creates a backup before restoring anything.
* **Reload Home Assistant:** Reload automations or scripts directly from the UI after a restore.
* **Scheduled Backups:** Configure automatic backups on a schedule.
* **Service Call Support:** Trigger backups from Home Assistant automations or scripts using the `hassio.addon_stdin` service.
* **Multi-language Support:** Available in English, Spanish, German, French, Dutch, and Italian.
* **Ingress Support:** Access through the Home Assistant UI without port forwarding.
* **Lovelace, ESPHome & Packages:** Full support for backing up and restoring dashboards, ESPHome files, and package configurations.
* **Max Backups & Flexible Locations:** Control retention limits and store backups in `/share`, `/backup`, `/media`, or remote shares.
* **Backup Lock & Context Menu:** Prevent accidental deletion by locking your favorite backups. Right-click any backup to Lock, Unlock, Export, or Delete it instantly.
* **REST API:** Full API for programmatic backup management.
## Installation
There are two ways to install Home Assistant Time Machine: as a Home Assistant add-on or as a standalone Docker container.
### 1. Home Assistant add-on (Recommended for most users)
1. **Add Repository:**
Click the button below to add the repository to your Home Assistant instance:
[](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https://github.com/saihgupr/ha-addons)
**Or manually add it:**
- Navigate to **Settings** → **Add-ons** → **Add-on Store**
- Click the three dots (⋮) in the top right corner and select **Repositories**
- Add the repository URL:
```
https://github.com/saihgupr/ha-addons
```
2. **Install the Add-on:**
The "Home Assistant Time Machine" add-on will now appear in the store. Click on it and then click "Install".
<details>
<summary><h3>2. Standalone Docker Installation</h3></summary>
For Docker users who aren't using the Home Assistant add-on, you have three deployment options:
**Option A: Docker Compose (recommended):**
1. Download the compose.yaml file:
```bash
curl -o compose.yaml https://github.com/saihgupr/HomeAssistantTimeMachine/raw/branch/main/compose.yaml
```
2. Edit the file to set your paths and credentials:
```bash
nano compose.yaml
```
3. Start the service:
```bash
docker compose up -d
```
**Option B: Docker Run (pre-built image):**
```bash
docker run -d \
-p 54000:54000 \
-e HOME_ASSISTANT_URL="http://your-ha-instance:8123" \
-e LONG_LIVED_ACCESS_TOKEN="your-long-lived-access-token" \
-e ESPHOME_CONFIG_PATH="/path/to/esphome/config" \
-e THEME="dark" \
-e DEBUG_LOGS="false" \
-v /path/to/your/ha/config:/config \
-v /path/to/your/backups:/media \
-v ha-time-machine-data:/data \
--name ha-time-machine \
ghcr.io/saihgupr/homeassistanttimemachine:latest
```
**Option C: Build locally:**
```bash
git clone https://github.com/saihgupr/HomeAssistantTimeMachine.git
cd HomeAssistantTimeMachine/homeassistant-time-machine
docker build -t ha-time-machine .
docker run -d \
-p 54000:54000 \
-e HOME_ASSISTANT_URL="http://your-ha-instance:8123" \
-e LONG_LIVED_ACCESS_TOKEN="your-long-lived-access-token" \
-e ESPHOME_CONFIG_PATH="/path/to/esphome/config" \
-e THEME="dark" \
-e DEBUG_LOGS="false" \
-v /path/to/your/ha/config:/config \
-v /path/to/your/backups:/media \
-v ha-time-machine-data:/data \
--name ha-time-machine \
ha-time-machine
```
Supplying the URL and token keeps credentials out of the UI. These environment variables are optional—if you set them, the settings fields are read-only; if you omit them, you can enter credentials in the web UI instead.
**Alternative:** omit the environment variables, start the container with the same volumes, then visit `http://localhost:54000` to enter credentials in the settings modal. They are stored in `/data/docker-ha-credentials.json`.
#### Changing Options in Docker
After the container is running, you can toggle ESPHome support, adjust text style, and switch light/dark modes by POSTing to the app settings API. This persists the value in `/data/homeassistant-time-machine/docker-app-settings.json` so the UI reflects it on reload:
```bash
curl -X POST http://localhost:54000/api/app-settings \
-H 'Content-Type: application/json' \
-d '{
"theme": "light",
"esphomeEnabled": true,
"packagesEnabled": true,
"language": "de"
}'
```
Adjust the payload if you need different paths, theme, or want to enable/disable features (`"esphomeEnabled": true|false`, `"packagesEnabled": true|false`, `"theme": light|dark`, `"language": en|es|de|fr|nl|it`).
#### Accessing the Web Interface
After starting the container, access the web interface at `http://localhost:54000` (or your server's IP/port).
> [!NOTE]
> The HA URL and token fields in settings will be read-only if configured via environment variables, or editable if configured through the web UI.
</details>
<details>
<summary><h3>HACS Companion Integration</h3></summary>
Enhance your Home Assistant experience by adding the Time Machine companion integration via HACS. This provides:
- **Sensors:** Track backup status and health directly in Home Assistant.
- **Services:** Trigger backups using native `time_machine.backup_now` service calls in your automations.
#### Installation & Setup:
<a href="https://my.home-assistant.io/redirect/hacs_repository/?owner=saihgupr&repository=HomeAssistantTimeMachine&category=integration">
<img src="https://my.home-assistant.io/badges/hacs_repository.svg" alt="Open your Home Assistant instance and open a repository inside the Home Assistant Community Store." />
</a>
**Or manually add the custom repository:**
1. Ensure [HACS](https://hacs.xyz/) is installed.
2. In Home Assistant, go to **HACS** → **Integrations**.
3. Click the three dots (⋮) in the top right and select **Custom repositories**.
4. Add `https://github.com/saihgupr/HomeAssistantTimeMachine` as an **Integration**.
5. Find **Home Assistant Time Machine** in HACS and click **Download**.
6. Go to **Settings** → **Devices & Services**.
7. Click **Add Integration** in the bottom right and search for **Home Assistant Time Machine**.
8. Follow the UI prompts.
* If installed via the official Home Assistant Add-on, it will automatically discover the instance!
* If installed via Docker (or if auto-discovery fails), you will be prompted to enter the instance URL.
> [!IMPORTANT]
> **Docker Users:** Use the internal container name (e.g., `http://ha-time-machine:54000`) if they share a network, or your server's LAN IP if they are on separate hosts.
> - **Note:** If `sensor.time_machine_status` shows as `Offline`, it usually means Home Assistant cannot reach the Time Machine API at that address.
#### Sensor: `sensor.time_machine_status`
Monitor your backup system health directly in Home Assistant.
| Attribute | Description | Example |
| :--- | :--- | :--- |
| `state` | Current status of the instance | `Online` |
| `version` | Running version | `2.3.1` |
| `backup_count` | Total number of backups stored | `764` |
| `last_backup` | Timestamp of the last backup | `2026-02-17-000000` |
| `disk_total_gb` | Total storage space | `111.73` |
| `disk_free_gb` | Available storage space | `13.68` |
| `disk_used_pct` | Storage usage percentage | `87.8%` |
| `last_backup_status` | Status of the most recent run | `success` |
#### Action: `time_machine.backup_now`
Trigger backups via service calls in your automations or scripts.
| Parameter | Description | Example |
| :--- | :--- | :--- |
| `url` | (Optional) The URL of your Time Machine instance. Uses the integration's configured URL if left blank. | `http://192.168.1.4:54000` |
| `smart_backup_enabled` | Only backup if changes are detected compared to the last snapshot. | `true` |
| `max_backups_enabled` | Whether to enforce the maximum number of backups to keep. | `true` |
| `max_backups_count` | The number of backups to keep before removing oldest ones. | `100` |
| `live_config_path` | The source path in the container to backup (default is `/config`). | `/config` |
| `backup_folder_path` | The destination path in the container for backups (default is `/media/timemachine`). | `/media/timemachine` |
| `timezone` | The timezone to use for the backup folder name (e.g., `America/New_York`). | `America/New_York` |
**Example Automation:**
```yaml
action: time_machine.backup_now
data:
smart_backup_enabled: true
max_backups_enabled: true
max_backups_count: 100
timezone: "America/New_York"
```
</details>
## Usage
> [!TIP]
> If you expose port `54000/tcp` (for example, via the add-on's Configuration tab), you can open the UI directly at `http://your-host:54000` without relying on ingress.
### Home Assistant add-on
1. **Configure the add-on:** In the add-on's configuration tab, set theme, language, esphome/packages toggle, and port.
2. **Start the add-on.**
3. **Open the Web UI:**
* Use **Open Web UI** from the add-on panel to launch ingress (default recommended when the external port is disabled).
* Or, if you've enabled port `54000/tcp` in the add-on configuration, browse to `http://homeassistant.local:54000` (or your configured host/port).
4. **In-app setup:**
* In the web UI, go to the settings menu.
* **Live Home Assistant Folder Path:** Set the path to your Home Assistant configuration directory (e.g., `/config`).
* **Backup Folder Path:** Set the path to the directory where your backups are stored (e.g., `/media/timemachine`).
### Docker Container
1. **Start the container** with the required volume mounts (see Docker installation above).
2. **Open the Web UI** at `http://localhost:54000` (or your server's IP/port).
3. **In-app setup:**
* In the web UI, go to the settings menu.
* **Live Home Assistant Folder Path:** Set to `/config` (this is the mounted volume).
* **Backup Folder Path:** Set to `/media/timemachine` (this is the mounted volume).
### Triggering Backups from Automations
**Basic Method (Add-on built-in):**
You can trigger a backup from Home Assistant automations or scripts using the `hassio.addon_stdin` service:
```yaml
service: hassio.addon_stdin
data:
addon: 0f6ec05b_homeassistant-time-machine
input: backup
```
> [!NOTE]
> Replace `0f6ec05b_homeassistant-time-machine` with your addon's slug if different.
For more control over your backups (like setting a custom timezone, limiting max backups, or only backing up when changes occur), install the [HACS Companion Integration](#hacs-companion-integration) and use the `time_machine.backup_now` service instead.
## Backup to Remote Share
To configure backups to a remote share, first set up network storage within Home Assistant (Settings > System > Storage > 'Add network storage'). Name the share 'backups' and set its usage to 'Media'. Once configured, you can then specify the backup path in Home Assistant Time Machine settings as '/media/backups', which will direct backups to your remote share.
<details>
<summary><h2>API Endpoints</h2></summary>
- **POST /api/backup-now**: Trigger an immediate backup. Requires `liveFolderPath` and `backupFolderPath`. Optional parameters (`smartBackupEnabled`, `maxBackupsEnabled`, `maxBackupsCount`, `timezone`) fall back to saved settings when not provided.
- **POST /api/restore-automation** / **POST /api/restore-script**: Restore a single automation or script after creating a safety backup.
- **POST /api/restore-lovelace-file** / **POST /api/restore-esphome-file** / **POST /api/restore-packages-file**: Restore Lovelace, ESPHome, or package files with automatic pre-restore backups.
- **POST /api/get-backup-* ** & **/api/get-live-* ** families: Fetch specific items from backups or the live config (automations, scripts, Lovelace, ESPHome, packages).
- **GET /api/schedule-backup** / **POST /api/schedule-backup**: Inspect or update scheduled backup jobs.
- **POST /api/scan-backups**: Scan the backup directory tree and list discovered backups.
- **POST /api/validate-path** / **POST /api/validate-backup-path**: Verify that provided directories exist and contain Home Assistant data/backups.
- **POST /api/test-home-assistant-connection**: Confirm stored Home Assistant credentials work before saving.
- **POST /api/reload-home-assistant**: Invoke a Home Assistant reload service (e.g., `automation.reload`).
- **GET /api/health**: Simple status endpoint exposing version, ingress state, and timestamp.
Example usage:
```bash
# Trigger backup
curl -X POST http://localhost:54000/api/backup-now \
-H "Content-Type: application/json" \
-d '{"liveFolderPath": "/config", "backupFolderPath": "/media/timemachine"}'
# Get scheduled jobs
curl http://localhost:54000/api/schedule-backup
# Scan backups
curl -X POST http://localhost:54000/api/scan-backups \
-H "Content-Type: application/json" \
-d '{"backupRootPath": "/media/timemachine"}'
```
</details>
## Alternative Options
For detailed history tracking powered by a local Git backend, check out [Home Assistant Version Control](https://github.com/saihgupr/HomeAssistantVersionControl/). It provides complete version history for your setup by automatically tracking every change to your YAML files.
## Press & Community
Thank you to everyone who has written about or featured Home Assistant Time Machine!
- [XDA Developers – "Home Assistant Time Machine tool is amazing"](https://www.xda-developers.com/home-assistant-time-machine-tool-is-amazing/)
- [Glooob Domo – YouTube Video](https://www.youtube.com/watch?v=aWZ0ON8b8io)
- [smarterkram | Olli – YouTube Video](https://www.youtube.com/watch?v=zyTExP_ebAE)
## Contributing & Support
If you encounter a bug or have a feature request, feel free to [open an issue](https://github.com/saihgupr/HomeAssistantTimeMachine/issues). If you'd like to contribute, check out the [contribution guidelines](CONTRIBUTING.md).
If you find this add-on useful, consider giving it a ⭐ star or making a [donation](https://ko-fi.com/saihgupr) to support development.
## /homeassistant-time-machine/app.js
```js path="/homeassistant-time-machine/app.js"
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const YAML = require('yaml');
const jsyaml = require('js-yaml');
const cron = require('node-cron');
const fetch = require('node-fetch');
const https = require('https');
const readline = require('readline');
const { spawn } = require('child_process');
const DATA_DIR = (() => {
const addonDataRoot = '/data';
if (fsSync.existsSync(addonDataRoot)) {
const dir = path.join(addonDataRoot, 'homeassistant-time-machine');
try {
fsSync.mkdirSync(dir, { recursive: true });
} catch (error) {
console.error('[data-dir] Failed to ensure addon data directory exists:', error);
}
return dir;
}
const fallback = path.join(__dirname, 'data');
try {
fsSync.mkdirSync(fallback, { recursive: true });
} catch (error) {
console.error('[data-dir] Failed to ensure local data directory exists:', error);
}
return fallback;
})();
const version = '2.3.1';
const DEBUG_LOGS = process.env.DEBUG_LOGS === 'true';
const debugLog = (...args) => {
if (DEBUG_LOGS) {
console.log(...args);
}
};
// Track the state of the last backup
let LAST_BACKUP_STATE = {
status: 'never_run',
timestamp: null,
error: null,
source: null
};
// Persistence helpers
const BACKUP_STATE_FILE = path.join(DATA_DIR, 'backup-state.json');
async function saveBackupState() {
try {
await fs.writeFile(BACKUP_STATE_FILE, JSON.stringify(LAST_BACKUP_STATE, null, 2));
debugLog('[state] Saved backup state to disk');
} catch (e) {
console.error('[state] Failed to save backup state:', e.message);
}
}
async function loadBackupState() {
try {
const data = await fs.readFile(BACKUP_STATE_FILE, 'utf-8');
LAST_BACKUP_STATE = JSON.parse(data);
debugLog('[state] Loaded backup state from disk:', LAST_BACKUP_STATE.status);
} catch (e) {
debugLog('[state] No saved backup state found, starting fresh');
await saveBackupState();
}
}
const TLS_CERT_ERROR_CODES = new Set([
'SELF_SIGNED_CERT_IN_CHAIN',
'DEPTH_ZERO_SELF_SIGNED_CERT',
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
'ERR_TLS_CERT_ALTNAME_INVALID',
'ERR_TLS_CERT_SIGNATURE_ALGORITHM_UNSUPPORTED',
]);
const TLS_ERROR_TEXT_PATTERN = /self signed certificate|unable to verify the first certificate/i;
const isTlsCertificateError = (error) => {
if (!error) return false;
const nestedCandidates = [
error,
error.cause,
error.reason,
error.cause?.cause,
].filter(Boolean);
for (const candidate of nestedCandidates) {
if (candidate.code && TLS_CERT_ERROR_CODES.has(candidate.code)) {
return true;
}
if (typeof candidate.message === 'string' && TLS_ERROR_TEXT_PATTERN.test(candidate.message)) {
return true;
}
}
return false;
};
const app = express();
app.use(express.json());
const PORT = process.env.PORT || 54000;
const HOST = process.env.HOST || '0.0.0.0';
const INGRESS_PATH = process.env.INGRESS_ENTRY || '';
const basePath = INGRESS_PATH || '';
const BODY_SIZE_LIMIT = '50mb';
console.log('[data-dir] Using persistent data directory:', DATA_DIR);
// Set up stdin listener for hassio.addon_stdin service
// Toggle backup lock
app.post('/api/toggle-lock', async (req, res) => {
try {
const { backupPath } = req.body;
if (!backupPath) {
return res.status(400).json({ error: 'backupPath is required' });
}
const lockFile = path.join(backupPath, '.lock');
let locked = false;
try {
await fs.access(lockFile);
// If it exists, remove it
await fs.unlink(lockFile);
locked = false;
} catch (e) {
// If it doesn't exist, create it
await fs.writeFile(lockFile, 'locked', 'utf-8');
locked = true;
}
res.json({ success: true, locked });
} catch (error) {
console.error('[toggle-lock] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Helper to retry deletion on ENOTEMPTY
async function rmWithRetry(dirPath, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
await fs.rm(dirPath, { recursive: true, force: true });
return; // Success
} catch (err) {
if (err.code === 'ENOTEMPTY' && i < retries - 1) {
console.log(`[rmWithRetry] ENOTEMPTY for ${dirPath}, retrying in ${delay}ms... (${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw err; // Re-throw if not ENOTEMPTY or out of retries
}
}
}
app.post('/api/delete-backup', async (req, res) => {
const { backupPath } = req.body;
if (!backupPath) {
return res.status(400).json({ error: 'backupPath is required' });
}
try {
// Check if locked
const lockFile = path.join(backupPath, '.lock');
if (fsSync.existsSync(lockFile)) {
return res.status(403).json({ error: 'This backup is protected and cannot be deleted.' });
}
console.log(`[api] Manually deleting backup: ${backupPath}`);
await rmWithRetry(backupPath);
res.json({ success: true });
} catch (error) {
console.error('[api] Error deleting backup:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/export-backup', async (req, res) => {
try {
const backupPath = req.query.backupPath;
if (!backupPath || typeof backupPath !== 'string') {
return res.status(400).json({ error: 'backupPath is required' });
}
const options = await getAddonOptions();
const settings = await loadDockerSettings();
const configuredBackupRoot = options.backupFolderPath || settings.backupFolderPath || '/media/timemachine';
const resolvedRoot = path.resolve(configuredBackupRoot);
const resolvedBackupPath = path.resolve(backupPath);
const rootWithSep = resolvedRoot.endsWith(path.sep) ? resolvedRoot : `${resolvedRoot}${path.sep}`;
if (resolvedBackupPath !== resolvedRoot && !resolvedBackupPath.startsWith(rootWithSep)) {
return res.status(403).json({ error: 'Invalid backup path' });
}
const stats = await fs.stat(resolvedBackupPath);
if (!stats.isDirectory()) {
return res.status(400).json({ error: 'backupPath must be a directory' });
}
const parentDir = path.dirname(resolvedBackupPath);
const folderName = path.basename(resolvedBackupPath);
const safeName = folderName.replace(/[^a-zA-Z0-9._-]/g, '_');
const archiveName = `${safeName}.tar.gz`;
res.setHeader('Content-Type', 'application/gzip');
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
const tarProcess = spawn('tar', ['-czf', '-', '-C', parentDir, folderName], {
stdio: ['ignore', 'pipe', 'pipe']
});
let stderrOutput = '';
tarProcess.stderr.on('data', (chunk) => {
stderrOutput += chunk.toString();
});
tarProcess.on('error', (error) => {
console.error('[export-backup] Failed to spawn tar:', error.message);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to export backup archive' });
} else if (!res.writableEnded) {
res.end();
}
});
req.on('close', () => {
if (!tarProcess.killed) {
tarProcess.kill('SIGTERM');
}
});
tarProcess.on('close', (code) => {
if (code !== 0) {
console.error('[export-backup] tar exited with code', code, stderrOutput.trim());
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to export backup archive' });
} else if (!res.writableEnded) {
res.end();
}
}
});
tarProcess.stdout.pipe(res);
} catch (error) {
console.error('[export-backup] Error:', error);
res.status(500).json({ error: error.message });
}
});
// This allows triggering backups from Home Assistant automations/scripts
// Must be at top level to catch stdin before server starts
const setupStdinListener = () => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false
});
// Ensure stdin is flowing
process.stdin.resume();
console.log('[stdin] Listener initialized, waiting for commands (backup, backup_now)...');
rl.on('line', async (line) => {
// Strip quotes - Home Assistant may send input with JSON encoding
const command = line.trim().toLowerCase().replace(/^["']+|["']+$/g, '');
console.log(`[stdin] Received command: "${command}"`);
switch (command) {
case 'backup':
case 'backup_now':
try {
console.log('[stdin] Triggering backup...');
const options = await getAddonOptions();
// Load settings from docker-app-settings.json for paths
const settings = await loadDockerSettings();
// Load scheduled jobs to get smartBackupEnabled (saved via UI toggle)
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
const smartBackupEnabled = defaultJob.smartBackupEnabled ?? settings.smartBackupEnabled ?? false;
console.log(`[stdin] Smart backup mode: ${smartBackupEnabled}`);
const backupPath = await performBackup(
options.liveConfigPath || settings.liveConfigPath || '/config',
options.backupFolderPath || settings.backupFolderPath || '/media/timemachine',
'stdin-service',
defaultJob.maxBackupsEnabled ?? false,
defaultJob.maxBackupsCount ?? 100,
defaultJob.timezone ?? null,
smartBackupEnabled
);
if (backupPath === null) {
console.log('[stdin] No changes detected since last backup (smart backup mode)');
} else {
console.log(`[stdin] Backup completed successfully: ${backupPath}`);
}
} catch (error) {
console.error('[stdin] Backup failed:', error.message);
}
break;
default:
if (command) {
console.log(`[stdin] Unknown command: "${command}". Available: backup, backup_now`);
}
}
});
rl.on('close', () => {
console.log('[stdin] Input stream closed');
});
rl.on('error', (err) => {
console.error('[stdin] Error:', err.message);
});
};
// Initialize stdin listener
setupStdinListener();
// Log ingress configuration immediately
console.log('[INIT] INGRESS_ENTRY env var:', process.env.INGRESS_ENTRY || '(not set)');
console.log('[INIT] basePath will be:', basePath || '(empty - direct access)');
// Middleware
app.use(express.json({ limit: BODY_SIZE_LIMIT }));
app.use(express.urlencoded({ extended: true, limit: BODY_SIZE_LIMIT }));
// Error handling middleware for payload size errors
app.use((err, req, res, next) => {
if (err.type === 'entity.too.large') {
return res.status(413).json({
error: `Payload too large: ${err.message}`,
limit: BODY_SIZE_LIMIT
});
}
next(err);
});
// Ingress path detection and URL rewriting middleware
app.use((req, res, next) => {
debugLog(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
// Detect ingress path from headers
const ingressPath = req.headers['x-ingress-path'] ||
req.headers['x-forwarded-prefix'] ||
req.headers['x-external-url'] ||
'';
// Make ingress path available to templates
res.locals.ingressPath = ingressPath;
res.locals.url = (path) => ingressPath + path;
if (ingressPath) {
debugLog(`[ingress] Detected: ${ingressPath}, Original URL: ${req.originalUrl}`);
// Strip ingress prefix from URL for routing
if (req.originalUrl.startsWith(ingressPath)) {
req.url = req.originalUrl.substring(ingressPath.length) || '/';
debugLog(`[ingress] Rewritten URL: ${req.url}`);
}
}
next();
});
// Set up view engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Static files - serve at both root and any ingress path
app.use('/static', express.static(path.join(__dirname, 'public')));
// Also handle ingress paths like /api/hassio_ingress/TOKEN/static
app.use('*/static', express.static(path.join(__dirname, 'public')));
console.log(`[static] Static files configured for direct and ingress access`);
// Favicon routes
app.get('/favicon.ico', (req, res) => {
res.sendFile(path.join(__dirname, 'public/images/favicon.ico'));
});
// Home page
app.get('/', async (req, res) => {
try {
const options = await getAddonOptions();
res.render('index', {
title: 'Home Assistant Time Machine',
version,
currentMode: 'automations',
esphomeEnabled: options.esphome,
packagesEnabled: options.packages,
language: options.language || 'en'
});
} catch (error) {
console.error('[home] Failed to determine feature status:', error);
res.render('index', {
title: 'Home Assistant Time Machine',
version,
currentMode: 'automations',
esphomeEnabled: false,
packagesEnabled: false,
language: 'en'
});
}
});
const normalizeHomeAssistantUrl = (url) => {
if (!url) return null;
return url.replace(/\/$/, '').replace(/\/+$/, '');
};
const toApiBase = (url) => {
const normalized = normalizeHomeAssistantUrl(url);
if (!normalized) return null;
return normalized.endsWith('/api') ? normalized : `${normalized}/api`;
};
const resolveSupervisorToken = () => {
const possibleTokens = [process.env.SUPERVISOR_TOKEN, process.env.HASSIO_TOKEN];
for (const token of possibleTokens) {
if (token && token.trim()) {
return token.trim();
}
}
return null;
};
// Cache for parsed YAML files (with size limit to prevent memory bloat)
const yamlCache = new Map();
const YAML_CACHE_MAX_SIZE = 100; // Limit cache entries to prevent memory issues
async function loadYamlWithCache(filePath) {
try {
const stats = await fs.stat(filePath);
const mtime = stats.mtime.getTime();
if (yamlCache.has(filePath)) {
const cached = yamlCache.get(filePath);
if (cached.mtime === mtime) {
return cached.data;
}
}
const content = await fs.readFile(filePath, 'utf-8');
const data = jsyaml.load(content);
// Evict oldest entries if cache is too large
if (yamlCache.size >= YAML_CACHE_MAX_SIZE) {
const firstKey = yamlCache.keys().next().value;
yamlCache.delete(firstKey);
}
yamlCache.set(filePath, {
mtime,
data
});
return data;
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}
throw error;
}
}
// Clear cache entries for backup paths (to free memory after filtering)
function clearBackupCacheEntries() {
const keysToDelete = [];
for (const key of yamlCache.keys()) {
// Keep live config cache, clear backup entries
if (!key.includes('/config/')) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => yamlCache.delete(key));
console.log(`[cache] Cleared ${keysToDelete.length} backup cache entries`);
}
const YAML_EXTENSIONS = new Set(['.yaml', '.yml']);
/**
* Parse configuration.yaml to find automation and script file locations
* Supports !include, !include_dir_list, !include_dir_named, !include_dir_merge_list, !include_dir_merge_named
* @param {string} configPath - Path to the config directory
* @returns {Object} Object with automationPaths (array), scriptPaths (array), and automationDirs/scriptDirs for directory includes
*/
async function getConfigFilePaths(configPath) {
const configFile = path.join(configPath, 'configuration.yaml');
const automationPaths = [];
const scriptPaths = [];
const automationDirs = [];
const scriptDirs = [];
try {
const configContent = await fs.readFile(configFile, 'utf-8');
debugLog('[getConfigFilePaths] Found configuration.yaml, parsing...');
const lines = configContent.split('\n');
for (const line of lines) {
const trimmedLine = line.trim();
// Match automation: !include filename.yaml
const autoIncludeMatch = trimmedLine.match(/^automation:\s*!include\s+(.+)$/);
if (autoIncludeMatch) {
const file = autoIncludeMatch[1].trim();
automationPaths.push(path.join(configPath, file));
}
// Match script: !include filename.yaml
const scriptIncludeMatch = trimmedLine.match(/^script:\s*!include\s+(.+)$/);
if (scriptIncludeMatch) {
const file = scriptIncludeMatch[1].trim();
scriptPaths.push(path.join(configPath, file));
}
// Match automation: !include_dir_list dir_name or !include_dir_merge_list dir_name
const autoDirListMatch = trimmedLine.match(/^automation:\s*!include_dir_(?:merge_)?list\s+(.+)$/);
if (autoDirListMatch) {
const dir = autoDirListMatch[1].trim();
const fullDir = path.join(configPath, dir);
automationDirs.push(fullDir);
try {
const files = await listYamlFilesRecursive(fullDir);
for (const file of files) {
automationPaths.push(path.join(fullDir, file));
}
} catch (err) {
debugLog(`[getConfigFilePaths] Could not read automation directory ${fullDir}:`, err.message);
}
}
// Match script: !include_dir_named dir_name or !include_dir_merge_named dir_name
const scriptDirNamedMatch = trimmedLine.match(/^script:\s*!include_dir_(?:merge_)?named\s+(.+)$/);
if (scriptDirNamedMatch) {
const dir = scriptDirNamedMatch[1].trim();
const fullDir = path.join(configPath, dir);
scriptDirs.push(fullDir);
try {
const files = await listYamlFilesRecursive(fullDir);
for (const file of files) {
scriptPaths.push(path.join(fullDir, file));
}
} catch (err) {
debugLog(`[getConfigFilePaths] Could not read script directory ${fullDir}:`, err.message);
}
}
}
// Default fallback if nothing found in config
if (automationPaths.length === 0) {
automationPaths.push(path.join(configPath, 'automations.yaml'));
}
if (scriptPaths.length === 0) {
scriptPaths.push(path.join(configPath, 'scripts.yaml'));
}
} catch (error) {
// If configuration.yaml doesn't exist or can't be read, use defaults
debugLog('[getConfigFilePaths] Could not read configuration.yaml, using defaults:', error.message);
automationPaths.push(path.join(configPath, 'automations.yaml'));
scriptPaths.push(path.join(configPath, 'scripts.yaml'));
}
debugLog('[getConfigFilePaths] Automation paths:', automationPaths);
debugLog('[getConfigFilePaths] Script paths:', scriptPaths);
debugLog('[getConfigFilePaths] Automation dirs:', automationDirs);
debugLog('[getConfigFilePaths] Script dirs:', scriptDirs);
return { automationPaths, scriptPaths, automationDirs, scriptDirs };
}
async function listYamlFilesRecursive(rootDir) {
const results = [];
async function walk(currentDir, relativePrefix) {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch (err) {
if (err.code === 'ENOENT') {
return;
}
throw err;
}
for (const entry of entries) {
if (entry.name.startsWith('._')) {
continue;
}
const entryRelativePath = relativePrefix ? path.join(relativePrefix, entry.name) : entry.name;
const fullPath = path.join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
continue;
}
if (entry.isDirectory()) {
await walk(fullPath, entryRelativePath);
} else if (entry.isFile() && YAML_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
results.push(entryRelativePath);
}
}
}
await walk(rootDir, '');
results.sort((a, b) => a.localeCompare(b));
return results;
}
function resolveWithinDirectory(baseDir, relativePath) {
if (typeof relativePath !== 'string') {
const error = new Error('Invalid path');
error.code = 'INVALID_PATH';
throw error;
}
const trimmed = relativePath.trim();
if (!trimmed) {
const error = new Error('Invalid path');
error.code = 'INVALID_PATH';
throw error;
}
const base = path.resolve(baseDir);
const target = path.resolve(baseDir, trimmed);
const baseWithSep = base.endsWith(path.sep) ? base : `${base}${path.sep}`;
if (target === base || !target.startsWith(baseWithSep)) {
const error = new Error('Invalid path');
error.code = 'INVALID_PATH';
throw error;
}
return target;
}
// Get addon options (addon mode) or use environment variables (Docker mode)
async function getAddonOptions() {
const supervisorToken = resolveSupervisorToken();
// Check if running in addon mode (has /data/options.json)
try {
await fs.access('/data/options.json');
debugLog('[options] Running in addon mode, reading /data/options.json');
const options = await fs.readFile('/data/options.json', 'utf-8');
debugLog('[options] Successfully read /data/options.json');
const parsedOptions = JSON.parse(options);
debugLog('[options] theme configured as:', parsedOptions?.theme || 'dark');
debugLog('[options] language configured as:', parsedOptions?.language || 'en');
let esphomeEnabled = parsedOptions?.esphome ?? false;
let packagesEnabled = parsedOptions?.packages ?? false;
let dockerSettings = {};
try {
dockerSettings = await loadDockerSettings();
if (dockerSettings.__loadedFromFile) {
if (typeof dockerSettings.packagesEnabled === 'boolean') {
packagesEnabled = dockerSettings.packagesEnabled;
}
}
} catch (settingsError) {
debugLog('[options] Failed to load Docker settings:', settingsError.message);
}
return {
mode: 'addon',
home_assistant_url: null,
long_lived_access_token: null,
supervisor_token: supervisorToken,
credentials_source: supervisorToken ? 'supervisor' : 'none',
theme: parsedOptions?.theme || 'dark',
language: parsedOptions?.language || 'en',
esphome: esphomeEnabled,
packages: packagesEnabled,
backupFolderPath: dockerSettings.backupFolderPath,
liveConfigPath: dockerSettings.liveConfigPath,
};
} catch (error) {
debugLog('[options] Running in Docker/local mode, checking for environment variables or saved settings');
let dockerSettings = {};
try {
dockerSettings = await loadDockerSettings();
} catch (settingsError) {
debugLog('[options] Failed to load Docker settings for ESPHome flag:', settingsError.message);
}
// First try environment variables
if (process.env.HOME_ASSISTANT_URL && process.env.LONG_LIVED_ACCESS_TOKEN) {
return {
mode: 'docker',
home_assistant_url: process.env.HOME_ASSISTANT_URL,
long_lived_access_token: process.env.LONG_LIVED_ACCESS_TOKEN,
supervisor_token: supervisorToken,
credentials_source: 'env',
theme: process.env.THEME || dockerSettings.theme || 'dark',
language: dockerSettings.language || 'en',
esphome: dockerSettings.esphomeEnabled ?? false,
packages: dockerSettings.packagesEnabled ?? false,
};
}
// Fall back to saved HA credentials for Docker/local
try {
const savedCreds = await fs.readFile(path.join(DATA_DIR, 'docker-ha-credentials.json'), 'utf-8');
const parsed = JSON.parse(savedCreds);
const hasSavedCreds = !!(parsed.home_assistant_url && parsed.long_lived_access_token);
return {
mode: 'docker',
home_assistant_url: parsed.home_assistant_url || null,
long_lived_access_token: parsed.long_lived_access_token || null,
supervisor_token: supervisorToken,
credentials_source: hasSavedCreds ? 'stored' : 'none',
theme: process.env.THEME || dockerSettings.theme || parsed.theme || 'dark',
language: dockerSettings.language || parsed.language || 'en',
esphome: dockerSettings.esphomeEnabled ?? false,
packages: dockerSettings.packagesEnabled ?? false,
};
} catch (credError) {
// No credentials configured
return {
mode: 'docker',
home_assistant_url: null,
long_lived_access_token: null,
supervisor_token: supervisorToken,
credentials_source: 'none',
theme: process.env.THEME || dockerSettings.theme || 'dark',
language: dockerSettings.language || 'en',
esphome: dockerSettings.esphomeEnabled ?? false,
packages: dockerSettings.packagesEnabled ?? false,
};
}
}
}
async function getHomeAssistantAuth(optionsOverride, manualOverride) {
if (manualOverride?.haUrl && manualOverride?.haToken) {
return {
baseUrl: toApiBase(manualOverride.haUrl),
token: manualOverride.haToken,
source: 'manual',
options: optionsOverride || await getAddonOptions(),
};
}
const options = optionsOverride || await getAddonOptions();
if (options.supervisor_token) {
console.log('[auth] Using supervisor proxy for Home Assistant requests');
return {
baseUrl: 'http://supervisor/core/api',
token: options.supervisor_token,
source: 'supervisor',
options,
};
}
if (options.home_assistant_url && options.long_lived_access_token) {
return {
baseUrl: toApiBase(options.home_assistant_url),
token: options.long_lived_access_token,
source: options.credentials_source || 'options',
options,
};
}
return {
baseUrl: null,
token: null,
source: 'none',
options,
};
}
async function isEsphomeEnabled() {
try {
const options = await getAddonOptions();
return !!(options?.esphome);
} catch (error) {
console.error('[esphome] Failed to determine ESPHome status:', error);
return false;
}
}
async function isPackagesEnabled() {
try {
const options = await getAddonOptions();
return !!(options?.packages);
} catch (error) {
console.error('[packages] Failed to determine Packages status:', error);
return false;
}
}
// App settings endpoint (expose config to frontend, excluding sensitive data)
app.get('/api/app-settings', async (req, res) => {
try {
debugLog('[app-settings] --- Start ESPHome Flag Resolution ---');
const options = await getAddonOptions();
debugLog('[app-settings] Addon options loaded:', {
esphome: options.esphome,
mode: options.mode
});
let esphomeEnabled = !!(options.esphome);
debugLog(`[app-settings] Initial esphomeEnabled from options: ${esphomeEnabled}`);
let storedSettings = null;
if (options.mode === 'addon') {
try {
storedSettings = await loadDockerSettings();
debugLog('[app-settings] Loaded stored settings (docker-app-settings.json):', {
esphomeEnabled: storedSettings.esphomeEnabled,
__loadedFromFile: storedSettings.__loadedFromFile
});
} catch (settingsError) {
debugLog('[app-settings] Failed to load saved settings for ESPHome flag:', settingsError.message);
}
}
const auth = await getHomeAssistantAuth(options);
const packagesEnabled = await isPackagesEnabled();
const baseResponse = {
mode: options.mode,
haUrl: options.home_assistant_url,
haToken: options.long_lived_access_token ? 'configured' : null,
haAuthMode: auth.source,
haAuthConfigured: !!auth.token,
haCredentialsSource: options.credentials_source || null,
theme: options.theme || 'dark',
esphomeEnabled,
packagesEnabled,
diffPalette: options.diffPalette || 1,
};
debugLog('[app-settings] Base response object created:', { esphomeEnabled: baseResponse.esphomeEnabled });
if (options.mode === 'addon') {
const savedSettings = storedSettings || await loadDockerSettings();
debugLog('[app-settings] Addon mode: final check of savedSettings for merge:', {
esphomeEnabled: savedSettings.esphomeEnabled
});
const finalEsphomeEnabled = baseResponse.esphomeEnabled;
debugLog(`[app-settings] Addon mode: finalEsphomeEnabled resolved to: ${finalEsphomeEnabled}`);
const finalPackagesEnabled = typeof savedSettings.packagesEnabled === 'boolean'
? savedSettings.packagesEnabled
: packagesEnabled;
const mergedSettings = {
liveConfigPath: savedSettings.liveConfigPath || '/config',
backupFolderPath: savedSettings.backupFolderPath || '/media/backups/yaml',
theme: options.theme || savedSettings.theme || baseResponse.theme || 'dark',
esphomeEnabled: options.esphome ?? finalEsphomeEnabled,
packagesEnabled: finalPackagesEnabled,
smartBackupEnabled: savedSettings.smartBackupEnabled ?? false,
diffPalette: savedSettings.diffPalette || 1,
showOnlyChanges: savedSettings.showOnlyChanges ?? false,
};
global.dockerSettings = { ...global.dockerSettings, ...mergedSettings };
debugLog('[app-settings] Addon mode: global.dockerSettings updated:', { esphomeEnabled: global.dockerSettings.esphomeEnabled });
const finalResponse = {
...baseResponse,
backupFolderPath: mergedSettings.backupFolderPath,
liveConfigPath: mergedSettings.liveConfigPath,
theme: mergedSettings.theme,
esphomeEnabled: mergedSettings.esphomeEnabled,
smartBackupEnabled: mergedSettings.smartBackupEnabled,
diffPalette: mergedSettings.diffPalette,
showOnlyChanges: mergedSettings.showOnlyChanges,
};
debugLog('[app-settings] Addon mode: Final response payload:', { esphomeEnabled: finalResponse.esphomeEnabled });
debugLog('[app-settings] --- End ESPHome Flag Resolution ---');
res.json(finalResponse);
return;
}
debugLog('[app-settings] Docker mode detected.');
const dockerSettings = await loadDockerSettings();
debugLog('[app-settings] Docker mode: loaded dockerSettings:', { esphomeEnabled: dockerSettings.esphomeEnabled });
const finalEsphomeEnabled = dockerSettings.esphomeEnabled ?? baseResponse.esphomeEnabled;
debugLog(`[app-settings] Docker mode: finalEsphomeEnabled resolved to: ${finalEsphomeEnabled}`);
const effectiveTheme = process.env.THEME || dockerSettings.theme || baseResponse.theme || 'dark';
const finalResponse = {
...baseResponse,
backupFolderPath: dockerSettings.backupFolderPath || '/media/timemachine',
liveConfigPath: dockerSettings.liveConfigPath || '/config',
theme: effectiveTheme,
language: dockerSettings.language || 'en',
esphomeEnabled: finalEsphomeEnabled,
packagesEnabled: dockerSettings.packagesEnabled ?? false,
smartBackupEnabled: dockerSettings.smartBackupEnabled ?? false,
diffPalette: dockerSettings.diffPalette || 1,
showOnlyChanges: dockerSettings.showOnlyChanges ?? false,
};
debugLog('[app-settings] Docker mode: Final response payload:', { esphomeEnabled: finalResponse.esphomeEnabled });
debugLog('[app-settings] --- End ESPHome Flag Resolution ---');
res.json(finalResponse);
} catch (error) {
console.error('[app-settings] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Save Docker app settings
app.post('/api/app-settings', async (req, res) => {
try {
const { liveConfigPath, backupFolderPath, theme, esphomeEnabled, packagesEnabled, language, smartBackupEnabled, diffPalette, showOnlyChanges } = req.body;
const existingSettings = await loadDockerSettings();
const settings = {
liveConfigPath: liveConfigPath || existingSettings.liveConfigPath || '/config',
backupFolderPath: backupFolderPath || existingSettings.backupFolderPath || '/media/backups/yaml',
theme: theme || existingSettings.theme || 'dark',
language: language || existingSettings.language || 'en',
esphomeEnabled: typeof esphomeEnabled === 'boolean' ? esphomeEnabled : existingSettings.esphomeEnabled ?? false,
packagesEnabled: typeof packagesEnabled === 'boolean' ? packagesEnabled : existingSettings.packagesEnabled ?? false,
smartBackupEnabled: typeof smartBackupEnabled === 'boolean' ? smartBackupEnabled : existingSettings.smartBackupEnabled ?? false,
diffPalette: diffPalette || existingSettings.diffPalette || 1,
showOnlyChanges: typeof showOnlyChanges === 'boolean' ? showOnlyChanges : existingSettings.showOnlyChanges ?? false,
};
await saveDockerSettings(settings);
console.log('[save-docker-settings] Saved Docker app settings:', settings);
res.json({ success: true, message: 'Settings saved successfully' });
} catch (error) {
console.error('[save-docker-settings] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Save Docker HA credentials (fallback when env vars not set)
app.post('/api/docker-ha-credentials', async (req, res) => {
try {
const { homeAssistantUrl, longLivedAccessToken } = req.body;
// Only allow saving credentials in Docker mode and when env vars aren't set
if (process.env.HOME_ASSISTANT_URL || process.env.LONG_LIVED_ACCESS_TOKEN) {
return res.status(400).json({ error: 'HA credentials are configured via environment variables' });
}
const credentials = {
home_assistant_url: homeAssistantUrl,
long_lived_access_token: longLivedAccessToken
};
// Ensure data directory exists
await fs.writeFile(path.join(DATA_DIR, 'docker-ha-credentials.json'), JSON.stringify(credentials, null, 2), 'utf-8');
console.log('[docker-ha-credentials] Saved Docker HA credentials to', path.join(DATA_DIR, 'docker-ha-credentials.json'));
res.json({ success: true, message: 'HA credentials saved successfully' });
} catch (error) {
console.error('[docker-ha-credentials] Error:', error);
res.status(500).json({ error: error.message });
}
});
// App settings endpoint (expose config to frontend, excluding sensitive data)
async function loadDockerSettings() {
const cachedSettings = (global.dockerSettings && typeof global.dockerSettings === 'object') ? global.dockerSettings : {};
const defaultSettings = {
liveConfigPath: '/config',
backupFolderPath: '/media/timemachine',
theme: process.env.THEME || 'dark',
language: 'en',
esphomeEnabled: false,
packagesEnabled: false,
smartBackupEnabled: false,
diffPalette: 1,
showOnlyChanges: false,
...cachedSettings
};
try {
const settingsPath = path.join(DATA_DIR, 'docker-app-settings.json');
// Check if settings file exists
try {
await fs.access(settingsPath);
const content = await fs.readFile(settingsPath, 'utf-8');
const parsed = JSON.parse(content);
// Merge with defaults to ensure all fields are present
const settings = { ...defaultSettings, ...parsed };
// Update in-memory settings
global.dockerSettings = settings;
debugLog('Loaded settings from file:', settings);
return settings;
} catch (err) {
if (err.code === 'ENOENT') {
} else {
console.error('Error loading settings:', err);
}
// Ensure in-memory settings are set to defaults
global.dockerSettings = defaultSettings;
return defaultSettings;
}
} catch (error) {
console.error('Error in loadDockerSettings:', error);
// Ensure in-memory settings are set to defaults even if there's an error
global.dockerSettings = defaultSettings;
return defaultSettings;
}
}
// Save Docker settings to file
async function saveDockerSettings(settings) {
// Ensure all required fields are present with defaults
const settingsToSave = {
liveConfigPath: settings.liveConfigPath || '/config',
backupFolderPath: settings.backupFolderPath || '/media/timemachine',
theme: settings.theme || 'dark',
language: settings.language || 'en',
esphomeEnabled: settings.esphomeEnabled ?? false,
packagesEnabled: settings.packagesEnabled ?? false,
smartBackupEnabled: settings.smartBackupEnabled ?? false,
diffPalette: settings.diffPalette || 1,
showOnlyChanges: settings.showOnlyChanges ?? false
};
// Save to file
const settingsPath = path.join(DATA_DIR, 'docker-app-settings.json');
try {
await fs.mkdir(DATA_DIR, { recursive: true });
} catch (error) {
console.error('[saveDockerSettings] Failed to ensure data directory exists:', error);
}
await fs.writeFile(settingsPath, JSON.stringify(settingsToSave, null, 2), 'utf-8');
console.log('Settings saved successfully to', settingsPath);
// Update the in-memory settings
global.dockerSettings = settingsToSave;
return settingsToSave;
}
const SKIP_BACKUP_DIRS = new Set(['esphome', '.storage', 'packages']);
// Recursive function to find backup directories
async function getBackupDirs(dir, depth = 0) {
let results = [];
const indent = ' '.repeat(depth);
try {
const list = await fs.readdir(dir, { withFileTypes: true });
for (const dirent of list) {
const fullPath = path.resolve(dir, dirent.name);
if (dirent.isDirectory()) {
// Skip known non-backup directories
if (SKIP_BACKUP_DIRS.has(dirent.name)) {
continue;
}
const name = dirent.name;
const dashedPattern = /^\d{4}-\d{2}-\d{2}-\d{6}$/;
const numericPattern = /^\d{12}$/;
let isBackupFolder = dashedPattern.test(name) || numericPattern.test(name);
// Fallback: if folder contains common YAML backup files, treat as backup folder
if (!isBackupFolder) {
try {
const inner = await fs.readdir(fullPath);
const hasYaml = inner.some(f => f.endsWith('.yaml') || f.endsWith('.yml'));
const hasKnownFiles = inner.includes('automations.yaml') || inner.includes('scripts.yaml');
if (hasYaml || hasKnownFiles) {
isBackupFolder = true;
}
} catch (err) {
// Skip directories we can't read
}
}
if (isBackupFolder) {
const stats = await fs.stat(fullPath);
let locked = false;
try {
await fs.access(path.join(fullPath, '.lock'));
locked = true;
} catch (e) {
// Not locked
}
results.push({ path: fullPath, folderName: name, mtime: stats.mtime, locked });
}
// Continue scanning deeper regardless to support nested structures like /year/month/backup
try {
const nestedResults = await getBackupDirs(fullPath, depth + 1);
results = results.concat(nestedResults);
} catch (err) {
// Skip directories we can't read
}
}
}
} catch (error) {
console.error(`${indent}[scan-backups] Error reading ${dir}:`, error.message);
}
return results.filter(result => !SKIP_BACKUP_DIRS.has(path.basename(result.path)));
}
// Scan backups
app.post('/api/scan-backups', async (req, res) => {
try {
// Accept backupRootPath from request body or use default
const backupRootPath = req.body?.backupRootPath || '/media/timemachine';
const mode = req.body?.mode; // Optional mode filter: automations, scripts, lovelace, esphome, packages
console.log('[scan-backups] Scanning backup directory:', backupRootPath, mode ? `for mode: ${mode}` : '');
// Basic security check
if (backupRootPath.includes('..')) {
return res.status(400).json({ error: 'Invalid path' });
}
let backups = await getBackupDirs(backupRootPath);
// Sort descending to show newest first
backups.sort((a, b) => b.folderName.localeCompare(a.folderName));
// If mode is specified, filter backups to only include those with relevant files
if (mode) {
const filteredBackups = [];
for (const backup of backups) {
try {
const manifestPath = path.join(backup.path, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
let hasRelevantFiles = false;
switch (mode) {
case 'automations':
// Check if automations.yaml is in root files
hasRelevantFiles = manifest.files?.root?.includes('automations.yaml') ?? false;
break;
case 'scripts':
// Check if scripts.yaml is in root files
hasRelevantFiles = manifest.files?.root?.includes('scripts.yaml') ?? false;
break;
case 'lovelace':
// Check if any lovelace files are in storage
hasRelevantFiles = (manifest.files?.storage?.some(f => f.startsWith('lovelace'))) ?? false;
break;
case 'esphome':
// Check if any esphome files exist
hasRelevantFiles = (manifest.files?.esphome?.length > 0) ?? false;
break;
case 'packages':
// Check if any packages files exist
hasRelevantFiles = (manifest.files?.packages?.length > 0) ?? false;
break;
default:
// Unknown mode, include the backup
hasRelevantFiles = true;
}
if (hasRelevantFiles) {
filteredBackups.push(backup);
}
} catch (manifestErr) {
// No manifest or error reading it - this is an old-style full backup
// Include it to be safe (assume it has all files)
filteredBackups.push(backup);
}
}
backups = filteredBackups;
console.log('[scan-backups] Filtered to', backups.length, 'backups with', mode, 'files');
}
console.log('[scan-backups] Found backups:', backups.length);
res.json({ backups });
} catch (error) {
console.error('[scan-backups] Error:', error);
if (error.code === 'ENOENT') {
return res.status(404).json({
error: `Directory not found: ${error.path}`,
code: 'DIR_NOT_FOUND'
});
}
res.status(500).json({ error: 'Failed to scan backup directory.', details: error.message });
}
});
// Check if a snapshot has any changes compared to live config
app.post('/api/check-snapshot-changes', async (req, res) => {
try {
const { backupPath, liveConfigPath, mode } = req.body;
const configPath = liveConfigPath || '/config';
if (!backupPath) {
return res.status(400).json({ error: 'backupPath is required' });
}
const hasChanges = await checkSnapshotHasChanges(backupPath, configPath, mode || 'automations');
res.json({ hasChanges });
} catch (error) {
console.error('[check-snapshot-changes] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Clear cache after filtering to free memory
app.post('/api/clear-cache', (req, res) => {
clearBackupCacheEntries();
res.json({ success: true, message: 'Cache cleared' });
});
// Batch check multiple snapshots for changes (more efficient)
app.post('/api/check-snapshots-batch', async (req, res) => {
try {
const { backupPaths, liveConfigPath } = req.body;
const configPath = liveConfigPath || '/config';
if (!backupPaths || !Array.isArray(backupPaths)) {
return res.status(400).json({ error: 'backupPaths array is required' });
}
// Check all snapshots in parallel (limit concurrency to avoid overwhelming)
const BATCH_SIZE = 10;
const results = {};
for (let i = 0; i < backupPaths.length; i += BATCH_SIZE) {
const batch = backupPaths.slice(i, i + BATCH_SIZE);
const batchResults = await Promise.all(
batch.map(async (backupPath) => {
try {
const hasChanges = await checkSnapshotHasChanges(backupPath, configPath);
return { path: backupPath, hasChanges };
} catch (err) {
// On error, include the backup to be safe
return { path: backupPath, hasChanges: true };
}
})
);
batchResults.forEach(r => { results[r.path] = r.hasChanges; });
}
res.json({ results });
} catch (error) {
console.error('[check-snapshots-batch] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Helper function to check if a single snapshot has changes (mode-aware)
async function checkSnapshotHasChanges(backupPath, configPath, mode) {
// Check only the relevant files based on mode
if (mode === 'automations') {
return await checkAutomationsChanges(backupPath, configPath);
} else if (mode === 'scripts') {
return await checkScriptsChanges(backupPath, configPath);
} else if (mode === 'lovelace') {
return await checkLovelaceChanges(backupPath, configPath);
} else if (mode === 'esphome') {
return await checkEsphomeChanges(backupPath, configPath);
} else if (mode === 'packages') {
return await checkPackagesChanges(backupPath, configPath);
}
// Default: check automations
return await checkAutomationsChanges(backupPath, configPath);
}
// Check automations for changes (supports split configs)
async function checkAutomationsChanges(backupPath, configPath) {
try {
// Get all automation file paths from configuration.yaml
const { automationPaths } = await getConfigFilePaths(configPath);
// Load backup automations (check both root automations.yaml and any backed-up directories/files)
// We search all files in the backup that match the automation file pattern
let backupArray = [];
const manifestPath = path.join(backupPath, '.backup_manifest.json');
try {
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
let autoFiles = null;
if (manifest.automation_files) {
autoFiles = manifest.automation_files;
} else if (manifest.files && manifest.files.root) {
autoFiles = manifest.files.root.filter(f =>
f === 'automations.yaml' ||
f.startsWith('automations/') ||
f.match(/^[^/]+\/.*\.ya?ml$/)
);
}
if (autoFiles) {
for (const file of autoFiles) {
try {
const filePath = path.join(backupPath, file);
const fileData = await loadYamlWithCache(filePath);
if (Array.isArray(fileData)) {
backupArray = backupArray.concat(fileData);
}
} catch (err) { /* Skip */ }
}
}
} catch (e) {
// Fallback for old backups
try {
const backupFile = path.join(backupPath, 'automations.yaml');
const backupData = await loadYamlWithCache(backupFile);
backupArray = Array.isArray(backupData) ? backupData : [];
} catch (err) { /* No backup file */ }
}
// Load all live automations from all configured paths
let liveArray = [];
for (const filePath of automationPaths) {
try {
const fileData = await loadYamlWithCache(filePath);
if (Array.isArray(fileData)) {
liveArray = liveArray.concat(fileData);
}
} catch (err) { /* File not found, skip */ }
}
// Only check for deleted or modified items (not new items, since UI only shows backup items)
for (const backupItem of backupArray) {
const key = backupItem.id || backupItem.alias;
if (!key) continue;
const liveItem = liveArray.find(l => l.id === key || l.alias === key);
if (!liveItem) return true; // Deleted
if (jsyaml.dump(backupItem) !== jsyaml.dump(liveItem)) return true; // Modified
}
return false;
} catch (err) {
return false;
}
}
// Check scripts for changes (supports split configs)
async function checkScriptsChanges(backupPath, configPath) {
try {
// Get all script file paths from configuration.yaml
const { scriptPaths } = await getConfigFilePaths(configPath);
// Load backup scripts
let backupScripts = {};
const manifestPath = path.join(backupPath, '.backup_manifest.json');
try {
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
let scriptFiles = null;
if (manifest.script_files) {
scriptFiles = manifest.script_files;
} else if (manifest.files && manifest.files.root) {
scriptFiles = manifest.files.root.filter(f =>
f === 'scripts.yaml' ||
f.startsWith('scripts/') ||
f.match(/^[^/]+\/.*\.ya?ml$/)
);
}
if (scriptFiles) {
for (const file of scriptFiles) {
try {
const filePath = path.join(backupPath, file);
const fileData = await loadYamlWithCache(filePath);
if (fileData && typeof fileData === 'object' && !Array.isArray(fileData)) {
Object.assign(backupScripts, fileData);
}
} catch (err) { /* Skip */ }
}
}
} catch (e) {
// Fallback for old backups
try {
const backupFile = path.join(backupPath, 'scripts.yaml');
const backupRaw = await loadYamlWithCache(backupFile);
backupScripts = (backupRaw && typeof backupRaw === 'object' && !Array.isArray(backupRaw)) ? backupRaw : {};
} catch (err) { /* No backup file */ }
}
// Load all live scripts from all configured paths
let liveScripts = {};
for (const filePath of scriptPaths) {
try {
const fileData = await loadYamlWithCache(filePath);
if (fileData && typeof fileData === 'object' && !Array.isArray(fileData)) {
// Merge scripts from this file
Object.assign(liveScripts, fileData);
}
} catch (err) { /* File not found, skip */ }
}
// Only check for deleted or modified items (not new items, since UI only shows backup items)
for (const scriptId of Object.keys(backupScripts)) {
if (!liveScripts[scriptId]) return true; // Deleted
if (jsyaml.dump(backupScripts[scriptId]) !== jsyaml.dump(liveScripts[scriptId])) return true; // Modified
}
return false;
} catch (err) {
return false;
}
}
// Check lovelace files for changes
async function checkLovelaceChanges(backupPath, configPath) {
try {
// Lovelace files are in .storage directory
const backupStorageDir = path.join(backupPath, '.storage');
const liveStorageDir = path.join(configPath, '.storage');
// Get list of lovelace files from backup
const backupFiles = await fs.readdir(backupStorageDir).catch(() => []);
const lovelaceFiles = backupFiles.filter(f => f.startsWith('lovelace'));
for (const file of lovelaceFiles) {
const backupFile = path.join(backupStorageDir, file);
const liveFile = path.join(liveStorageDir, file);
try {
const [backupContent, liveContent] = await Promise.all([
fs.readFile(backupFile, 'utf-8').catch(() => null),
fs.readFile(liveFile, 'utf-8').catch(() => null)
]);
if (backupContent === null && liveContent !== null) return true; // Added
if (backupContent !== null && liveContent === null) return true; // Deleted
if (backupContent !== liveContent) return true; // Modified
} catch (err) {
// Continue checking other files
}
}
// Also check for NEW lovelace files in live
const liveFiles = await fs.readdir(liveStorageDir).catch(() => []);
const liveLovelaceFiles = liveFiles.filter(f => f.startsWith('lovelace'));
for (const file of liveLovelaceFiles) {
if (!lovelaceFiles.includes(file)) return true; // New file added
}
return false;
} catch (err) {
return false;
}
}
// Check esphome files for changes
async function checkEsphomeChanges(backupPath, configPath) {
try {
const backupEsphomeDir = path.join(backupPath, 'esphome');
const liveEsphomeDir = process.env.ESPHOME_CONFIG_PATH || path.join(configPath, 'esphome');
const backupFiles = await fs.readdir(backupEsphomeDir).catch(() => []);
const yamlFiles = backupFiles.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
for (const file of yamlFiles) {
const backupFile = path.join(backupEsphomeDir, file);
const liveFile = path.join(liveEsphomeDir, file);
try {
const [backupContent, liveContent] = await Promise.all([
fs.readFile(backupFile, 'utf-8').catch(() => null),
fs.readFile(liveFile, 'utf-8').catch(() => null)
]);
if (backupContent === null && liveContent !== null) return true;
if (backupContent !== null && liveContent === null) return true;
if (backupContent !== liveContent) return true;
} catch (err) {
// Continue
}
}
return false;
} catch (err) {
return false;
}
}
// Check packages files for changes
async function checkPackagesChanges(backupPath, configPath) {
try {
const backupPackagesDir = path.join(backupPath, 'packages');
const livePackagesDir = path.join(configPath, 'packages');
const backupFiles = await fs.readdir(backupPackagesDir).catch(() => []);
const yamlFiles = backupFiles.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
for (const file of yamlFiles) {
const backupFile = path.join(backupPackagesDir, file);
const liveFile = path.join(livePackagesDir, file);
try {
const [backupContent, liveContent] = await Promise.all([
fs.readFile(backupFile, 'utf-8').catch(() => null),
fs.readFile(liveFile, 'utf-8').catch(() => null)
]);
if (backupContent === null && liveContent !== null) return true;
if (backupContent !== null && liveContent === null) return true;
if (backupContent !== liveContent) return true;
} catch (err) {
// Continue
}
}
return false;
} catch (err) {
return false;
}
}
// Get backup automations (supports split configs)
app.post('/api/get-backup-automations', async (req, res) => {
try {
const { backupPath } = req.body;
let allAutomations = [];
// Check manifest for split config files
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
let autoFiles = null;
if (manifest.automation_files) {
autoFiles = manifest.automation_files;
} else if (manifest.files && manifest.files.root) {
autoFiles = manifest.files.root.filter(f =>
f === 'automations.yaml' ||
f.startsWith('automations/') ||
f.match(/^[^/]+\/.*\.ya?ml$/) // e.g., "auto_dir/lights.yaml"
);
}
if (autoFiles) {
for (const file of autoFiles) {
try {
const filePath = path.join(backupPath, file);
const fileData = await loadYamlWithCache(filePath);
if (Array.isArray(fileData)) {
allAutomations = allAutomations.concat(fileData);
}
} catch (err) { /* File not found, skip */ }
}
if (allAutomations.length > 0) {
return res.json({ automations: allAutomations });
}
// If no automation files in manifest, return empty
if (manifest.automation_files || autoFiles.includes('automations.yaml')) {
// If we have explicit list OR it included standard file, we return what we found (even if empty)
// unless it's an old manifest without explicit list and didn't include automations.yaml
return res.json({ automations: allAutomations });
}
}
} catch (e) {
// Manifest missing -> assume old full backup -> proceed to resolve
}
// Fallback: try standard automations.yaml
try {
const automationsFile = await resolveFileInBackupChain(backupPath, 'automations.yaml');
const automations = await loadYamlWithCache(automationsFile) || [];
allAutomations = Array.isArray(automations) ? automations : [];
} catch (err) { /* No automations.yaml */ }
res.json({ automations: allAutomations });
} catch (error) {
console.error('[get-backup-automations] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Get backup scripts (supports split configs)
app.post('/api/get-backup-scripts', async (req, res) => {
try {
const { backupPath } = req.body;
let allScripts = [];
// Helper function to process script data
const processScriptData = (data) => {
if (data && typeof data === 'object' && !Array.isArray(data)) {
return Object.keys(data).map(scriptId => ({
id: scriptId,
...data[scriptId]
}));
} else if (Array.isArray(data)) {
return data;
}
return [];
};
// Check manifest for split config files
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
let scriptFiles = null;
if (manifest.script_files) {
scriptFiles = manifest.script_files;
} else if (manifest.files && manifest.files.root) {
scriptFiles = manifest.files.root.filter(f =>
f === 'scripts.yaml' ||
f.startsWith('scripts/') ||
f.match(/^[^/]+\/.*\.ya?ml$/) // e.g., "script_dir/utilities.yaml"
);
}
if (scriptFiles) {
for (const file of scriptFiles) {
try {
const filePath = path.join(backupPath, file);
const fileData = await loadYamlWithCache(filePath);
allScripts = allScripts.concat(processScriptData(fileData));
} catch (err) { /* File not found, skip */ }
}
if (allScripts.length > 0) {
return res.json({ scripts: allScripts });
}
// If no script files in manifest, return empty
if (manifest.script_files || scriptFiles.includes('scripts.yaml')) {
return res.json({ scripts: allScripts });
}
}
} catch (e) {
// Manifest missing -> assume old full backup -> proceed to resolve
}
// Fallback: try standard scripts.yaml
try {
const scriptsFile = await resolveFileInBackupChain(backupPath, 'scripts.yaml');
const scriptsData = await loadYamlWithCache(scriptsFile);
allScripts = processScriptData(scriptsData);
} catch (err) { /* No scripts.yaml */ }
res.json({ scripts: allScripts });
} catch (error) {
console.error('[get-backup-scripts] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Get live items (automations or scripts) - supports split configs
app.post('/api/get-live-items', async (req, res) => {
try {
const { itemIdentifiers, mode, liveConfigPath } = req.body;
const configPath = liveConfigPath || '/config';
// Get all file paths from configuration.yaml
const { automationPaths, scriptPaths } = await getConfigFilePaths(configPath);
const filePaths = mode === 'automations' ? automationPaths : scriptPaths;
// Load all items from all configured paths
let allItems = [];
for (const filePath of filePaths) {
try {
const fileData = await loadYamlWithCache(filePath);
if (mode === 'automations') {
if (Array.isArray(fileData)) {
allItems = allItems.concat(fileData);
}
} else if (mode === 'scripts') {
// Handle scripts dictionary format
if (fileData && typeof fileData === 'object' && !Array.isArray(fileData)) {
const scriptItems = Object.keys(fileData).map(scriptId => ({
id: scriptId,
...fileData[scriptId]
}));
allItems = allItems.concat(scriptItems);
} else if (Array.isArray(fileData)) {
allItems = allItems.concat(fileData);
}
}
} catch (err) { /* File not found, skip */ }
}
const liveItems = {};
itemIdentifiers.forEach(identifier => {
const item = allItems.find(i => (i.id === identifier || i.alias === identifier));
if (item) {
liveItems[identifier] = item;
}
});
res.json({ liveItems });
} catch (error) {
console.error('[get-live-items] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Get live automation (supports split configs)
app.post('/api/get-live-automation', async (req, res) => {
try {
const { automationIdentifier, liveConfigPath } = req.body;
const configPath = liveConfigPath || '/config';
// Get all automation file paths from configuration.yaml
const { automationPaths } = await getConfigFilePaths(configPath);
// Search all automation files for the requested automation
let automation = null;
for (const filePath of automationPaths) {
try {
const automations = await loadYamlWithCache(filePath) || [];
if (Array.isArray(automations)) {
automation = automations.find(a => a.id === automationIdentifier || a.alias === automationIdentifier);
if (automation) break;
}
} catch (err) { /* File not found, continue */ }
}
if (!automation) {
return res.status(404).json({ error: 'Automation not found' });
}
res.json({ automation });
} catch (error) {
console.error('[get-live-automation] Error:', error);
res.status(404).json({ error: error.message });
}
});
// Get live script (supports split configs)
app.post('/api/get-live-script', async (req, res) => {
try {
const { automationIdentifier, liveConfigPath } = req.body;
const configPath = liveConfigPath || '/config';
// Get all script file paths from configuration.yaml
const { scriptPaths } = await getConfigFilePaths(configPath);
// Search all script files for the requested script
let script = null;
for (const filePath of scriptPaths) {
try {
const scriptsData = await loadYamlWithCache(filePath);
// Scripts can be in dictionary format (key: script_id, value: script object)
if (scriptsData && typeof scriptsData === 'object' && !Array.isArray(scriptsData)) {
if (scriptsData[automationIdentifier]) {
script = { id: automationIdentifier, ...scriptsData[automationIdentifier] };
break;
}
// Also search by alias
for (const [id, scriptObj] of Object.entries(scriptsData)) {
if (scriptObj.alias === automationIdentifier) {
script = { id, ...scriptObj };
break;
}
}
if (script) break;
} else if (Array.isArray(scriptsData)) {
// Fallback for array format
script = scriptsData.find(s => s.id === automationIdentifier || s.alias === automationIdentifier);
if (script) break;
}
} catch (err) { /* File not found, continue */ }
}
if (!script) {
return res.status(404).json({ error: 'Script not found' });
}
res.json({ script });
} catch (error) {
console.error('[get-live-script] Error:', error);
res.status(404).json({ error: error.message });
}
});
// Helper to find the full range of a YAML item including comments and structure
function findFullRange(content, node, isListItem) {
let start = node.range[0];
let end = node.range[1];
// 1. Find the start of the item structure (dash or key)
if (isListItem) {
// Scan backwards for dash
while (start > 0 && content[start] !== '-') {
start--;
}
} else {
// For map item (script), node is the value. We need to find the key.
// Scan backwards for ':'
while (start > 0 && content[start] !== ':') {
start--;
}
// Now scan backwards for the key start (start of line or after whitespace)
if (start > 0) {
// Scan back to newline or start of file.
while (start > 0 && content[start - 1] !== '\n') {
start--;
}
}
}
// 2. Scan backwards for comments and empty lines
let current = start;
while (current > 0) {
const prevChar = content[current - 1];
if (prevChar === '\n') {
// Check the line before this newline
let lineEnd = current - 1;
let lineStart = lineEnd;
while (lineStart > 0 && content[lineStart - 1] !== '\n') {
lineStart--;
}
const line = content.substring(lineStart, lineEnd);
if (line.trim().startsWith('#') || line.trim() === '') {
// Include this line
current = lineStart;
} else {
// This line is content (previous item), stop.
break;
}
} else {
// Consume spaces/indentation before the item start
current--;
}
}
start = current;
return [start, end];
}
// Restore automation
app.post('/api/restore-automation', async (req, res) => {
try {
const { backupPath, automationIdentifier, timezone, liveConfigPath, smartBackupEnabled } = req.body;
if (!backupPath || !automationIdentifier) {
return res.status(400).json({ error: 'Missing required parameters: backupPath and automationIdentifier' });
}
// Perform a backup before restoring
let effectiveSmartBackup = smartBackupEnabled;
if (typeof smartBackupEnabled === 'undefined') {
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
effectiveSmartBackup = defaultJob.smartBackupEnabled ?? false;
}
await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone, effectiveSmartBackup);
const configPath = liveConfigPath || '/config';
// Find which file in the backup contains the requested automation
let relativeFilePath = 'automations.yaml';
let backupFilePath = null;
let autoFiles = null;
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
if (manifest.automation_files) {
autoFiles = manifest.automation_files;
} else if (manifest.files && manifest.files.root) {
autoFiles = manifest.files.root.filter(f =>
f === 'automations.yaml' ||
f.startsWith('automations/') ||
f.match(/^[^/]+\/.*\.ya?ml$/)
);
}
if (autoFiles) {
for (const file of autoFiles) {
try {
const potentialBackupPath = path.join(backupPath, file);
const data = await loadYamlWithCache(potentialBackupPath);
if (Array.isArray(data) && data.some(a => a.id === automationIdentifier || a.alias === automationIdentifier)) {
relativeFilePath = file;
backupFilePath = potentialBackupPath;
break;
}
} catch (err) { /* Skip */ }
}
}
} catch (e) { /* Proceed to fallback */ }
if (!backupFilePath) {
// Fallback: search the backup chain for automations.yaml
backupFilePath = await resolveFileInBackupChain(backupPath, 'automations.yaml');
}
// Determine target live file path
const liveFilePath = path.join(configPath, relativeFilePath);
const backupContent = await fs.readFile(backupFilePath, 'utf-8');
// Read live contents
let liveContent = '';
try {
liveContent = await fs.readFile(liveFilePath, 'utf-8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
liveContent = '[]';
}
// Parse documents (preserves ranges)
const liveDoc = YAML.parseDocument(liveContent);
const backupDoc = YAML.parseDocument(backupContent);
// Find backup node
const backupItems = backupDoc.contents?.items || [];
const backupIndex = backupItems.findIndex(item => {
const obj = item.toJSON();
return obj.id === automationIdentifier || obj.alias === automationIdentifier;
});
if (backupIndex === -1) {
return res.status(404).json({ error: 'Automation not found in backup' });
}
const backupNode = backupItems[backupIndex];
const [backupStart, backupEnd] = findFullRange(backupContent, backupNode, true);
const backupSnippet = backupContent.substring(backupStart, backupEnd);
// Find live node
const liveItems = liveDoc.contents?.items || [];
const liveIndex = liveItems.findIndex(item => {
const obj = item.toJSON();
return obj.id === automationIdentifier || obj.alias === automationIdentifier;
});
let newLiveContent;
if (liveIndex !== -1) {
const liveNode = liveItems[liveIndex];
const [liveStart, liveEnd] = findFullRange(liveContent, liveNode, true);
newLiveContent = liveContent.substring(0, liveStart) + backupSnippet + liveContent.substring(liveEnd);
} else {
const prefix = (liveContent.length > 0 && !liveContent.endsWith('\n')) ? '\n' : '';
newLiveContent = liveContent + prefix + backupSnippet;
}
// Write back
await fs.writeFile(liveFilePath, newLiveContent, 'utf-8');
res.json({ success: true, message: `Automation restored successfully to ${relativeFilePath}` });
} catch (error) {
console.error('[restore-automation] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Restore script
// Restore script
app.post('/api/restore-script', async (req, res) => {
try {
const { backupPath, automationIdentifier: scriptIdentifier, timezone, liveConfigPath, smartBackupEnabled } = req.body;
if (!backupPath || !scriptIdentifier) {
return res.status(400).json({ error: 'Missing required parameters: backupPath and automationIdentifier' });
}
// Perform a backup before restoring
let effectiveSmartBackup = smartBackupEnabled;
if (typeof smartBackupEnabled === 'undefined') {
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
effectiveSmartBackup = defaultJob.smartBackupEnabled ?? false;
}
await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone, effectiveSmartBackup);
const configPath = liveConfigPath || '/config';
// Find which file in the backup contains the requested script
let relativeFilePath = 'scripts.yaml';
let backupFilePath = null;
let scriptFiles = null;
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
if (manifest.script_files) {
scriptFiles = manifest.script_files;
} else if (manifest.files && manifest.files.root) {
scriptFiles = manifest.files.root.filter(f =>
f === 'scripts.yaml' ||
f.startsWith('scripts/') ||
f.match(/^[^/]+\/.*\.ya?ml$/)
);
}
if (scriptFiles) {
for (const file of scriptFiles) {
try {
const potentialBackupPath = path.join(backupPath, file);
const data = await loadYamlWithCache(potentialBackupPath);
if (data && typeof data === 'object' && !Array.isArray(data)) {
if (data[scriptIdentifier] || Object.values(data).some(s => s.alias === scriptIdentifier)) {
relativeFilePath = file;
backupFilePath = potentialBackupPath;
break;
}
}
} catch (err) { /* Skip */ }
}
}
} catch (e) { /* Proceed to fallback */ }
if (!backupFilePath) {
backupFilePath = await resolveFileInBackupChain(backupPath, 'scripts.yaml');
}
const liveFilePath = path.join(configPath, relativeFilePath);
const backupContent = await fs.readFile(backupFilePath, 'utf-8');
// Read live contents
let liveContent = '';
try {
liveContent = await fs.readFile(liveFilePath, 'utf-8');
} catch (err) {
if (err.code !== 'ENOENT') throw err;
liveContent = '{}';
}
// Parse documents (preserves ranges)
const liveDoc = YAML.parseDocument(liveContent);
const backupDoc = YAML.parseDocument(backupContent);
// Find backup node
const backupNode = backupDoc.get(scriptIdentifier);
if (!backupNode) {
return res.status(404).json({ error: 'Script not found in backup' });
}
const [backupStart, backupEnd] = findFullRange(backupContent, backupNode, false);
const backupSnippet = backupContent.substring(backupStart, backupEnd);
// Find live node
const liveNode = liveDoc.get(scriptIdentifier);
let newLiveContent;
if (liveNode) {
const [liveStart, liveEnd] = findFullRange(liveContent, liveNode, false);
newLiveContent = liveContent.substring(0, liveStart) + backupSnippet + liveContent.substring(liveEnd);
} else {
const prefix = (liveContent.length > 0 && !liveContent.endsWith('\n')) ? '\n' : '';
newLiveContent = liveContent + prefix + backupSnippet;
}
// Write back
await fs.writeFile(liveFilePath, newLiveContent, 'utf-8');
res.json({ success: true, message: `Script restored successfully to ${relativeFilePath}` });
} catch (error) {
console.error('[restore-script] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Reload Home Assistant
app.post('/api/reload-home-assistant', async (req, res) => {
try {
const { service } = req.body;
if (!service) {
return res.status(400).json({ error: 'Missing required parameter: service' });
}
const auth = await getHomeAssistantAuth();
if (!auth.baseUrl || !auth.token) {
return res.status(400).json({ error: 'Home Assistant access is not configured for this environment.' });
}
const serviceUrl = `${auth.baseUrl}/services/${service.replace('.', '/')}`;
const headers = {
'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (auth.source === 'supervisor') {
headers['X-Supervisor-Token'] = auth.token;
}
// Make async call to HA (don't wait for response)
fetch(serviceUrl, {
method: 'POST',
headers,
body: JSON.stringify({})
}).catch(err => console.error('[reload-home-assistant] Background error:', err));
res.json({ message: 'Home Assistant reload initiated successfully' });
} catch (error) {
console.error('[reload-home-assistant] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Helper to check if directory contains backups (recursively)
async function hasBackupsRecursive(dir, depth = 0, maxDepth = 5) {
if (depth > maxDepth) return false;
try {
const list = await fs.readdir(dir, { withFileTypes: true });
// Check for YAML files in current directory
const hasYaml = list.some(item => !item.isDirectory() && (item.name.endsWith('.yaml') || item.name.endsWith('.yml')));
if (hasYaml) return true;
// Check for backup-pattern directories
const hasBackupPattern = list.some(item => {
if (!item.isDirectory()) return false;
const name = item.name;
return /^\d{4}-\d{2}-\d{2}-\d{6}$/.test(name) || /^\d{12}$/.test(name);
});
if (hasBackupPattern) return true;
// Recursively check subdirectories
for (const item of list) {
if (item.isDirectory()) {
const fullPath = path.resolve(dir, item.name);
const hasNested = await hasBackupsRecursive(fullPath, depth + 1, maxDepth);
if (hasNested) return true;
}
}
return false;
} catch (error) {
return false;
}
}
// Validate backup path
app.post('/api/validate-backup-path', async (req, res) => {
try {
const { path: folderPath } = req.body;
if (!folderPath) {
return res.status(400).json({ isValid: false, error: 'Path is required' });
}
const stats = await fs.stat(folderPath);
if (!stats.isDirectory()) {
return res.status(400).json({ isValid: false, error: 'Provided path is not a directory' });
}
// Check recursively for backups or YAML files
const hasBackups = await hasBackupsRecursive(folderPath);
if (!hasBackups) {
return res.status(400).json({
isValid: false,
error: 'No backup folders or YAML files found in directory tree (searched 5 levels deep)'
});
}
res.json({ isValid: true });
} catch (error) {
if (error.code === 'ENOENT') {
return res.status(400).json({ isValid: false, error: 'Directory does not exist' });
}
if (error.code === 'EACCES') {
return res.status(400).json({ isValid: false, error: 'Permission denied - cannot access directory' });
}
res.status(500).json({ isValid: false, error: error.message });
}
});
// Test Home Assistant connection
app.post('/api/test-home-assistant-connection', async (req, res) => {
try {
// Allow overriding with request body for testing before saving (Docker mode)
const providedHaUrl = req.body.haUrl;
const providedHaToken = req.body.haToken;
const manualOverride = (providedHaUrl && providedHaToken)
? { haUrl: providedHaUrl, haToken: providedHaToken }
: null;
const auth = await getHomeAssistantAuth(null, manualOverride);
if (!auth.baseUrl || !auth.token) {
res.status(400).json({ success: false, message: 'Home Assistant access is not configured. For Docker deployments without ingress, supply a URL and long-lived token.' });
return;
}
const headers = {
'Authorization': `Bearer ${auth.token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (auth.source === 'supervisor') {
headers['X-Supervisor-Token'] = auth.token;
}
const endpoint = `${auth.baseUrl}/states`;
const fetchOptions = { headers };
let tlsFallbackUsed = false;
let response;
try {
response = await fetch(endpoint, fetchOptions);
} catch (fetchError) {
if (auth.baseUrl.startsWith('https://') && isTlsCertificateError(fetchError)) {
tlsFallbackUsed = true;
console.warn('[test-connection] TLS verification failed, retrying with relaxed validation:', {
endpoint,
code: fetchError.code,
message: fetchError.message,
causeCode: fetchError.cause?.code,
});
const insecureAgent = new https.Agent({ rejectUnauthorized: false });
response = await fetch(endpoint, { ...fetchOptions, agent: insecureAgent });
} else {
throw fetchError;
}
}
if (response.ok) {
res.json({
success: true,
message: 'Connected to Home Assistant successfully.',
authMode: auth.source,
tlsFallback: tlsFallbackUsed ? 'insecure' : 'strict',
});
} else {
const errorText = await response.text();
console.error('[test-connection] HA response error', {
status: response.status,
authMode: auth.source,
baseUrl: auth.baseUrl,
errorText,
});
res.status(response.status).json({
success: false,
message: `Connection failed: ${response.status} - ${errorText}`,
tlsFallback: tlsFallbackUsed ? 'insecure' : 'strict',
});
}
} catch (error) {
console.error('[test-connection] Error:', error);
res.status(500).json({ success: false, message: `Connection failed: ${error.message}` });
}
});
// Schedule backup endpoints
let scheduledJobs = {};
const SCHEDULE_FILE = path.join(DATA_DIR, 'scheduled-jobs.json');
// Load scheduled jobs from file
async function loadScheduledJobs() {
try {
const content = await fs.readFile(SCHEDULE_FILE, 'utf-8');
const data = JSON.parse(content);
// Normalize: ensure we only have { jobs: {...} } structure
// Remove any legacy top-level job keys
if (!data.jobs) {
data.jobs = {};
}
// Clean up: return only the jobs wrapper
return { jobs: data.jobs };
} catch (error) {
return { jobs: {} };
}
}
// Save scheduled jobs to file
async function saveScheduledJobs(jobs) {
await fs.writeFile(SCHEDULE_FILE, JSON.stringify(jobs, null, 2));
}
// Get schedule
app.get('/api/schedule-backup', async (req, res) => {
try {
const jobs = await loadScheduledJobs();
res.json(jobs);
} catch (error) {
console.error('[get-schedule] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Set schedule
app.post('/api/schedule-backup', async (req, res) => {
try {
const { id, cronExpression, enabled, timezone, liveConfigPath, backupFolderPath, maxBackupsEnabled, maxBackupsCount, smartBackupEnabled } = req.body;
const jobs = await loadScheduledJobs();
jobs.jobs = jobs.jobs || {};
jobs.jobs[id] = { cronExpression, enabled, timezone, liveConfigPath, backupFolderPath, maxBackupsEnabled, maxBackupsCount, smartBackupEnabled };
console.log('[scheduler] New schedule saved:', jobs.jobs[id]);
// Clean structure: only save { jobs: {...} }
const cleanJobs = { jobs: jobs.jobs };
await saveScheduledJobs(cleanJobs);
// Stop existing cron job if any
if (scheduledJobs[id]) {
scheduledJobs[id].stop();
delete scheduledJobs[id];
}
// Start new cron job if enabled
const jobConfig = jobs.jobs[id];
if (enabled) {
console.log(`[scheduler] Setting up schedule "${id}" with cron "${cronExpression}" and timezone "${timezone}"`);
scheduledJobs[id] = cron.schedule(cronExpression, async () => {
console.log(`[cron] Triggered backup job: ${id} at ${new Date().toISOString()}`);
try {
const effectiveLivePath = jobConfig.liveConfigPath || '/config';
const effectiveBackupPath = jobConfig.backupFolderPath || '/media/timemachine';
console.log(`[cron] Using live path "${effectiveLivePath}" and backup path "${effectiveBackupPath}".`);
try {
const response = await fetch(`http://localhost:${PORT}/api/backup-now`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
liveConfigPath: effectiveLivePath,
backupFolderPath: effectiveBackupPath,
maxBackupsEnabled: jobConfig.maxBackupsEnabled,
maxBackupsCount: jobConfig.maxBackupsCount,
timezone: jobConfig.timezone,
smartBackupEnabled: jobConfig.smartBackupEnabled
})
});
const result = await response.json();
if (response.ok) {
console.log(`[cron] Backup triggered successfully: ${result.message}`);
} else {
console.error(`[cron] Backup trigger failed: ${result.error}`);
}
} catch (error) {
console.error(`[cron] Error triggering backup:`, error);
}
} catch (error) {
console.error(`[cron] Error during scheduled backup for job ${id}:`, error);
}
}, { timezone });
}
res.json({ success: true, message: 'Schedule updated successfully' });
} catch (error) {
console.error('[set-schedule] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Validate path
app.post('/api/validate-path', async (req, res) => {
try {
const { path: requestedPath, type } = req.body;
if (!requestedPath) {
return res.json({ errorCode: 'directory_not_found' });
}
try {
const stats = await fs.stat(requestedPath);
if (!stats.isDirectory()) {
return res.json({ errorCode: 'not_directory', path: requestedPath });
}
if (type === 'live') {
// Check if config has automations (either standard or split config)
const { automationPaths } = await getConfigFilePaths(requestedPath);
// Check if at least one automation file exists
let hasAutomations = false;
for (const autoPath of automationPaths) {
try {
await fs.access(autoPath);
hasAutomations = true;
break;
} catch (err) {
// File doesn't exist, try next
}
}
if (!hasAutomations) {
return res.json({ errorCode: 'missing_automations', path: requestedPath });
}
}
return res.json({ success: true });
} catch (err) {
if (err.code === 'ENOENT') {
return res.json({ errorCode: 'directory_not_found', path: requestedPath });
}
return res.json({ errorCode: 'cannot_access', path: requestedPath, details: err.message });
}
} catch (error) {
console.error('[validate-path] Error:', error);
res.status(500).json({ error: error.message, errorCode: 'unknown' });
}
});
// Helper function to get all backup paths in reverse chronological order
async function getAllBackupPaths(backupRoot) {
const allBackups = [];
try {
const years = await fs.readdir(backupRoot);
const yearDirs = years.filter(y => /^\d{4}$/.test(y));
yearDirs.sort().reverse();
for (const year of yearDirs) {
const yearPath = path.join(backupRoot, year);
const months = await fs.readdir(yearPath);
const monthDirs = months.filter(m => /^\d{2}$/.test(m));
monthDirs.sort().reverse();
for (const month of monthDirs) {
const monthPath = path.join(yearPath, month);
const backups = await fs.readdir(monthPath);
const backupDirs = backups.filter(b => /^\d{4}-\d{2}-\d{2}-\d{6}$/.test(b));
backupDirs.sort().reverse();
for (const backup of backupDirs) {
allBackups.push(path.join(monthPath, backup));
}
}
}
return allBackups;
} catch (err) {
console.log('[smart-backup] Error getting backup paths:', err.message);
return [];
}
}
// Helper function to find the most recent version of a file by walking the backup chain
async function findFileInBackupChain(backupPaths, relativeFilePath) {
for (const backupPath of backupPaths) {
const filePath = path.join(backupPath, relativeFilePath);
try {
await fs.access(filePath);
return filePath; // Found the file
} catch (err) {
// File doesn't exist in this backup, continue to older backup
}
}
return null; // File not found in any backup
}
// Helper function to check if a file has changed compared to the backup chain
async function hasFileChanged(sourceFile, backupPaths, relativeFilePath) {
try {
const sourceContent = await fs.readFile(sourceFile, 'utf-8');
// Find the most recent backed-up version of this file
const backupFilePath = await findFileInBackupChain(backupPaths, relativeFilePath);
if (!backupFilePath) {
// File doesn't exist in any backup, it's "new"
return true;
}
const backupContent = await fs.readFile(backupFilePath, 'utf-8');
return sourceContent !== backupContent;
} catch (err) {
// If source can't be read, skip it
}
}
// Helper function to find the correct version of a file in the backup chain, starting from a specific backup.
async function resolveFileInBackupChain(targetBackupPath, relativeFilePath) {
try {
// Determine backup root by going up 3 levels (backup -> MM -> YYYY -> root)
let rootPath = targetBackupPath;
for (let i = 0; i < 3; i++) rootPath = path.dirname(rootPath);
const allBackups = await getAllBackupPaths(rootPath);
const targetBase = path.basename(targetBackupPath);
// Find index of the target backup in the sorted list (newest first)
// Note: backup folder names are timestamps, so they are unique
const startIndex = allBackups.findIndex(p => path.basename(p) === targetBase);
if (startIndex === -1) {
// Fallback: just check the target path if not in list
return path.join(targetBackupPath, relativeFilePath);
}
// Iterate from startIndex onwards (covering target and older backups)
for (let i = startIndex; i < allBackups.length; i++) {
const potentialPath = path.join(allBackups[i], relativeFilePath);
try {
await fs.access(potentialPath);
return potentialPath; // Found it!
} catch (e) {
// Not here, continue to older backup
}
}
// If not found in history, return the path in target backup (let caller handle ENOENT)
return path.join(targetBackupPath, relativeFilePath);
} catch (err) {
console.error('[resolveFileInBackupChain] Error:', err);
// Fallback
return path.join(targetBackupPath, relativeFilePath);
}
}
// Reusable backup function
async function performBackup(liveConfigPath, backupFolderPath, source = 'manual', maxBackupsEnabled = false, maxBackupsCount = 100, timezone = null, smartBackupEnabled = false) {
const configPath = liveConfigPath || '/config';
const backupRoot = backupFolderPath || '/media/timemachine';
LAST_BACKUP_STATE = {
status: 'in_progress',
timestamp: Date.now(),
error: null,
source: source
};
console.log(`[backup-${source}] Starting backup...`);
console.log(`[backup-${source}] Config path:`, configPath);
console.log(`[backup-${source}] Backup root:`, backupRoot);
console.log(`[backup-${source}] Max backups enabled:`, maxBackupsEnabled, 'count:', maxBackupsCount);
console.log(`[backup-${source}] Smart backup enabled:`, smartBackupEnabled);
try {
// Check if backup root exists and is writable
await fs.access(backupRoot, fs.constants.R_OK | fs.constants.W_OK);
console.log(`[backup-${source}] Backup root is accessible and writable`);
} catch (err) {
if (err.code === 'ENOENT') {
try {
await fs.mkdir(backupRoot, { recursive: true });
console.log(`[backup-${source}] Backup root did not exist. Created: ${backupRoot}`);
// Verify access after creation
await fs.access(backupRoot, fs.constants.R_OK | fs.constants.W_OK);
} catch (mkdirErr) {
console.error(`[backup-${source}] Failed to create backup root:`, mkdirErr.message);
const createError = new Error('backup_dir_create_failed');
createError.code = 'BACKUP_DIR_CREATE_FAILED';
createError.meta = { path: backupRoot };
throw createError;
}
} else {
console.error(`[backup-${source}] Backup root access check failed:`, err.message);
const accessError = new Error('backup_dir_unwritable');
accessError.code = 'BACKUP_DIR_UNWRITABLE';
accessError.meta = { path: backupRoot };
throw accessError;
}
}
// Create backup folder with timestamp
let now = new Date();
let YYYY, MM, DD, HH, mm, ss;
if (timezone) {
// Use the specified timezone
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
hourCycle: 'h23'
});
const parts = formatter.formatToParts(now);
YYYY = parts.find(p => p.type === 'year').value;
MM = parts.find(p => p.type === 'month').value;
DD = parts.find(p => p.type === 'day').value;
HH = parts.find(p => p.type === 'hour').value;
if (HH === '24') {
HH = '00';
}
mm = parts.find(p => p.type === 'minute').value;
ss = parts.find(p => p.type === 'second').value;
} else {
// Use server's local time (fallback)
YYYY = String(now.getFullYear());
MM = String(now.getMonth() + 1).padStart(2, '0');
DD = String(now.getDate()).padStart(2, '0');
HH = String(now.getHours()).padStart(2, '0');
mm = String(now.getMinutes()).padStart(2, '0');
ss = String(now.getSeconds()).padStart(2, '0');
}
const timestamp = `${YYYY}-${MM}-${DD}-${HH}${mm}${ss}`;
const backupPath = path.join(backupRoot, YYYY, MM, timestamp);
// Get all backup paths for smart backup comparison BEFORE creating new directory
let allBackupPaths = [];
if (smartBackupEnabled) {
allBackupPaths = await getAllBackupPaths(backupRoot);
if (allBackupPaths.length > 0) {
console.log(`[backup-${source}] Smart backup: found ${allBackupPaths.length} previous backups to compare against`);
} else {
console.log(`[backup-${source}] Smart backup: no previous backups found, performing full backup`);
}
}
console.log(`[backup-${source}] Creating directory:`, backupPath);
const manifest = {
version: 1,
generatedAt: new Date().toISOString(),
files: {
root: [],
storage: [],
esphome: [],
packages: []
},
automation_files: [],
script_files: []
};
try {
await fs.mkdir(backupPath, { recursive: true });
console.log(`[backup-${source}] Directory created successfully`);
} catch (err) {
console.error(`[backup-${source}] Failed to create directory:`, err);
const mkdirError = new Error('backup_dir_create_failed');
mkdirError.code = 'BACKUP_DIR_CREATE_FAILED';
mkdirError.meta = { path: backupPath, parent: backupRoot };
throw mkdirError;
}
// Copy YAML files
const files = await fs.readdir(configPath);
const yamlFiles = files.filter(file => file.endsWith('.yaml') || file.endsWith('.yml'));
console.log(`[backup-${source}] Found ${yamlFiles.length} YAML files to check.`);
let copiedYamlCount = 0;
let skippedYamlCount = 0;
for (const file of yamlFiles) {
const sourcePath = path.join(configPath, file);
const destPath = path.join(backupPath, file);
try {
// Smart backup mode: only copy if file has changed
if (smartBackupEnabled && allBackupPaths.length > 0) {
const changed = await hasFileChanged(sourcePath, allBackupPaths, file);
if (!changed) {
skippedYamlCount++;
continue; // Skip unchanged files
}
}
await fs.copyFile(sourcePath, destPath);
manifest.files.root.push(file); // Only add to manifest if file was actually copied
copiedYamlCount++;
} catch (err) {
console.error(`[backup-${source}] Error copying ${file}:`, err.message);
}
}
console.log(`[backup-${source}] Copied ${copiedYamlCount} YAML files${smartBackupEnabled ? `, skipped ${skippedYamlCount} unchanged` : ''}.`);
// Backup split config directories (automations/, scripts/, etc.)
// These are directories containing YAML files used via !include_dir_list or !include_dir_named
const { automationPaths, scriptPaths, automationDirs, scriptDirs } = await getConfigFilePaths(configPath);
// Record which files are automations and scripts in the manifest (relative to config root)
manifest.automation_files = automationPaths.map(p => path.relative(configPath, p));
manifest.script_files = scriptPaths.map(p => path.relative(configPath, p));
const splitDirs = [...new Set([...automationDirs, ...scriptDirs])]; // Dedupe
let copiedSplitCount = 0;
let skippedSplitCount = 0;
for (const dirPath of splitDirs) {
try {
const dirName = path.relative(configPath, dirPath);
const backupDirPath = path.join(backupPath, dirName);
const dirFiles = await fs.readdir(dirPath);
const yamlDirFiles = dirFiles.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
if (yamlDirFiles.length > 0) {
await fs.mkdir(backupDirPath, { recursive: true });
for (const file of yamlDirFiles) {
const srcFile = path.join(dirPath, file);
const destFile = path.join(backupDirPath, file);
const relativePath = path.join(dirName, file);
try {
// Smart backup mode: only copy if file has changed
if (smartBackupEnabled && allBackupPaths.length > 0) {
const changed = await hasFileChanged(srcFile, allBackupPaths, relativePath);
if (!changed) {
skippedSplitCount++;
continue;
}
}
await fs.copyFile(srcFile, destFile);
manifest.files.root.push(relativePath);
copiedSplitCount++;
} catch (err) {
console.error(`[backup-${source}] Error copying split config ${relativePath}:`, err.message);
}
}
}
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(`[backup-${source}] Error reading split config directory ${dirPath}:`, err.message);
}
}
}
if (splitDirs.length > 0) {
console.log(`[backup-${source}] Copied ${copiedSplitCount} split config files${smartBackupEnabled ? `, skipped ${skippedSplitCount} unchanged` : ''}.`);
}
// Backup individual split config files (!include path/to/file.yaml)
// These are specific files detected in configuration.yaml that might be in subdirectories
const individuaSplitFiles = [...new Set([...automationPaths, ...scriptPaths])]
.filter(f => {
const rel = path.relative(configPath, f);
return rel !== 'automations.yaml' && rel !== 'scripts.yaml' && !rel.startsWith('..');
});
let copiedIndividualCount = 0;
let skippedIndividualCount = 0;
for (const srcFile of individuaSplitFiles) {
const relativePath = path.relative(configPath, srcFile);
const destFile = path.join(backupPath, relativePath);
try {
// Smart backup mode: only copy if file has changed
if (smartBackupEnabled && allBackupPaths.length > 0) {
const changed = await hasFileChanged(srcFile, allBackupPaths, relativePath);
if (!changed) {
skippedIndividualCount++;
continue;
}
}
// Ensure target directory exists
await fs.mkdir(path.dirname(destFile), { recursive: true });
await fs.copyFile(srcFile, destFile);
manifest.files.root.push(relativePath);
copiedIndividualCount++;
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(`[backup-${source}] Error copying individual split config ${relativePath}:`, err.message);
}
}
}
if (individuaSplitFiles.length > 0) {
console.log(`[backup-${source}] Copied ${copiedIndividualCount} individual split files${smartBackupEnabled ? `, skipped ${skippedIndividualCount} unchanged` : ''}.`);
}
// Backup Lovelace files
const storagePath = path.join(configPath, '.storage');
const backupStoragePath = path.join(backupPath, '.storage');
let storageDirectoryCreated = false;
let copiedLovelaceCount = 0;
let skippedLovelaceCount = 0;
try {
const storageFiles = await fs.readdir(storagePath);
const lovelaceFiles = storageFiles.filter(file => file.startsWith('lovelace'));
console.log(`[backup-${source}] Found ${lovelaceFiles.length} Lovelace files to check.`);
for (const file of lovelaceFiles) {
const sourcePath = path.join(storagePath, file);
const destPath = path.join(backupStoragePath, file);
try {
// Smart backup mode: only copy if file has changed
if (smartBackupEnabled && allBackupPaths.length > 0) {
const changed = await hasFileChanged(sourcePath, allBackupPaths, path.join('.storage', file));
if (!changed) {
skippedLovelaceCount++;
continue;
}
}
// Create directory only when first file needs to be copied
if (!storageDirectoryCreated) {
await fs.mkdir(backupStoragePath, { recursive: true });
storageDirectoryCreated = true;
}
await fs.copyFile(sourcePath, destPath);
manifest.files.storage.push(file); // Only add to manifest if file was actually copied
copiedLovelaceCount++;
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(`[backup-${source}] Error copying Lovelace file ${file}:`, err.message);
}
}
}
console.log(`[backup-${source}] Copied ${copiedLovelaceCount} Lovelace files${smartBackupEnabled ? `, skipped ${skippedLovelaceCount} unchanged` : ''}.`);
} catch (err) {
console.error(`[backup-${source}] Error reading .storage directory:`, err.message);
}
const esphomeEnabled = await isEsphomeEnabled();
const packagesEnabled = await isPackagesEnabled();
let copiedEsphomeCount = 0;
let skippedEsphomeCount = 0;
let copiedPackagesCount = 0;
let skippedPackagesCount = 0;
if (esphomeEnabled) {
// Backup ESPHome files
const esphomePath = process.env.ESPHOME_CONFIG_PATH || path.join(configPath, 'esphome');
const backupEsphomePath = path.join(backupPath, 'esphome');
try {
const esphomeYamlFiles = await listYamlFilesRecursive(esphomePath);
console.log(`[backup-${source}] Found ${esphomeYamlFiles.length} ESPHome YAML files to copy.`);
for (const relativePath of esphomeYamlFiles) {
const sourcePath = path.join(esphomePath, relativePath);
const destPath = path.join(backupEsphomePath, relativePath);
try {
// Smart backup mode: only copy if file has changed
if (smartBackupEnabled && allBackupPaths.length > 0) {
const changed = await hasFileChanged(sourcePath, allBackupPaths, path.join('esphome', relativePath));
if (!changed) {
skippedEsphomeCount++;
continue;
}
}
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(sourcePath, destPath);
manifest.files.esphome.push(relativePath); // Only add to manifest if file was actually copied
copiedEsphomeCount++;
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(`[backup-${source}] Error copying ESPHome file ${relativePath}:`, err.message);
}
}
}
console.log(`[backup-${source}] Copied ${copiedEsphomeCount} ESPHome files${smartBackupEnabled ? `, skipped ${skippedEsphomeCount} unchanged` : ''}.`);
} catch (err) {
console.error(`[backup-${source}] Error reading esphome directory:`, err.message);
}
} else {
console.log(`[backup-${source}] Skipping ESPHome backups (feature disabled).`);
}
if (packagesEnabled) {
// Backup Packages files
const packagesPath = path.join(configPath, 'packages');
const backupPackagesPath = path.join(backupPath, 'packages');
try {
const packagesYamlFiles = await listYamlFilesRecursive(packagesPath);
console.log(`[backup-${source}] Found ${packagesYamlFiles.length} Packages YAML files to copy.`);
for (const relativePath of packagesYamlFiles) {
const sourcePath = path.join(packagesPath, relativePath);
const destPath = path.join(backupPackagesPath, relativePath);
try {
// Smart backup mode: only copy if file has changed
if (smartBackupEnabled && allBackupPaths.length > 0) {
const changed = await hasFileChanged(sourcePath, allBackupPaths, path.join('packages', relativePath));
if (!changed) {
skippedPackagesCount++;
continue;
}
}
await fs.mkdir(path.dirname(destPath), { recursive: true });
await fs.copyFile(sourcePath, destPath);
manifest.files.packages.push(relativePath); // Only add to manifest if file was actually copied
copiedPackagesCount++;
} catch (err) {
if (err.code !== 'ENOENT') {
console.error(`[backup-${source}] Error copying Packages file ${relativePath}:`, err.message);
}
}
}
console.log(`[backup-${source}] Copied ${copiedPackagesCount} Packages files${smartBackupEnabled ? `, skipped ${skippedPackagesCount} unchanged` : ''}.`);
} catch (err) {
console.error(`[backup-${source}] Error reading packages directory:`, err.message);
}
} else {
console.log(`[backup-${source}] Skipping Packages backups (feature disabled).`);
}
// In smart backup mode, check if any files were actually copied
// If not, delete the empty backup folder and return early
if (smartBackupEnabled && allBackupPaths.length > 0) {
const totalCopied = copiedYamlCount + copiedLovelaceCount + copiedEsphomeCount + copiedPackagesCount;
if (totalCopied === 0) {
console.log(`[backup-${source}] No files changed since last backup. Removing empty backup folder.`);
try {
await fs.rm(backupPath, { recursive: true, force: true });
// Also clean up empty parent directories (MM and YYYY) if they're now empty
const monthPath = path.dirname(backupPath);
const yearPath = path.dirname(monthPath);
try {
const monthContents = await fs.readdir(monthPath);
if (monthContents.length === 0) {
await fs.rmdir(monthPath);
console.log(`[backup-${source}] Removed empty month directory: ${monthPath}`);
// Check if year directory is also empty now
const yearContents = await fs.readdir(yearPath);
if (yearContents.length === 0) {
await fs.rmdir(yearPath);
console.log(`[backup-${source}] Removed empty year directory: ${yearPath}`);
}
}
} catch (cleanupErr) {
// Ignore errors cleaning up parent directories
}
} catch (rmErr) {
console.error(`[backup-${source}] Failed to remove empty backup folder:`, rmErr.message);
}
// Even when no snapshot is created, still enforce retention policy.
if (maxBackupsEnabled && maxBackupsCount > 0) {
try {
console.log(`[backup-${source}] No new snapshot, but enforcing max backups (${maxBackupsCount})...`);
await cleanupOldBackups(backupRoot, maxBackupsCount);
} catch (cleanupError) {
console.error(`[backup-${source}] Error during cleanup:`, cleanupError.message);
// Don't fail the backup flow if cleanup fails
}
}
LAST_BACKUP_STATE.status = 'no_changes';
LAST_BACKUP_STATE.timestamp = Date.now();
await saveBackupState();
return null; // Indicate no backup was created
}
}
// Write Manifest only if smart backup is enabled
if (smartBackupEnabled) {
try {
await fs.writeFile(path.join(backupPath, '.backup_manifest.json'), JSON.stringify(manifest, null, 2));
} catch (err) {
console.error(`[backup-${source}] Failed to write backup manifest:`, err.message);
}
}
console.log(`[backup-${source}] Backup completed successfully at:`, backupPath);
// Cleanup old backups if maxBackups is enabled
if (maxBackupsEnabled && maxBackupsCount > 0) {
try {
console.log(`[backup-${source}] Cleaning up old backups, keeping max ${maxBackupsCount}...`);
await cleanupOldBackups(backupRoot, maxBackupsCount);
} catch (cleanupError) {
console.error(`[backup-${source}] Error during cleanup:`, cleanupError.message);
// Don't fail the backup if cleanup fails
}
}
LAST_BACKUP_STATE.status = 'success';
LAST_BACKUP_STATE.timestamp = Date.now();
await saveBackupState();
return backupPath;
}
// Cleanup old backups function
async function cleanupOldBackups(backupRoot, maxBackupsCount) {
try {
console.log(`[cleanup] Scanning backup directory: ${backupRoot}`);
const allBackups = await getBackupDirs(backupRoot);
// Sort by folderName descending (newest first)
allBackups.sort((a, b) => b.folderName.localeCompare(a.folderName));
// Filter out locked backups
const candidates = allBackups.filter(b => !b.locked);
console.log(`[cleanup] Found ${allBackups.length} total backups, ${allBackups.length - candidates.length} are locked.`);
if (candidates.length <= maxBackupsCount) {
console.log(`[cleanup] No cleanup needed - only ${candidates.length} unlockable backups exist`);
return;
}
// Get backups to delete (all beyond maxBackupsCount)
const backupsToDelete = candidates.slice(maxBackupsCount);
console.log(`[cleanup] Will delete ${backupsToDelete.length} old backups`);
for (const backup of backupsToDelete) {
try {
console.log(`[cleanup] Deleting old backup: ${backup.path}`);
await fs.rm(backup.path, { recursive: true, force: true });
console.log(`[cleanup] Successfully deleted: ${backup.path}`);
} catch (deleteError) {
console.error(`[cleanup] Error deleting ${backup.path}:`, deleteError.message);
// Continue with other deletions even if one fails
}
}
console.log(`[cleanup] Cleanup completed. Kept ${Math.min(allBackups.length, maxBackupsCount)} backups.`);
} catch (error) {
console.error('[cleanup] Error during cleanup:', error.message);
throw error;
}
}
// Backup now
app.post('/api/backup-now', async (req, res) => {
try {
const { liveConfigPath, backupFolderPath, maxBackupsEnabled, maxBackupsCount, timezone, smartBackupEnabled } = req.body;
// If smartBackupEnabled not explicitly provided, read from scheduled jobs settings
let effectiveSmartBackup = smartBackupEnabled;
let effectiveMaxBackupsEnabled = maxBackupsEnabled;
let effectiveMaxBackupsCount = maxBackupsCount;
let effectiveTimezone = timezone;
if (typeof smartBackupEnabled === 'undefined') {
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
effectiveSmartBackup = defaultJob.smartBackupEnabled ?? false;
effectiveMaxBackupsEnabled = maxBackupsEnabled ?? defaultJob.maxBackupsEnabled ?? false;
effectiveMaxBackupsCount = maxBackupsCount ?? defaultJob.maxBackupsCount ?? 100;
effectiveTimezone = timezone ?? defaultJob.timezone ?? null;
console.log(`[backup-now] Using saved settings - Smart backup: ${effectiveSmartBackup}`);
}
const backupPath = await performBackup(liveConfigPath, backupFolderPath, 'manual', effectiveMaxBackupsEnabled, effectiveMaxBackupsCount, effectiveTimezone, effectiveSmartBackup);
// null means no changes detected in smart backup mode
if (backupPath === null) {
return res.json({ success: true, noChanges: true, message: 'No changes detected since last backup.' });
}
res.json({ success: true, path: backupPath, message: `Backup created successfully at ${backupPath}` });
} catch (error) {
console.error('[backup-now] Error:', error);
LAST_BACKUP_STATE.status = 'failed';
LAST_BACKUP_STATE.timestamp = Date.now();
LAST_BACKUP_STATE.error = error.message;
await saveBackupState();
res.status(500).json({
error: error.message,
errorCode: error.code || 'BACKUP_FAILED',
meta: error.meta || null
});
}
});
// Lovelace endpoints
app.post('/api/get-backup-lovelace', async (req, res) => {
try {
const { backupPath } = req.body;
// Check manifest
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
if (manifest.files && manifest.files.storage) {
// Use manifest list (already relative to .storage if it was just filenames)
// Wait, logic in performBackup: manifest.files.storage.push(file) where file is just filename
// Filter for 'lovelace' prefix
const lovelaceFiles = manifest.files.storage.filter(f => f.startsWith('lovelace'));
return res.json({ lovelaceFiles });
}
} catch (e) {
// Fallback to directory scan
}
const lovelaceDir = path.join(backupPath, '.storage');
const files = await fs.readdir(lovelaceDir);
const lovelaceFiles = files.filter(f => f.startsWith('lovelace'));
res.json({ lovelaceFiles });
} catch (error) {
console.error('[get-backup-lovelace] Error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/get-backup-lovelace-file', async (req, res) => {
try {
const { backupPath, fileName } = req.body;
// Use chain resolution
const filePath = await resolveFileInBackupChain(backupPath, path.join('.storage', fileName));
console.log(`[get-backup-lovelace-file] Request for file: ${fileName} in backup: ${backupPath} -> Resolved: ${filePath}`);
res.sendFile(filePath, (err) => {
if (err) {
console.error('[get-backup-lovelace-file] Error sending file:', err);
res.status(err.status || 500).json({ error: err.message });
}
});
} catch (error) {
console.error('[get-backup-lovelace-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
const getLiveLovelaceFile = async (req, res) => {
try {
const payload = req.method === 'GET' ? req.query : req.body;
const fileName = payload?.fileName;
const liveConfigPath = payload?.liveConfigPath;
if (!fileName) {
return res.status(400).json({ error: 'fileName is required' });
}
const configPath = liveConfigPath || '/config';
const filePath = path.join(configPath, '.storage', fileName);
console.log(`[get-live-lovelace-file] Request for file: ${fileName} in config: ${configPath}`);
res.sendFile(filePath, (err) => {
if (err) {
console.error('[get-live-lovelace-file] Error sending file:', err);
res.status(err.status || 404).json({ error: 'File not found' });
}
});
} catch (error) {
console.error('[get-live-lovelace-file] Error:', error);
res.status(404).json({ error: 'File not found' });
}
};
app.get('/api/get-live-lovelace-file', getLiveLovelaceFile);
app.post('/api/get-live-lovelace-file', getLiveLovelaceFile);
app.post('/api/restore-lovelace-file', async (req, res) => {
try {
const { fileName, backupPath, content, timezone, liveConfigPath, smartBackupEnabled } = req.body;
if (!fileName) {
return res.status(400).json({ error: 'fileName is required' });
}
if (!backupPath && typeof content === 'undefined') {
return res.status(400).json({ error: 'backupPath or content is required' });
}
// Perform a backup before restoring - respect Smart Backup setting
// If smartBackupEnabled not explicitly provided, read from scheduled jobs settings
let effectiveSmartBackup = smartBackupEnabled;
if (typeof smartBackupEnabled === 'undefined') {
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
effectiveSmartBackup = defaultJob.smartBackupEnabled ?? false;
}
await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone, effectiveSmartBackup);
const configPath = liveConfigPath || '/config';
const targetFilePath = path.join(configPath, '.storage', fileName);
await fs.mkdir(path.dirname(targetFilePath), { recursive: true });
if (backupPath) {
const sourceFilePath = path.join(backupPath, '.storage', fileName);
try {
await fs.copyFile(sourceFilePath, targetFilePath);
} catch (copyError) {
console.error('[restore-lovelace-file] Copy from backup failed, falling back to write:', copyError.message);
const backupContent = await fs.readFile(sourceFilePath, 'utf-8');
await fs.writeFile(targetFilePath, backupContent, 'utf-8');
}
} else {
const contentToWrite = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
await fs.writeFile(targetFilePath, contentToWrite, 'utf-8');
}
// Check if HA config is available to determine if a restart is needed
const auth = await getHomeAssistantAuth();
const needsRestart = !!(auth.baseUrl && auth.token);
res.json({ success: true, message: 'Lovelace file restored successfully', needsRestart });
} catch (error) {
console.error('[restore-lovelace-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
// ESPHome endpoints
app.post('/api/get-backup-esphome', async (req, res) => {
try {
await loadDockerSettings();
if (!(await isEsphomeEnabled())) {
return res.status(404).json({ error: 'ESPHome feature disabled' });
}
const { backupPath } = req.body;
// Check manifest
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
if (manifest.files && manifest.files.esphome) {
return res.json({ esphomeFiles: manifest.files.esphome });
}
} catch (e) {
// Fallback
}
const esphomeDir = path.join(backupPath, 'esphome');
const esphomeFiles = await listYamlFilesRecursive(esphomeDir);
res.json({ esphomeFiles });
} catch (error) {
console.error('[get-backup-esphome] Error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/get-backup-esphome-file', async (req, res) => {
try {
if (!(await isEsphomeEnabled())) {
return res.status(404).json({ error: 'ESPHome feature disabled' });
}
const { backupPath, fileName } = req.body;
// Use chain resolution
// fileName is relative to esphome directory, so join 'esphome'
const filePath = await resolveFileInBackupChain(backupPath, path.join('esphome', fileName));
const content = await fs.readFile(filePath, 'utf-8');
res.json({ content });
} catch (error) {
if (error.code === 'INVALID_PATH') {
return res.status(400).json({ error: 'Invalid file path' });
}
console.error('[get-backup-esphome-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/get-live-esphome-file', async (req, res) => {
try {
if (!(await isEsphomeEnabled())) {
return res.status(404).json({ error: 'ESPHome feature disabled' });
}
const { fileName, liveConfigPath } = req.body;
const configPath = liveConfigPath || '/config';
const esphomeDir = process.env.ESPHOME_CONFIG_PATH || path.join(configPath, 'esphome');
const filePath = resolveWithinDirectory(esphomeDir, fileName);
const content = await fs.readFile(filePath, 'utf-8');
res.json({ content });
} catch (error) {
if (error.code === 'INVALID_PATH') {
return res.status(400).json({ error: 'Invalid file path' });
}
console.error('[get-live-esphome-file] Error:', error);
res.status(404).json({ error: 'File not found' });
}
});
app.post('/api/restore-esphome-file', async (req, res) => {
try {
if (!(await isEsphomeEnabled())) {
return res.status(404).json({ error: 'ESPHome feature disabled' });
}
const { fileName, content, timezone, liveConfigPath, smartBackupEnabled } = req.body;
// Perform a backup before restoring - respect Smart Backup setting
// If smartBackupEnabled not explicitly provided, read from scheduled jobs settings
let effectiveSmartBackup = smartBackupEnabled;
if (typeof smartBackupEnabled === 'undefined') {
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
effectiveSmartBackup = defaultJob.smartBackupEnabled ?? false;
}
await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone, effectiveSmartBackup);
const configPath = liveConfigPath || '/config';
const esphomeDir = process.env.ESPHOME_CONFIG_PATH || path.join(configPath, 'esphome');
const filePath = resolveWithinDirectory(esphomeDir, fileName);
await fs.mkdir(path.dirname(filePath), { recursive: true });
// Handle content being an object or a string
const contentToWrite = typeof content === 'string' ? content : YAML.stringify(content);
await fs.writeFile(filePath, contentToWrite, 'utf-8');
// Check if HA config is available to determine if a restart is needed
const auth = await getHomeAssistantAuth();
const needsRestart = !!(auth.baseUrl && auth.token);
res.json({ success: true, message: 'ESPHome file restored successfully', needsRestart });
} catch (error) {
console.error('[restore-esphome-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
// Packages endpoints
app.post('/api/get-backup-packages', async (req, res) => {
try {
await loadDockerSettings();
if (!(await isPackagesEnabled())) {
return res.status(404).json({ error: 'Packages feature disabled' });
}
const { backupPath } = req.body;
// Check manifest
try {
const manifestPath = path.join(backupPath, '.backup_manifest.json');
const manifestData = await fs.readFile(manifestPath, 'utf8');
const manifest = JSON.parse(manifestData);
if (manifest.files && manifest.files.packages) {
return res.json({ packagesFiles: manifest.files.packages });
}
} catch (e) {
// Fallback
}
const packagesDir = path.join(backupPath, 'packages');
try {
// Check if packages directory exists
await fs.access(packagesDir);
const packageFiles = await listYamlFilesRecursive(packagesDir);
return res.json({ packagesFiles: packageFiles });
} catch (dirError) {
if (dirError.code === 'ENOENT') {
// Directory doesn't exist, return empty array
return res.json({ packagesFiles: [] });
}
throw dirError; // Re-throw other errors
}
} catch (error) {
console.error('[get-backup-packages] Error:', error);
if (error.code === 'ENOENT') {
return res.json({ packagesFiles: [] });
}
res.status(500).json({ error: error.message });
}
});
app.post('/api/get-backup-packages-file', async (req, res) => {
try {
if (!(await isPackagesEnabled())) {
return res.status(404).json({ error: 'Packages feature disabled' });
}
const { backupPath, fileName } = req.body;
// Use chain resolution
const filePath = await resolveFileInBackupChain(backupPath, path.join('packages', fileName));
const content = await fs.readFile(filePath, 'utf-8');
res.json({ content });
} catch (error) {
if (error.code === 'INVALID_PATH') {
return res.status(400).json({ error: 'Invalid file path' });
}
console.error('[get-backup-packages-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/get-live-packages-file', async (req, res) => {
try {
if (!(await isPackagesEnabled())) {
return res.status(404).json({ error: 'Packages feature disabled' });
}
const { fileName, liveConfigPath } = req.body;
const configPath = liveConfigPath || '/config';
const packagesDir = path.join(configPath, 'packages');
const filePath = resolveWithinDirectory(packagesDir, fileName);
const content = await fs.readFile(filePath, 'utf-8');
res.json({ content });
} catch (error) {
if (error.code === 'INVALID_PATH') {
return res.status(400).json({ error: 'Invalid file path' });
}
if (error.code === 'ENOENT') {
return res.status(404).json({ error: 'File not found' });
}
console.error('[get-live-packages-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
app.post('/api/restore-packages-file', async (req, res) => {
try {
if (!(await isPackagesEnabled())) {
return res.status(404).json({ error: 'Packages feature disabled' });
}
const { fileName, content, timezone, liveConfigPath, smartBackupEnabled } = req.body;
// Perform a backup before restoring - respect Smart Backup setting
// If smartBackupEnabled not explicitly provided, read from scheduled jobs settings
let effectiveSmartBackup = smartBackupEnabled;
if (typeof smartBackupEnabled === 'undefined') {
const scheduledJobsData = await loadScheduledJobs();
const defaultJob = scheduledJobsData.jobs?.['default-backup-job'] || {};
effectiveSmartBackup = defaultJob.smartBackupEnabled ?? false;
}
await performBackup(liveConfigPath || null, null, 'pre-restore', false, 100, timezone, effectiveSmartBackup);
const configPath = liveConfigPath || '/config';
const packagesDir = path.join(configPath, 'packages');
const filePath = resolveWithinDirectory(packagesDir, fileName);
await fs.mkdir(path.dirname(filePath), { recursive: true });
// Handle content being an object or a string
const contentToWrite = typeof content === 'string' ? content : YAML.stringify(content);
await fs.writeFile(filePath, contentToWrite, 'utf-8');
// Check if HA config is available to determine if a restart is needed
const auth = await getHomeAssistantAuth();
const needsRestart = !!(auth.baseUrl && auth.token);
res.json({ success: true, message: 'Package file restored successfully', needsRestart });
} catch (error) {
console.error('[restore-packages-file] Error:', error);
res.status(500).json({ error: error.message });
}
});
app.get('/api/health', async (req, res) => {
try {
const options = await getAddonOptions();
const backupRoot = options.backupFolderPath || '/media/timemachine';
let allBackups = [];
try {
allBackups = await getAllBackupPaths(backupRoot);
} catch (e) {
debugLog('[health] Could not get backup paths:', e.message);
}
let lastBackup = null;
if (allBackups.length > 0) {
lastBackup = path.basename(allBackups[0]);
}
// Disk usage
let disk_info = {};
try {
const stats = await fs.statfs(backupRoot);
const total = Number(stats.blocks * stats.bsize);
const free = Number(stats.bfree * stats.bsize);
disk_info = {
total_gb: (total / (1024 ** 3)).toFixed(2),
free_gb: (free / (1024 ** 3)).toFixed(2),
used_pct: (((total - free) / total) * 100).toFixed(1)
};
} catch (e) {
debugLog('[health] Could not get disk stats:', e.message);
}
// Schedules
const jobs = await loadScheduledJobs();
const active_schedules = Object.values(jobs.jobs || {}).filter(j => j.enabled).length;
res.json({
ok: true,
version,
mode: options.mode,
backup_count: allBackups.length,
last_backup: lastBackup,
disk_usage: disk_info,
active_schedules,
last_backup_status: LAST_BACKUP_STATE.status,
last_backup_error: LAST_BACKUP_STATE.error
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Start server
loadBackupState().then(() => {
app.listen(PORT, HOST, () => {
console.log('='.repeat(60));
console.log(`Home Assistant Time Machine v${version}`);
console.log('='.repeat(60));
console.log(`Server running at http://${HOST}:${PORT}`);
if (INGRESS_PATH) {
console.log(`[ingress] Ingress path detected: ${INGRESS_PATH}`);
}
// Initialize scheduled jobs
loadScheduledJobs().then(jobs => {
console.log('[scheduler] Loaded schedules:', jobs.jobs);
console.log('[scheduler] Initializing schedules on startup...');
Object.entries(jobs.jobs || {}).forEach(([id, job]) => {
if (job.enabled) {
console.log(`[scheduler] Setting up schedule "${id}" with cron "${job.cronExpression}" and timezone "${job.timezone}"`);
scheduledJobs[id] = cron.schedule(job.cronExpression, async () => {
console.log(`[cron] Triggered backup job: ${id} at ${new Date().toISOString()}`);
try {
console.log(`[cron] Fetching addon options for job ${id}...`);
const options = await getAddonOptions();
const sanitizedOptions = JSON.parse(JSON.stringify(options));
if (sanitizedOptions.long_lived_access_token) {
sanitizedOptions.long_lived_access_token = 'REDACTED';
}
console.log(`[cron] Addon options for job ${id}:`, sanitizedOptions);
try {
const response = await fetch(`http://localhost:${PORT}/api/backup-now`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
liveConfigPath: job.liveConfigPath || options.liveConfigPath || '/config',
backupFolderPath: job.backupFolderPath || options.backupFolderPath || '/media/timemachine',
maxBackupsEnabled: job.maxBackupsEnabled,
maxBackupsCount: job.maxBackupsCount
})
});
const result = await response.json();
if (response.ok) {
console.log(`[cron] Backup triggered successfully: ${result.message}`);
} else {
console.error(`[cron] Backup trigger failed: ${result.error}`);
}
} catch (error) {
console.error(`[cron] Error triggering backup:`, error);
}
} catch (error) {
console.error(`[cron] Error during scheduled backup for job ${id}:`, error);
}
}, { timezone: job.timezone });
}
});
console.log('[scheduler] Initialization complete.');
});
});
});
```
## /homeassistant-time-machine/compose.yaml
```yaml path="/homeassistant-time-machine/compose.yaml"
services:
ha-time-machine:
image: ghcr.io/diggingfordinos/homeassistanttimemachinebeta:latest
container_name: ha-time-machine
ports:
- "54000:54000"
environment:
- HOME_ASSISTANT_URL=http://ha-ip-address:8123
- LONG_LIVED_ACCESS_TOKEN=your-long-lived-access-token
volumes:
- /mnt/homeassistant/config:/config
- /mnt/homeassistant/timemachine:/media/timemachine
- ha-time-machine-data:/data
restart: unless-stopped
volumes:
ha-time-machine-data:
```
## /homeassistant-time-machine/config.yaml
```yaml path="/homeassistant-time-machine/config.yaml"
name: Home Assistant Time Machine
version: 2.3.1
slug: homeassistant-time-machine
description: Browse and restore Home Assistant configuration backups
url: https://github.com/saihgupr/HomeAssistantTimeMachine
changelog: https://github.com/saihgupr/HomeAssistantTimeMachine/blob/develop/homeassistant-time-machine/CHANGELOG.md
arch:
- amd64
- aarch64
startup: application
boot: auto
init: false
webui: http://[HOST]:[PORT:54000]
hassio_api: true
auth_api: true
homeassistant_api: true
hassio_role: default
ingress: true
ingress_port: 54000
stdin: true
panel_icon: mdi:history
panel_title: Time Machine
map: [config:rw, backup:rw, media:rw, share:rw, ssl:rw, addons:rw]
ports:
54000/tcp: null
options:
theme: dark
esphome: false
packages: false
language: 'en'
schema:
theme: list(dark|light)
esphome: bool?
packages: bool?
language: 'list(en|es|de|fr|nl|it)'
```
## /homeassistant-time-machine/data/docker-app-settings.json
```json path="/homeassistant-time-machine/data/docker-app-settings.json"
{
"liveConfigPath": "/config",
"backupFolderPath": "/media/timemachine",
"textStyle": "default",
"theme": "dark"
}
```
## /homeassistant-time-machine/data/scheduled-jobs.json
```json path="/homeassistant-time-machine/data/scheduled-jobs.json"
{
"jobs": {
"default-backup-job": {
"cronExpression": "0 0 * * *",
"enabled": false,
"timezone": "America/New_York",
"liveConfigPath": "/config/",
"backupFolderPath": "/media/timemachine",
"maxBackupsEnabled": false,
"maxBackupsCount": 100
}
}
}
```
## /homeassistant-time-machine/icon.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/icon.png
## /homeassistant-time-machine/logo.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/logo.png
## /homeassistant-time-machine/package-lock.json
```json path="/homeassistant-time-machine/package-lock.json"
{
"name": "home-assistant-time-machine",
"version": "2.3.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "home-assistant-time-machine",
"version": "2.3.0",
"dependencies": {
"ejs": "^3.1.9",
"express": "^4.18.2",
"js-yaml": "^4.1.0",
"node-cron": "^4.2.1",
"node-fetch": "^2.6.12",
"yaml": "^2.8.1"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
"license": "Apache-2.0",
"dependencies": {
"jake": "^10.8.5"
},
"bin": {
"ejs": "bin/cli.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
"integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==",
"license": "Apache-2.0",
"dependencies": {
"minimatch": "^5.0.1"
}
},
"node_modules/finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"license": "MIT",
"dependencies": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/jake": {
"version": "10.9.4",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.6",
"filelist": "^1.0.4",
"picocolors": "^1.1.1"
},
"bin": {
"jake": "bin/cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-cron": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.0.6"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
"integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
}
}
}
}
```
## /homeassistant-time-machine/package.json
```json path="/homeassistant-time-machine/package.json"
{
"name": "home-assistant-time-machine",
"version": "2.3.1",
"repository": {
"type": "git",
"url": "https://github.com/saihgupr/HomeAssistantTimeMachine.git"
},
"description": "Browse and restore Home Assistant configuration backups",
"private": true,
"scripts": {
"start": "node app.js",
"dev": "node app.js"
},
"dependencies": {
"ejs": "^3.1.9",
"express": "^4.18.2",
"js-yaml": "^4.1.0",
"node-cron": "^4.2.1",
"node-fetch": "^2.6.12",
"yaml": "^2.8.1"
},
"overrides": {
"brace-expansion": "2.0.2"
}
}
```
## /homeassistant-time-machine/public/images/favicon.ico
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/public/images/favicon.ico
## /homeassistant-time-machine/public/images/icon.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/homeassistant-time-machine/public/images/icon.png
## /homeassistant-time-machine/run.sh
```sh path="/homeassistant-time-machine/run.sh"
#!/bin/sh
export NODE_ENV="${NODE_ENV:-production}"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-54000}"
echo "======================================"
echo "Home Assistant Time Machine v2.3.1"
echo "======================================"
echo "Starting server..."
echo "======================================"
node app.js
```
## /icon.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/icon.png
## /images/1.1.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/1.1.png
## /images/1.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/1.png
## /images/2.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/2.png
## /images/3.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/3.png
## /images/4.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/4.png
## /images/5.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/5.png
## /images/6.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/6.png
## /images/history.svg
```svg path="/images/history.svg"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3" /></svg>
```
## /images/icon.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/icon.png
## /images/integration.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/integration.png
## /images/palettes/Screenshot 2025-12-12 at 1.28.53â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.28.53â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.28.56â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.28.56â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.28.59â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.28.59â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.29.02â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.29.02â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.29.07â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.29.07â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.29.10â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.29.10â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.29.13â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.29.13â¯PM.png
## /images/palettes/Screenshot 2025-12-12 at 1.29.45â¯PM.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/images/palettes/Screenshot 2025-12-12 at 1.29.45â¯PM.png
## /logo.png
Binary file available at https://raw.githubusercontent.com/saihgupr/HomeAssistantTimeMachine/refs/heads/main/logo.png
## /repository.json
```json path="/repository.json"
{
"name": "Home Assistant Time Machine Add-on Repository",
"url": "https://github.com/saihgupr/HomeAssistantTimeMachine",
"maintainer": "saihgupr"
}
```
The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.