Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 19 additions & 33 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ on:
default: "dev"
required: true

env:
STARTUP_TIMEOUT_SECONDS: 300

permissions: {}

concurrency:
Expand All @@ -33,35 +30,20 @@ jobs:
- arch: aarch64
runs-on: ubuntu-24.04-arm
env:
IMAGE: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }}
BASE_URL: http://localhost:8123
CURL_OPTS: --silent --max-time 10
services:
homeassistant:
image: ghcr.io/home-assistant/home-assistant${{ startsWith(inputs.version, 'sha256:') && '@' || ':' }}${{ inputs.version }} # zizmor: ignore[unpinned-images]
ports:
- 8123:8123
# Gate steps until Home Assistant answers (60 x 5s ≈ 300s startup budget)
options: >-
--health-cmd="curl --fail --silent --max-time 10 --output /dev/null http://127.0.0.1:8123/"
--health-start-period=10s
--health-interval=5s
--health-retries=60
steps:
- name: Pull image
id: pull
run: |
docker pull "$IMAGE"
docker image inspect -f 'Testing {{index .RepoDigests 0}} ({{.Os}}/{{.Architecture}}), created {{.Created}}' "$IMAGE"

- name: Start container
run: |
docker run -d --name homeassistant -p 8123:8123 "$IMAGE"

- name: Wait for Home Assistant to start
run: |
timeout=$((SECONDS + STARTUP_TIMEOUT_SECONDS))
while ! curl $CURL_OPTS --fail --output /dev/null "$BASE_URL/"; do
if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then
echo "::error::Container exited before Home Assistant started"
exit 1
fi
if [ "$SECONDS" -ge "$timeout" ]; then
echo "::error::Home Assistant did not respond on port 8123 within ${STARTUP_TIMEOUT_SECONDS}s"
exit 1
fi
sleep 5
done

- name: Check frontend is served
run: |
# Pre-onboarding, / redirects to /onboarding.html; --location follows it
Expand All @@ -77,18 +59,22 @@ jobs:
| jq -e 'type == "array" and length > 0'

- name: Check container is still running
env:
CONTAINER: ${{ job.services.homeassistant.id }}
run: |
if [ "$(docker inspect -f '{{.State.Running}}' homeassistant)" != "true" ]; then
if [ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER")" != "true" ]; then
echo "::error::Container is no longer running after checks"
exit 1
fi

- name: Dump container logs
if: always() && steps.pull.outcome == 'success'
run: docker logs homeassistant > homeassistant.log 2>&1 || true
if: always()
env:
CONTAINER: ${{ job.services.homeassistant.id }}
run: docker logs "$CONTAINER" > homeassistant.log 2>&1 || true

- name: Upload container logs
if: always() && steps.pull.outcome == 'success'
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-logs-${{ matrix.arch }}
Expand Down
1 change: 1 addition & 0 deletions .strict-typing
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ homeassistant.components.fujitsu_fglair.*
homeassistant.components.fully_kiosk.*
homeassistant.components.fumis.*
homeassistant.components.fyta.*
homeassistant.components.gatus.*
homeassistant.components.generic_hygrostat.*
homeassistant.components.generic_thermostat.*
homeassistant.components.geo_location.*
Expand Down
2 changes: 2 additions & 0 deletions CODEOWNERS

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions homeassistant/components/blebox/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
),
BinarySensorEntityDescription(
key="input",
translation_key="input",
),
)

Expand Down
2 changes: 0 additions & 2 deletions homeassistant/components/blebox/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ async def async_setup_entry(
class BleBoxButtonEntity(BleBoxEntity[blebox_uniapi.button.Button], ButtonEntity):
"""Representation of BleBox buttons."""

_attr_name = None

def __init__(
self, coordinator: BleBoxCoordinator, feature: blebox_uniapi.button.Button
) -> None:
Expand Down
10 changes: 10 additions & 0 deletions homeassistant/components/blebox/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
}
},
"entity": {
"binary_sensor": {
"input": { "name": "Input" }
},
"button": {
"close": { "name": "Close" },
"down": { "name": "Down" },
"fav": { "name": "Favorite" },
"open": { "name": "Open" },
"up": { "name": "Up" }
},
"light": { "channel": { "name": "Channel {index}" } },
"sensor": {
"active_power": { "name": "Active power" },
Expand Down
25 changes: 25 additions & 0 deletions homeassistant/components/gatus/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""The Gatus integration."""

from homeassistant.const import CONF_URL, Platform
from homeassistant.core import HomeAssistant

from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator

_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR]


async def async_setup_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool:
"""Set up Gatus from a config entry."""
coordinator = GatusDataUpdateCoordinator(hass, entry, entry.data[CONF_URL])

await coordinator.async_config_entry_first_refresh()

entry.runtime_data = coordinator

await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS)
return True


async def async_unload_entry(hass: HomeAssistant, entry: GatusConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)
105 changes: 105 additions & 0 deletions homeassistant/components/gatus/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Support for Gatus binary sensors."""

from typing import override

from gatus_api import EndpointStatus, Result

from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity

from .const import DOMAIN
from .coordinator import GatusConfigEntry, GatusDataUpdateCoordinator

PARALLEL_UPDATES = 0


async def async_setup_entry(
hass: HomeAssistant,
entry: GatusConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Gatus binary sensor platform."""
coordinator = entry.runtime_data

async_add_entities(
GatusEndpointBinarySensor(coordinator, entry, endpoint_key)
for endpoint_key in coordinator.data
)


class GatusEndpointBinarySensor(
CoordinatorEntity[GatusDataUpdateCoordinator], BinarySensorEntity
):
"""Representation of a Gatus endpoint status."""

_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
_attr_has_entity_name = True
_attr_name = None

def __init__(
self,
coordinator: GatusDataUpdateCoordinator,
entry: GatusConfigEntry,
endpoint_key: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._endpoint_key = endpoint_key

endpoint_data = self.endpoint_data

endpoint_name = endpoint_data.name
if endpoint_data.group is not None:
device_name = f"{endpoint_data.group} {endpoint_name}"
else:
device_name = endpoint_name

self._attr_unique_id = f"{entry.entry_id}_{endpoint_key}"

self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"{entry.entry_id}_{endpoint_key}")},
name=device_name,
manufacturer="Gatus",
entry_type=DeviceEntryType.SERVICE,
)

@property
@override
def is_on(self) -> bool | None:
"""Return true if the endpoint is up and healthy."""
latest_result = self.latest_result
if latest_result is None:
return None

return latest_result.success

@property
@override
def available(self) -> bool:
"""Return True if entity is available."""
data = self.coordinator.data
# Guard for empty results list, which could imply a brand new endpoint
return (
super().available
and self._endpoint_key in data
and bool(data[self._endpoint_key].results)
)

@property
def endpoint_data(self) -> EndpointStatus:
"""Return this specific endpoint's data from the coordinator."""
return self.coordinator.data[self._endpoint_key]

@property
def latest_result(self) -> Result | None:
"""Return the most recent monitoring result (Gatus appends newest last)."""
results = self.endpoint_data.results
if not results:
return None
return results[-1]
87 changes: 87 additions & 0 deletions homeassistant/components/gatus/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Config flow for the Gatus integration."""

import logging
from typing import Any, override

from gatus_api import GatusClient, GatusClientError
import voluptuous as vol
from yarl import URL

from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_get_clientsession

from .const import DOMAIN

_LOGGER = logging.getLogger(__name__)

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_URL): str,
}
)


async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate that the user input allows us to connect to Gatus and return data."""
client = GatusClient(url=data[CONF_URL], session=async_get_clientsession(hass))

try:
await client.get_endpoints_statuses()
except GatusClientError as err:
_LOGGER.debug("Cannot connect to Gatus instance at %s: %s", data[CONF_URL], err)
raise CannotConnect from err


class GatusConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Gatus."""

@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial setup step when adding the integration via the UI."""
errors: dict[str, str] = {}

if user_input is not None:
try:
url = URL(user_input[CONF_URL])
except ValueError:
errors["base"] = "invalid_url"
else:
if url.scheme not in {"http", "https"} or not url.host:
errors["base"] = "invalid_url"
else:
normalized_url = str(
url.with_query(None)
.with_fragment(None)
.with_user(None)
.with_password(None)
).rstrip("/")
user_input[CONF_URL] = normalized_url

self._async_abort_entries_match({CONF_URL: normalized_url})

try:
await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception during Gatus setup")
errors["base"] = "unknown"
else:
return self.async_create_entry(title="Gatus", data=user_input)

return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_DATA_SCHEMA, user_input
),
errors=errors,
)


class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect to the server."""
3 changes: 3 additions & 0 deletions homeassistant/components/gatus/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Constants for the Gatus integration."""

DOMAIN = "gatus"
Loading
Loading