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
2 changes: 1 addition & 1 deletion .github/workflows/hassfest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Validate with hassfest
uses: home-assistant/actions/hassfest@master
2 changes: 1 addition & 1 deletion .github/workflows/python-smoke.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7

- name: Set up Python
uses: actions/setup-python@v6
Expand Down
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 1.1.4

- added Home Assistant entities for the Repeater dev sensor-manager summary and configured sensor plug-in readings, including UPS/power/environment values and boolean flags exposed under `stats.sensors.readings`
- made sensor-manager entities appear when readings arrive after integration setup instead of requiring the readings to be present during the first Home Assistant entity setup pass
- rebranded the Home Assistant integration, HACS name, dashboard title, diagnostics text, and bundled icons from pyMC Repeater to openHop Repeater while keeping the existing `pymc_repeater` integration domain for compatibility

## 1.1.3

- added a Home Assistant action for the new repeater `broker_presets` API endpoint so bundled MC2MQTT broker templates can be queried from HA
Expand Down Expand Up @@ -33,7 +39,7 @@

## 1.0.0

Initial stable release of the pyMC Repeater Home Assistant integration.
Initial stable release of the openHop Repeater Home Assistant integration.

- config flow setup using repeater host, port, and admin password
- dedicated API token bootstrap and token-based polling
Expand Down
2 changes: 1 addition & 1 deletion LICENSE
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
MIT License

Copyright (c) 2026 pyMC.dev
Copyright (c) 2026 openHop

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
32 changes: 10 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
# pyMC Repeater for Home Assistant
# openHop Repeater for Home Assistant

Custom Home Assistant integration for [pyMC_Repeater](https://github.com/rightup/pyMC_Repeater).
Custom Home Assistant integration for [openHop Repeater](https://github.com/openhop-dev/openhop_repeater).

This integration connects directly to the repeater's local HTTP API, signs in once with the admin password, creates a dedicated API token for Home Assistant, and then uses that token for ongoing polling and history-friendly logging inside HA.

The Home Assistant integration domain and folder remain `pymc_repeater` for compatibility with existing installations while the user-facing brand is openHop Repeater.

## Features

- UI config flow inside Home Assistant
- Prompts for repeater IP or hostname, port, and admin password
- Automatically creates a dedicated API token for Home Assistant
- Stores the API token instead of the admin password after setup
- Polls repeater telemetry, packet stats, radio metrics, hardware stats, database stats, MQTT status, ACL stats, and identity totals
- Polls repeater telemetry, packet stats, radio metrics, hardware stats, database stats, MQTT status, ACL stats, identity totals, and configured Repeater sensor-manager readings
- Exposes Home Assistant sensors and binary sensors for easy dashboards, history graphs, and automations

## Requirements

- A running [pyMC_Repeater](https://github.com/rightup/pyMC_Repeater) instance
- A running [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) instance
- The repeater web/API port reachable from Home Assistant
- The repeater admin password
- A trusted local network, VPN, or other secure path between Home Assistant and the repeater
Expand All @@ -32,12 +34,12 @@ This integration is intended to be installed in HACS as a custom repository.
4. Paste:

```text
https://github.com/pyMC-dev/pyMC-HA-Integration
https://github.com/openhop-dev/openHop-HA-Integration
```

5. Choose `Integration` as the category.
6. Add the repository.
7. Find `pyMC Repeater` in HACS and install it.
7. Find `openHop Repeater` in HACS and install it.
8. Restart Home Assistant.

### Manual installation
Expand Down Expand Up @@ -65,7 +67,7 @@ After installation and restart:

1. Open `Settings` -> `Devices & Services`.
2. Click `Add Integration`.
3. Search for `pyMC Repeater`.
3. Search for `openHop Repeater`.
4. Enter:
- Repeater IP address or hostname
- Repeater HTTP API port
Expand All @@ -81,20 +83,6 @@ During setup the integration will:
5. Discard the admin password after the setup flow finishes
6. Start polling repeater data automatically

## What gets added to Home Assistant

Version `1.0.0` includes entities for:

- repeater version and build info
- total, transmitted, and dropped packets
- average RSSI, SNR, and TX delay
- average noise floor
- MQTT broker connection state
- ACL client totals
- registered identity totals
- database size
- CPU, memory, disk usage, and uptime

## Dashboard template

A native Lovelace dashboard template is included at:
Expand Down Expand Up @@ -144,7 +132,7 @@ README.md

- The config flow follows current Home Assistant custom integration patterns with `manifest.json`, `config_flow.py`, and `translations/en.json`
- The integration uses coordinated polling rather than per-entity API calls
- The client implementation matches the current [pyMC_Repeater](https://github.com/rightup/pyMC_Repeater) auth flow:
- The client implementation matches the current [openHop Repeater](https://github.com/openhop-dev/openhop_repeater) auth flow:
- `POST /auth/login`
- `POST /api/auth/tokens`
- ongoing reads with `X-API-Key`
Expand Down
10 changes: 5 additions & 5 deletions custom_components/pymc_repeater/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The pyMC Repeater integration."""
"""The openHop Repeater integration."""

from __future__ import annotations

Expand Down Expand Up @@ -70,7 +70,7 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool:


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up pyMC Repeater from a config entry."""
"""Set up openHop Repeater from a config entry."""
session = async_get_clientsession(hass)
api = PyMCRepeaterApiClient(
session=session,
Expand Down Expand Up @@ -116,19 +116,19 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non
def _resolve_entry_id(hass: HomeAssistant, service_data: dict) -> str:
entries = hass.data.get(DOMAIN, {})
if not entries:
raise HomeAssistantError("No pyMC Repeater entries are loaded")
raise HomeAssistantError("No openHop Repeater entries are loaded")

requested = service_data.get(CONF_ENTRY_ID) or service_data.get(LEGACY_CONF_ENTRY_ID)
if requested:
if requested not in entries:
raise HomeAssistantError(f"Unknown pyMC Repeater entry_id: {requested}")
raise HomeAssistantError(f"Unknown openHop Repeater entry_id: {requested}")
return requested

if len(entries) == 1:
return next(iter(entries))

raise HomeAssistantError(
"Multiple pyMC Repeater entries are configured; provide entry_id"
"Multiple openHop Repeater entries are configured; provide entry_id"
)


Expand Down
8 changes: 4 additions & 4 deletions custom_components/pymc_repeater/api.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Async API client for pyMC Repeater."""
"""Async API client for openHop Repeater."""

from __future__ import annotations

Expand All @@ -18,7 +18,7 @@


class PyMCRepeaterError(Exception):
"""Base error for the pyMC Repeater client."""
"""Base error for the openHop Repeater client."""


class PyMCRepeaterCannotConnect(PyMCRepeaterError):
Expand Down Expand Up @@ -78,13 +78,13 @@ def normalize_host(value: str) -> str:


def build_home_assistant_token_name(home_assistant_hostname: str | None = None) -> str:
"""Build the pyMC API token label for this Home Assistant instance."""
"""Build the openHop API token label for this Home Assistant instance."""
hostname = (home_assistant_hostname or "").strip() or socket.gethostname()
return f"Home Assistant ({hostname})"


class PyMCRepeaterApiClient:
"""HTTP client for pyMC Repeater."""
"""HTTP client for openHop Repeater."""

def __init__(
self,
Expand Down
121 changes: 117 additions & 4 deletions custom_components/pymc_repeater/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Binary sensors for pyMC Repeater."""
"""Binary sensors for openHop Repeater."""

from __future__ import annotations

Expand All @@ -18,7 +18,14 @@
from homeassistant.util import slugify

from .const import DOMAIN
from .sensor import PyMCBaseEntity, _companion_items, _nested, _room_items
from .sensor import (
PyMCBaseEntity,
_companion_items,
_external_sensor_identity,
_external_sensor_readings,
_nested,
_room_items,
)


@dataclass(frozen=True, kw_only=True)
Expand Down Expand Up @@ -126,7 +133,7 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up pyMC Repeater binary sensors."""
"""Set up openHop Repeater binary sensors."""
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
entities: list[BinarySensorEntity] = [
PyMCBinarySensorEntity(entry, coordinator, description)
Expand All @@ -151,11 +158,43 @@ async def async_setup_entry(
if isinstance(companion, dict) and companion.get("companion_name"):
entities.append(PyMCCompanionBridgeBinarySensor(entry, coordinator, companion))

external_sensor_unique_ids: set[str] = set()

def add_external_sensor_entities() -> None:
new_entities: list[BinarySensorEntity] = []
for reading in _external_sensor_readings(coordinator.data):
if not isinstance(reading, dict) or not reading.get("name"):
continue
ok_entity = PyMCExternalSensorOkBinarySensor(entry, coordinator, reading)
ok_unique_id = str(ok_entity.unique_id)
if ok_unique_id not in external_sensor_unique_ids:
external_sensor_unique_ids.add(ok_unique_id)
new_entities.append(ok_entity)

payload = reading.get("data") or {}
if not isinstance(payload, dict):
continue
for field, value in payload.items():
if not isinstance(value, bool):
continue
flag_entity = PyMCExternalSensorFlagBinarySensor(
entry, coordinator, reading, str(field)
)
flag_unique_id = str(flag_entity.unique_id)
if flag_unique_id in external_sensor_unique_ids:
continue
external_sensor_unique_ids.add(flag_unique_id)
new_entities.append(flag_entity)
if new_entities:
async_add_entities(new_entities)

async_add_entities(entities)
add_external_sensor_entities()
entry.async_on_unload(coordinator.async_add_listener(add_external_sensor_entities))


class PyMCBinarySensorEntity(PyMCBaseEntity, BinarySensorEntity):
"""Representation of a pyMC Repeater binary sensor."""
"""Representation of an openHop Repeater binary sensor."""

entity_description: PyMCBinarySensorDescription

Expand Down Expand Up @@ -359,3 +398,77 @@ def extra_state_attributes(self) -> dict[str, Any]:
"contacts_count": companion.get("contacts_count"),
"channels_count": companion.get("channels_count"),
}


class PyMCExternalSensorOkBinarySensor(PyMCBaseEntity, BinarySensorEntity):
"""Binary sensor showing whether a configured repeater sensor read succeeded."""

_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_has_entity_name = True
_attr_icon = "mdi:check-network-outline"

def __init__(self, entry: ConfigEntry, coordinator, reading: dict[str, Any]) -> None:
super().__init__(entry, coordinator)
self._identity = _external_sensor_identity(reading)
self._attr_name = f"Sensor {reading.get('name') or 'sensor'} OK"
self._attr_unique_id = (
f"{entry.unique_id or entry.entry_id}_external_sensor_{self._identity}_ok"
)

def _get_reading(self) -> dict[str, Any] | None:
for reading in _external_sensor_readings(self.coordinator.data):
if isinstance(reading, dict) and _external_sensor_identity(reading) == self._identity:
return reading
return None

@property
def available(self) -> bool:
return super().available and self._get_reading() is not None

@property
def is_on(self) -> bool:
reading = self._get_reading() or {}
return bool(reading.get("ok"))

@property
def extra_state_attributes(self) -> dict[str, Any]:
reading = self._get_reading() or {}
return {
"sensor_name": reading.get("name"),
"sensor_type": reading.get("type"),
"timestamp": reading.get("timestamp"),
"error": reading.get("error"),
}


class PyMCExternalSensorFlagBinarySensor(PyMCExternalSensorOkBinarySensor):
"""Binary sensor for boolean fields returned by repeater sensor plug-ins."""

_attr_icon = "mdi:toggle-switch-outline"

def __init__(
self,
entry: ConfigEntry,
coordinator,
reading: dict[str, Any],
field: str,
) -> None:
super().__init__(entry, coordinator, reading)
self._field = field
self._attr_name = f"Sensor {reading.get('name') or 'sensor'} {field.replace('_', ' ')}"
self._attr_unique_id = (
f"{entry.unique_id or entry.entry_id}_external_sensor_{self._identity}_"
f"{slugify(field) or 'flag'}"
)

@property
def available(self) -> bool:
reading = self._get_reading()
payload = reading.get("data") if isinstance(reading, dict) else None
return super().available and isinstance(payload, dict) and self._field in payload

@property
def is_on(self) -> bool:
reading = self._get_reading() or {}
payload = reading.get("data") or {}
return bool(payload.get(self._field)) if isinstance(payload, dict) else False
Binary file modified custom_components/pymc_repeater/brand/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified custom_components/pymc_repeater/brand/icon@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions custom_components/pymc_repeater/button.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Button entities for pyMC Repeater."""
"""Button entities for openHop Repeater."""

from __future__ import annotations

Expand All @@ -17,7 +17,7 @@

@dataclass(frozen=True, kw_only=True)
class PyMCButtonDescription(ButtonEntityDescription):
"""Description for a pyMC button."""
"""Description for an openHop button."""

press_fn: Callable[[object], Awaitable[object]]
refresh_after: bool = True
Expand Down Expand Up @@ -69,14 +69,14 @@ async def async_setup_entry(
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up pyMC Repeater buttons."""
"""Set up openHop Repeater buttons."""
coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
api = hass.data[DOMAIN][entry.entry_id]["api"]
async_add_entities(PyMCButtonEntity(entry, coordinator, api, desc) for desc in BUTTONS)


class PyMCButtonEntity(PyMCBaseEntity, ButtonEntity):
"""A pyMC button entity."""
"""An openHop button entity."""

entity_description: PyMCButtonDescription

Expand Down
Loading