Compare commits

..

No commits in common. "main" and "2025.03.2" have entirely different histories.

4006 changed files with 6826 additions and 12478 deletions

69
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,69 @@
---
name: Report a bug with the Supervisor on a supported System
about: Report an issue related to the Home Assistant Supervisor.
labels: bug
---
<!-- READ THIS FIRST:
- If you need additional help with this template please refer to https://www.home-assistant.io/help/reporting_issues/
- This is for bugs only. Feature and enhancement requests should go in our community forum: https://community.home-assistant.io/c/feature-requests
- Provide as many details as possible. Paste logs, configuration sample and code into the backticks. Do not delete any text from this template!
- If you have a problem with an add-on, make an issue in it's repository.
-->
<!--
Important: You can only fill a bug repport for an supported system! If you run an unsupported installation. This report would be closed without comment.
-->
### Describe the issue
<!-- Provide as many details as possible. -->
### Steps to reproduce
<!-- What do you do to encounter the issue. -->
1. ...
2. ...
3. ...
### Enviroment details
<!-- You can find these details in the system tab of the supervisor panel, or by using the `ha` CLI. -->
- **Operating System:**: xxx
- **Supervisor version:**: xxx
- **Home Assistant version**: xxx
### Supervisor logs
<details>
<summary>Supervisor logs</summary>
<!--
- Frontend -> Supervisor -> System
- Or use this command: ha supervisor logs
- Logs are more than just errors, even if you don't think it's important, it is.
-->
```
Paste supervisor logs here
```
</details>
### System Information
<details>
<summary>System Information</summary>
<!--
- Use this command: ha info
-->
```
Paste system info here
```
</details>

View File

@ -1,5 +1,6 @@
name: Report an issue with Home Assistant Supervisor
name: Bug Report Form
description: Report an issue related to the Home Assistant Supervisor.
labels: bug
body:
- type: markdown
attributes:
@ -8,7 +9,7 @@ body:
If you have a feature or enhancement request, please use the [feature request][fr] section of our [Community Forum][fr].
[fr]: https://github.com/orgs/home-assistant/discussions
[fr]: https://community.home-assistant.io/c/feature-requests
- type: textarea
validations:
required: true

View File

@ -13,7 +13,7 @@ contact_links:
about: Our documentation has its own issue tracker. Please report issues with the website there.
- name: Request a feature for the Supervisor
url: https://github.com/orgs/home-assistant/discussions
url: https://community.home-assistant.io/c/feature-requests
about: Request an new feature for the Supervisor.
- name: I have a question or need support

View File

@ -1,53 +0,0 @@
name: Task
description: For staff only - Create a task
type: Task
body:
- type: markdown
attributes:
value: |
## ⚠️ RESTRICTED ACCESS
**This form is restricted to Open Home Foundation staff and authorized contributors only.**
If you are a community member wanting to contribute, please:
- For bug reports: Use the [bug report form](https://github.com/home-assistant/supervisor/issues/new?template=bug_report.yml)
- For feature requests: Submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)
---
### For authorized contributors
Use this form to create tasks for development work, improvements, or other actionable items that need to be tracked.
- type: textarea
id: description
attributes:
label: Description
description: |
Provide a clear and detailed description of the task that needs to be accomplished.
Be specific about what needs to be done, why it's important, and any constraints or requirements.
placeholder: |
Describe the task, including:
- What needs to be done
- Why this task is needed
- Expected outcome
- Any constraints or requirements
validations:
required: true
- type: textarea
id: additional_context
attributes:
label: Additional context
description: |
Any additional information, links, research, or context that would be helpful.
Include links to related issues, research, prototypes, roadmap opportunities etc.
placeholder: |
- Roadmap opportunity: [link]
- Epic: [link]
- Feature request: [link]
- Technical design documents: [link]
- Prototype/mockup: [link]
- Dependencies: [links]
validations:
required: false

View File

@ -1,288 +0,0 @@
# GitHub Copilot & Claude Code Instructions
This repository contains the Home Assistant Supervisor, a Python 3 based container
orchestration and management system for Home Assistant.
## Supervisor Capabilities & Features
### Architecture Overview
Home Assistant Supervisor is a Python-based container orchestration system that
communicates with the Docker daemon to manage containerized components. It is tightly
integrated with the underlying Operating System and core Operating System components
through D-Bus.
**Managed Components:**
- **Home Assistant Core**: The main home automation application running in its own
container (also provides the web interface)
- **Add-ons**: Third-party applications and services (each add-on runs in its own
container)
- **Plugins**: Built-in system services like DNS, Audio, CLI, Multicast, and Observer
- **Host System Integration**: OS-level operations and hardware access via D-Bus
- **Container Networking**: Internal Docker network management and external
connectivity
- **Storage & Backup**: Data persistence and backup management across all containers
**Key Dependencies:**
- **Docker Engine**: Required for all container operations
- **D-Bus**: System-level communication with the host OS
- **systemd**: Service management for host system operations
- **NetworkManager**: Network configuration and management
### Add-on System
**Add-on Architecture**: Add-ons are containerized applications available through
add-on stores. Each store contains multiple add-ons, and each add-on includes metadata
that tells Supervisor the version, startup configuration (permissions), and available
user configurable options. Add-on metadata typically references a container image that
Supervisor fetches during installation. If not, the Supervisor builds the container
image from a Dockerfile.
**Built-in Stores**: Supervisor comes with several pre-configured stores:
- **Core Add-ons**: Official add-ons maintained by the Home Assistant team
- **Community Add-ons**: Popular third-party add-ons repository
- **ESPHome**: Add-ons for ESPHome ecosystem integration
- **Music Assistant**: Audio and music-related add-ons
- **Local Development**: Local folder for testing custom add-ons during development
**Store Management**: Stores are Git-based repositories that are periodically updated.
When updates are available, users receive notifications.
**Add-on Lifecycle**:
- **Installation**: Supervisor fetches or builds container images based on add-on
metadata
- **Configuration**: Schema-validated options with integrated UI management
- **Runtime**: Full container lifecycle management, health monitoring
- **Updates**: Automatic or manual version management
### Update System
**Core Components**: Supervisor, Home Assistant Core, HAOS, and built-in plugins
receive version information from a central JSON file fetched from
`https://version.home-assistant.io/{channel}.json`. The `Updater` class handles
fetching this data, validating signatures, and updating internal version tracking.
**Update Channels**: Three channels (`stable`/`beta`/`dev`) determine which version
JSON file is fetched, allowing users to opt into different release streams.
**Add-on Updates**: Add-on version information comes from store repository updates, not
the central JSON file. When repositories are refreshed via the store system, add-ons
compare their local versions against repository versions to determine update
availability.
### Backup & Recovery System
**Backup Capabilities**:
- **Full Backups**: Complete system state capture including all add-ons,
configuration, and data
- **Partial Backups**: Selective backup of specific components (Home Assistant,
add-ons, folders)
- **Encrypted Backups**: Optional backup encryption with user-provided passwords
- **Multiple Storage Locations**: Local storage and remote backup destinations
**Recovery Features**:
- **One-click Restore**: Simple restoration from backup files
- **Selective Restore**: Choose specific components to restore
- **Automatic Recovery**: Self-healing for common system issues
---
## Supervisor Development
### Python Requirements
- **Compatibility**: Python 3.13+
- **Language Features**: Use modern Python features:
- Type hints with `typing` module
- f-strings (preferred over `%` or `.format()`)
- Dataclasses and enum classes
- Async/await patterns
- Pattern matching where appropriate
### Code Quality Standards
- **Formatting**: Ruff
- **Linting**: PyLint and Ruff
- **Type Checking**: MyPy
- **Testing**: pytest with asyncio support
- **Language**: American English for all code, comments, and documentation
### Code Organization
**Core Structure**:
```
supervisor/
├── __init__.py # Package initialization
├── const.py # Constants and enums
├── coresys.py # Core system management
├── bootstrap.py # System initialization
├── exceptions.py # Custom exception classes
├── api/ # REST API endpoints
├── addons/ # Add-on management
├── backups/ # Backup system
├── docker/ # Docker integration
├── host/ # Host system interface
├── homeassistant/ # Home Assistant Core management
├── dbus/ # D-Bus system integration
├── hardware/ # Hardware detection and management
├── plugins/ # Plugin system
├── resolution/ # Issue detection and resolution
├── security/ # Security management
├── services/ # Service discovery and management
├── store/ # Add-on store management
└── utils/ # Utility functions
```
**Shared Constants**: Use constants from `supervisor/const.py` instead of hardcoding
values. Define new constants following existing patterns and group related constants
together.
### Supervisor Architecture Patterns
**CoreSysAttributes Inheritance Pattern**: Nearly all major classes in Supervisor
inherit from `CoreSysAttributes`, providing access to the centralized system state
via `self.coresys` and convenient `sys_*` properties.
```python
# Standard Supervisor class pattern
class MyManager(CoreSysAttributes):
"""Manage my functionality."""
def __init__(self, coresys: CoreSys):
"""Initialize manager."""
self.coresys: CoreSys = coresys
self._component: MyComponent = MyComponent(coresys)
@property
def component(self) -> MyComponent:
"""Return component handler."""
return self._component
# Access system components via inherited properties
async def do_something(self):
await self.sys_docker.containers.get("my_container")
self.sys_bus.fire_event(BusEvent.MY_EVENT, {"data": "value"})
```
**Key Inherited Properties from CoreSysAttributes**:
- `self.sys_docker` - Docker API access
- `self.sys_run_in_executor()` - Execute blocking operations
- `self.sys_create_task()` - Create async tasks
- `self.sys_bus` - Event bus for system events
- `self.sys_config` - System configuration
- `self.sys_homeassistant` - Home Assistant Core management
- `self.sys_addons` - Add-on management
- `self.sys_host` - Host system access
- `self.sys_dbus` - D-Bus system interface
**Load Pattern**: Many components implement a `load()` method which effectively
initialize the component from external sources (containers, files, D-Bus services).
### API Development
**REST API Structure**:
- **Base Path**: `/api/` for all endpoints
- **Authentication**: Bearer token authentication
- **Consistent Response Format**: `{"result": "ok", "data": {...}}` or
`{"result": "error", "message": "..."}`
- **Validation**: Use voluptuous schemas with `api_validate()`
**Use `@api_process` Decorator**: This decorator handles all standard error handling
and response formatting automatically. The decorator catches `APIError`, `HassioError`,
and other exceptions, returning appropriate HTTP responses.
```python
from ..api.utils import api_process, api_validate
@api_process
async def backup_full(self, request: web.Request) -> dict[str, Any]:
"""Create full backup."""
body = await api_validate(SCHEMA_BACKUP_FULL, request)
job = await self.sys_backups.do_backup_full(**body)
return {ATTR_JOB_ID: job.uuid}
```
### Docker Integration
- **Container Management**: Use Supervisor's Docker manager instead of direct
Docker API
- **Networking**: Supervisor manages internal Docker networks with predefined IP
ranges
- **Security**: AppArmor profiles, capability restrictions, and user namespace
isolation
- **Health Checks**: Implement health monitoring for all managed containers
### D-Bus Integration
- **Use dbus-fast**: Async D-Bus library for system integration
- **Service Management**: systemd, NetworkManager, hostname management
- **Error Handling**: Wrap D-Bus exceptions in Supervisor-specific exceptions
### Async Programming
- **All I/O operations must be async**: File operations, network calls, subprocess
execution
- **Use asyncio patterns**: Prefer `asyncio.gather()` over sequential awaits
- **Executor jobs**: Use `self.sys_run_in_executor()` for blocking operations
- **Two-phase initialization**: `__init__` for sync setup, `post_init()` for async
initialization
### Testing
- **Location**: `tests/` directory with module mirroring
- **Fixtures**: Extensive use of pytest fixtures for CoreSys setup
- **Mocking**: Mock external dependencies (Docker, D-Bus, network calls)
- **Coverage**: Minimum 90% test coverage, 100% for security-sensitive code
### Error Handling
- **Custom Exceptions**: Defined in `exceptions.py` with clear inheritance hierarchy
- **Error Propagation**: Use `from` clause for exception chaining
- **API Errors**: Use `APIError` with appropriate HTTP status codes
### Security Considerations
- **Container Security**: AppArmor profiles mandatory for add-ons, minimal
capabilities
- **Authentication**: Token-based API authentication with role-based access
- **Data Protection**: Backup encryption, secure secret management, comprehensive
input validation
### Development Commands
```bash
# Run tests, adjust paths as necessary
pytest -qsx tests/
# Linting and formatting
ruff check supervisor/
ruff format supervisor/
# Type checking
mypy --ignore-missing-imports supervisor/
# Pre-commit hooks
pre-commit run --all-files
```
Always run the pre-commit hooks at the end of code editing.
### Common Patterns to Follow
**✅ Use These Patterns**:
- Inherit from `CoreSysAttributes` for system access
- Use `@api_process` decorator for API endpoints
- Use `self.sys_run_in_executor()` for blocking operations
- Access Docker via `self.sys_docker` not direct Docker API
- Use constants from `const.py` instead of hardcoding
- Store types in (per-module) `const.py` (e.g. supervisor/store/const.py)
**❌ Avoid These Patterns**:
- Direct Docker API usage - use Supervisor's Docker manager
- Blocking operations in async context (use asyncio alternatives)
- Hardcoded values - use constants from `const.py`
- Manual error handling in API endpoints - let `@api_process` handle it
This guide provides the foundation for contributing to Home Assistant Supervisor.
Follow these patterns and guidelines to ensure code quality, security, and
maintainability.

View File

@ -106,7 +106,7 @@ jobs:
- name: Build wheels
if: needs.init.outputs.requirements == 'true'
uses: home-assistant/wheels@2025.03.0
uses: home-assistant/wheels@2025.02.0
with:
abi: cp313
tag: musllinux_1_2
@ -125,15 +125,15 @@ jobs:
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
if: needs.init.outputs.publish == 'true'
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Install Cosign
if: needs.init.outputs.publish == 'true'
uses: sigstore/cosign-installer@v3.9.2
uses: sigstore/cosign-installer@v3.8.1
with:
cosign-release: "v2.4.3"
cosign-release: "v2.4.0"
- name: Install dirhash and calc hash
if: needs.init.outputs.publish == 'true'
@ -149,7 +149,7 @@ jobs:
- name: Login to GitHub Container Registry
if: needs.init.outputs.publish == 'true'
uses: docker/login-action@v3.4.0
uses: docker/login-action@v3.3.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@ -160,7 +160,7 @@ jobs:
run: echo "BUILD_ARGS=--test" >> $GITHUB_ENV
- name: Build supervisor
uses: home-assistant/builder@2025.03.0
uses: home-assistant/builder@2025.02.0
with:
args: |
$BUILD_ARGS \
@ -207,7 +207,7 @@ jobs:
- name: Build the Supervisor
if: needs.init.outputs.publish != 'true'
uses: home-assistant/builder@2025.03.0
uses: home-assistant/builder@2025.02.0
with:
args: |
--test \

View File

@ -10,7 +10,6 @@ on:
env:
DEFAULT_PYTHON: "3.13"
PRE_COMMIT_CACHE: ~/.cache/pre-commit
MYPY_CACHE_VERSION: 1
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
@ -29,12 +28,12 @@ jobs:
uses: actions/checkout@v4.2.2
- name: Set up Python
id: python
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -48,7 +47,7 @@ jobs:
pip install -r requirements.txt -r requirements_tests.txt
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
lookup-only: true
@ -70,13 +69,13 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -88,7 +87,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@ -113,13 +112,13 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -131,7 +130,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@ -171,13 +170,13 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -189,7 +188,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@ -215,13 +214,13 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -233,7 +232,7 @@ jobs:
exit 1
- name: Restore pre-commit environment from cache
id: cache-precommit
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: ${{ env.PRE_COMMIT_CACHE }}
key: |
@ -259,13 +258,13 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -287,52 +286,6 @@ jobs:
. venv/bin/activate
pylint supervisor tests
mypy:
name: Check mypy
runs-on: ubuntu-latest
needs: prepare
steps:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Generate partial mypy restore key
id: generate-mypy-key
run: |
mypy_version=$(cat requirements_test.txt | grep mypy | cut -d '=' -f 3)
echo "version=$mypy_version" >> $GITHUB_OUTPUT
echo "key=mypy-${{ env.MYPY_CACHE_VERSION }}-$mypy_version-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
with:
path: venv
key: >-
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_tests.txt') }}
- name: Fail job if Python cache restore failed
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Restore mypy cache
uses: actions/cache@v4.2.3
with:
path: .mypy_cache
key: >-
${{ runner.os }}-mypy-${{ needs.prepare.outputs.python-version }}-${{ steps.generate-mypy-key.outputs.key }}
restore-keys: >-
${{ runner.os }}-venv-${{ needs.prepare.outputs.python-version }}-mypy-${{ env.MYPY_CACHE_VERSION }}-${{ steps.generate-mypy-key.outputs.version }}
- name: Register mypy problem matcher
run: |
echo "::add-matcher::.github/workflows/matchers/mypy.json"
- name: Run mypy
run: |
. venv/bin/activate
mypy --ignore-missing-imports supervisor
pytest:
runs-on: ubuntu-latest
needs: prepare
@ -341,17 +294,17 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Install Cosign
uses: sigstore/cosign-installer@v3.9.2
uses: sigstore/cosign-installer@v3.8.1
with:
cosign-release: "v2.4.3"
cosign-release: "v2.4.0"
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -386,7 +339,7 @@ jobs:
-o console_output_style=count \
tests
- name: Upload coverage artifact
uses: actions/upload-artifact@v4.6.2
uses: actions/upload-artifact@v4.6.1
with:
name: coverage-${{ matrix.python-version }}
path: .coverage
@ -400,13 +353,13 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Set up Python ${{ needs.prepare.outputs.python-version }}
uses: actions/setup-python@v5.6.0
uses: actions/setup-python@v5.4.0
id: python
with:
python-version: ${{ needs.prepare.outputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
uses: actions/cache@v4.2.3
uses: actions/cache@v4.2.2
with:
path: venv
key: |
@ -417,7 +370,7 @@ jobs:
echo "Failed to restore Python virtual environment from cache"
exit 1
- name: Download all coverage artifacts
uses: actions/download-artifact@v4.3.0
uses: actions/download-artifact@v4.1.9
- name: Combine coverage results
run: |
. venv/bin/activate
@ -425,4 +378,4 @@ jobs:
coverage report
coverage xml
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5.4.3
uses: codecov/codecov-action@v5.4.0

View File

@ -1,16 +0,0 @@
{
"problemMatcher": [
{
"owner": "mypy",
"pattern": [
{
"regexp": "^(.+):(\\d+):\\s(error|warning):\\s(.+)$",
"file": 1,
"line": 2,
"severity": 3,
"message": 4
}
]
}
]
}

View File

@ -1,58 +0,0 @@
name: Restrict task creation
# yamllint disable-line rule:truthy
on:
issues:
types: [opened]
jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.issue_type == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/supervisor/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});

View File

@ -12,7 +12,7 @@ jobs:
- name: Check out code from GitHub
uses: actions/checkout@v4.2.2
- name: Sentry Release
uses: getsentry/action-release@v3.2.0
uses: getsentry/action-release@v3.0.0
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}

View File

@ -64,9 +64,6 @@ jobs:
fileName: home_assistant_frontend_supervisor-${{ needs.check-version.outputs.latest_version }}.tar.gz
extract: true
out-file-path: supervisor/api/panel/
- name: Remove release assets archive
run: |
rm -f supervisor/api/panel/home_assistant_frontend_supervisor-*.tar.gz
- name: Create PR
uses: peter-evans/create-pull-request@v7
with:

View File

@ -1 +1 @@
20250401.0
20250221.0

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.10
rev: v0.9.1
hooks:
- id: ruff
args:
@ -13,15 +13,3 @@ repos:
- id: check-executables-have-shebangs
stages: [manual]
- id: check-json
- repo: local
hooks:
# Run mypy through our wrapper script in order to get the possible
# pyenv and/or virtualenv activated; it may not have been e.g. if
# committing from a GUI tool that was not launched from an activated
# shell.
- id: mypy
name: mypy
entry: script/run-in-env.sh mypy --ignore-missing-imports
language: script
types_or: [python, pyi]
files: ^supervisor/.+\.(py|pyi)$

View File

@ -1 +0,0 @@
.github/copilot-instructions.md

View File

@ -29,7 +29,7 @@ RUN \
\
&& curl -Lso /usr/bin/cosign "https://github.com/home-assistant/cosign/releases/download/${COSIGN_VERSION}/cosign_${BUILD_ARCH}" \
&& chmod a+x /usr/bin/cosign \
&& pip3 install uv==0.6.17
&& pip3 install uv==0.6.1
# Install requirements
COPY requirements.txt .

View File

@ -12,7 +12,7 @@ cosign:
base_identity: https://github.com/home-assistant/docker-base/.*
identity: https://github.com/home-assistant/supervisor/.*
args:
COSIGN_VERSION: 2.4.3
COSIGN_VERSION: 2.4.0
labels:
io.hass.type: supervisor
org.opencontainers.image.title: Home Assistant Supervisor

View File

@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools~=80.9.0", "wheel~=0.46.1"]
requires = ["setuptools~=75.8.0", "wheel~=0.45.0"]
build-backend = "setuptools.build_meta"
[project]
@ -230,9 +230,6 @@ filterwarnings = [
"ignore:pkg_resources is deprecated as an API:DeprecationWarning:dirhash",
"ignore::pytest.PytestUnraisableExceptionWarning",
]
markers = [
"no_mock_init_websession: disable the autouse mock of init_websession for this test",
]
[tool.ruff]
lint.select = [
@ -275,6 +272,7 @@ lint.select = [
"S317", # suspicious-xml-sax-usage
"S318", # suspicious-xml-mini-dom-usage
"S319", # suspicious-xml-pull-dom-usage
"S320", # suspicious-xmle-tree-usage
"S601", # paramiko-call
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true

View File

@ -1,30 +1,29 @@
aiodns==3.5.0
aiohttp==3.12.14
aiodns==3.2.0
aiohttp==3.11.13
atomicwrites-homeassistant==1.4.1
attrs==25.3.0
awesomeversion==25.5.0
blockbuster==1.5.25
attrs==25.1.0
awesomeversion==24.6.0
brotli==1.1.0
ciso8601==2.3.2
colorlog==6.9.0
cpe==1.3.1
cryptography==45.0.5
debugpy==1.8.15
cryptography==44.0.2
debugpy==1.8.13
deepmerge==2.0
dirhash==0.5.0
docker==7.1.0
faust-cchardet==2.1.19
gitpython==3.1.44
jinja2==3.1.6
log-rate-limit==1.4.2
orjson==3.11.0
jinja2==3.1.5
orjson==3.10.12
pulsectl==24.12.0
pyudev==0.24.3
PyYAML==6.0.2
requests==2.32.4
requests==2.32.3
securetar==2025.2.1
sentry-sdk==2.33.2
setuptools==80.9.0
sentry-sdk==2.22.0
setuptools==75.8.2
voluptuous==0.15.2
dbus-fast==2.44.2
dbus-fast==2.35.1
typing_extensions==4.12.2
zlib-fast==0.2.1

View File

@ -1,16 +1,13 @@
astroid==3.3.11
coverage==7.9.2
mypy==1.17.0
pre-commit==4.2.0
pylint==3.3.7
astroid==3.3.8
coverage==7.6.12
pre-commit==4.1.0
pylint==3.3.4
pytest-aiohttp==1.1.0
pytest-asyncio==0.25.2
pytest-cov==6.2.1
pytest-timeout==2.4.0
pytest==8.4.1
ruff==0.12.4
pytest-cov==6.0.0
pytest-timeout==2.3.1
pytest==8.3.5
ruff==0.9.9
time-machine==2.16.0
types-docker==7.1.0.20250705
types-pyyaml==6.0.12.20250516
types-requests==2.32.4.20250611
urllib3==2.5.0
typing_extensions==4.12.2
urllib3==2.3.0

View File

@ -1,30 +0,0 @@
#!/usr/bin/env sh
set -eu
# Used in venv activate script.
# Would be an error if undefined.
OSTYPE="${OSTYPE-}"
# Activate pyenv and virtualenv if present, then run the specified command
# pyenv, pyenv-virtualenv
if [ -s .python-version ]; then
PYENV_VERSION=$(head -n 1 .python-version)
export PYENV_VERSION
fi
if [ -n "${VIRTUAL_ENV-}" ] && [ -f "${VIRTUAL_ENV}/bin/activate" ]; then
. "${VIRTUAL_ENV}/bin/activate"
else
# other common virtualenvs
my_path=$(git rev-parse --show-toplevel)
for venv in venv .venv .; do
if [ -f "${my_path}/${venv}/bin/activate" ]; then
. "${my_path}/${venv}/bin/activate"
break
fi
done
fi
exec "$@"

View File

@ -11,12 +11,10 @@ import zlib_fast
# Enable fast zlib before importing supervisor
zlib_fast.enable()
# pylint: disable=wrong-import-position
from supervisor import bootstrap # noqa: E402
from supervisor.utils.blockbuster import BlockBusterManager # noqa: E402
from supervisor.utils.logging import activate_log_queue_handler # noqa: E402
# pylint: enable=wrong-import-position
from supervisor import bootstrap # pylint: disable=wrong-import-position # noqa: E402
from supervisor.utils.logging import ( # pylint: disable=wrong-import-position # noqa: E402
activate_log_queue_handler,
)
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -54,8 +52,6 @@ if __name__ == "__main__":
_LOGGER.info("Initializing Supervisor setup")
coresys = loop.run_until_complete(bootstrap.initialize_coresys())
loop.set_debug(coresys.config.debug)
if coresys.config.detect_blocking_io:
BlockBusterManager.activate()
loop.run_until_complete(coresys.core.connect())
loop.run_until_complete(bootstrap.supervisor_debugger(coresys))
@ -66,15 +62,8 @@ if __name__ == "__main__":
_LOGGER.info("Setting up Supervisor")
loop.run_until_complete(coresys.core.setup())
bootstrap.register_signal_handlers(loop, coresys)
try:
loop.run_until_complete(coresys.core.start())
except Exception as err: # pylint: disable=broad-except
# Supervisor itself is running at this point, just something didn't
# start as expected. Log with traceback to get more insights for
# such cases.
_LOGGER.critical("Supervisor start failed: %s", err, exc_info=True)
loop.call_soon_threadsafe(loop.create_task, coresys.core.start())
loop.call_soon_threadsafe(bootstrap.reg_signal, loop, coresys)
try:
_LOGGER.info("Running Supervisor")

View File

@ -18,7 +18,7 @@ from tempfile import TemporaryDirectory
from typing import Any, Final
import aiohttp
from awesomeversion import AwesomeVersion, AwesomeVersionCompareException
from awesomeversion import AwesomeVersionCompareException
from deepmerge import Merger
from securetar import AddFileError, atomic_contents_add, secure_path
import voluptuous as vol
@ -33,6 +33,8 @@ from ..const import (
ATTR_AUDIO_OUTPUT,
ATTR_AUTO_UPDATE,
ATTR_BOOT,
ATTR_DATA,
ATTR_EVENT,
ATTR_IMAGE,
ATTR_INGRESS_ENTRY,
ATTR_INGRESS_PANEL,
@ -48,6 +50,7 @@ from ..const import (
ATTR_SYSTEM,
ATTR_SYSTEM_MANAGED,
ATTR_SYSTEM_MANAGED_CONFIG_ENTRY,
ATTR_TYPE,
ATTR_USER,
ATTR_UUID,
ATTR_VERSION,
@ -76,7 +79,7 @@ from ..exceptions import (
HostAppArmorError,
)
from ..hardware.data import Device
from ..homeassistant.const import WSEvent
from ..homeassistant.const import WSEvent, WSType
from ..jobs.const import JobExecutionLimit
from ..jobs.decorator import Job
from ..resolution.const import ContextType, IssueType, UnhealthyReason
@ -193,12 +196,15 @@ class Addon(AddonModel):
):
self.sys_resolution.dismiss_issue(self.device_access_missing_issue)
self.sys_homeassistant.websocket.supervisor_event_custom(
WSEvent.ADDON,
self.sys_homeassistant.websocket.send_message(
{
ATTR_SLUG: self.slug,
ATTR_STATE: new_state,
},
ATTR_TYPE: WSType.SUPERVISOR_EVENT,
ATTR_DATA: {
ATTR_EVENT: WSEvent.ADDON,
ATTR_SLUG: self.slug,
ATTR_STATE: new_state,
},
}
)
@property
@ -279,28 +285,28 @@ class Addon(AddonModel):
@property
def with_icon(self) -> bool:
"""Return True if an icon exists."""
if self.is_detached or not self.addon_store:
if self.is_detached:
return super().with_icon
return self.addon_store.with_icon
@property
def with_logo(self) -> bool:
"""Return True if a logo exists."""
if self.is_detached or not self.addon_store:
if self.is_detached:
return super().with_logo
return self.addon_store.with_logo
@property
def with_changelog(self) -> bool:
"""Return True if a changelog exists."""
if self.is_detached or not self.addon_store:
if self.is_detached:
return super().with_changelog
return self.addon_store.with_changelog
@property
def with_documentation(self) -> bool:
"""Return True if a documentation exists."""
if self.is_detached or not self.addon_store:
if self.is_detached:
return super().with_documentation
return self.addon_store.with_documentation
@ -310,7 +316,7 @@ class Addon(AddonModel):
return self._available(self.data_store)
@property
def version(self) -> AwesomeVersion:
def version(self) -> str | None:
"""Return installed version."""
return self.persist[ATTR_VERSION]
@ -360,7 +366,7 @@ class Addon(AddonModel):
@property
def auto_update(self) -> bool:
"""Return if auto update is enable."""
return self.persist.get(ATTR_AUTO_UPDATE, False)
return self.persist.get(ATTR_AUTO_UPDATE, super().auto_update)
@auto_update.setter
def auto_update(self, value: bool) -> None:
@ -458,7 +464,7 @@ class Addon(AddonModel):
return None
@property
def latest_version(self) -> AwesomeVersion:
def latest_version(self) -> str:
"""Return version of add-on."""
return self.data_store[ATTR_VERSION]
@ -512,8 +518,9 @@ class Addon(AddonModel):
def webui(self) -> str | None:
"""Return URL to webui or None."""
url = super().webui
if not url or not (webui := RE_WEBUI.match(url)):
if not url:
return None
webui = RE_WEBUI.match(url)
# extract arguments
t_port = webui.group("t_port")
@ -668,9 +675,10 @@ class Addon(AddonModel):
async def watchdog_application(self) -> bool:
"""Return True if application is running."""
url = self.watchdog_url
if not url or not (application := RE_WATCHDOG.match(url)):
url = super().watchdog
if not url:
return True
application = RE_WATCHDOG.match(url)
# extract arguments
t_port = int(application.group("t_port"))
@ -679,10 +687,8 @@ class Addon(AddonModel):
s_suffix = application.group("s_suffix") or ""
# search host port for this docker port
if self.host_network and self.ports:
port = self.ports.get(f"{t_port}/tcp")
if port is None:
port = t_port
if self.host_network:
port = self.ports.get(f"{t_port}/tcp", t_port)
else:
port = t_port
@ -747,12 +753,9 @@ class Addon(AddonModel):
for listener in self._listeners:
self.sys_bus.remove_listener(listener)
def remove_data_dir():
if self.path_data.is_dir():
_LOGGER.info("Removing add-on data folder %s", self.path_data)
remove_data(self.path_data)
await self.sys_run_in_executor(remove_data_dir)
if self.path_data.is_dir():
_LOGGER.info("Removing add-on data folder %s", self.path_data)
await remove_data(self.path_data)
async def _check_ingress_port(self):
"""Assign a ingress port if dynamic port selection is used."""
@ -771,20 +774,14 @@ class Addon(AddonModel):
)
async def install(self) -> None:
"""Install and setup this addon."""
if not self.addon_store:
raise AddonsError("Missing from store, cannot install!")
await self.sys_addons.data.install(self.addon_store)
await self.load()
def setup_data():
if not self.path_data.is_dir():
_LOGGER.info(
"Creating Home Assistant add-on data folder %s", self.path_data
)
self.path_data.mkdir()
await self.sys_run_in_executor(setup_data)
if not self.path_data.is_dir():
_LOGGER.info(
"Creating Home Assistant add-on data folder %s", self.path_data
)
self.path_data.mkdir()
# Setup/Fix AppArmor profile
await self.install_apparmor()
@ -823,17 +820,14 @@ class Addon(AddonModel):
await self.unload()
def cleanup_config_and_audio():
# Remove config if present and requested
if self.addon_config_used and remove_config:
remove_data(self.path_config)
# Remove config if present and requested
if self.addon_config_used and remove_config:
await remove_data(self.path_config)
# Cleanup audio settings
if self.path_pulse.exists():
with suppress(OSError):
self.path_pulse.unlink()
await self.sys_run_in_executor(cleanup_config_and_audio)
# Cleanup audio settings
if self.path_pulse.exists():
with suppress(OSError):
self.path_pulse.unlink()
# Cleanup AppArmor profile
with suppress(HostAppArmorError):
@ -846,10 +840,9 @@ class Addon(AddonModel):
await self.sys_ingress.update_hass_panel(self)
# Cleanup Ingress dynamic port assignment
need_ingress_token_cleanup = False
if self.with_ingress:
need_ingress_token_cleanup = True
await self.sys_ingress.del_dynamic_port(self.slug)
self.sys_create_task(self.sys_ingress.reload())
# Cleanup discovery data
for message in self.sys_discovery.list_messages:
@ -864,12 +857,8 @@ class Addon(AddonModel):
await service.del_service_data(self)
# Remove from addon manager
self.sys_addons.local.pop(self.slug)
await self.sys_addons.data.uninstall(self)
# Cleanup Ingress tokens
if need_ingress_token_cleanup:
await self.sys_ingress.reload()
self.sys_addons.local.pop(self.slug)
@Job(
name="addon_update",
@ -882,9 +871,6 @@ class Addon(AddonModel):
Returns a Task that completes when addon has state 'started' (see start)
if it was running. Else nothing is returned.
"""
if not self.addon_store:
raise AddonsError("Missing from store, cannot update!")
old_image = self.image
# Cache data to prevent races with other updates to global
store = self.addon_store.clone()
@ -941,9 +927,7 @@ class Addon(AddonModel):
except DockerError as err:
raise AddonsError() from err
if self.addon_store:
await self.sys_addons.data.update(self.addon_store)
await self.sys_addons.data.update(self.addon_store)
await self._check_ingress_port()
_LOGGER.info("Add-on '%s' successfully rebuilt", self.slug)
@ -972,9 +956,7 @@ class Addon(AddonModel):
await self.sys_run_in_executor(write_pulse_config)
except OSError as err:
if err.errno == errno.EBADMSG:
self.sys_resolution.add_unhealthy_reason(
UnhealthyReason.OSERROR_BAD_MESSAGE
)
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error(
"Add-on %s can't write pulse/client.config: %s", self.slug, err
)
@ -986,7 +968,7 @@ class Addon(AddonModel):
async def install_apparmor(self) -> None:
"""Install or Update AppArmor profile for Add-on."""
exists_local = self.sys_host.apparmor.exists(self.slug)
exists_addon = await self.sys_run_in_executor(self.path_apparmor.exists)
exists_addon = self.path_apparmor.exists()
# Nothing to do
if not exists_local and not exists_addon:
@ -1322,8 +1304,8 @@ class Addon(AddonModel):
arcname="data",
)
# Backup config (if used and existing, restore handles this gracefully)
if addon_config_used and self.path_config.is_dir():
# Backup config
if addon_config_used:
atomic_contents_add(
backup,
self.path_config,
@ -1333,7 +1315,7 @@ class Addon(AddonModel):
arcname="config",
)
wait_for_start: asyncio.Task | None = None
wait_for_start: Awaitable[None] | None = None
data = {
ATTR_USER: self.persist,
@ -1359,7 +1341,9 @@ class Addon(AddonModel):
)
_LOGGER.info("Finish backup for addon %s", self.slug)
except (tarfile.TarError, OSError, AddFileError) as err:
raise AddonsError(f"Can't write tarfile: {err}", _LOGGER.error) from err
raise AddonsError(
f"Can't write tarfile {tar_file}: {err}", _LOGGER.error
) from err
finally:
if was_running:
wait_for_start = await self.end_backup()
@ -1377,7 +1361,7 @@ class Addon(AddonModel):
Returns a Task that completes when addon has state 'started' (see start)
if addon is started after restore. Else nothing is returned.
"""
wait_for_start: asyncio.Task | None = None
wait_for_start: Awaitable[None] | None = None
# Extract backup
def _extract_tarfile() -> tuple[TemporaryDirectory, dict[str, Any]]:
@ -1460,12 +1444,6 @@ class Addon(AddonModel):
# Restore data and config
def _restore_data():
"""Restore data and config."""
_LOGGER.info("Restoring data and config for addon %s", self.slug)
if self.path_data.is_dir():
remove_data(self.path_data)
if self.path_config.is_dir():
remove_data(self.path_config)
temp_data = Path(tmp.name, "data")
if temp_data.is_dir():
shutil.copytree(temp_data, self.path_data, symlinks=True)
@ -1478,6 +1456,12 @@ class Addon(AddonModel):
elif self.addon_config_used:
self.path_config.mkdir()
_LOGGER.info("Restoring data and config for addon %s", self.slug)
if self.path_data.is_dir():
await remove_data(self.path_data)
if self.path_config.is_dir():
await remove_data(self.path_config)
try:
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
@ -1487,7 +1471,7 @@ class Addon(AddonModel):
# Restore AppArmor
profile_file = Path(tmp.name, "apparmor.txt")
if await self.sys_run_in_executor(profile_file.exists):
if profile_file.exists():
try:
await self.sys_host.apparmor.load_profile(
self.slug, profile_file
@ -1508,7 +1492,7 @@ class Addon(AddonModel):
if data[ATTR_STATE] == AddonState.STARTED:
wait_for_start = await self.start()
finally:
await self.sys_run_in_executor(tmp.cleanup)
tmp.cleanup()
_LOGGER.info("Finished restore for add-on %s", self.slug)
return wait_for_start
@ -1601,6 +1585,6 @@ class Addon(AddonModel):
def refresh_path_cache(self) -> Awaitable[None]:
"""Refresh cache of existing paths."""
if self.is_detached or not self.addon_store:
if self.is_detached:
return super().refresh_path_cache()
return self.addon_store.refresh_path_cache()

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from functools import cached_property
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from awesomeversion import AwesomeVersion
@ -15,7 +15,6 @@ from ..const import (
ATTR_SQUASH,
FILE_SUFFIX_CONFIGURATION,
META_ADDON,
SOCKET_DOCKER,
)
from ..coresys import CoreSys, CoreSysAttributes
from ..docker.interface import MAP_ARCH
@ -24,7 +23,7 @@ from ..utils.common import FileConfiguration, find_one_filetype
from .validate import SCHEMA_BUILD_CONFIG
if TYPE_CHECKING:
from .manager import AnyAddon
from . import AnyAddon
class AddonBuild(FileConfiguration, CoreSysAttributes):
@ -64,7 +63,7 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
@cached_property
def arch(self) -> str:
"""Return arch of the add-on."""
return self.sys_arch.match([self.addon.arch])
return self.sys_arch.match(self.addon.arch)
@property
def base_image(self) -> str:
@ -82,6 +81,13 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
)
return self._data[ATTR_BUILD_FROM][self.arch]
@property
def dockerfile(self) -> Path:
"""Return Dockerfile path."""
if self.addon.path_location.joinpath(f"Dockerfile.{self.arch}").exists():
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
return self.addon.path_location.joinpath("Dockerfile")
@property
def squash(self) -> bool:
"""Return True or False if squash is active."""
@ -97,89 +103,49 @@ class AddonBuild(FileConfiguration, CoreSysAttributes):
"""Return additional Docker labels."""
return self._data[ATTR_LABELS]
def get_dockerfile(self) -> Path:
"""Return Dockerfile path.
Must be run in executor.
"""
if self.addon.path_location.joinpath(f"Dockerfile.{self.arch}").exists():
return self.addon.path_location.joinpath(f"Dockerfile.{self.arch}")
return self.addon.path_location.joinpath("Dockerfile")
async def is_valid(self) -> bool:
@property
def is_valid(self) -> bool:
"""Return true if the build env is valid."""
def build_is_valid() -> bool:
try:
return all(
[
self.addon.path_location.is_dir(),
self.get_dockerfile().is_file(),
self.dockerfile.is_file(),
]
)
try:
return await self.sys_run_in_executor(build_is_valid)
except HassioArchNotFound:
return False
def get_docker_args(
self, version: AwesomeVersion, image_tag: str
) -> dict[str, Any]:
"""Create a dict with Docker run args."""
dockerfile_path = self.get_dockerfile().relative_to(self.addon.path_location)
build_cmd = [
"docker",
"buildx",
"build",
".",
"--tag",
image_tag,
"--file",
str(dockerfile_path),
"--platform",
MAP_ARCH[self.arch],
"--pull",
]
labels = {
"io.hass.version": version,
"io.hass.arch": self.arch,
"io.hass.type": META_ADDON,
"io.hass.name": self._fix_label("name"),
"io.hass.description": self._fix_label("description"),
**self.additional_labels,
def get_docker_args(self, version: AwesomeVersion, image: str | None = None):
"""Create a dict with Docker build arguments."""
args = {
"path": str(self.addon.path_location),
"tag": f"{image or self.addon.image}:{version!s}",
"dockerfile": str(self.dockerfile),
"pull": True,
"forcerm": not self.sys_dev,
"squash": self.squash,
"platform": MAP_ARCH[self.arch],
"labels": {
"io.hass.version": version,
"io.hass.arch": self.arch,
"io.hass.type": META_ADDON,
"io.hass.name": self._fix_label("name"),
"io.hass.description": self._fix_label("description"),
**self.additional_labels,
},
"buildargs": {
"BUILD_FROM": self.base_image,
"BUILD_VERSION": version,
"BUILD_ARCH": self.sys_arch.default,
**self.additional_args,
},
}
if self.addon.url:
labels["io.hass.url"] = self.addon.url
args["labels"]["io.hass.url"] = self.addon.url
for key, value in labels.items():
build_cmd.extend(["--label", f"{key}={value}"])
build_args = {
"BUILD_FROM": self.base_image,
"BUILD_VERSION": version,
"BUILD_ARCH": self.sys_arch.default,
**self.additional_args,
}
for key, value in build_args.items():
build_cmd.extend(["--build-arg", f"{key}={value}"])
# The addon path will be mounted from the host system
addon_extern_path = self.sys_config.local_to_extern_path(
self.addon.path_location
)
return {
"command": build_cmd,
"volumes": {
SOCKET_DOCKER: {"bind": "/var/run/docker.sock", "mode": "rw"},
addon_extern_path: {"bind": "/addon", "mode": "ro"},
},
"working_dir": "/addon",
}
return args
def _fix_label(self, label_name: str) -> str:
"""Remove characters they are not supported."""

View File

@ -67,10 +67,6 @@ class AddonManager(CoreSysAttributes):
return self.store.get(addon_slug)
return None
def get_local_only(self, addon_slug: str) -> Addon | None:
"""Return an installed add-on from slug."""
return self.local.get(addon_slug)
def from_token(self, token: str) -> Addon | None:
"""Return an add-on from Supervisor token."""
for addon in self.installed:
@ -198,7 +194,6 @@ class AddonManager(CoreSysAttributes):
_LOGGER.info("Add-on '%s' successfully installed", slug)
@Job(name="addon_manager_uninstall")
async def uninstall(self, slug: str, *, remove_config: bool = False) -> None:
"""Remove an add-on."""
if slug not in self.local:
@ -266,7 +261,7 @@ class AddonManager(CoreSysAttributes):
],
on_condition=AddonsJobError,
)
async def rebuild(self, slug: str, *, force: bool = False) -> asyncio.Task | None:
async def rebuild(self, slug: str) -> asyncio.Task | None:
"""Perform a rebuild of local build add-on.
Returns a Task that completes when addon has state 'started' (see addon.start)
@ -289,7 +284,7 @@ class AddonManager(CoreSysAttributes):
raise AddonsError(
"Version changed, use Update instead Rebuild", _LOGGER.error
)
if not force and not addon.need_build:
if not addon.need_build:
raise AddonsNotSupportedError(
"Can't rebuild a image based add-on", _LOGGER.error
)
@ -318,7 +313,7 @@ class AddonManager(CoreSysAttributes):
if slug not in self.local:
_LOGGER.debug("Add-on %s is not local available for restore", slug)
addon = Addon(self.coresys, slug)
had_ingress: bool | None = False
had_ingress = False
else:
_LOGGER.debug("Add-on %s is local available for restore", slug)
addon = self.local[slug]

View File

@ -294,7 +294,7 @@ class AddonModel(JobGroup, ABC):
return self.data.get(ATTR_WEBUI)
@property
def watchdog_url(self) -> str | None:
def watchdog(self) -> str | None:
"""Return URL to for watchdog or None."""
return self.data.get(ATTR_WATCHDOG)
@ -606,7 +606,7 @@ class AddonModel(JobGroup, ABC):
return AddonOptions(self.coresys, raw_schema, self.name, self.slug)
@property
def schema_ui(self) -> list[dict[Any, Any]] | None:
def schema_ui(self) -> list[dict[any, any]] | None:
"""Create a UI schema for add-on options."""
raw_schema = self.data[ATTR_SCHEMA]
@ -664,16 +664,12 @@ class AddonModel(JobGroup, ABC):
"""Validate if addon is available for current system."""
return self._validate_availability(self.data, logger=_LOGGER.error)
def __eq__(self, other: Any) -> bool:
"""Compare add-on objects."""
def __eq__(self, other):
"""Compaired add-on objects."""
if not isinstance(other, AddonModel):
return False
return self.slug == other.slug
def __hash__(self) -> int:
"""Hash for add-on objects."""
return hash(self.slug)
def _validate_availability(
self, config, *, logger: Callable[..., None] | None = None
) -> None:

View File

@ -137,7 +137,7 @@ class AddonOptions(CoreSysAttributes):
) from None
# prepare range
range_args: dict[str, Any] = {}
range_args = {}
for group_name in _SCHEMA_LENGTH_PARTS:
group_value = match.group(group_name)
if group_value:
@ -390,14 +390,14 @@ class UiOptions(CoreSysAttributes):
multiple: bool = False,
) -> None:
"""UI nested dict items."""
ui_node: dict[str, Any] = {
ui_node = {
"name": key,
"type": "schema",
"optional": True,
"multiple": multiple,
}
nested_schema: list[dict[str, Any]] = []
nested_schema = []
for c_key, c_value in option_dict.items():
# Nested?
if isinstance(c_value, list):
@ -413,7 +413,7 @@ def _create_device_filter(str_filter: str) -> dict[str, Any]:
"""Generate device Filter."""
raw_filter = dict(value.split("=") for value in str_filter.split(";"))
clean_filter: dict[str, Any] = {}
clean_filter = {}
for key, value in raw_filter.items():
if key == "subsystem":
clean_filter[key] = UdevSubsystem(value)

View File

@ -2,9 +2,9 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
from ..const import ROLE_ADMIN, ROLE_MANAGER, SECURITY_DISABLE, SECURITY_PROFILE
@ -86,20 +86,18 @@ def rating_security(addon: AddonModel) -> int:
return max(min(8, rating), 1)
def remove_data(folder: Path) -> None:
"""Remove folder and reset privileged.
Must be run in executor.
"""
async def remove_data(folder: Path) -> None:
"""Remove folder and reset privileged."""
try:
subprocess.run(
["rm", "-rf", str(folder)], stdout=subprocess.DEVNULL, text=True, check=True
proc = await asyncio.create_subprocess_exec(
"rm", "-rf", str(folder), stdout=asyncio.subprocess.DEVNULL
)
_, error_msg = await proc.communicate()
except OSError as err:
error_msg = str(err)
except subprocess.CalledProcessError as procerr:
error_msg = procerr.stderr.strip()
else:
return
if proc.returncode == 0:
return
_LOGGER.error("Can't remove Add-on Data: %s", error_msg)

View File

@ -1,14 +1,13 @@
"""Init file for Supervisor RESTful API."""
from dataclasses import dataclass
from functools import partial
import logging
from pathlib import Path
from typing import Any
from aiohttp import hdrs, web
from aiohttp import web
from ..const import SUPERVISOR_DOCKER_NAME, AddonState
from ..const import AddonState
from ..coresys import CoreSys, CoreSysAttributes
from ..exceptions import APIAddonNotInstalled, HostNotSupportedError
from ..utils.sentry import async_capture_exception
@ -48,14 +47,6 @@ MAX_CLIENT_SIZE: int = 1024**2 * 16
MAX_LINE_SIZE: int = 24570
@dataclass(slots=True, frozen=True)
class StaticResourceConfig:
"""Configuration for a static resource."""
prefix: str
path: Path
class RestAPI(CoreSysAttributes):
"""Handle RESTful API for Supervisor."""
@ -82,12 +73,12 @@ class RestAPI(CoreSysAttributes):
self._site: web.TCPSite | None = None
# share single host API handler for reuse in logging endpoints
self._api_host: APIHost = APIHost()
self._api_host.coresys = coresys
self._api_host: APIHost | None = None
async def load(self) -> None:
"""Register REST API Calls."""
static_resource_configs: list[StaticResourceConfig] = []
self._api_host = APIHost()
self._api_host.coresys = self.coresys
self._register_addons()
self._register_audio()
@ -107,7 +98,7 @@ class RestAPI(CoreSysAttributes):
self._register_network()
self._register_observer()
self._register_os()
static_resource_configs.extend(self._register_panel())
self._register_panel()
self._register_proxy()
self._register_resolution()
self._register_root()
@ -116,17 +107,6 @@ class RestAPI(CoreSysAttributes):
self._register_store()
self._register_supervisor()
if static_resource_configs:
def process_configs() -> list[web.StaticResource]:
return [
web.StaticResource(config.prefix, config.path)
for config in static_resource_configs
]
for resource in await self.sys_run_in_executor(process_configs):
self.webapp.router.register_resource(resource)
await self.start()
def _register_advanced_logs(self, path: str, syslog_identifier: str):
@ -237,8 +217,6 @@ class RestAPI(CoreSysAttributes):
[
web.get("/os/info", api_os.info),
web.post("/os/update", api_os.update),
web.get("/os/config/swap", api_os.config_swap_info),
web.post("/os/config/swap", api_os.config_swap_options),
web.post("/os/config/sync", api_os.config_sync),
web.post("/os/datadisk/move", api_os.migrate_data),
web.get("/os/datadisk/list", api_os.list_data),
@ -345,9 +323,6 @@ class RestAPI(CoreSysAttributes):
api_root.coresys = self.coresys
self.webapp.add_routes([web.get("/info", api_root.info)])
self.webapp.add_routes([web.post("/reload_updates", api_root.reload_updates)])
# Discouraged
self.webapp.add_routes([web.post("/refresh_updates", api_root.refresh_updates)])
self.webapp.add_routes(
[web.get("/available_updates", api_root.available_updates)]
@ -426,7 +401,7 @@ class RestAPI(CoreSysAttributes):
async def get_supervisor_logs(*args, **kwargs):
try:
return await self._api_host.advanced_logs_handler(
*args, identifier=SUPERVISOR_DOCKER_NAME, **kwargs
*args, identifier="hassio_supervisor", **kwargs
)
except Exception as err: # pylint: disable=broad-exception-caught
# Supervisor logs are critical, so catch everything, log the exception
@ -529,7 +504,7 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
web.get("/addons", api_addons.list_addons),
web.get("/addons", api_addons.list),
web.post("/addons/{addon}/uninstall", api_addons.uninstall),
web.post("/addons/{addon}/start", api_addons.start),
web.post("/addons/{addon}/stop", api_addons.stop),
@ -597,9 +572,7 @@ class RestAPI(CoreSysAttributes):
web.post("/ingress/session", api_ingress.create_session),
web.post("/ingress/validate_session", api_ingress.validate_session),
web.get("/ingress/panels", api_ingress.panels),
web.route(
hdrs.METH_ANY, "/ingress/{token}/{path:.*}", api_ingress.handler
),
web.view("/ingress/{token}/{path:.*}", api_ingress.handler),
]
)
@ -610,7 +583,7 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
web.get("/backups", api_backups.list_backups),
web.get("/backups", api_backups.list),
web.get("/backups/info", api_backups.info),
web.post("/backups/options", api_backups.options),
web.post("/backups/reload", api_backups.reload),
@ -637,7 +610,7 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
web.get("/services", api_services.list_services),
web.get("/services", api_services.list),
web.get("/services/{service}", api_services.get_service),
web.post("/services/{service}", api_services.set_service),
web.delete("/services/{service}", api_services.del_service),
@ -651,7 +624,7 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
web.get("/discovery", api_discovery.list_discovery),
web.get("/discovery", api_discovery.list),
web.get("/discovery/{uuid}", api_discovery.get_discovery),
web.delete("/discovery/{uuid}", api_discovery.del_discovery),
web.post("/discovery", api_discovery.set_discovery),
@ -777,9 +750,10 @@ class RestAPI(CoreSysAttributes):
]
)
def _register_panel(self) -> list[StaticResourceConfig]:
def _register_panel(self) -> None:
"""Register panel for Home Assistant."""
return [StaticResourceConfig("/app", Path(__file__).parent.joinpath("panel"))]
panel_dir = Path(__file__).parent.joinpath("panel")
self.webapp.add_routes([web.static("/app", panel_dir)])
def _register_docker(self) -> None:
"""Register docker configuration functions."""
@ -789,7 +763,6 @@ class RestAPI(CoreSysAttributes):
self.webapp.add_routes(
[
web.get("/docker/info", api_docker.info),
web.post("/docker/options", api_docker.options),
web.get("/docker/registries", api_docker.registries),
web.post("/docker/registries", api_docker.create_registry),
web.delete("/docker/registries/{hostname}", api_docker.remove_registry),

View File

@ -3,13 +3,14 @@
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any, TypedDict
from typing import Any
from aiohttp import web
import voluptuous as vol
from voluptuous.humanize import humanize_error
from ..addons.addon import Addon
from ..addons.manager import AnyAddon
from ..addons.utils import rating_security
from ..const import (
ATTR_ADDONS,
@ -36,7 +37,6 @@ from ..const import (
ATTR_DNS,
ATTR_DOCKER_API,
ATTR_DOCUMENTATION,
ATTR_FORCE,
ATTR_FULL_ACCESS,
ATTR_GPIO,
ATTR_HASSIO_API,
@ -63,6 +63,7 @@ from ..const import (
ATTR_MEMORY_LIMIT,
ATTR_MEMORY_PERCENT,
ATTR_MEMORY_USAGE,
ATTR_MESSAGE,
ATTR_NAME,
ATTR_NETWORK,
ATTR_NETWORK_DESCRIPTION,
@ -71,6 +72,7 @@ from ..const import (
ATTR_OPTIONS,
ATTR_PRIVILEGED,
ATTR_PROTECTED,
ATTR_PWNED,
ATTR_RATING,
ATTR_REPOSITORY,
ATTR_SCHEMA,
@ -88,6 +90,7 @@ from ..const import (
ATTR_UPDATE_AVAILABLE,
ATTR_URL,
ATTR_USB,
ATTR_VALID,
ATTR_VERSION,
ATTR_VERSION_LATEST,
ATTR_VIDEO,
@ -140,25 +143,15 @@ SCHEMA_SECURITY = vol.Schema({vol.Optional(ATTR_PROTECTED): vol.Boolean()})
SCHEMA_UNINSTALL = vol.Schema(
{vol.Optional(ATTR_REMOVE_CONFIG, default=False): vol.Boolean()}
)
SCHEMA_REBUILD = vol.Schema({vol.Optional(ATTR_FORCE, default=False): vol.Boolean()})
# pylint: enable=no-value-for-parameter
class OptionsValidateResponse(TypedDict):
"""Response object for options validate."""
message: str
valid: bool
pwned: bool | None
class APIAddons(CoreSysAttributes):
"""Handle RESTful API for add-on functions."""
def get_addon_for_request(self, request: web.Request) -> Addon:
"""Return addon, throw an exception if it doesn't exist."""
addon_slug: str = request.match_info["addon"]
addon_slug: str = request.match_info.get("addon")
# Lookup itself
if addon_slug == "self":
@ -176,7 +169,7 @@ class APIAddons(CoreSysAttributes):
return addon
@api_process
async def list_addons(self, request: web.Request) -> dict[str, Any]:
async def list(self, request: web.Request) -> dict[str, Any]:
"""Return all add-ons or repositories."""
data_addons = [
{
@ -211,7 +204,7 @@ class APIAddons(CoreSysAttributes):
async def info(self, request: web.Request) -> dict[str, Any]:
"""Return add-on information."""
addon: Addon = self.get_addon_for_request(request)
addon: AnyAddon = self.get_addon_for_request(request)
data = {
ATTR_NAME: addon.name,
@ -346,10 +339,10 @@ class APIAddons(CoreSysAttributes):
await addon.save_persist()
@api_process
async def options_validate(self, request: web.Request) -> OptionsValidateResponse:
async def options_validate(self, request: web.Request) -> None:
"""Validate user options for add-on."""
addon = self.get_addon_for_request(request)
data = OptionsValidateResponse(message="", valid=True, pwned=False)
data = {ATTR_MESSAGE: "", ATTR_VALID: True, ATTR_PWNED: False}
options = await request.json(loads=json_loads) or addon.options
@ -358,8 +351,8 @@ class APIAddons(CoreSysAttributes):
try:
options_schema.validate(options)
except vol.Invalid as ex:
data["message"] = humanize_error(options, ex)
data["valid"] = False
data[ATTR_MESSAGE] = humanize_error(options, ex)
data[ATTR_VALID] = False
if not self.sys_security.pwned:
return data
@ -370,24 +363,24 @@ class APIAddons(CoreSysAttributes):
await self.sys_security.verify_secret(secret)
continue
except PwnedSecret:
data["pwned"] = True
data[ATTR_PWNED] = True
except PwnedError:
data["pwned"] = None
data[ATTR_PWNED] = None
break
if self.sys_security.force and data["pwned"] in (None, True):
data["valid"] = False
if data["pwned"] is None:
data["message"] = "Error happening on pwned secrets check!"
if self.sys_security.force and data[ATTR_PWNED] in (None, True):
data[ATTR_VALID] = False
if data[ATTR_PWNED] is None:
data[ATTR_MESSAGE] = "Error happening on pwned secrets check!"
else:
data["message"] = "Add-on uses pwned secrets!"
data[ATTR_MESSAGE] = "Add-on uses pwned secrets!"
return data
@api_process
async def options_config(self, request: web.Request) -> None:
"""Validate user options for add-on."""
slug: str = request.match_info["addon"]
slug: str = request.match_info.get("addon")
if slug != "self":
raise APIForbidden("This can be only read by the Add-on itself!")
addon = self.get_addon_for_request(request)
@ -464,11 +457,7 @@ class APIAddons(CoreSysAttributes):
async def rebuild(self, request: web.Request) -> None:
"""Rebuild local build add-on."""
addon = self.get_addon_for_request(request)
body: dict[str, Any] = await api_validate(SCHEMA_REBUILD, request)
if start_task := await asyncio.shield(
self.sys_addons.rebuild(addon.slug, force=body[ATTR_FORCE])
):
if start_task := await asyncio.shield(self.sys_addons.rebuild(addon.slug)):
await start_task
@api_process

View File

@ -124,7 +124,7 @@ class APIAudio(CoreSysAttributes):
@api_process
async def set_volume(self, request: web.Request) -> None:
"""Set audio volume on stream."""
source: StreamType = StreamType(request.match_info["source"])
source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_VOLUME, request)
@ -137,7 +137,7 @@ class APIAudio(CoreSysAttributes):
@api_process
async def set_mute(self, request: web.Request) -> None:
"""Mute audio volume on stream."""
source: StreamType = StreamType(request.match_info["source"])
source: StreamType = StreamType(request.match_info.get("source"))
application: bool = request.path.endswith("application")
body = await api_validate(SCHEMA_MUTE, request)
@ -150,7 +150,7 @@ class APIAudio(CoreSysAttributes):
@api_process
async def set_default(self, request: web.Request) -> None:
"""Set audio default stream."""
source: StreamType = StreamType(request.match_info["source"])
source: StreamType = StreamType(request.match_info.get("source"))
body = await api_validate(SCHEMA_DEFAULT, request)
await asyncio.shield(self.sys_host.sound.set_default(source, body[ATTR_NAME]))

View File

@ -1,21 +1,19 @@
"""Init file for Supervisor auth/SSO RESTful API."""
import asyncio
from collections.abc import Awaitable
import logging
from typing import Any, cast
from typing import Any
from aiohttp import BasicAuth, web
from aiohttp.hdrs import AUTHORIZATION, CONTENT_TYPE, WWW_AUTHENTICATE
from aiohttp.web import FileField
from aiohttp.web_exceptions import HTTPUnauthorized
from multidict import MultiDictProxy
import voluptuous as vol
from ..addons.addon import Addon
from ..const import ATTR_NAME, ATTR_PASSWORD, ATTR_USERNAME, REQUEST_FROM
from ..coresys import CoreSysAttributes
from ..exceptions import APIForbidden
from ..utils.json import json_loads
from .const import (
ATTR_GROUP_IDS,
ATTR_IS_ACTIVE,
@ -25,7 +23,7 @@ from .const import (
CONTENT_TYPE_JSON,
CONTENT_TYPE_URL,
)
from .utils import api_process, api_validate, json_loads
from .utils import api_process, api_validate
_LOGGER: logging.Logger = logging.getLogger(__name__)
@ -44,7 +42,7 @@ REALM_HEADER: dict[str, str] = {
class APIAuth(CoreSysAttributes):
"""Handle RESTful API for auth functions."""
def _process_basic(self, request: web.Request, addon: Addon) -> Awaitable[bool]:
def _process_basic(self, request: web.Request, addon: Addon) -> bool:
"""Process login request with basic auth.
Return a coroutine.
@ -53,11 +51,8 @@ class APIAuth(CoreSysAttributes):
return self.sys_auth.check_login(addon, auth.login, auth.password)
def _process_dict(
self,
request: web.Request,
addon: Addon,
data: dict[str, Any] | MultiDictProxy[str | bytes | FileField],
) -> Awaitable[bool]:
self, request: web.Request, addon: Addon, data: dict[str, str]
) -> bool:
"""Process login with dict data.
Return a coroutine.
@ -65,22 +60,14 @@ class APIAuth(CoreSysAttributes):
username = data.get("username") or data.get("user")
password = data.get("password")
# Test that we did receive strings and not something else, raise if so
try:
_ = username.encode and password.encode # type: ignore
except AttributeError:
raise HTTPUnauthorized(headers=REALM_HEADER) from None
return self.sys_auth.check_login(
addon, cast(str, username), cast(str, password)
)
return self.sys_auth.check_login(addon, username, password)
@api_process
async def auth(self, request: web.Request) -> bool:
"""Process login request."""
addon = request[REQUEST_FROM]
if not isinstance(addon, Addon) or not addon.access_auth_api:
if not addon.access_auth_api:
raise APIForbidden("Can't use Home Assistant auth!")
# BasicAuth
@ -92,18 +79,13 @@ class APIAuth(CoreSysAttributes):
# Json
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_JSON:
data = await request.json(loads=json_loads)
if not await self._process_dict(request, addon, data):
raise HTTPUnauthorized()
return True
return await self._process_dict(request, addon, data)
# URL encoded
if request.headers.get(CONTENT_TYPE) == CONTENT_TYPE_URL:
data = await request.post()
if not await self._process_dict(request, addon, data):
raise HTTPUnauthorized()
return True
return await self._process_dict(request, addon, data)
# Advertise Basic authentication by default
raise HTTPUnauthorized(headers=REALM_HEADER)
@api_process

View File

@ -10,9 +10,9 @@ import logging
from pathlib import Path
import re
from tempfile import TemporaryDirectory
from typing import Any, cast
from typing import Any
from aiohttp import BodyPartReader, web
from aiohttp import web
from aiohttp.hdrs import CONTENT_DISPOSITION
import voluptuous as vol
from voluptuous.humanize import humanize_error
@ -36,6 +36,7 @@ from ..const import (
ATTR_LOCATION,
ATTR_NAME,
ATTR_PASSWORD,
ATTR_PATH,
ATTR_PROTECTED,
ATTR_REPOSITORIES,
ATTR_SIZE,
@ -51,7 +52,7 @@ from ..const import (
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APIForbidden, APINotFound
from ..jobs import JobSchedulerOptions, SupervisorJob
from ..jobs import JobSchedulerOptions
from ..mounts.const import MountUsage
from ..resolution.const import UnhealthyReason
from .const import (
@ -154,8 +155,8 @@ class APIBackups(CoreSysAttributes):
"""Make location attributes dictionary."""
return {
loc if loc else LOCATION_LOCAL: {
ATTR_PROTECTED: backup.all_locations[loc].protected,
ATTR_SIZE_BYTES: backup.all_locations[loc].size_bytes,
ATTR_PROTECTED: backup.all_locations[loc][ATTR_PROTECTED],
ATTR_SIZE_BYTES: backup.all_locations[loc][ATTR_SIZE_BYTES],
}
for loc in backup.locations
}
@ -186,7 +187,7 @@ class APIBackups(CoreSysAttributes):
]
@api_process
async def list_backups(self, request):
async def list(self, request):
"""Return backup list."""
data_backups = self._list_backups()
@ -260,7 +261,7 @@ class APIBackups(CoreSysAttributes):
def _location_to_mount(self, location: str | None) -> LOCATION_TYPE:
"""Convert a single location to a mount if possible."""
if not location or location == LOCATION_CLOUD_BACKUP:
return cast(LOCATION_TYPE, location)
return location
mount = self.sys_mounts.get(location)
if mount.usage != MountUsage.BACKUP:
@ -294,11 +295,8 @@ class APIBackups(CoreSysAttributes):
) -> tuple[asyncio.Task, str]:
"""Start backup task in background and return task and job ID."""
event = asyncio.Event()
job, backup_task = cast(
tuple[SupervisorJob, asyncio.Task],
self.sys_jobs.schedule_job(
backup_method, JobSchedulerOptions(), *args, **kwargs
),
job, backup_task = self.sys_jobs.schedule_job(
backup_method, JobSchedulerOptions(), *args, **kwargs
)
async def release_on_freeze(new_state: CoreState):
@ -313,7 +311,10 @@ class APIBackups(CoreSysAttributes):
try:
event_task = self.sys_create_task(event.wait())
_, pending = await asyncio.wait(
(backup_task, event_task),
(
backup_task,
event_task,
),
return_when=asyncio.FIRST_COMPLETED,
)
# It seems backup returned early (error or something), make sure to cancel
@ -472,9 +473,9 @@ class APIBackups(CoreSysAttributes):
raise APIError(f"Backup {backup.slug} is not in location {location}")
_LOGGER.info("Downloading backup %s", backup.slug)
filename = backup.all_locations[location].path
filename = backup.all_locations[location][ATTR_PATH]
# If the file is missing, return 404 and trigger reload of location
if not await self.sys_run_in_executor(filename.is_file):
if not filename.is_file():
self.sys_create_task(self.sys_backups.reload(location))
return web.Response(status=404)
@ -494,12 +495,10 @@ class APIBackups(CoreSysAttributes):
"""Upload a backup file."""
location: LOCATION_TYPE = None
locations: list[LOCATION_TYPE] | None = None
tmp_path = self.sys_config.path_tmp
if ATTR_LOCATION in request.query:
location_names: list[str] = request.query.getall(ATTR_LOCATION, [])
self._validate_cloud_backup_location(
request, cast(list[str | None], location_names)
)
location_names: list[str] = request.query.getall(ATTR_LOCATION)
self._validate_cloud_backup_location(request, location_names)
# Convert empty string to None if necessary
locations = [
self._location_to_mount(location)
@ -509,6 +508,9 @@ class APIBackups(CoreSysAttributes):
]
location = locations.pop(0)
if location and location != LOCATION_CLOUD_BACKUP:
tmp_path = location.local_where
filename: str | None = None
if ATTR_FILENAME in request.query:
filename = request.query.get(ATTR_FILENAME)
@ -517,14 +519,13 @@ class APIBackups(CoreSysAttributes):
except vol.Invalid as ex:
raise APIError(humanize_error(filename, ex)) from None
tmp_path = await self.sys_backups.get_upload_path_for_location(location)
temp_dir: TemporaryDirectory | None = None
backup_file_stream: IOBase | None = None
def open_backup_file() -> Path:
nonlocal temp_dir, backup_file_stream
temp_dir = TemporaryDirectory(dir=tmp_path.as_posix())
tar_file = Path(temp_dir.name, "upload.tar")
tar_file = Path(temp_dir.name, "backup.tar")
backup_file_stream = tar_file.open("wb")
return tar_file
@ -539,15 +540,10 @@ class APIBackups(CoreSysAttributes):
try:
reader = await request.multipart()
contents = await reader.next()
if not isinstance(contents, BodyPartReader):
raise APIError("Improperly formatted upload, could not read backup")
tar_file = await self.sys_run_in_executor(open_backup_file)
while chunk := await contents.read_chunk(size=2**16):
await self.sys_run_in_executor(
cast(IOBase, backup_file_stream).write, chunk
)
await self.sys_run_in_executor(cast(IOBase, backup_file_stream).close)
await self.sys_run_in_executor(backup_file_stream.write, chunk)
await self.sys_run_in_executor(backup_file_stream.close)
backup = await asyncio.shield(
self.sys_backups.import_backup(
@ -562,9 +558,7 @@ class APIBackups(CoreSysAttributes):
LOCATION_CLOUD_BACKUP,
None,
}:
self.sys_resolution.add_unhealthy_reason(
UnhealthyReason.OSERROR_BAD_MESSAGE
)
self.sys_resolution.unhealthy = UnhealthyReason.OSERROR_BAD_MESSAGE
_LOGGER.error("Can't write new backup file: %s", err)
return False

View File

@ -80,11 +80,3 @@ class BootSlot(StrEnum):
A = "A"
B = "B"
class DetectBlockingIO(StrEnum):
"""Enable/Disable detection for blocking I/O in event loop."""
OFF = "off"
ON = "on"
ON_AT_STARTUP = "on-at-startup"

View File

@ -1,9 +1,7 @@
"""Init file for Supervisor network RESTful API."""
import logging
from typing import Any
from aiohttp import web
import voluptuous as vol
from ..addons.addon import Addon
@ -18,7 +16,6 @@ from ..const import (
AddonState,
)
from ..coresys import CoreSysAttributes
from ..discovery import Message
from ..exceptions import APIForbidden, APINotFound
from .utils import api_process, api_validate, require_home_assistant
@ -35,16 +32,16 @@ SCHEMA_DISCOVERY = vol.Schema(
class APIDiscovery(CoreSysAttributes):
"""Handle RESTful API for discovery functions."""
def _extract_message(self, request: web.Request) -> Message:
def _extract_message(self, request):
"""Extract discovery message from URL."""
message = self.sys_discovery.get(request.match_info["uuid"])
message = self.sys_discovery.get(request.match_info.get("uuid"))
if not message:
raise APINotFound("Discovery message not found")
return message
@api_process
@require_home_assistant
async def list_discovery(self, request: web.Request) -> dict[str, Any]:
async def list(self, request):
"""Show registered and available services."""
# Get available discovery
discovery = [
@ -55,16 +52,12 @@ class APIDiscovery(CoreSysAttributes):
ATTR_CONFIG: message.config,
}
for message in self.sys_discovery.list_messages
if (
discovered := self.sys_addons.get_local_only(
message.addon,
)
)
and discovered.state == AddonState.STARTED
if (addon := self.sys_addons.get(message.addon, local_only=True))
and addon.state == AddonState.STARTED
]
# Get available services/add-ons
services: dict[str, list[str]] = {}
services = {}
for addon in self.sys_addons.all:
for name in addon.discovery:
services.setdefault(name, []).append(addon.slug)
@ -72,7 +65,7 @@ class APIDiscovery(CoreSysAttributes):
return {ATTR_DISCOVERY: discovery, ATTR_SERVICES: services}
@api_process
async def set_discovery(self, request: web.Request) -> dict[str, str]:
async def set_discovery(self, request):
"""Write data into a discovery pipeline."""
body = await api_validate(SCHEMA_DISCOVERY, request)
addon: Addon = request[REQUEST_FROM]
@ -96,7 +89,7 @@ class APIDiscovery(CoreSysAttributes):
@api_process
@require_home_assistant
async def get_discovery(self, request: web.Request) -> dict[str, Any]:
async def get_discovery(self, request):
"""Read data into a discovery message."""
message = self._extract_message(request)
@ -108,7 +101,7 @@ class APIDiscovery(CoreSysAttributes):
}
@api_process
async def del_discovery(self, request: web.Request) -> None:
async def del_discovery(self, request):
"""Delete data into a discovery message."""
message = self._extract_message(request)
addon = request[REQUEST_FROM]
@ -118,3 +111,4 @@ class APIDiscovery(CoreSysAttributes):
raise APIForbidden("Can't remove discovery message")
await self.sys_discovery.remove(message)
return True

View File

@ -7,7 +7,6 @@ from aiohttp import web
import voluptuous as vol
from ..const import (
ATTR_ENABLE_IPV6,
ATTR_HOSTNAME,
ATTR_LOGGING,
ATTR_PASSWORD,
@ -31,39 +30,10 @@ SCHEMA_DOCKER_REGISTRY = vol.Schema(
}
)
# pylint: disable=no-value-for-parameter
SCHEMA_OPTIONS = vol.Schema({vol.Optional(ATTR_ENABLE_IPV6): vol.Boolean()})
class APIDocker(CoreSysAttributes):
"""Handle RESTful API for Docker configuration."""
@api_process
async def info(self, request: web.Request):
"""Get docker info."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():
data_registries[hostname] = {
ATTR_USERNAME: registry[ATTR_USERNAME],
}
return {
ATTR_VERSION: self.sys_docker.info.version,
ATTR_ENABLE_IPV6: self.sys_docker.config.enable_ipv6,
ATTR_STORAGE: self.sys_docker.info.storage,
ATTR_LOGGING: self.sys_docker.info.logging,
ATTR_REGISTRIES: data_registries,
}
@api_process
async def options(self, request: web.Request) -> None:
"""Set docker options."""
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_ENABLE_IPV6 in body:
self.sys_docker.config.enable_ipv6 = body[ATTR_ENABLE_IPV6]
await self.sys_docker.config.save_data()
@api_process
async def registries(self, request) -> dict[str, Any]:
"""Return the list of registries."""
@ -94,3 +64,18 @@ class APIDocker(CoreSysAttributes):
del self.sys_docker.config.registries[hostname]
await self.sys_docker.config.save_data()
@api_process
async def info(self, request: web.Request):
"""Get docker info."""
data_registries = {}
for hostname, registry in self.sys_docker.config.registries.items():
data_registries[hostname] = {
ATTR_USERNAME: registry[ATTR_USERNAME],
}
return {
ATTR_VERSION: self.sys_docker.info.version,
ATTR_STORAGE: self.sys_docker.info.storage,
ATTR_LOGGING: self.sys_docker.info.logging,
ATTR_REGISTRIES: data_registries,
}

View File

@ -68,10 +68,7 @@ def filesystem_struct(fs_block: UDisks2Block) -> dict[str, Any]:
ATTR_NAME: fs_block.id_label,
ATTR_SYSTEM: fs_block.hint_system,
ATTR_MOUNT_POINTS: [
str(mount_point)
for mount_point in (
fs_block.filesystem.mount_points if fs_block.filesystem else []
)
str(mount_point) for mount_point in fs_block.filesystem.mount_points
],
}

View File

@ -118,7 +118,7 @@ class APIHomeAssistant(CoreSysAttributes):
body = await api_validate(SCHEMA_OPTIONS, request)
if ATTR_IMAGE in body:
self.sys_homeassistant.set_image(body[ATTR_IMAGE])
self.sys_homeassistant.image = body[ATTR_IMAGE]
self.sys_homeassistant.override_image = (
self.sys_homeassistant.image != self.sys_homeassistant.default_image
)

View File

@ -3,9 +3,8 @@
import asyncio
from contextlib import suppress
import logging
from typing import Any
from aiohttp import ClientConnectionResetError, ClientPayloadError, web
from aiohttp import ClientConnectionResetError, web
from aiohttp.hdrs import ACCEPT, RANGE
import voluptuous as vol
from voluptuous.error import CoerceInvalid
@ -37,7 +36,6 @@ from ..host.const import (
LogFormat,
LogFormatter,
)
from ..host.logs import SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX
from ..utils.systemd_journal import journal_logs_reader
from .const import (
ATTR_AGENT_VERSION,
@ -197,18 +195,20 @@ class APIHost(CoreSysAttributes):
) -> web.StreamResponse:
"""Return systemd-journald logs."""
log_formatter = LogFormatter.PLAIN
params: dict[str, Any] = {}
params = {}
if identifier:
params[PARAM_SYSLOG_IDENTIFIER] = identifier
elif IDENTIFIER in request.match_info:
params[PARAM_SYSLOG_IDENTIFIER] = request.match_info[IDENTIFIER]
params[PARAM_SYSLOG_IDENTIFIER] = request.match_info.get(IDENTIFIER)
else:
params[PARAM_SYSLOG_IDENTIFIER] = self.sys_host.logs.default_identifiers
# host logs should be always verbose, no matter what Accept header is used
log_formatter = LogFormatter.VERBOSE
if BOOTID in request.match_info:
params[PARAM_BOOT_ID] = await self._get_boot_id(request.match_info[BOOTID])
params[PARAM_BOOT_ID] = await self._get_boot_id(
request.match_info.get(BOOTID)
)
if follow:
params[PARAM_FOLLOW] = ""
@ -239,11 +239,13 @@ class APIHost(CoreSysAttributes):
# return 2 lines at minimum.
lines = max(2, lines)
# entries=cursor[[:num_skip]:num_entries]
range_header = f"entries=:-{lines - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else lines}"
range_header = f"entries=:-{lines - 1}:{'' if follow else lines}"
elif RANGE in request.headers:
range_header = request.headers[RANGE]
range_header = request.headers.get(RANGE)
else:
range_header = f"entries=:-{DEFAULT_LINES - 1}:{SYSTEMD_JOURNAL_GATEWAYD_LINES_MAX if follow else DEFAULT_LINES}"
range_header = (
f"entries=:-{DEFAULT_LINES - 1}:{'' if follow else DEFAULT_LINES}"
)
async with self.sys_host.logs.journald_logs(
params=params, range_header=range_header, accept=LogFormat.JOURNAL
@ -269,15 +271,7 @@ class APIHost(CoreSysAttributes):
err,
)
break
except ConnectionError as err:
_LOGGER.warning(
"%s raised when returning journal logs: %s",
type(err).__name__,
err,
)
break
except (ConnectionResetError, ClientPayloadError) as ex:
# ClientPayloadError is most likely caused by the closing the connection
except ConnectionResetError as ex:
raise APIError(
"Connection reset when trying to fetch data from systemd-journald."
) from ex

View File

@ -83,7 +83,7 @@ class APIIngress(CoreSysAttributes):
def _extract_addon(self, request: web.Request) -> Addon:
"""Return addon, throw an exception it it doesn't exist."""
token = request.match_info["token"]
token = request.match_info.get("token")
# Find correct add-on
addon = self.sys_ingress.get(token)
@ -132,7 +132,7 @@ class APIIngress(CoreSysAttributes):
@api_process
@require_home_assistant
async def validate_session(self, request: web.Request) -> None:
async def validate_session(self, request: web.Request) -> dict[str, Any]:
"""Validate session and extending how long it's valid for."""
data = await api_validate(VALIDATE_SESSION_DATA, request)
@ -147,14 +147,14 @@ class APIIngress(CoreSysAttributes):
"""Route data to Supervisor ingress service."""
# Check Ingress Session
session = request.cookies.get(COOKIE_INGRESS, "")
session = request.cookies.get(COOKIE_INGRESS)
if not self.sys_ingress.validate_session(session):
_LOGGER.warning("No valid ingress session %s", session)
raise HTTPUnauthorized()
# Process requests
addon = self._extract_addon(request)
path = request.match_info.get("path", "")
path = request.match_info.get("path")
session_data = self.sys_ingress.get_session_data(session)
try:
# Websocket
@ -183,7 +183,7 @@ class APIIngress(CoreSysAttributes):
for proto in request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
]
else:
req_protocols = []
req_protocols = ()
ws_server = web.WebSocketResponse(
protocols=req_protocols, autoclose=False, autoping=False
@ -279,7 +279,7 @@ class APIIngress(CoreSysAttributes):
try:
response.headers["X-Accel-Buffering"] = "no"
await response.prepare(request)
async for data, _ in result.content.iter_chunks():
async for data in result.content.iter_chunked(4096):
await response.write(data)
except (
@ -309,9 +309,9 @@ class APIIngress(CoreSysAttributes):
def _init_header(
request: web.Request, addon: Addon, session_data: IngressSessionData | None
) -> CIMultiDict[str]:
) -> CIMultiDict | dict[str, str]:
"""Create initial header."""
headers = CIMultiDict[str]()
headers = {}
if session_data is not None:
headers[HEADER_REMOTE_USER_ID] = session_data.user.id
@ -337,20 +337,19 @@ def _init_header(
istr(HEADER_REMOTE_USER_DISPLAY_NAME),
):
continue
headers.add(name, value)
headers[name] = value
# Update X-Forwarded-For
if request.transport:
forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
headers[hdrs.X_FORWARDED_FOR] = f"{forward_for}, {connected_ip!s}"
forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
headers[hdrs.X_FORWARDED_FOR] = f"{forward_for}, {connected_ip!s}"
return headers
def _response_header(response: aiohttp.ClientResponse) -> CIMultiDict[str]:
def _response_header(response: aiohttp.ClientResponse) -> dict[str, str]:
"""Create response header."""
headers = CIMultiDict[str]()
headers = {}
for name, value in response.headers.items():
if name in (
@ -360,7 +359,7 @@ def _response_header(response: aiohttp.ClientResponse) -> CIMultiDict[str]:
hdrs.CONTENT_ENCODING,
):
continue
headers.add(name, value)
headers[name] = value
return headers

View File

@ -26,7 +26,7 @@ class APIJobs(CoreSysAttributes):
def _extract_job(self, request: web.Request) -> SupervisorJob:
"""Extract job from request or raise."""
try:
return self.sys_jobs.get_job(request.match_info["uuid"])
return self.sys_jobs.get_job(request.match_info.get("uuid"))
except JobNotFound:
raise APINotFound("Job does not exist") from None
@ -71,10 +71,7 @@ class APIJobs(CoreSysAttributes):
if current_job.uuid in jobs_by_parent:
queue.extend(
[
(child_jobs, job)
for job in jobs_by_parent.get(current_job.uuid, [])
]
[(child_jobs, job) for job in jobs_by_parent.get(current_job.uuid)]
)
return job_list

View File

@ -1,12 +1,11 @@
"""Handle security part of this API."""
from collections.abc import Callable
import logging
import re
from typing import Final
from urllib.parse import unquote
from aiohttp.web import Request, Response, middleware
from aiohttp.web import Request, RequestHandler, Response, middleware
from aiohttp.web_exceptions import HTTPBadRequest, HTTPForbidden, HTTPUnauthorized
from awesomeversion import AwesomeVersion
@ -20,11 +19,11 @@ from ...const import (
ROLE_DEFAULT,
ROLE_HOMEASSISTANT,
ROLE_MANAGER,
VALID_API_STATES,
CoreState,
)
from ...coresys import CoreSys, CoreSysAttributes
from ...utils import version_is_new_enough
from ..utils import api_return_error, extract_supervisor_token
from ..utils import api_return_error, excract_supervisor_token
_LOGGER: logging.Logger = logging.getLogger(__name__)
_CORE_VERSION: Final = AwesomeVersion("2023.3.4")
@ -180,7 +179,9 @@ class SecurityMiddleware(CoreSysAttributes):
return unquoted
@middleware
async def block_bad_requests(self, request: Request, handler: Callable) -> Response:
async def block_bad_requests(
self, request: Request, handler: RequestHandler
) -> Response:
"""Process request and tblock commonly known exploit attempts."""
if FILTERS.search(self._recursive_unquote(request.path)):
_LOGGER.warning(
@ -198,9 +199,15 @@ class SecurityMiddleware(CoreSysAttributes):
return await handler(request)
@middleware
async def system_validation(self, request: Request, handler: Callable) -> Response:
async def system_validation(
self, request: Request, handler: RequestHandler
) -> Response:
"""Check if core is ready to response."""
if self.sys_core.state not in VALID_API_STATES:
if self.sys_core.state not in (
CoreState.STARTUP,
CoreState.RUNNING,
CoreState.FREEZE,
):
return api_return_error(
message=f"System is not ready with state: {self.sys_core.state}"
)
@ -208,10 +215,12 @@ class SecurityMiddleware(CoreSysAttributes):
return await handler(request)
@middleware
async def token_validation(self, request: Request, handler: Callable) -> Response:
async def token_validation(
self, request: Request, handler: RequestHandler
) -> Response:
"""Check security access of this layer."""
request_from: CoreSysAttributes | None = None
supervisor_token = extract_supervisor_token(request)
request_from = None
supervisor_token = excract_supervisor_token(request)
# Blacklist
if BLACKLIST.match(request.path):
@ -279,7 +288,7 @@ class SecurityMiddleware(CoreSysAttributes):
raise HTTPForbidden()
@middleware
async def core_proxy(self, request: Request, handler: Callable) -> Response:
async def core_proxy(self, request: Request, handler: RequestHandler) -> Response:
"""Validate user from Core API proxy."""
if (
request[REQUEST_FROM] != self.sys_homeassistant

View File

@ -1,6 +1,6 @@
"""Inits file for supervisor mounts REST API."""
from typing import Any, cast
from typing import Any
from aiohttp import web
import voluptuous as vol
@ -10,7 +10,7 @@ from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APINotFound
from ..mounts.const import ATTR_DEFAULT_BACKUP_MOUNT, MountUsage
from ..mounts.mount import Mount
from ..mounts.validate import SCHEMA_MOUNT_CONFIG, MountData
from ..mounts.validate import SCHEMA_MOUNT_CONFIG
from .const import ATTR_MOUNTS, ATTR_USER_PATH
from .utils import api_process, api_validate
@ -26,7 +26,7 @@ class APIMounts(CoreSysAttributes):
def _extract_mount(self, request: web.Request) -> Mount:
"""Extract mount from request or raise."""
name = request.match_info["mount"]
name = request.match_info.get("mount")
if name not in self.sys_mounts:
raise APINotFound(f"No mount exists with name {name}")
return self.sys_mounts.get(name)
@ -71,10 +71,10 @@ class APIMounts(CoreSysAttributes):
@api_process
async def create_mount(self, request: web.Request) -> None:
"""Create a new mount in supervisor."""
body = cast(MountData, await api_validate(SCHEMA_MOUNT_CONFIG, request))
body = await api_validate(SCHEMA_MOUNT_CONFIG, request)
if body["name"] in self.sys_mounts:
raise APIError(f"A mount already exists with name {body['name']}")
if body[ATTR_NAME] in self.sys_mounts:
raise APIError(f"A mount already exists with name {body[ATTR_NAME]}")
mount = Mount.from_dict(self.coresys, body)
await self.sys_mounts.create_mount(mount)
@ -97,10 +97,7 @@ class APIMounts(CoreSysAttributes):
{vol.Optional(ATTR_NAME, default=current.name): current.name},
extra=vol.ALLOW_EXTRA,
)
body = cast(
MountData,
await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request),
)
body = await api_validate(vol.All(name_schema, SCHEMA_MOUNT_CONFIG), request)
mount = Mount.from_dict(self.coresys, body)
await self.sys_mounts.create_mount(mount)

View File

@ -10,7 +10,6 @@ import voluptuous as vol
from ..const import (
ATTR_ACCESSPOINTS,
ATTR_ADDR_GEN_MODE,
ATTR_ADDRESS,
ATTR_AUTH,
ATTR_CONNECTED,
@ -23,7 +22,6 @@ from ..const import (
ATTR_ID,
ATTR_INTERFACE,
ATTR_INTERFACES,
ATTR_IP6_PRIVACY,
ATTR_IPV4,
ATTR_IPV6,
ATTR_MAC,
@ -40,18 +38,15 @@ from ..const import (
ATTR_TYPE,
ATTR_VLAN,
ATTR_WIFI,
DOCKER_IPV4_NETWORK_MASK,
DOCKER_NETWORK,
DOCKER_NETWORK_MASK,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APIError, APINotFound, HostNetworkNotFound
from ..host.configuration import (
AccessPoint,
Interface,
InterfaceAddrGenMode,
InterfaceIp6Privacy,
InterfaceMethod,
Ip6Setting,
IpConfig,
IpSetting,
VlanConfig,
@ -73,8 +68,6 @@ _SCHEMA_IPV6_CONFIG = vol.Schema(
{
vol.Optional(ATTR_ADDRESS): [vol.Coerce(IPv6Interface)],
vol.Optional(ATTR_METHOD): vol.Coerce(InterfaceMethod),
vol.Optional(ATTR_ADDR_GEN_MODE): vol.Coerce(InterfaceAddrGenMode),
vol.Optional(ATTR_IP6_PRIVACY): vol.Coerce(InterfaceIp6Privacy),
vol.Optional(ATTR_GATEWAY): vol.Coerce(IPv6Address),
vol.Optional(ATTR_NAMESERVERS): [vol.Coerce(IPv6Address)],
}
@ -101,8 +94,8 @@ SCHEMA_UPDATE = vol.Schema(
)
def ip4config_struct(config: IpConfig, setting: IpSetting) -> dict[str, Any]:
"""Return a dict with information about IPv4 configuration."""
def ipconfig_struct(config: IpConfig, setting: IpSetting) -> dict[str, Any]:
"""Return a dict with information about ip configuration."""
return {
ATTR_METHOD: setting.method,
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
@ -112,19 +105,6 @@ def ip4config_struct(config: IpConfig, setting: IpSetting) -> dict[str, Any]:
}
def ip6config_struct(config: IpConfig, setting: Ip6Setting) -> dict[str, Any]:
"""Return a dict with information about IPv6 configuration."""
return {
ATTR_METHOD: setting.method,
ATTR_ADDR_GEN_MODE: setting.addr_gen_mode,
ATTR_IP6_PRIVACY: setting.ip6_privacy,
ATTR_ADDRESS: [address.with_prefixlen for address in config.address],
ATTR_NAMESERVERS: [str(address) for address in config.nameservers],
ATTR_GATEWAY: str(config.gateway) if config.gateway else None,
ATTR_READY: config.ready,
}
def wifi_struct(config: WifiConfig) -> dict[str, Any]:
"""Return a dict with information about wifi configuration."""
return {
@ -152,12 +132,8 @@ def interface_struct(interface: Interface) -> dict[str, Any]:
ATTR_CONNECTED: interface.connected,
ATTR_PRIMARY: interface.primary,
ATTR_MAC: interface.mac,
ATTR_IPV4: ip4config_struct(interface.ipv4, interface.ipv4setting)
if interface.ipv4 and interface.ipv4setting
else None,
ATTR_IPV6: ip6config_struct(interface.ipv6, interface.ipv6setting)
if interface.ipv6 and interface.ipv6setting
else None,
ATTR_IPV4: ipconfig_struct(interface.ipv4, interface.ipv4setting),
ATTR_IPV6: ipconfig_struct(interface.ipv6, interface.ipv6setting),
ATTR_WIFI: wifi_struct(interface.wifi) if interface.wifi else None,
ATTR_VLAN: vlan_struct(interface.vlan) if interface.vlan else None,
}
@ -203,7 +179,7 @@ class APINetwork(CoreSysAttributes):
],
ATTR_DOCKER: {
ATTR_INTERFACE: DOCKER_NETWORK,
ATTR_ADDRESS: str(DOCKER_IPV4_NETWORK_MASK),
ATTR_ADDRESS: str(DOCKER_NETWORK_MASK),
ATTR_GATEWAY: str(self.sys_docker.network.gateway),
ATTR_DNS: str(self.sys_docker.network.dns),
},
@ -214,14 +190,14 @@ class APINetwork(CoreSysAttributes):
@api_process
async def interface_info(self, request: web.Request) -> dict[str, Any]:
"""Return network information for a interface."""
interface = self._get_interface(request.match_info[ATTR_INTERFACE])
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
return interface_struct(interface)
@api_process
async def interface_update(self, request: web.Request) -> None:
"""Update the configuration of an interface."""
interface = self._get_interface(request.match_info[ATTR_INTERFACE])
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
# Validate data
body = await api_validate(SCHEMA_UPDATE, request)
@ -232,31 +208,25 @@ class APINetwork(CoreSysAttributes):
for key, config in body.items():
if key == ATTR_IPV4:
interface.ipv4setting = IpSetting(
method=config.get(ATTR_METHOD, InterfaceMethod.STATIC),
address=config.get(ATTR_ADDRESS, []),
gateway=config.get(ATTR_GATEWAY),
nameservers=config.get(ATTR_NAMESERVERS, []),
config.get(ATTR_METHOD, InterfaceMethod.STATIC),
config.get(ATTR_ADDRESS, []),
config.get(ATTR_GATEWAY),
config.get(ATTR_NAMESERVERS, []),
)
elif key == ATTR_IPV6:
interface.ipv6setting = Ip6Setting(
method=config.get(ATTR_METHOD, InterfaceMethod.STATIC),
addr_gen_mode=config.get(
ATTR_ADDR_GEN_MODE, InterfaceAddrGenMode.DEFAULT
),
ip6_privacy=config.get(
ATTR_IP6_PRIVACY, InterfaceIp6Privacy.DEFAULT
),
address=config.get(ATTR_ADDRESS, []),
gateway=config.get(ATTR_GATEWAY),
nameservers=config.get(ATTR_NAMESERVERS, []),
interface.ipv6setting = IpSetting(
config.get(ATTR_METHOD, InterfaceMethod.STATIC),
config.get(ATTR_ADDRESS, []),
config.get(ATTR_GATEWAY),
config.get(ATTR_NAMESERVERS, []),
)
elif key == ATTR_WIFI:
interface.wifi = WifiConfig(
mode=config.get(ATTR_MODE, WifiMode.INFRASTRUCTURE),
ssid=config.get(ATTR_SSID, ""),
auth=config.get(ATTR_AUTH, AuthMethod.OPEN),
psk=config.get(ATTR_PSK, None),
signal=None,
config.get(ATTR_MODE, WifiMode.INFRASTRUCTURE),
config.get(ATTR_SSID, ""),
config.get(ATTR_AUTH, AuthMethod.OPEN),
config.get(ATTR_PSK, None),
None,
)
elif key == ATTR_ENABLED:
interface.enabled = config
@ -273,7 +243,7 @@ class APINetwork(CoreSysAttributes):
@api_process
async def scan_accesspoints(self, request: web.Request) -> dict[str, Any]:
"""Scan and return a list of available networks."""
interface = self._get_interface(request.match_info[ATTR_INTERFACE])
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
# Only wlan is supported
if interface.type != InterfaceType.WIRELESS:
@ -286,10 +256,8 @@ class APINetwork(CoreSysAttributes):
@api_process
async def create_vlan(self, request: web.Request) -> None:
"""Create a new vlan."""
interface = self._get_interface(request.match_info[ATTR_INTERFACE])
vlan = int(request.match_info.get(ATTR_VLAN, -1))
if vlan < 0:
raise APIError(f"Invalid vlan specified: {vlan}")
interface = self._get_interface(request.match_info.get(ATTR_INTERFACE))
vlan = int(request.match_info.get(ATTR_VLAN))
# Only ethernet is supported
if interface.type != InterfaceType.ETHERNET:
@ -303,25 +271,19 @@ class APINetwork(CoreSysAttributes):
ipv4_setting = None
if ATTR_IPV4 in body:
ipv4_setting = IpSetting(
method=body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
address=body[ATTR_IPV4].get(ATTR_ADDRESS, []),
gateway=body[ATTR_IPV4].get(ATTR_GATEWAY, None),
nameservers=body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
body[ATTR_IPV4].get(ATTR_METHOD, InterfaceMethod.AUTO),
body[ATTR_IPV4].get(ATTR_ADDRESS, []),
body[ATTR_IPV4].get(ATTR_GATEWAY, None),
body[ATTR_IPV4].get(ATTR_NAMESERVERS, []),
)
ipv6_setting = None
if ATTR_IPV6 in body:
ipv6_setting = Ip6Setting(
method=body[ATTR_IPV6].get(ATTR_METHOD, InterfaceMethod.AUTO),
addr_gen_mode=body[ATTR_IPV6].get(
ATTR_ADDR_GEN_MODE, InterfaceAddrGenMode.DEFAULT
),
ip6_privacy=body[ATTR_IPV6].get(
ATTR_IP6_PRIVACY, InterfaceIp6Privacy.DEFAULT
),
address=body[ATTR_IPV6].get(ATTR_ADDRESS, []),
gateway=body[ATTR_IPV6].get(ATTR_GATEWAY, None),
nameservers=body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
ipv6_setting = IpSetting(
body[ATTR_IPV6].get(ATTR_METHOD, InterfaceMethod.AUTO),
body[ATTR_IPV6].get(ATTR_ADDRESS, []),
body[ATTR_IPV6].get(ATTR_GATEWAY, None),
body[ATTR_IPV6].get(ATTR_NAMESERVERS, []),
)
vlan_interface = Interface(

View File

@ -3,7 +3,6 @@
import asyncio
from collections.abc import Awaitable
import logging
import re
from typing import Any
from aiohttp import web
@ -22,14 +21,12 @@ from ..const import (
ATTR_SERIAL,
ATTR_SIZE,
ATTR_STATE,
ATTR_SWAP_SIZE,
ATTR_SWAPPINESS,
ATTR_UPDATE_AVAILABLE,
ATTR_VERSION,
ATTR_VERSION_LATEST,
)
from ..coresys import CoreSysAttributes
from ..exceptions import APINotFound, BoardInvalidError
from ..exceptions import BoardInvalidError
from ..resolution.const import ContextType, IssueType, SuggestionType
from ..validate import version_tag
from .const import (
@ -68,15 +65,6 @@ SCHEMA_GREEN_OPTIONS = vol.Schema(
vol.Optional(ATTR_SYSTEM_HEALTH_LED): vol.Boolean(),
}
)
RE_SWAP_SIZE = re.compile(r"^\d+([KMG](i?B)?|B)?$", re.IGNORECASE)
SCHEMA_SWAP_OPTIONS = vol.Schema(
{
vol.Optional(ATTR_SWAP_SIZE): vol.Match(RE_SWAP_SIZE),
vol.Optional(ATTR_SWAPPINESS): vol.All(int, vol.Range(min=0, max=200)),
}
)
# pylint: enable=no-value-for-parameter
@ -224,53 +212,3 @@ class APIOS(CoreSysAttributes):
)
return {}
@api_process
async def config_swap_info(self, request: web.Request) -> dict[str, Any]:
"""Get swap settings."""
if (
not self.coresys.os.available
or not self.coresys.os.version
or self.coresys.os.version < "15.0"
):
raise APINotFound(
"Home Assistant OS 15.0 or newer required for swap settings"
)
return {
ATTR_SWAP_SIZE: self.sys_dbus.agent.swap.swap_size,
ATTR_SWAPPINESS: self.sys_dbus.agent.swap.swappiness,
}
@api_process
async def config_swap_options(self, request: web.Request) -> None:
"""Update swap settings."""
if (
not self.coresys.os.available
or not self.coresys.os.version
or self.coresys.os.version < "15.0"
):
raise APINotFound(
"Home Assistant OS 15.0 or newer required for swap settings"
)
body = await api_validate(SCHEMA_SWAP_OPTIONS, request)
reboot_required = False
if ATTR_SWAP_SIZE in body:
old_size = self.sys_dbus.agent.swap.swap_size
await self.sys_dbus.agent.swap.set_swap_size(body[ATTR_SWAP_SIZE])
reboot_required = reboot_required or old_size != body[ATTR_SWAP_SIZE]
if ATTR_SWAPPINESS in body:
old_swappiness = self.sys_dbus.agent.swap.swappiness
await self.sys_dbus.agent.swap.set_swappiness(body[ATTR_SWAPPINESS])
reboot_required = reboot_required or old_swappiness != body[ATTR_SWAPPINESS]
if reboot_required:
self.sys_resolution.create_issue(
IssueType.REBOOT_REQUIRED,
ContextType.SYSTEM,
suggestions=[SuggestionType.EXECUTE_REBOOT],
)

View File

@ -1 +1 @@
!function(){function d(d){var e=document.createElement("script");e.src=d,document.body.appendChild(e)}if(/Edge?\/(12[4-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Firefox\/(12[5-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Chrom(ium|e)\/(109|1[1-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|(Maci|X1{2}).+ Version\/(17\.([5-9]|\d{2,})|(1[89]|[2-9]\d|\d{3,})\.\d+)([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/(1{2}\d|1[2-9]\d|[2-9]\d{2}|\d{4,})\.\d+\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(15[._]([6-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})[._]\d+)([._]\d+|)|Android:?[ /-](12[4-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})(\.\d+|)(\.\d+|)|Mobile Safari.+OPR\/([89]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(12[5-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(12[4-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|SamsungBrowser\/(2[5-9]|[3-9]\d|\d{3,})\.\d+|Home As{2}istant\/[\d.]+ \(.+; macOS (1[2-9]|[2-9]\d|\d{3,})\.\d+(\.\d+)?\)/.test(navigator.userAgent))try{new Function("import('/api/hassio/app/frontend_latest/entrypoint.35399ae87c70acf8.js')")()}catch(e){d("/api/hassio/app/frontend_es5/entrypoint.476bfed22da63267.js")}else d("/api/hassio/app/frontend_es5/entrypoint.476bfed22da63267.js")}()
!function(){function d(d){var e=document.createElement("script");e.src=d,document.body.appendChild(e)}if(/Edge?\/(12[2-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Firefox\/(12[4-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Chrom(ium|e)\/(109|1[1-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|(Maci|X1{2}).+ Version\/(17\.([4-9]|\d{2,})|(1[89]|[2-9]\d|\d{3,})\.\d+)([,.]\d+|)( \(\w+\)|)( Mobile\/\w+|) Safari\/|Chrome.+OPR\/(10[89]|1[1-9]\d|[2-9]\d{2}|\d{4,})\.\d+\.\d+|(CPU[ +]OS|iPhone[ +]OS|CPU[ +]iPhone|CPU IPhone OS|CPU iPad OS)[ +]+(15[._]([6-9]|\d{2,})|(1[6-9]|[2-9]\d|\d{3,})[._]\d+)([._]\d+|)|Android:?[ /-](12[3-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})(\.\d+|)(\.\d+|)|Mobile Safari.+OPR\/([89]\d|\d{3,})\.\d+\.\d+|Android.+Firefox\/(12[4-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|Android.+Chrom(ium|e)\/(12[3-9]|1[3-9]\d|[2-9]\d{2}|\d{4,})\.\d+(\.\d+|)|SamsungBrowser\/(2[4-9]|[3-9]\d|\d{3,})\.\d+|Home As{2}istant\/[\d.]+ \(.+; macOS (1[2-9]|[2-9]\d|\d{3,})\.\d+(\.\d+)?\)/.test(navigator.userAgent))try{new Function("import('/api/hassio/app/frontend_latest/entrypoint.9ac99222ee42fbb3.js')")()}catch(e){d("/api/hassio/app/frontend_es5/entrypoint.85ccafe1fda9d9a5.js")}else d("/api/hassio/app/frontend_es5/entrypoint.85ccafe1fda9d9a5.js")}()

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"version":3,"file":"1081.91949d686e61cc12.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250401.0/src/components/ha-button-toggle-group.ts","https://raw.githubusercontent.com/home-assistant/frontend/20250401.0/src/components/ha-selector/ha-selector-button-toggle.ts"],"names":["_decorate","customElement","_initialize","_LitElement","F","constructor","args","d","kind","decorators","property","attribute","key","value","type","Boolean","queryAll","html","_t","_","this","buttons","map","button","iconPath","_t2","label","active","_handleClick","_t3","styleMap","width","fullWidth","length","dense","_this$_buttons","_buttons","forEach","async","updateComplete","shadowRoot","querySelector","style","margin","ev","currentTarget","fireEvent","static","css","_t4","LitElement","HaButtonToggleSelector","_this$selector$button","_this$selector$button2","_this$selector$button3","options","selector","button_toggle","option","translationKey","translation_key","localizeValue","localizedLabel","sort","a","b","caseInsensitiveStringCompare","hass","locale","language","toggleButtons","item","_valueChanged","_ev$detail","_this$value","stopPropagation","detail","target","disabled","undefined"],"mappings":"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"}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"version":3,"file":"1081.e647cbe586ff9dd0.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250221.0/src/components/ha-button-toggle-group.ts","https://raw.githubusercontent.com/home-assistant/frontend/20250221.0/src/components/ha-selector/ha-selector-button-toggle.ts"],"names":["_decorate","customElement","_initialize","_LitElement","F","constructor","args","d","kind","decorators","property","attribute","key","value","type","Boolean","queryAll","html","_t","_","this","buttons","map","button","iconPath","_t2","label","active","_handleClick","_t3","styleMap","width","fullWidth","length","dense","_this$_buttons","_buttons","forEach","async","updateComplete","shadowRoot","querySelector","style","margin","ev","currentTarget","fireEvent","static","css","_t4","LitElement","HaButtonToggleSelector","_this$selector$button","_this$selector$button2","_this$selector$button3","options","selector","button_toggle","option","translationKey","translation_key","localizeValue","localizedLabel","sort","a","b","caseInsensitiveStringCompare","hass","locale","language","toggleButtons","item","_valueChanged","_ev$detail","_this$value","stopPropagation","detail","target","disabled","undefined"],"mappings":"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"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
{"version":3,"file":"12.ffa1bdc0a98802fa.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250401.0/src/components/ha-selector/ha-selector-navigation.ts"],"names":["HaNavigationSelector","_decorate","customElement","_initialize","_LitElement","F","constructor","args","d","kind","decorators","property","attribute","key","value","type","Boolean","reflect","html","_t","_","this","hass","label","required","disabled","helper","_valueChanged","ev","fireEvent","detail","LitElement"],"mappings":"mVAQaA,GAAoBC,EAAAA,EAAAA,GAAA,EADhCC,EAAAA,EAAAA,IAAc,4BAAyB,SAAAC,EAAAC,GAiCvC,OAAAC,EAjCD,cACiCD,EAAoBE,WAAAA,IAAAC,GAAA,SAAAA,GAAAJ,EAAA,QAApBK,EAAA,EAAAC,KAAA,QAAAC,WAAA,EAC9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,OAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,WAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,SAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,QAASC,SAAS,KAAOJ,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAElEC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAI,IAAAL,KAAA,SAAAI,IAAA,SAAAC,MAEnD,WACE,OAAOI,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,mKAECC,KAAKC,KACJD,KAAKE,MACLF,KAAKP,MACFO,KAAKG,SACLH,KAAKI,SACPJ,KAAKK,OACEL,KAAKM,cAG5B,GAAC,CAAAlB,KAAA,SAAAI,IAAA,gBAAAC,MAED,SAAsBc,IACpBC,EAAAA,EAAAA,GAAUR,KAAM,gBAAiB,CAAEP,MAAOc,EAAGE,OAAOhB,OACtD,IAAC,GA/BuCiB,EAAAA,I"}
{"version":3,"file":"12.ffa1bdc0a98802fa.js","sources":["https://raw.githubusercontent.com/home-assistant/frontend/20250221.0/src/components/ha-selector/ha-selector-navigation.ts"],"names":["HaNavigationSelector","_decorate","customElement","_initialize","_LitElement","F","constructor","args","d","kind","decorators","property","attribute","key","value","type","Boolean","reflect","html","_t","_","this","hass","label","required","disabled","helper","_valueChanged","ev","fireEvent","detail","LitElement"],"mappings":"mVAQaA,GAAoBC,EAAAA,EAAAA,GAAA,EADhCC,EAAAA,EAAAA,IAAc,4BAAyB,SAAAC,EAAAC,GAiCvC,OAAAC,EAjCD,cACiCD,EAAoBE,WAAAA,IAAAC,GAAA,SAAAA,GAAAJ,EAAA,QAApBK,EAAA,EAAAC,KAAA,QAAAC,WAAA,EAC9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,OAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,IAAS,CAAEC,WAAW,KAAQC,IAAA,WAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAE9BC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,QAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,OAAUE,IAAA,SAAAC,WAAA,IAAAL,KAAA,QAAAC,WAAA,EAEVC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,QAASC,SAAS,KAAOJ,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAK,IAAAL,KAAA,QAAAC,WAAA,EAElEC,EAAAA,EAAAA,IAAS,CAAEI,KAAMC,WAAUH,IAAA,WAAAC,KAAAA,GAAA,OAAmB,CAAI,IAAAL,KAAA,SAAAI,IAAA,SAAAC,MAEnD,WACE,OAAOI,EAAAA,EAAAA,IAAIC,IAAAA,EAAAC,CAAA,mKAECC,KAAKC,KACJD,KAAKE,MACLF,KAAKP,MACFO,KAAKG,SACLH,KAAKI,SACPJ,KAAKK,OACEL,KAAKM,cAG5B,GAAC,CAAAlB,KAAA,SAAAI,IAAA,gBAAAC,MAED,SAAsBc,IACpBC,EAAAA,EAAAA,GAAUR,KAAM,gBAAiB,CAAEP,MAAOc,EAAGE,OAAOhB,OACtD,IAAC,GA/BuCiB,EAAAA,I"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,47 +0,0 @@
/**
* @license
* Copyright 2017 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2021 Google LLC
* SPDX-LIcense-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
/**
* @license
* Copyright 2023 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
"use strict";(self.webpackChunkhome_assistant_frontend=self.webpackChunkhome_assistant_frontend||[]).push([["1390"],{48825:function(t,a,i){i.r(a);var e=i(73577),o=(i(71695),i(40251),i(47021),i(31622),i(57243)),n=i(50778),r=i(27486),s=i(36522),l=(i(73729),i(29073),i(56785)),c=i(74617),d=i(28008);let u,h,m,p=t=>t;const v=(0,r.Z)((()=>[{name:"default_backup_mount",required:!0,selector:{backup_location:{}}}]));(0,e.Z)([(0,n.Mo)("dialog-hassio-backup-location")],(function(t,a){return{F:class extends a{constructor(...a){super(...a),t(this)}},d:[{kind:"field",decorators:[(0,n.Cb)({attribute:!1})],key:"hass",value:void 0},{kind:"field",decorators:[(0,n.SB)()],key:"_dialogParams",value:void 0},{kind:"field",decorators:[(0,n.SB)()],key:"_data",value:void 0},{kind:"field",decorators:[(0,n.SB)()],key:"_waiting",value:void 0},{kind:"field",decorators:[(0,n.SB)()],key:"_error",value:void 0},{kind:"method",key:"showDialog",value:async function(t){this._dialogParams=t}},{kind:"method",key:"closeDialog",value:function(){this._data=void 0,this._error=void 0,this._waiting=void 0,this._dialogParams=void 0,(0,s.B)(this,"dialog-closed",{dialog:this.localName})}},{kind:"method",key:"render",value:function(){return this._dialogParams?(0,o.dy)(u||(u=p` <ha-dialog open scrimClickAction escapeKeyAction .heading="${0}" @closed="${0}"> ${0} <ha-form .hass="${0}" .data="${0}" .schema="${0}" .computeLabel="${0}" .computeHelper="${0}" @value-changed="${0}" dialogInitialFocus></ha-form> <mwc-button slot="secondaryAction" @click="${0}" dialogInitialFocus> ${0} </mwc-button> <mwc-button .disabled="${0}" slot="primaryAction" @click="${0}"> ${0} </mwc-button> </ha-dialog> `),this._dialogParams.supervisor.localize("dialog.backup_location.title"),this.closeDialog,this._error?(0,o.dy)(h||(h=p`<ha-alert alert-type="error">${0}</ha-alert>`),this._error):o.Ld,this.hass,this._data,v(),this._computeLabelCallback,this._computeHelperCallback,this._valueChanged,this.closeDialog,this._dialogParams.supervisor.localize("common.cancel"),this._waiting||!this._data,this._changeMount,this._dialogParams.supervisor.localize("common.save")):o.Ld}},{kind:"field",key:"_computeLabelCallback",value(){return t=>this._dialogParams.supervisor.localize(`dialog.backup_location.options.${t.name}.name`)||t.name}},{kind:"field",key:"_computeHelperCallback",value(){return t=>this._dialogParams.supervisor.localize(`dialog.backup_location.options.${t.name}.description`)}},{kind:"method",key:"_valueChanged",value:function(t){const a=t.detail.value.default_backup_mount;this._data={default_backup_mount:"/backup"===a?null:a}}},{kind:"method",key:"_changeMount",value:async function(){if(this._data){this._error=void 0,this._waiting=!0;try{await(0,c.Cl)(this.hass,this._data)}catch(t){return this._error=(0,l.js)(t),void(this._waiting=!1)}this.closeDialog()}}},{kind:"get",static:!0,key:"styles",value:function(){return[d.Qx,d.yu,(0,o.iv)(m||(m=p`.delete-btn{--mdc-theme-primary:var(--error-color)}`))]}}]}}),o.oi)},74617:function(t,a,i){i.d(a,{Cl:()=>r,eX:()=>o,mw:()=>e,rE:()=>n});i(40251);let e=function(t){return t.BIND="bind",t.CIFS="cifs",t.NFS="nfs",t}({}),o=function(t){return t.BACKUP="backup",t.MEDIA="media",t.SHARE="share",t}({});const n=async t=>t.callWS({type:"supervisor/api",endpoint:"/mounts",method:"get",timeout:null}),r=async(t,a)=>t.callWS({type:"supervisor/api",endpoint:"/mounts/options",method:"post",timeout:null,data:a})},30338:function(t,a,i){var e=i(97934),o=i(71998),n=i(4576),r=i(36760);t.exports=function(t,a){a&&"string"==typeof t||o(t);var i=r(t);return n(o(void 0!==i?e(i,t):t))}},60933:function(t,a,i){var e=i(40810),o=i(57877),n=i(63983),r=i(12360),s=i(13053),l=i(47645);e({target:"Array",proto:!0},{flatMap:function(t){var a,i=r(this),e=s(i);return n(t),(a=l(i,0)).length=o(a,i,i,e,0,1,t,arguments.length>1?arguments[1]:void 0),a}})},32126:function(t,a,i){i(35709)("flatMap")},25677:function(t,a,i){var e=i(40810),o=i(97934),n=i(63983),r=i(71998),s=i(4576),l=i(30338),c=i(79995),d=i(14181),u=i(92288),h=c((function(){for(var t,a,i=this.iterator,e=this.mapper;;){if(a=this.inner)try{if(!(t=r(o(a.next,a.iterator))).done)return t.value;this.inner=null}catch(n){d(i,"throw",n)}if(t=r(o(this.next,i)),this.done=!!t.done)return;try{this.inner=l(e(t.value,this.counter++),!1)}catch(n){d(i,"throw",n)}}}));e({target:"Iterator",proto:!0,real:!0,forced:u},{flatMap:function(t){return r(this),n(t),new h(s(this),{mapper:t,inner:null})}})},34810:function(t,a,i){i(25677)}}]);
//# sourceMappingURL=1390.f8ddc371bbdbc7c7.js.map

Some files were not shown because too many files have changed in this diff Show More