initial commit
This commit is contained in:
commit
84b956b7f7
45
.devcontainer.json
Normal file
45
.devcontainer.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "skullnet/sys_mon",
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/python:3.11-bullseye",
|
||||||
|
"postCreateCommand": "scripts/setup",
|
||||||
|
"forwardPorts": [
|
||||||
|
8123
|
||||||
|
],
|
||||||
|
"portsAttributes": {
|
||||||
|
"8123": {
|
||||||
|
"label": "Home Assistant",
|
||||||
|
"onAutoForward": "notify"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runArgs": [
|
||||||
|
"--add-host=host.docker.internal:host-gateway"
|
||||||
|
],
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"github.vscode-pull-request-github",
|
||||||
|
"ryanluker.vscode-coverage-gutters",
|
||||||
|
"ms-python.vscode-pylance"
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"files.eol": "\n",
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"python.pythonPath": "/usr/bin/python3",
|
||||||
|
"python.analysis.autoSearchPaths": false,
|
||||||
|
"python.linting.pylintEnabled": true,
|
||||||
|
"python.linting.enabled": true,
|
||||||
|
"python.formatting.provider": "black",
|
||||||
|
"python.formatting.blackPath": "/usr/local/py-utils/bin/black",
|
||||||
|
"editor.formatOnPaste": false,
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.formatOnType": true,
|
||||||
|
"files.trimTrailingWhitespace": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"remoteUser": "vscode",
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/rust:1": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# artifacts
|
||||||
|
__pycache__
|
||||||
|
.pytest*
|
||||||
|
*.egg-info
|
||||||
|
*/build/*
|
||||||
|
*/dist/*
|
||||||
|
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.coverage
|
||||||
|
.vscode
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
|
||||||
|
# Home Assistant configuration
|
||||||
|
config/*
|
||||||
|
!config/configuration.yaml
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "custom_components/sys_mon/sys_mon_agent"]
|
||||||
|
path = custom_components/sys_mon/sys_mon_agent
|
||||||
|
url = https://git.skullnet.me/erki/ha-sys-mon-agent.git
|
||||||
48
.ruff.toml
Normal file
48
.ruff.toml
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# The contents of this file is based on https://github.com/home-assistant/core/blob/dev/pyproject.toml
|
||||||
|
|
||||||
|
target-version = "py310"
|
||||||
|
|
||||||
|
select = [
|
||||||
|
"B007", # Loop control variable {name} not used within loop body
|
||||||
|
"B014", # Exception handler with duplicate exception
|
||||||
|
"C", # complexity
|
||||||
|
"D", # docstrings
|
||||||
|
"E", # pycodestyle
|
||||||
|
"F", # pyflakes/autoflake
|
||||||
|
"ICN001", # import concentions; {name} should be imported as {asname}
|
||||||
|
"PGH004", # Use specific rule codes when using noqa
|
||||||
|
"PLC0414", # Useless import alias. Import alias does not rename original package.
|
||||||
|
"SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass
|
||||||
|
"SIM117", # Merge with-statements that use the same scope
|
||||||
|
"SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys()
|
||||||
|
"SIM201", # Use {left} != {right} instead of not {left} == {right}
|
||||||
|
"SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a}
|
||||||
|
"SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'.
|
||||||
|
"SIM401", # Use get from dict with default instead of an if block
|
||||||
|
"T20", # flake8-print
|
||||||
|
"TRY004", # Prefer TypeError exception for invalid type
|
||||||
|
"RUF006", # Store a reference to the return value of asyncio.create_task
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"W", # pycodestyle
|
||||||
|
]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
"D202", # No blank lines allowed after function docstring
|
||||||
|
"D203", # 1 blank line required before class docstring
|
||||||
|
"D213", # Multi-line docstring summary should start at the second line
|
||||||
|
"D404", # First word of the docstring should not be This
|
||||||
|
"D406", # Section name should end with a newline
|
||||||
|
"D407", # Section name underlining
|
||||||
|
"D411", # Missing blank line before section
|
||||||
|
"E501", # line too long
|
||||||
|
"E731", # do not assign a lambda expression, use a def
|
||||||
|
]
|
||||||
|
|
||||||
|
[flake8-pytest-style]
|
||||||
|
fixture-parentheses = false
|
||||||
|
|
||||||
|
[pyupgrade]
|
||||||
|
keep-runtime-typing = true
|
||||||
|
|
||||||
|
[mccabe]
|
||||||
|
max-complexity = 25
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Rusted Skull
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
26
config/configuration.yaml
Normal file
26
config/configuration.yaml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# https://www.home-assistant.io/integrations/default_config/
|
||||||
|
default_config:
|
||||||
|
|
||||||
|
# https://www.home-assistant.io/integrations/logger/
|
||||||
|
logger:
|
||||||
|
default: info
|
||||||
|
logs:
|
||||||
|
custom_components.sys_mon: debug
|
||||||
|
|
||||||
|
sys_mon:
|
||||||
|
agents:
|
||||||
|
- address: "127.0.0.1:8202"
|
||||||
|
hostname: "local.skullnet.me"
|
||||||
|
services_to_monitor: [
|
||||||
|
]
|
||||||
|
storage_to_monitor: [
|
||||||
|
]
|
||||||
|
- address: "host.docker.internal:8203"
|
||||||
|
hostname: "localhost"
|
||||||
|
services_to_monitor: [
|
||||||
|
"sshd.service",
|
||||||
|
"docker.service"
|
||||||
|
]
|
||||||
|
storage_to_monitor: [
|
||||||
|
"/dev/sda"
|
||||||
|
]
|
||||||
37
custom_components/sys_mon/__init__.py
Normal file
37
custom_components/sys_mon/__init__.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
"""Custom integration to integrate sys_mon with Home Assistant."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
import homeassistant.helpers.config_validation as cv
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_ADDRESS, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER, CONF_STORAGE, CONF_SERVICES, CONF_AGENTS
|
||||||
|
from .coordinator import SysMonCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
|
LOGGER.warning(f"We're actually here. Woups? {config[DOMAIN]}")
|
||||||
|
|
||||||
|
coord = SysMonCoordinator(hass)
|
||||||
|
|
||||||
|
hass.data[DOMAIN] = {}
|
||||||
|
# We go do this shit: https://github.com/home-assistant/example-custom-config/blob/master/custom_components/example_load_platform/__init__.py
|
||||||
|
|
||||||
|
for entry in config[DOMAIN][CONF_AGENTS]:
|
||||||
|
LOGGER.debug(f"Agent entry: {entry}")
|
||||||
|
await coord.add_agent(entry)
|
||||||
|
|
||||||
|
hass.helpers.discovery.load_platform("sensor", DOMAIN, {"coordinator": coord, "config": config[DOMAIN][CONF_AGENTS]}, config)
|
||||||
|
|
||||||
|
await coord.async_config_entry_first_refresh()
|
||||||
|
|
||||||
|
LOGGER.debug(f"Coordinator data: {coord.data}")
|
||||||
|
|
||||||
|
return True
|
||||||
17
custom_components/sys_mon/const.py
Normal file
17
custom_components/sys_mon/const.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"""Constants for Sys Mon."""
|
||||||
|
from logging import Logger, getLogger
|
||||||
|
|
||||||
|
LOGGER: Logger = getLogger(__package__)
|
||||||
|
|
||||||
|
NAME = "Sys Mon"
|
||||||
|
DOMAIN = "sys_mon"
|
||||||
|
VERSION = "0.1.0"
|
||||||
|
ATTRIBUTION = "who me"
|
||||||
|
|
||||||
|
CONF_SERVICES = "services_to_monitor"
|
||||||
|
CONF_STORAGE = "storage_to_monitor"
|
||||||
|
CONF_AGENTS = "agents"
|
||||||
|
CONF_HOSTNAME = "hostname"
|
||||||
|
|
||||||
|
STATE_IS_CONNECTED = "connected"
|
||||||
|
STATE_DATA = "data"
|
||||||
137
custom_components/sys_mon/coordinator.py
Normal file
137
custom_components/sys_mon/coordinator.py
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
DataUpdateCoordinator,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
from homeassistant.const import CONF_ADDRESS
|
||||||
|
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN, LOGGER, CONF_SERVICES, CONF_STORAGE, CONF_HOSTNAME, STATE_DATA, STATE_IS_CONNECTED
|
||||||
|
|
||||||
|
import grpc
|
||||||
|
from google.protobuf.empty_pb2 import Empty
|
||||||
|
from .sys_mon_agent import api
|
||||||
|
|
||||||
|
_EMPTY_AGENT_DATA = {
|
||||||
|
STATE_IS_CONNECTED: False,
|
||||||
|
STATE_DATA: None
|
||||||
|
}
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MonitoredAgent:
|
||||||
|
address: str
|
||||||
|
hostname: str
|
||||||
|
configuration_data: dict[str: Any]
|
||||||
|
configured: bool = False
|
||||||
|
|
||||||
|
# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities
|
||||||
|
class SysMonCoordinator(DataUpdateCoordinator):
|
||||||
|
"""Class to manage fetching data from various hosts."""
|
||||||
|
|
||||||
|
config_entry: ConfigEntry
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
hass: HomeAssistant
|
||||||
|
) -> None:
|
||||||
|
"""Initialize."""
|
||||||
|
super().__init__(
|
||||||
|
hass=hass,
|
||||||
|
logger=LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=timedelta(seconds=10),
|
||||||
|
)
|
||||||
|
|
||||||
|
self._client_data: list[MonitoredAgent] = []
|
||||||
|
|
||||||
|
async def add_agent(self, agent_conf):
|
||||||
|
agent = MonitoredAgent(agent_conf[CONF_ADDRESS], agent_conf[CONF_HOSTNAME], agent_conf, configured=False)
|
||||||
|
agent.configured = await self._configure_agent(agent)
|
||||||
|
self._client_data.append(agent)
|
||||||
|
|
||||||
|
|
||||||
|
async def _async_update_data(self):
|
||||||
|
"""Update data via library."""
|
||||||
|
try:
|
||||||
|
# this will populate ["something"]["state"] exciting.
|
||||||
|
ctx = list(self.async_contexts())
|
||||||
|
LOGGER.info(f"SysMonCoordinator is updating data. Context: {ctx}")
|
||||||
|
|
||||||
|
if len(ctx) == 0:
|
||||||
|
data = {}
|
||||||
|
for agent in self._client_data:
|
||||||
|
if agent.configured:
|
||||||
|
data[agent.hostname] = await self._poll_host(agent)
|
||||||
|
else:
|
||||||
|
data[agent.hostname] = _EMPTY_AGENT_DATA
|
||||||
|
|
||||||
|
LOGGER.debug(f"SysMonCoordinator final coordinator data: {data}.")
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
for hostname in ctx:
|
||||||
|
agent = [a for a in self._client_data if a.hostname == hostname][0]
|
||||||
|
if not agent.configured:
|
||||||
|
success = await self._configure_agent(agent)
|
||||||
|
if success:
|
||||||
|
agent.configured = True
|
||||||
|
self.data[hostname] = await self._poll_host(agent)
|
||||||
|
else:
|
||||||
|
self.data[hostname] = _EMPTY_AGENT_DATA
|
||||||
|
else:
|
||||||
|
self.data[hostname] = await self._poll_host(agent)
|
||||||
|
LOGGER.debug(f"SysMonCoordinator final coordinator data: {self.data}.")
|
||||||
|
return self.data
|
||||||
|
except Exception as exception:
|
||||||
|
raise UpdateFailed(exception) from exception
|
||||||
|
|
||||||
|
async def _poll_host(self, client: MonitoredAgent) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
async with grpc.aio.insecure_channel(client.address) as channel:
|
||||||
|
stub = api.AgentStub(channel)
|
||||||
|
|
||||||
|
response: api.MonitoringStats = await stub.Poll(
|
||||||
|
Empty()
|
||||||
|
)
|
||||||
|
|
||||||
|
data = {
|
||||||
|
STATE_IS_CONNECTED: True,
|
||||||
|
STATE_DATA: response
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
except Exception as err:
|
||||||
|
LOGGER.warning(f"SysMonCoordinator error polling host {client.hostname}: {err}.")
|
||||||
|
return _EMPTY_AGENT_DATA
|
||||||
|
|
||||||
|
async def _configure_agent(self, agent: MonitoredAgent) -> bool:
|
||||||
|
try:
|
||||||
|
async with grpc.aio.insecure_channel(agent.address) as channel:
|
||||||
|
stub = api.AgentStub(channel)
|
||||||
|
|
||||||
|
services: list[str] | None = None
|
||||||
|
if agent.configuration_data[CONF_SERVICES] and len(agent.configuration_data[CONF_SERVICES]):
|
||||||
|
services = agent.configuration_data[CONF_SERVICES]
|
||||||
|
|
||||||
|
storage: list[str] | None = None
|
||||||
|
if agent.configuration_data[CONF_STORAGE] and len(agent.configuration_data[CONF_STORAGE]):
|
||||||
|
storage = agent.configuration_data[CONF_STORAGE]
|
||||||
|
|
||||||
|
response: api.AgentConfigurationResponse = await stub.Configure(
|
||||||
|
api.AgentConfiguration(
|
||||||
|
services_to_monitor=services,
|
||||||
|
storage_to_monitor=storage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
LOGGER.debug(f"SysMonCoordinator successfully configured agent {agent.hostname}.")
|
||||||
|
return True
|
||||||
|
except grpc.aio.AioRpcError as rpc_error:
|
||||||
|
LOGGER.warning(f"SysMonCoordinator failed to configure agent {agent.hostname}.")
|
||||||
|
return False
|
||||||
17
custom_components/sys_mon/entity.py
Normal file
17
custom_components/sys_mon/entity.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from homeassistant.core import callback
|
||||||
|
from homeassistant.helpers.update_coordinator import (
|
||||||
|
CoordinatorEntity,
|
||||||
|
UpdateFailed,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .coordinator import SysMonCoordinator
|
||||||
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
|
class BaseSysMonEntity(CoordinatorEntity):
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str):
|
||||||
|
super().__init__(coordinator, context=hostname)
|
||||||
|
self.hostname = hostname
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
LOGGER.debug(f"We have data: {self.coordinator.data}")
|
||||||
15
custom_components/sys_mon/manifest.json
Normal file
15
custom_components/sys_mon/manifest.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"domain": "sys_mon",
|
||||||
|
"name": "Sys Mon",
|
||||||
|
"codeowners": [
|
||||||
|
"@skull132"
|
||||||
|
],
|
||||||
|
"iot_class": "cloud_polling",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"requirements": [
|
||||||
|
"dbus-next",
|
||||||
|
"grpcio",
|
||||||
|
"protobuf",
|
||||||
|
"pySMART"
|
||||||
|
]
|
||||||
|
}
|
||||||
293
custom_components/sys_mon/sensor.py
Normal file
293
custom_components/sys_mon/sensor.py
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorStateClass
|
||||||
|
)
|
||||||
|
from homeassistant.components.binary_sensor import (
|
||||||
|
BinarySensorDeviceClass,
|
||||||
|
BinarySensorEntity,
|
||||||
|
BinarySensorEntityDescription
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
from .entity import BaseSysMonEntity
|
||||||
|
from .coordinator import SysMonCoordinator
|
||||||
|
from .const import CONF_HOSTNAME, CONF_STORAGE, CONF_SERVICES, STATE_IS_CONNECTED, STATE_DATA, LOGGER
|
||||||
|
from .sys_mon_agent import api
|
||||||
|
|
||||||
|
def setup_platform(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
|
add_entities: AddEntitiesCallback,
|
||||||
|
discovery_info: DiscoveryInfoType | None = None
|
||||||
|
) -> None:
|
||||||
|
if discovery_info is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
hostname_list = [agent[CONF_HOSTNAME] for agent in discovery_info["config"]]
|
||||||
|
LOGGER.debug(f"Adding sensors. Recognized agent hostnames: {hostname_list}")
|
||||||
|
|
||||||
|
entities = []
|
||||||
|
for agent in discovery_info["config"]:
|
||||||
|
entities.append(AgentStatusSensor(discovery_info["coordinator"], agent[CONF_HOSTNAME]))
|
||||||
|
|
||||||
|
if CONF_STORAGE in agent:
|
||||||
|
for storage in agent[CONF_STORAGE]:
|
||||||
|
entities.append(StorageStatusSensor(discovery_info["coordinator"], agent[CONF_HOSTNAME], storage))
|
||||||
|
|
||||||
|
if CONF_SERVICES in agent:
|
||||||
|
for service in agent[CONF_SERVICES]:
|
||||||
|
entities.append(ServiceStateSensor(discovery_info["coordinator"], agent[CONF_HOSTNAME], service))
|
||||||
|
entities.append(ServiceStartedSensor(discovery_info["coordinator"], agent[CONF_HOSTNAME], service))
|
||||||
|
entities.append(ServiceStoppedSensor(discovery_info["coordinator"], agent[CONF_HOSTNAME], service))
|
||||||
|
entities.append(ServiceResultStatusSensor(discovery_info["coordinator"], agent[CONF_HOSTNAME], service))
|
||||||
|
|
||||||
|
add_entities(entities)
|
||||||
|
|
||||||
|
class AgentStatusSensor(BinarySensorEntity, BaseSysMonEntity):
|
||||||
|
_attr_device_class = BinarySensorDeviceClass.CONNECTIVITY
|
||||||
|
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str):
|
||||||
|
super().__init__(coordinator, hostname)
|
||||||
|
self._hostname = hostname
|
||||||
|
self._attr_is_on = None
|
||||||
|
|
||||||
|
LOGGER.debug(f"AgentStatusSensor[{self._hostname}] created.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"SysMon Agent Status: {self._hostname}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
LOGGER.debug(f"AgentStatusSensor[{self._hostname}] polled state. Received: {self.coordinator.data[self._hostname]}. State: {self.coordinator.data[self._hostname][STATE_IS_CONNECTED]}")
|
||||||
|
self._attr_is_on = self.coordinator.data[self._hostname][STATE_IS_CONNECTED]
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
class StorageStatusSensor(SensorEntity, BaseSysMonEntity):
|
||||||
|
_attr_device_class = SensorDeviceClass.ENUM
|
||||||
|
_attr_options = [
|
||||||
|
"HEALTHY",
|
||||||
|
"FAILING",
|
||||||
|
"MISSING"
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str, storage_name: str):
|
||||||
|
super().__init__(coordinator, hostname)
|
||||||
|
self._hostname = hostname
|
||||||
|
self._storage_name = storage_name
|
||||||
|
self._attr_native_value = None
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: created.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_log(self) -> str:
|
||||||
|
return f"StorageStatusSensor[{self._hostname}][{self._storage_name}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"SysMon {self._hostname} storage: {self._storage_name}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
if not self.coordinator.data[self._hostname][STATE_IS_CONNECTED]:
|
||||||
|
if self._attr_native_value is not None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
state_data: api.MonitoringStats = self.coordinator.data[self._hostname][STATE_DATA]
|
||||||
|
data: Optional[api.MonitoredStorage] = state_data.storage_by_name(self._storage_name)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
self._attr_native_value = None
|
||||||
|
elif not data.present:
|
||||||
|
self._attr_native_value = "MISSING"
|
||||||
|
elif data.smart_pass:
|
||||||
|
self._attr_native_value = "HEALTHY"
|
||||||
|
else:
|
||||||
|
self._attr_native_value = "FAILING"
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
class ServiceStateSensor(SensorEntity, BaseSysMonEntity):
|
||||||
|
_attr_device_class = SensorDeviceClass.ENUM
|
||||||
|
_attr_options = [
|
||||||
|
"ACTIVE",
|
||||||
|
"RELOADING",
|
||||||
|
"INACTIVE",
|
||||||
|
"FAILED",
|
||||||
|
"ACTIVATING",
|
||||||
|
"DEACTIVATING"
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str, service_name: str):
|
||||||
|
super().__init__(coordinator, hostname)
|
||||||
|
self._hostname = hostname
|
||||||
|
self._service_name = service_name
|
||||||
|
self._attr_native_value = None
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: created.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_log(self) -> str:
|
||||||
|
return f"ServiceStateSensor[{self._hostname}][{self._service_name}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"SysMon {self._hostname} service: {self._service_name}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
if not self.coordinator.data[self._hostname][STATE_IS_CONNECTED]:
|
||||||
|
if self._attr_native_value is not None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent is NOT connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent IS connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
state_data: api.MonitoringStats = self.coordinator.data[self._hostname][STATE_DATA]
|
||||||
|
data: Optional[api.MonitoredService] = state_data.service_by_name(self._service_name)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
else:
|
||||||
|
self._attr_native_value = self._attr_options[data.state]
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
class ServiceStartedSensor(SensorEntity, BaseSysMonEntity):
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str, service_name: str):
|
||||||
|
super().__init__(coordinator, hostname)
|
||||||
|
self._hostname = hostname
|
||||||
|
self._service_name = service_name
|
||||||
|
self._attr_native_value = None
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: created.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_log(self) -> str:
|
||||||
|
return f"ServiceStartedSensor[{self._hostname}][{self._service_name}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"SysMon {self._hostname} service started: {self._service_name}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
if not self.coordinator.data[self._hostname][STATE_IS_CONNECTED]:
|
||||||
|
if self._attr_native_value is not None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent is NOT connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent IS connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
state_data: api.MonitoringStats = self.coordinator.data[self._hostname][STATE_DATA]
|
||||||
|
data: Optional[api.MonitoredService] = state_data.service_by_name(self._service_name)
|
||||||
|
|
||||||
|
if data is None or data.main_started.ToSeconds() == 0:
|
||||||
|
self._attr_native_value = None
|
||||||
|
else:
|
||||||
|
timestamp = data.main_started.ToDatetime()
|
||||||
|
timezone = datetime.datetime.now().astimezone().tzinfo
|
||||||
|
self._attr_native_value = timestamp.replace(tzinfo=timezone)
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
class ServiceStoppedSensor(SensorEntity, BaseSysMonEntity):
|
||||||
|
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||||
|
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str, service_name: str):
|
||||||
|
super().__init__(coordinator, hostname)
|
||||||
|
self._hostname = hostname
|
||||||
|
self._service_name = service_name
|
||||||
|
self._attr_native_value = None
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: created.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_log(self) -> str:
|
||||||
|
return f"ServiceStoppedSensor[{self._hostname}][{self._service_name}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"SysMon {self._hostname} service stopped: {self._service_name}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
if not self.coordinator.data[self._hostname][STATE_IS_CONNECTED]:
|
||||||
|
if self._attr_native_value is not None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent is NOT connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent IS connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
state_data: api.MonitoringStats = self.coordinator.data[self._hostname][STATE_DATA]
|
||||||
|
data: Optional[api.MonitoredService] = state_data.service_by_name(self._service_name)
|
||||||
|
|
||||||
|
if data is None or data.main_exited.ToSeconds() == 0:
|
||||||
|
self._attr_native_value = None
|
||||||
|
else:
|
||||||
|
timestamp = data.main_exited.ToDatetime()
|
||||||
|
timezone = datetime.datetime.now().astimezone().tzinfo
|
||||||
|
self._attr_native_value = timestamp.replace(tzinfo=timezone)
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
class ServiceResultStatusSensor(BinarySensorEntity, BaseSysMonEntity):
|
||||||
|
_attr_device_class = BinarySensorDeviceClass.PROBLEM
|
||||||
|
|
||||||
|
def __init__(self, coordinator: SysMonCoordinator, hostname: str, service_name: str):
|
||||||
|
super().__init__(coordinator, hostname)
|
||||||
|
self._hostname = hostname
|
||||||
|
self._service_name = service_name
|
||||||
|
self._attr_is_on = None
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: created.")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name_log(self) -> str:
|
||||||
|
return f"ServiceResultStatusSensor[{self._hostname}][{self._service_name}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return f"SysMon {self._hostname} service failed: {self._service_name}"
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_coordinator_update(self) -> None:
|
||||||
|
if not self.coordinator.data[self._hostname][STATE_IS_CONNECTED]:
|
||||||
|
if self._attr_is_on is not None:
|
||||||
|
self._attr_is_on = None
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent is NOT connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
return
|
||||||
|
|
||||||
|
LOGGER.debug(f"{self.name_log}: polled state. Agent IS connected. Received: {self.coordinator.data[self._hostname]}.")
|
||||||
|
state_data: api.MonitoringStats = self.coordinator.data[self._hostname][STATE_DATA]
|
||||||
|
data: Optional[api.MonitoredService] = state_data.service_by_name(self._service_name)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
self._attr_is_on = None
|
||||||
|
else:
|
||||||
|
self._attr_is_on = not data.result_success
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
1
custom_components/sys_mon/sys_mon_agent
Submodule
1
custom_components/sys_mon/sys_mon_agent
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 675f35f2a60e8b4f93baf03aa1746b96d3f86f29
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
colorlog==6.7.0
|
||||||
|
homeassistant==2024.1.0
|
||||||
|
pip>=21.0,<23.2
|
||||||
|
ruff==0.0.292
|
||||||
20
scripts/develop
Executable file
20
scripts/develop
Executable file
@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
# Create config dir if not present
|
||||||
|
if [[ ! -d "${PWD}/config" ]]; then
|
||||||
|
mkdir -p "${PWD}/config"
|
||||||
|
hass --config "${PWD}/config" --script ensure_config
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set the path to custom_components
|
||||||
|
## This let's us have the structure we want <root>/custom_components/sys_mon
|
||||||
|
## while at the same time have Home Assistant configuration inside <root>/config
|
||||||
|
## without resulting to symlinks.
|
||||||
|
export PYTHONPATH="${PYTHONPATH}:${PWD}/custom_components"
|
||||||
|
|
||||||
|
# Start Home Assistant
|
||||||
|
hass --config "${PWD}/config" --debug
|
||||||
7
scripts/lint
Executable file
7
scripts/lint
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
ruff check . --fix
|
||||||
7
scripts/setup
Executable file
7
scripts/setup
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
python3 -m pip install --requirement requirements.txt
|
||||||
15
scripts/sys-mon-agent
Executable file
15
scripts/sys-mon-agent
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
if [[ ! -d "${PWD}/custom_components/sys_mon/sys_mon_agent/venv" ]]; then
|
||||||
|
python -m venv "${PWD}/custom_components/sys_mon/sys_mon_agent/venv"
|
||||||
|
source "${PWD}/custom_components/sys_mon/sys_mon_agent/venv/bin/activate"
|
||||||
|
pip install -r "${PWD}/custom_components/sys_mon/sys_mon_agent/requirements.txt"
|
||||||
|
else
|
||||||
|
source "${PWD}/custom_components/sys_mon/sys_mon_agent/venv/bin/activate"
|
||||||
|
fi
|
||||||
|
|
||||||
|
python "${PWD}/custom_components/sys_mon/sys_mon_agent/sys-mon-agent.py" -p 8202
|
||||||
Loading…
x
Reference in New Issue
Block a user