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