Home Assistant Git Exporter

This commit is contained in:
root
2024-08-09 06:45:02 +02:00
parent 60abdd866c
commit 80fc630f5e
624 changed files with 27739 additions and 4497 deletions

View File

@@ -1,17 +1,16 @@
"""https://github.com/dummylabs/thewatchman§"""
from datetime import timedelta
import logging
import os
import time
import json
import voluptuous as vol
from homeassistant.loader import async_get_integration
from anyio import Path
from homeassistant.helpers import config_validation as cv
from homeassistant.components import persistent_notification
from homeassistant.util import dt as dt_util
from homeassistant.helpers.event import async_track_point_in_utc_time
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.config_entries import ConfigEntry, SOURCE_IMPORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -21,7 +20,6 @@ from homeassistant.const import (
EVENT_SERVICE_REMOVED,
EVENT_STATE_CHANGED,
EVENT_CALL_SERVICE,
STATE_UNKNOWN,
)
from .coordinator import WatchmanCoordinator
@@ -33,7 +31,7 @@ from .utils import (
table_renderer,
text_renderer,
get_config,
get_report_path,
async_get_report_path,
)
from .const import (
@@ -97,7 +95,7 @@ CONFIG_SCHEMA = vol.Schema(
)
async def async_setup(hass: HomeAssistantType, config: dict):
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up is called when Home Assistant is loading our component."""
if config.get(DOMAIN) is None:
# We get here if the integration is set up using config flow
@@ -113,7 +111,7 @@ async def async_setup(hass: HomeAssistantType, config: dict):
return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up this integration using UI"""
_LOGGER.debug(entry.options)
_LOGGER.debug("Home assistant path: %s", hass.config.path(""))
@@ -134,19 +132,12 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
await add_event_handlers(hass)
if hass.is_running:
# integration reloaded or options changed via UI
parse_config(hass, reason="changes in watchman configuration")
await parse_config(hass, reason="changes in watchman configuration")
await coordinator.async_config_entry_first_refresh()
else:
# first run, home assistant is loading
# parse_config will be scheduled once HA is fully loaded
_LOGGER.info("Watchman started [%s]", VERSION)
# resources = hass.data["lovelace"]["resources"]
# await resources.async_get_info()
# for itm in resources.async_items():
# _LOGGER.debug(itm)
return True
@@ -155,9 +146,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(
hass: HomeAssistant, config_entry
): # pylint: disable=unused-argument
async def async_unload_entry(hass: HomeAssistant, config_entry): # pylint: disable=unused-argument
"""Handle integration unload"""
for cancel_handle in hass.data[DOMAIN].get("cancel_handlers", []):
if cancel_handle:
@@ -189,7 +178,7 @@ async def add_services(hass: HomeAssistant):
async def async_handle_report(call):
"""Handle the service call"""
config = hass.data.get(DOMAIN_DATA, {})
path = get_report_path(hass, config.get(CONF_REPORT_PATH, None))
path = await async_get_report_path(hass, config.get(CONF_REPORT_PATH, None))
send_notification = call.data.get(CONF_SEND_NOTIFICATION, False)
create_file = call.data.get(CONF_CREATE_FILE, True)
test_mode = call.data.get(CONF_TEST_MODE, False)
@@ -211,7 +200,7 @@ async def add_services(hass: HomeAssistant):
await async_notification(hass, "Watchman error", message, error=True)
if call.data.get(CONF_PARSE_CONFIG, False):
parse_config(hass, reason="service call")
await parse_config(hass, reason="service call")
if send_notification:
chunk_size = call.data.get(CONF_CHUNK_SIZE, config.get(CONF_CHUNK_SIZE))
@@ -227,7 +216,7 @@ async def add_services(hass: HomeAssistant):
error=True,
)
if onboarding(hass, service, path):
if await async_onboarding(hass, service, path):
await async_notification(
hass,
"🖖 Achievement unlocked: first report!",
@@ -271,7 +260,7 @@ async def add_event_handlers(hass: HomeAssistant):
await coordinator.async_refresh()
async def async_on_home_assistant_started(event): # pylint: disable=unused-argument
parse_config(hass, reason="HA restart")
await parse_config(hass, reason="HA restart")
startup_delay = get_config(hass, CONF_STARTUP_DELAY, 0)
await async_schedule_refresh_states(hass, startup_delay)
@@ -284,12 +273,12 @@ async def add_event_handlers(hass: HomeAssistant):
"reload_core_config",
"reload",
]:
parse_config(hass, reason="configuration changes")
await parse_config(hass, reason="configuration changes")
coordinator = hass.data[DOMAIN]["coordinator"]
await coordinator.async_refresh()
elif typ in [EVENT_AUTOMATION_RELOADED, EVENT_SCENE_RELOADED]:
parse_config(hass, reason="configuration changes")
await parse_config(hass, reason="configuration changes")
coordinator = hass.data[DOMAIN]["coordinator"]
await coordinator.async_refresh()
@@ -341,14 +330,14 @@ async def add_event_handlers(hass: HomeAssistant):
hass.data[DOMAIN]["cancel_handlers"] = hdlr
def parse_config(hass: HomeAssistant, reason=None):
async def parse_config(hass: HomeAssistant, reason=None):
"""parse home assistant configuration files"""
assert hass.data.get(DOMAIN_DATA)
start_time = time.time()
included_folders = get_included_folders(hass)
ignored_files = hass.data[DOMAIN_DATA].get(CONF_IGNORED_FILES, None)
entity_list, service_list, files_parsed, files_ignored = parse(
entity_list, service_list, files_parsed, files_ignored = await parse(
hass, included_folders, ignored_files, hass.config.config_dir
)
hass.data[DOMAIN]["entity_list"] = entity_list
@@ -376,10 +365,10 @@ def get_included_folders(hass):
config_folders = [hass.config.config_dir]
for fld in config_folders:
folders.append(os.path.join(fld, "**/*.yaml"))
folders.append((fld, "**/*.yaml"))
if DOMAIN_DATA in hass.data and hass.data[DOMAIN_DATA].get(CONF_CHECK_LOVELACE):
folders.append(os.path.join(hass.config.config_dir, ".storage/**/lovelace*"))
folders.append((hass.config.config_dir, ".storage/**/lovelace*"))
return folders
@@ -388,11 +377,16 @@ async def async_report_to_file(hass, path, test_mode):
"""save report to a file"""
coordinator = hass.data[DOMAIN]["coordinator"]
await coordinator.async_refresh()
report_chunks = report(hass, table_renderer, chunk_size=0, test_mode=test_mode)
# OSError exception is handled in async_handle_report
with open(path, "w", encoding="utf-8") as report_file:
for chunk in report_chunks:
report_file.write(chunk)
report_chunks = await report(
hass, table_renderer, chunk_size=0, test_mode=test_mode
)
def write(path):
with open(path, "w", encoding="utf-8") as report_file:
for chunk in report_chunks:
report_file.write(chunk)
await hass.async_add_executor_job(write, path)
async def async_report_to_notification(hass, service_str, service_data, chunk_size):
@@ -423,7 +417,7 @@ async def async_report_to_notification(hass, service_str, service_data, chunk_si
coordinator = hass.data[DOMAIN]["coordinator"]
await coordinator.async_refresh()
report_chunks = report(hass, text_renderer, chunk_size)
report_chunks = await report(hass, text_renderer, chunk_size)
for chunk in report_chunks:
data["message"] = chunk
# blocking=True ensures execution order
@@ -446,7 +440,7 @@ async def async_notification(hass, title, message, error=False, n_id="watchman")
raise HomeAssistantError(message.replace("`", ""))
def onboarding(hass, service, path):
async def async_onboarding(hass, service, path):
"""check if the user runs report for the first time"""
service = service or get_config(hass, CONF_SERVICE_NAME, None)
return not (service or os.path.exists(path))
return not (service or await Path(path).exists())

View File

@@ -1,4 +1,5 @@
"ConfigFlow definition for watchman"
from typing import Dict
import json
from json.decoder import JSONDecodeError
@@ -7,7 +8,7 @@ from homeassistant.config_entries import ConfigFlow, OptionsFlow, ConfigEntry
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv, selector
import voluptuous as vol
from .utils import is_service, get_columns_width, get_report_path
from .utils import is_service, get_columns_width, async_get_report_path
from .const import (
DOMAIN,
@@ -91,7 +92,7 @@ class OptionsFlowHandler(OptionsFlow):
def __init__(self, config_entry: ConfigEntry) -> None:
self.config_entry = config_entry
def default(self, key, uinput=None):
async def async_default(self, key, uinput=None):
"""provide default value for an OptionsFlow field"""
if uinput and key in uinput:
# supply last entered value to display an error during form validation
@@ -105,7 +106,7 @@ class OptionsFlowHandler(OptionsFlow):
if DEFAULT_DATA[key]:
result = DEFAULT_DATA[key]
elif key == CONF_REPORT_PATH:
result = get_report_path(self.hass, None)
result = await async_get_report_path(self.hass, None)
if isinstance(result, list):
return ", ".join([str(i) for i in result])
@@ -130,9 +131,7 @@ class OptionsFlowHandler(OptionsFlow):
errors[key] = f"invalid_{key}"
return val, errors
async def _show_options_form(
self, uinput=None, errors=None, placehoders=None
): # pylint: disable=unused-argument
async def _show_options_form(self, uinput=None, errors=None, placehoders=None): # pylint: disable=unused-argument
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
@@ -140,19 +139,23 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(
CONF_SERVICE_NAME,
description={
"suggested_value": self.default(CONF_SERVICE_NAME, uinput)
"suggested_value": await self.async_default(
CONF_SERVICE_NAME, uinput
)
},
): cv.string,
vol.Optional(
CONF_SERVICE_DATA2,
description={
"suggested_value": self.default(CONF_SERVICE_DATA2, uinput)
"suggested_value": await self.async_default(
CONF_SERVICE_DATA2, uinput
)
},
): selector.TemplateSelector(),
vol.Optional(
CONF_INCLUDED_FOLDERS,
description={
"suggested_value": self.default(
"suggested_value": await self.async_default(
CONF_INCLUDED_FOLDERS, uinput
)
},
@@ -162,19 +165,25 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(
CONF_HEADER,
description={
"suggested_value": self.default(CONF_HEADER, uinput)
"suggested_value": await self.async_default(
CONF_HEADER, uinput
)
},
): cv.string,
vol.Optional(
CONF_REPORT_PATH,
description={
"suggested_value": self.default(CONF_REPORT_PATH, uinput)
"suggested_value": await self.async_default(
CONF_REPORT_PATH, uinput
)
},
): cv.string,
vol.Optional(
CONF_IGNORED_ITEMS,
description={
"suggested_value": self.default(CONF_IGNORED_ITEMS, uinput)
"suggested_value": await self.async_default(
CONF_IGNORED_ITEMS, uinput
)
},
): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
@@ -182,7 +191,9 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(
CONF_IGNORED_STATES,
description={
"suggested_value": self.default(CONF_IGNORED_STATES, uinput)
"suggested_value": await self.async_default(
CONF_IGNORED_STATES, uinput
)
},
): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
@@ -190,13 +201,17 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(
CONF_CHUNK_SIZE,
description={
"suggested_value": self.default(CONF_CHUNK_SIZE, uinput)
"suggested_value": await self.async_default(
CONF_CHUNK_SIZE, uinput
)
},
): cv.positive_int,
vol.Optional(
CONF_IGNORED_FILES,
description={
"suggested_value": self.default(CONF_IGNORED_FILES, uinput)
"suggested_value": await self.async_default(
CONF_IGNORED_FILES, uinput
)
},
): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
@@ -204,25 +219,33 @@ class OptionsFlowHandler(OptionsFlow):
vol.Optional(
CONF_COLUMNS_WIDTH,
description={
"suggested_value": self.default(CONF_COLUMNS_WIDTH, uinput)
"suggested_value": await self.async_default(
CONF_COLUMNS_WIDTH, uinput
)
},
): cv.string,
vol.Optional(
CONF_STARTUP_DELAY,
description={
"suggested_value": self.default(CONF_STARTUP_DELAY, uinput)
"suggested_value": await self.async_default(
CONF_STARTUP_DELAY, uinput
)
},
): cv.positive_int,
vol.Optional(
CONF_FRIENDLY_NAMES,
description={
"suggested_value": self.default(CONF_FRIENDLY_NAMES, uinput)
"suggested_value": await self.async_default(
CONF_FRIENDLY_NAMES, uinput
)
},
): cv.boolean,
vol.Optional(
CONF_CHECK_LOVELACE,
description={
"suggested_value": self.default(CONF_CHECK_LOVELACE, uinput)
"suggested_value": await self.async_default(
CONF_CHECK_LOVELACE, uinput
)
},
): cv.boolean,
}

View File

@@ -1,9 +1,10 @@
"definition of constants"
from homeassistant.const import Platform
DOMAIN = "watchman"
DOMAIN_DATA = "watchman_data"
VERSION = "0.6.1"
VERSION = "0.6.3"
DEFAULT_REPORT_FILENAME = "watchman_report.txt"
DEFAULT_HEADER = "-== WATCHMAN REPORT ==- "

View File

@@ -1,7 +1,7 @@
"""Represents Watchman service in the device registry of Home Assistant"""
from homeassistant.helpers.entity import DeviceInfo, EntityDescription
from homeassistant.helpers.device_registry import DeviceEntryType
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,

View File

@@ -1,15 +1,15 @@
{
"domain": "watchman",
"name": "Watchman",
"documentation": "https://github.com/dummylabs/thewatchman",
"issue_tracker": "https://github.com/dummylabs/thewatchman/issues",
"iot_class": "local_push",
"version": "0.5.1",
"requirements": [
"prettytable==3.0.0"
],
"codeowners": [
"@dummylabs"
],
"config_flow": true
"config_flow": true,
"documentation": "https://github.com/dummylabs/thewatchman",
"iot_class": "local_push",
"issue_tracker": "https://github.com/dummylabs/thewatchman/issues",
"requirements": [
"prettytable==3.10.0"
],
"version": "0.6.3"
}

View File

@@ -1,9 +1,12 @@
"""Watchman sensors definition"""
import logging
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.components.sensor.const import (
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.core import callback

View File

@@ -2,45 +2,33 @@ report:
description: Run watchman report
fields:
create_file:
description: Whether report file should be created (optional, true by default)
example: true
name: Create file report
default: true
required: false
selector:
boolean:
send_notification:
description: Whether report should be sent via notification service (optional, false by default)
example: true
name: Send notification
default: false
required: false
selector:
boolean:
service:
description: Notification service to send report via (optional). Overrides "service" setting from watchman configuration
example: "notify.telegram"
name: Notification service
required: false
selector:
text:
data:
description: Additional data in form of key:value pairs for notification service (optional)
example: "parse_mode: html"
name: Notification service data parameters
parse_config:
description: Parse configuration files before report is created. Usually this is done by watchman automatically, so this flag is not required. (optional, false by default)
example: true
name: Parse configuration
default: false
required: false
selector:
boolean:
chunk_size:
description: Maximum message size in bytes. If report size exceeds chunk_size, the report will be sent in several subsequent notifications. (optional, default is 3500 or whatever specified in integration settings)
example: true
name: Chunk size
default: false
required: false
selector:

View File

@@ -41,5 +41,37 @@
"description": "[Help on settings](https://github.com/dummylabs/thewatchman#configuration)"
}
}
},
"services": {
"report": {
"name": "Report",
"description": "Run the Watchman report",
"fields": {
"create_file": {
"name": "Create file report",
"description": "Whether report file should be created (optional, true by default)"
},
"send_notification": {
"name": "Send notification",
"description": "Whether report should be sent via notification service (optional, false by default)"
},
"service": {
"name": "Notification service",
"description": "Notification service to send report via (optional). Overrides 'service' setting from watchman configuration"
},
"data": {
"name": "Notification service data parameters",
"description": "Additional data in form of key:value pairs for notification service (optional)"
},
"parse_config": {
"name": "Force configuration parsing",
"description": "Parse configuration files before report is created. Usually this is done by watchman automatically, so this flag is not required. (optional, false by default)"
},
"chunk_size": {
"name": "Report chunk size",
"description": "Maximum message size in bytes. If report size exceeds chunk_size, the report will be sent in several subsequent notifications. (optional, default is 3500 or whatever specified in integration settings)"
}
}
}
}
}

View File

@@ -1,5 +1,6 @@
"""Miscellaneous support functions for watchman"""
import glob
import anyio
import re
import fnmatch
import time
@@ -58,12 +59,12 @@ def get_config(hass: HomeAssistant, key, default):
return hass.data[DOMAIN_DATA].get(key, default)
def get_report_path(hass, path):
async def async_get_report_path(hass, path):
"""if path not specified, create report in config directory with default filename"""
if not path:
path = hass.config.path(DEFAULT_REPORT_FILENAME)
folder, _ = os.path.split(path)
if not os.path.exists(folder):
if not await anyio.Path(folder).exists():
raise HomeAssistantError(f"Incorrect report_path: {path}.")
return path
@@ -147,16 +148,25 @@ def text_renderer(hass, entry_type):
return f"Text render error: unknown entry type: {entry_type}"
def get_next_file(folder_list, ignored_files):
async def async_get_next_file(folder_tuples, ignored_files):
"""Returns next file for scan"""
if not ignored_files:
ignored_files = ""
else:
ignored_files = "|".join([f"({fnmatch.translate(f)})" for f in ignored_files])
ignored_files_re = re.compile(ignored_files)
for folder in folder_list:
for filename in glob.iglob(folder, recursive=True):
yield (filename, (ignored_files and ignored_files_re.match(filename)))
for folder_name, glob_pattern in folder_tuples:
_LOGGER.debug(
"Scan folder %s with pattern %s for configuration files",
folder_name,
glob_pattern,
)
async for filename in anyio.Path(folder_name).glob(glob_pattern):
_LOGGER.debug("Found file %s.", filename)
yield (
str(filename),
(ignored_files and ignored_files_re.match(str(filename))),
)
def add_entry(_list, entry, yaml_file, lineno):
@@ -231,7 +241,7 @@ def check_entitites(hass):
return entities_missing
def parse(hass, folders, ignored_files, root=None):
async def parse(hass, folders, ignored_files, root=None):
"""Parse a yaml or json file for entities/services"""
files_parsed = 0
entity_pattern = re.compile(
@@ -247,7 +257,7 @@ def parse(hass, folders, ignored_files, root=None):
service_list = {}
effectively_ignored = []
_LOGGER.debug("::parse started")
for yaml_file, ignored in get_next_file(folders, ignored_files):
async for yaml_file, ignored in async_get_next_file(folders, ignored_files):
short_path = os.path.relpath(yaml_file, root)
if ignored:
effectively_ignored.append(short_path)
@@ -255,19 +265,24 @@ def parse(hass, folders, ignored_files, root=None):
continue
try:
for i, line in enumerate(open(yaml_file, encoding="utf-8")):
line = re.sub(comment_pattern, "", line)
for match in re.finditer(entity_pattern, line):
typ, val = match.group(1), match.group(2)
if (
typ != "service:"
and "*" not in val
and not val.endswith(".yaml")
):
add_entry(entity_list, val, short_path, i + 1)
for match in re.finditer(service_pattern, line):
val = match.group(1)
add_entry(service_list, val, short_path, i + 1)
lineno = 1
async with await anyio.open_file(
yaml_file, mode="r", encoding="utf-8"
) as f:
async for line in f:
line = re.sub(comment_pattern, "", line)
for match in re.finditer(entity_pattern, line):
typ, val = match.group(1), match.group(2)
if (
typ != "service:"
and "*" not in val
and not val.endswith(".yaml")
):
add_entry(entity_list, val, short_path, lineno)
for match in re.finditer(service_pattern, line):
val = match.group(1)
add_entry(service_list, val, short_path, lineno)
lineno += 1
files_parsed += 1
_LOGGER.debug("%s parsed", yaml_file)
except OSError as exception:
@@ -312,9 +327,9 @@ def fill(data, width, extra=None):
)
def report(hass, render, chunk_size, test_mode=False):
async def report(hass, render, chunk_size, test_mode=False):
"""generates watchman report either as a table or as a list"""
if not DOMAIN in hass.data:
if DOMAIN not in hass.data:
raise HomeAssistantError("No data for report, refresh required.")
start_time = time.time()
@@ -354,7 +369,11 @@ def report(hass, render, chunk_size, test_mode=False):
rep += "your config are available!\n"
else:
rep += "\n-== No entities found in configuration files!\n"
timezone = pytz.timezone(hass.config.time_zone)
def get_timezone(hass):
return pytz.timezone(hass.config.time_zone)
timezone = await hass.async_add_executor_job(get_timezone, hass)
if not test_mode:
report_datetime = datetime.now(timezone).strftime("%d %b %Y %H:%M:%S")