diff --git a/ecowitt2mqtt/__init__.py b/ecowitt2mqtt/__init__.py index 3a44993e..6cf60a26 100644 --- a/ecowitt2mqtt/__init__.py +++ b/ecowitt2mqtt/__init__.py @@ -1,2 +1 @@ """Define the ecowitt2mqtt package.""" -from ecowitt2mqtt.const import __version__ # noqa diff --git a/ecowitt2mqtt/__main__.py b/ecowitt2mqtt/__main__.py new file mode 100644 index 00000000..44859e7b --- /dev/null +++ b/ecowitt2mqtt/__main__.py @@ -0,0 +1,352 @@ +"""Define the main interface to the CLI.""" +from __future__ import annotations + +import argparse +import asyncio +import os +import sys +from typing import Any + +import uvloop + +from ecowitt2mqtt.const import ( + CONF_BATTERY_OVERRIDES, + CONF_CONFIG, + CONF_DEFAULT_BATTERY_STRATEGY, + CONF_DIAGNOSTICS, + CONF_DISABLE_CALCULATED_DATA, + CONF_ENDPOINT, + CONF_HASS_DISCOVERY, + CONF_HASS_DISCOVERY_PREFIX, + CONF_HASS_ENTITY_ID_PREFIX, + CONF_INPUT_UNIT_SYSTEM, + CONF_MQTT_BROKER, + CONF_MQTT_PASSWORD, + CONF_MQTT_PORT, + CONF_MQTT_RETAIN, + CONF_MQTT_TLS, + CONF_MQTT_TOPIC, + CONF_MQTT_USERNAME, + CONF_OUTPUT_UNIT_SYSTEM, + CONF_PORT, + CONF_RAW_DATA, + CONF_VERBOSE, + DEFAULT_ENDPOINT, + DEFAULT_HASS_DISCOVERY_PREFIX, + DEFAULT_MQTT_PORT, + DEFAULT_PORT, + ENV_BATTERY_OVERRIDES, + ENV_CONFIG, + ENV_DEFAULT_BATTERY_STRATEGY, + ENV_DIAGNOSTICS, + ENV_DISABLE_CALCULATED_DATA, + ENV_ENDPOINT, + ENV_HASS_DISCOVERY, + ENV_HASS_DISCOVERY_PREFIX, + ENV_HASS_ENTITY_ID_PREFIX, + ENV_INPUT_UNIT_SYSTEM, + ENV_MQTT_BROKER, + ENV_MQTT_PASSWORD, + ENV_MQTT_PORT, + ENV_MQTT_RETAIN, + ENV_MQTT_TLS, + ENV_MQTT_TOPIC, + ENV_MQTT_USERNAME, + ENV_OUTPUT_UNIT_SYSTEM, + ENV_PORT, + ENV_RAW_DATA, + ENV_VERBOSE, + LEGACY_ENV_ENDPOINT, + LEGACY_ENV_HASS_DISCOVERY, + LEGACY_ENV_HASS_DISCOVERY_PREFIX, + LEGACY_ENV_HASS_ENTITY_ID_PREFIX, + LEGACY_ENV_INPUT_UNIT_SYSTEM, + LEGACY_ENV_LOG_LEVEL, + LEGACY_ENV_MQTT_BROKER, + LEGACY_ENV_MQTT_PASSWORD, + LEGACY_ENV_MQTT_PORT, + LEGACY_ENV_MQTT_TOPIC, + LEGACY_ENV_MQTT_USERNAME, + LEGACY_ENV_OUTPUT_UNIT_SYSTEM, + LEGACY_ENV_PORT, + LEGACY_ENV_RAW_DATA, + LOGGER, + UNIT_SYSTEM_IMPERIAL, + __version__, +) +from ecowitt2mqtt.core import Ecowitt +from ecowitt2mqtt.helpers.calculator.battery import BatteryStrategy + +DEPRECATED_ENV_VAR_MAP = { + LEGACY_ENV_ENDPOINT: ENV_ENDPOINT, + LEGACY_ENV_HASS_DISCOVERY: ENV_HASS_DISCOVERY, + LEGACY_ENV_HASS_DISCOVERY_PREFIX: ENV_HASS_DISCOVERY_PREFIX, + LEGACY_ENV_HASS_ENTITY_ID_PREFIX: ENV_HASS_ENTITY_ID_PREFIX, + LEGACY_ENV_INPUT_UNIT_SYSTEM: ENV_INPUT_UNIT_SYSTEM, + LEGACY_ENV_LOG_LEVEL: ENV_VERBOSE, + LEGACY_ENV_MQTT_BROKER: ENV_MQTT_BROKER, + LEGACY_ENV_MQTT_PASSWORD: ENV_MQTT_PASSWORD, + LEGACY_ENV_MQTT_PORT: ENV_MQTT_PORT, + LEGACY_ENV_MQTT_TOPIC: ENV_MQTT_TOPIC, + LEGACY_ENV_MQTT_USERNAME: ENV_MQTT_USERNAME, + LEGACY_ENV_OUTPUT_UNIT_SYSTEM: ENV_OUTPUT_UNIT_SYSTEM, + LEGACY_ENV_PORT: ENV_PORT, + LEGACY_ENV_RAW_DATA: ENV_RAW_DATA, +} + +ENV_VAR_TO_CONF_MAP = { + ENV_BATTERY_OVERRIDES: CONF_BATTERY_OVERRIDES, + ENV_CONFIG: CONF_CONFIG, + ENV_DEFAULT_BATTERY_STRATEGY: CONF_DEFAULT_BATTERY_STRATEGY, + ENV_DIAGNOSTICS: CONF_DIAGNOSTICS, + ENV_DISABLE_CALCULATED_DATA: CONF_DISABLE_CALCULATED_DATA, + ENV_ENDPOINT: CONF_ENDPOINT, + ENV_HASS_DISCOVERY: CONF_HASS_DISCOVERY, + ENV_HASS_DISCOVERY_PREFIX: CONF_HASS_DISCOVERY_PREFIX, + ENV_HASS_ENTITY_ID_PREFIX: CONF_HASS_ENTITY_ID_PREFIX, + ENV_INPUT_UNIT_SYSTEM: CONF_INPUT_UNIT_SYSTEM, + ENV_MQTT_BROKER: CONF_MQTT_BROKER, + ENV_MQTT_PASSWORD: CONF_MQTT_PASSWORD, + ENV_MQTT_PORT: CONF_MQTT_PORT, + ENV_MQTT_RETAIN: CONF_MQTT_RETAIN, + ENV_MQTT_TLS: CONF_MQTT_TLS, + ENV_MQTT_TOPIC: CONF_MQTT_TOPIC, + ENV_MQTT_USERNAME: CONF_MQTT_USERNAME, + ENV_OUTPUT_UNIT_SYSTEM: CONF_OUTPUT_UNIT_SYSTEM, + ENV_PORT: CONF_PORT, + ENV_RAW_DATA: CONF_RAW_DATA, + ENV_VERBOSE: CONF_VERBOSE, +} + + +def get_env_vars() -> dict[str, str]: + """Get environment variables.""" + env_vars = {} + + for env_var in ( + ENV_BATTERY_OVERRIDES, + ENV_CONFIG, + ENV_DEFAULT_BATTERY_STRATEGY, + ENV_DIAGNOSTICS, + ENV_DISABLE_CALCULATED_DATA, + ENV_ENDPOINT, + ENV_HASS_DISCOVERY, + ENV_HASS_DISCOVERY_PREFIX, + ENV_HASS_ENTITY_ID_PREFIX, + ENV_INPUT_UNIT_SYSTEM, + ENV_MQTT_BROKER, + ENV_MQTT_PASSWORD, + ENV_MQTT_PORT, + ENV_MQTT_RETAIN, + ENV_MQTT_TLS, + ENV_MQTT_TOPIC, + ENV_MQTT_USERNAME, + ENV_OUTPUT_UNIT_SYSTEM, + ENV_PORT, + ENV_RAW_DATA, + ENV_VERBOSE, + LEGACY_ENV_ENDPOINT, + LEGACY_ENV_HASS_DISCOVERY, + LEGACY_ENV_HASS_DISCOVERY_PREFIX, + LEGACY_ENV_HASS_ENTITY_ID_PREFIX, + LEGACY_ENV_INPUT_UNIT_SYSTEM, + LEGACY_ENV_LOG_LEVEL, + LEGACY_ENV_MQTT_BROKER, + LEGACY_ENV_MQTT_PASSWORD, + LEGACY_ENV_MQTT_PORT, + LEGACY_ENV_MQTT_TOPIC, + LEGACY_ENV_MQTT_USERNAME, + LEGACY_ENV_OUTPUT_UNIT_SYSTEM, + LEGACY_ENV_PORT, + LEGACY_ENV_RAW_DATA, + ): + if (env_var_value := os.getenv(env_var)) is None: + continue + + if (replacement_env_var := DEPRECATED_ENV_VAR_MAP.get(env_var)) is not None: + LOGGER.warning( + "Environment variable %s is deprecated; use %s instead", + env_var, + replacement_env_var, + ) + env_var = replacement_env_var + + config_option = ENV_VAR_TO_CONF_MAP[env_var] + env_vars[config_option] = env_var_value + + return env_vars + + +def get_cli_arguments(args: list[str]) -> dict[str, Any]: + """Get CLI arguments.""" + parser = argparse.ArgumentParser( + argument_default=argparse.SUPPRESS, + description="Send data from an Ecowitt gateway to an MQTT broker", + ) + parser.add_argument("--version", action="version", version=__version__) + parser.add_argument( + "--battery-override", + dest=CONF_BATTERY_OVERRIDES, + help="A battery configuration override (format: key,value)", + ) + parser.add_argument( + "-c", + "--config", + dest=CONF_CONFIG, + help="A path to a YAML or JSON config file", + metavar="config", + ) + parser.add_argument( + "--default-battery-strategy", + dest=CONF_DEFAULT_BATTERY_STRATEGY, + help=( + "The default battery config strategy to use " + f"(default: {BatteryStrategy.BOOLEAN})" + ), + metavar="default_battery_strategy", + ) + parser.add_argument( + "--diagnostics", + action="store_true", + dest=CONF_DIAGNOSTICS, + help="Output diagnostics", + ) + parser.add_argument( + "--disable-calculated-data", + action="store_true", + dest=CONF_DISABLE_CALCULATED_DATA, + help="Disable the output of calculated sensors", + ) + parser.add_argument( + "-e", + "--endpoint", + dest=CONF_ENDPOINT, + help=( + "The relative endpoint/path to serve ecowitt2mqtt on " + f"(default: {DEFAULT_ENDPOINT})" + ), + metavar="endpoint", + ) + parser.add_argument( + "--hass-discovery", + action="store_true", + dest=CONF_HASS_DISCOVERY, + help="Publish data in the Home Assistant MQTT Discovery format", + ) + parser.add_argument( + "--hass-discovery-prefix", + dest=CONF_HASS_DISCOVERY_PREFIX, + help=( + "The Home Assistant MQTT Discovery topic prefix to use " + f"(default: {DEFAULT_HASS_DISCOVERY_PREFIX})" + ), + metavar="hass_discovery_prefix", + ) + parser.add_argument( + "--hass-entity-id-prefix", + dest=CONF_HASS_ENTITY_ID_PREFIX, + help="The prefix to use for Home Assistant entity IDs", + metavar="hass_entity_id_prefix", + ) + parser.add_argument( + "--input-unit-system", + dest=CONF_INPUT_UNIT_SYSTEM, + help=( + "The input unit system used by the gateway " + f"(default: {UNIT_SYSTEM_IMPERIAL})" + ), + metavar="input_unit_system", + ) + parser.add_argument( + "-b", + "--mqtt-broker", + dest=CONF_MQTT_BROKER, + help="The hostname or IP address of an MQTT broker", + metavar="mqtt_broker", + ) + parser.add_argument( + "-p", + "--mqtt-password", + dest=CONF_MQTT_PASSWORD, + help="A valid password for the MQTT broker", + metavar="mqtt_password", + ) + parser.add_argument( + "--mqtt-port", + dest=CONF_MQTT_PORT, + help=f"The listenting port of the MQTT broker (default: {DEFAULT_MQTT_PORT})", + metavar="mqtt_port", + ) + parser.add_argument( + "--mqtt-retain", + action="store_true", + dest=CONF_MQTT_RETAIN, + help="Instruct the MQTT broker to retain messages", + ) + parser.add_argument( + "--mqtt-tls", + action="store_true", + dest=CONF_MQTT_TLS, + help="Enable MQTT over TLS", + ) + parser.add_argument( + "-t", + "--mqtt-topic", + dest=CONF_MQTT_TOPIC, + help="The MQTT topic to publish device data to", + metavar="mqtt_topic", + ) + parser.add_argument( + "-u", + "--mqtt-username", + dest=CONF_MQTT_USERNAME, + help="A valid username for the MQTT broker", + metavar="mqtt_username", + ) + parser.add_argument( + "--output-unit-system", + dest=CONF_OUTPUT_UNIT_SYSTEM, + help=( + "The output unit system used by the gateway " + f"(default: {UNIT_SYSTEM_IMPERIAL})" + ), + metavar="output_unit_system", + ) + parser.add_argument( + "--port", + dest=CONF_PORT, + help=f"The port to serve ecowitt2mqtt on (default: {DEFAULT_PORT})", + metavar="port", + ) + parser.add_argument( + "--raw-data", + action="store_true", + dest=CONF_RAW_DATA, + help="Return raw data (don't attempt to translate any values)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + dest=CONF_VERBOSE, + help="Increase verbosity of logged output", + ) + + arguments = parser.parse_args(args) + return vars(arguments) + + +def main() -> None: + """Run.""" + loop = uvloop.new_event_loop() + asyncio.set_event_loop(loop) + + cli_arguments = get_cli_arguments(sys.argv[1:]) + env_vars = get_env_vars() + params: dict[str, Any] = {**env_vars, **cli_arguments} + + if CONF_DIAGNOSTICS in params: + params[CONF_VERBOSE] = True + + ecowitt = Ecowitt(params) + loop.run_until_complete(ecowitt.async_start()) diff --git a/ecowitt2mqtt/cli.py b/ecowitt2mqtt/cli.py deleted file mode 100644 index 2875c321..00000000 --- a/ecowitt2mqtt/cli.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Define the main interface to the CLI.""" -import asyncio -from pathlib import Path -from typing import List - -import typer -import uvloop - -from ecowitt2mqtt.const import ( - CONF_VERBOSE, - ENV_BATTERY_OVERRIDE, - ENV_CONFIG, - ENV_DEFAULT_BATTERY_STRATEGY, - ENV_DIAGNOSTICS, - ENV_DISABLE_CALCULATED_DATA, - ENV_ENDPOINT, - ENV_HASS_DISCOVERY, - ENV_HASS_DISCOVERY_PREFIX, - ENV_HASS_ENTITY_ID_PREFIX, - ENV_INPUT_UNIT_SYSTEM, - ENV_MQTT_BROKER, - ENV_MQTT_PASSWORD, - ENV_MQTT_PORT, - ENV_MQTT_RETAIN, - ENV_MQTT_TLS, - ENV_MQTT_TOPIC, - ENV_MQTT_USERNAME, - ENV_OUTPUT_UNIT_SYSTEM, - ENV_PORT, - ENV_RAW_DATA, - ENV_VERBOSE, - LEGACY_ENV_ENDPOINT, - LEGACY_ENV_HASS_DISCOVERY, - LEGACY_ENV_HASS_DISCOVERY_PREFIX, - LEGACY_ENV_HASS_ENTITY_ID_PREFIX, - LEGACY_ENV_INPUT_UNIT_SYSTEM, - LEGACY_ENV_MQTT_BROKER, - LEGACY_ENV_MQTT_PASSWORD, - LEGACY_ENV_MQTT_PORT, - LEGACY_ENV_MQTT_TOPIC, - LEGACY_ENV_MQTT_USERNAME, - LEGACY_ENV_OUTPUT_UNIT_SYSTEM, - LEGACY_ENV_PORT, - LEGACY_ENV_RAW_DATA, - UNIT_SYSTEM_IMPERIAL, - UNIT_SYSTEM_METRIC, - __version__ as ecowitt2mqtt_version, -) -from ecowitt2mqtt.core import Ecowitt -from ecowitt2mqtt.helpers.calculator.battery import BatteryStrategy -from ecowitt2mqtt.helpers.logging import log_exception - -DEFAULT_ENDPOINT = "/data/report" -DEFAULT_HASS_DISCOVERY_PREFIX = "homeassistant" -DEFAULT_MQTT_PORT = 1883 -DEFAULT_PORT = 8080 - - -def validate_unit_system(value: str) -> str: - """Validate a passed unit system.""" - if value not in (UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC): - raise typer.BadParameter( - f"'{value}' is not one of '{UNIT_SYSTEM_IMPERIAL}', '{UNIT_SYSTEM_METRIC}'" - ) - return value - - -@log_exception() -def main( # pylint: disable=too-many-arguments,too-many-locals - ctx: typer.Context, - battery_override: List[str] = typer.Option( - None, - "--battery-override", - envvar=[ENV_BATTERY_OVERRIDE], - help="A battery configuration override (format: key,value)", - ), - config: Path = typer.Option( - None, - "--config", - "-c", - envvar=[ENV_CONFIG], - exists=True, - file_okay=True, - dir_okay=False, - help="A path to a YAML or JSON config file.", - resolve_path=True, - ), - default_battery_strategy: BatteryStrategy = typer.Option( - BatteryStrategy.BOOLEAN, - "--default-battery-strategy", - envvar=[ENV_DEFAULT_BATTERY_STRATEGY], - help="The default battery config strategy to use.", - metavar="TEXT", - ), - diagnostics: bool = typer.Option( - False, - "--diagnostics", - envvar=[ENV_DIAGNOSTICS], - help="Output diagnostics.", - ), - disable_calculated_data: bool = typer.Option( - False, - "--disable-calculated-data", - envvar=[ENV_DISABLE_CALCULATED_DATA], - help="Disable the output of calculated sensors.", - ), - endpoint: str = typer.Option( - DEFAULT_ENDPOINT, - "--endpoint", - "-e", - envvar=[ENV_ENDPOINT, LEGACY_ENV_ENDPOINT], - help="The relative endpoint/path to serve ecowitt2mqtt on.", - ), - hass_discovery: bool = typer.Option( - False, - "--hass-discovery", - envvar=[ENV_HASS_DISCOVERY, LEGACY_ENV_HASS_DISCOVERY], - help="Publish data in the Home Assistant MQTT Discovery format.", - ), - hass_discovery_prefix: str = typer.Option( - DEFAULT_HASS_DISCOVERY_PREFIX, - "--hass-discovery-prefix", - envvar=[ENV_HASS_DISCOVERY_PREFIX, LEGACY_ENV_HASS_DISCOVERY_PREFIX], - help="The Home Assistant discovery prefix to use.", - ), - hass_entity_id_prefix: str = typer.Option( - None, - "--hass-entity-id-prefix", - envvar=[ENV_HASS_ENTITY_ID_PREFIX, LEGACY_ENV_HASS_ENTITY_ID_PREFIX], - help="The prefix to use for Home Assistant entity IDs.", - ), - input_unit_system: str = typer.Option( - UNIT_SYSTEM_IMPERIAL, - "--input-unit-system", - callback=validate_unit_system, - envvar=[ENV_INPUT_UNIT_SYSTEM, LEGACY_ENV_INPUT_UNIT_SYSTEM], - help="The input unit system used by the device.", - ), - mqtt_broker: str = typer.Option( - None, - "--mqtt-broker", - "-b", - envvar=[ENV_MQTT_BROKER, LEGACY_ENV_MQTT_BROKER], - help="The hostname or IP address of an MQTT broker.", - ), - mqtt_password: str = typer.Option( - None, - "--mqtt-password", - "-p", - envvar=[ENV_MQTT_PASSWORD, LEGACY_ENV_MQTT_PASSWORD], - help="A valid password for the MQTT broker.", - ), - mqtt_port: int = typer.Option( - DEFAULT_MQTT_PORT, - "--mqtt-port", - envvar=[ENV_MQTT_PORT, LEGACY_ENV_MQTT_PORT], - help="The listenting port of the MQTT broker.", - ), - mqtt_retain: bool = typer.Option( - False, - "--mqtt-retain", - envvar=[ENV_MQTT_RETAIN], - help="Instruct the MQTT broker to retain messages.", - ), - mqtt_tls: bool = typer.Option( - False, - "--mqtt-tls", - envvar=[ENV_MQTT_TLS], - help="Enable MQTT over TLS.", - ), - mqtt_topic: str = typer.Option( - None, - "--mqtt-topic", - "-t", - envvar=[ENV_MQTT_TOPIC, LEGACY_ENV_MQTT_TOPIC], - help="The MQTT topic to publish device data to.", - ), - mqtt_username: str = typer.Option( - None, - "--mqtt-username", - "-u", - envvar=[ENV_MQTT_USERNAME, LEGACY_ENV_MQTT_USERNAME], - help="A valid username for the MQTT broker.", - ), - output_unit_system: str = typer.Option( - UNIT_SYSTEM_IMPERIAL, - "--output-unit-system", - callback=validate_unit_system, - envvar=[ENV_OUTPUT_UNIT_SYSTEM, LEGACY_ENV_OUTPUT_UNIT_SYSTEM], - help="The unit system to use in output.", - ), - port: int = typer.Option( - DEFAULT_PORT, - "--port", - envvar=[ENV_PORT, LEGACY_ENV_PORT], - help="The port to serve ecowitt2mqtt on.", - ), - raw_data: bool = typer.Option( - False, - "--raw-data", - envvar=[ENV_RAW_DATA, LEGACY_ENV_RAW_DATA], - help="Return raw data (don't attempt to translate any values).", - ), - verbose: bool = typer.Option( - False, - "--verbose", - "-v", - envvar=[ENV_VERBOSE], - help="Increase verbosity of logged output.", - ), - version: bool = typer.Option( - False, - "--version", - help="Return the application version.", - ), -) -> None: - """ecowitt2mqtt sends Ecowitt device data to an MQTT broker.""" - if version: - typer.echo(ecowitt2mqtt_version) - raise typer.Exit(code=0) - - loop = uvloop.new_event_loop() - asyncio.set_event_loop(loop) - - if diagnostics: - ctx.params[CONF_VERBOSE] = True - - ecowitt = Ecowitt(ctx.params) - loop.run_until_complete(ecowitt.async_start()) - - -CLI_APP = typer.Typer(callback=main, invoke_without_command=True) diff --git a/ecowitt2mqtt/config.py b/ecowitt2mqtt/config.py index ea81f50d..ecca293c 100644 --- a/ecowitt2mqtt/config.py +++ b/ecowitt2mqtt/config.py @@ -5,6 +5,7 @@ from typing import Any, Dict, cast from ruamel.yaml import YAML +import voluptuous as vol from ecowitt2mqtt.const import ( CONF_BATTERY_OVERRIDES, @@ -28,57 +29,74 @@ CONF_PORT, CONF_RAW_DATA, CONF_VERBOSE, - ENV_BATTERY_OVERRIDE, - ENV_ENDPOINT, - ENV_HASS_DISCOVERY, - ENV_HASS_DISCOVERY_PREFIX, - ENV_HASS_ENTITY_ID_PREFIX, - ENV_INPUT_UNIT_SYSTEM, - ENV_MQTT_BROKER, - ENV_MQTT_PASSWORD, - ENV_MQTT_PORT, - ENV_MQTT_TOPIC, - ENV_MQTT_USERNAME, - ENV_OUTPUT_UNIT_SYSTEM, - ENV_PORT, - ENV_RAW_DATA, - ENV_VERBOSE, - LEGACY_ENV_ENDPOINT, - LEGACY_ENV_HASS_DISCOVERY, - LEGACY_ENV_HASS_DISCOVERY_PREFIX, - LEGACY_ENV_HASS_ENTITY_ID_PREFIX, - LEGACY_ENV_INPUT_UNIT_SYSTEM, - LEGACY_ENV_LOG_LEVEL, - LEGACY_ENV_MQTT_BROKER, - LEGACY_ENV_MQTT_PASSWORD, - LEGACY_ENV_MQTT_PORT, - LEGACY_ENV_MQTT_TOPIC, - LEGACY_ENV_MQTT_USERNAME, - LEGACY_ENV_OUTPUT_UNIT_SYSTEM, - LEGACY_ENV_PORT, - LEGACY_ENV_RAW_DATA, - LOGGER, + DEFAULT_ENDPOINT, + DEFAULT_HASS_DISCOVERY_PREFIX, + DEFAULT_MQTT_PORT, + DEFAULT_PORT, + ENV_BATTERY_OVERRIDES, + UNIT_SYSTEM_IMPERIAL, + UNIT_SYSTEMS, ) from ecowitt2mqtt.errors import EcowittError from ecowitt2mqtt.helpers.calculator.battery import BatteryStrategy +import ecowitt2mqtt.helpers.config_validation as cv from ecowitt2mqtt.helpers.typing import UnitSystemType -DEPRECATED_ENV_VAR_MAP = { - LEGACY_ENV_ENDPOINT: ENV_ENDPOINT, - LEGACY_ENV_HASS_DISCOVERY: ENV_HASS_DISCOVERY, - LEGACY_ENV_HASS_DISCOVERY_PREFIX: ENV_HASS_DISCOVERY_PREFIX, - LEGACY_ENV_HASS_ENTITY_ID_PREFIX: ENV_HASS_ENTITY_ID_PREFIX, - LEGACY_ENV_INPUT_UNIT_SYSTEM: ENV_INPUT_UNIT_SYSTEM, - LEGACY_ENV_LOG_LEVEL: ENV_VERBOSE, - LEGACY_ENV_MQTT_BROKER: ENV_MQTT_BROKER, - LEGACY_ENV_MQTT_PASSWORD: ENV_MQTT_PASSWORD, - LEGACY_ENV_MQTT_PORT: ENV_MQTT_PORT, - LEGACY_ENV_MQTT_TOPIC: ENV_MQTT_TOPIC, - LEGACY_ENV_MQTT_USERNAME: ENV_MQTT_USERNAME, - LEGACY_ENV_OUTPUT_UNIT_SYSTEM: ENV_OUTPUT_UNIT_SYSTEM, - LEGACY_ENV_PORT: ENV_PORT, - LEGACY_ENV_RAW_DATA: ENV_RAW_DATA, -} +CONF_DEFAULT = "default" +CONF_GATEWAYS = "gateways" + +HASS_DISCOVERY_SCHEMA = vol.Schema( + { + vol.Required(CONF_HASS_DISCOVERY): vol.All(cv.boolean, True), + vol.Optional( + CONF_HASS_DISCOVERY_PREFIX, default=DEFAULT_HASS_DISCOVERY_PREFIX + ): str, + vol.Optional(CONF_HASS_ENTITY_ID_PREFIX): cv.optional_string, + }, + extra=vol.ALLOW_EXTRA, +) + +MQTT_TOPIC_SCHEMA = vol.Schema( + { + vol.Required(CONF_MQTT_TOPIC): str, + }, + extra=vol.ALLOW_EXTRA, +) + +CONFIG_SCHEMA = vol.All( + vol.Any( + HASS_DISCOVERY_SCHEMA, + MQTT_TOPIC_SCHEMA, + msg="Must provide an MQTT topic or enable Home Assistant MQTT Discovery", + ), + vol.Schema( + { + vol.Required(CONF_MQTT_BROKER): str, + vol.Optional(CONF_BATTERY_OVERRIDES, default={}): cv.battery_override, + vol.Optional( + CONF_DEFAULT_BATTERY_STRATEGY, default=BatteryStrategy.BOOLEAN + ): vol.Coerce(BatteryStrategy), + vol.Optional(CONF_DIAGNOSTICS, default=False): cv.boolean, + vol.Optional(CONF_DISABLE_CALCULATED_DATA, default=False): cv.boolean, + vol.Optional(CONF_ENDPOINT, default=DEFAULT_ENDPOINT): str, + vol.Optional(CONF_INPUT_UNIT_SYSTEM, default=UNIT_SYSTEM_IMPERIAL): vol.All( + str, vol.In(UNIT_SYSTEMS) + ), + vol.Optional(CONF_MQTT_PASSWORD): cv.optional_string, + vol.Optional(CONF_MQTT_PORT, default=DEFAULT_MQTT_PORT): cv.port, + vol.Optional(CONF_MQTT_RETAIN, default=False): cv.boolean, + vol.Optional(CONF_MQTT_TLS, default=False): cv.boolean, + vol.Optional(CONF_MQTT_USERNAME): cv.optional_string, + vol.Optional( + CONF_OUTPUT_UNIT_SYSTEM, default=UNIT_SYSTEM_IMPERIAL + ): vol.All(str, vol.In(UNIT_SYSTEMS)), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_RAW_DATA, default=False): cv.boolean, + vol.Optional(CONF_VERBOSE, default=False): cv.boolean, + }, + extra=vol.ALLOW_EXTRA, + ), +) class ConfigError(EcowittError): @@ -87,124 +105,81 @@ class ConfigError(EcowittError): pass -def convert_battery_config(configs: str | tuple) -> dict[str, BatteryStrategy]: - """Normalize incoming battery configurations depending on the input format. - - 1. Environment Variables (str): "key1=value1;key2=value2" - 2. CLI Options (tuple): ("key1=val1", "key2=val2") - """ - try: - if isinstance(configs, str): - return { - pair[0]: BatteryStrategy(pair[1]) - for assignment in configs.split(";") - if (pair := assignment.split("=")) - } - return { - pair[0]: BatteryStrategy(pair[1]) - for assignment in configs - if (pair := assignment.split("=")) - } - except (IndexError, KeyError, ValueError) as err: - raise ConfigError(f"Unable to parse battery configurations: {configs}") from err - - -class Config: - """Define the configuration management object.""" +def load_config_from_file(config_path: str) -> dict[str, Any]: + """Load config data from a YAML or JSON file.""" + config_file_data = {} - def __init__(self, params: dict[str, Any]) -> None: - """Initialize.""" - LOGGER.debug("CLI options: %s", params) + parser = YAML(typ="safe") + with open(config_path, encoding="utf-8") as config_file: + config_file_data = parser.load(config_file) + + if not isinstance(config_file_data, dict): + raise ConfigError(f"Unable to parse config file: {config_path}") - for legacy_env_var, new_env_var in DEPRECATED_ENV_VAR_MAP.items(): - if os.getenv(legacy_env_var) is None: - continue - LOGGER.warning( - "Environment variable %s is deprecated; use %s instead", - legacy_env_var, - new_env_var, - ) + return config_file_data + +class Config: # pylint: disable=too-many-public-methods + """Define the configuration management object.""" + + def __init__(self, config: dict[str, Any]) -> None: + """Initialize.""" self._config = {} # If the user provides a config file, attempt to load it: - if config_path := params.get(CONF_CONFIG): - parser = YAML(typ="safe") - with open(config_path, encoding="utf-8") as config_file: - self._config = parser.load(config_file) - - if not isinstance(self._config, dict): - raise ConfigError(f"Unable to parse config file: {config_path}") - - if not any(data.get(CONF_MQTT_BROKER) for data in (self._config, params)): - raise ConfigError("Missing required option: --mqtt-broker") - - if not any( - data.get(key) - for key in (CONF_MQTT_TOPIC, CONF_HASS_DISCOVERY) - for data in (self._config, params) - ): - raise ConfigError( - "Missing required option: --mqtt-topic or --hass-discovery" - ) - - self._config.setdefault(CONF_BATTERY_OVERRIDES, {}) - - # Merge the CLI options/environment variables; if the value is falsey (but *not* - # False), ignore it: - for key, value in params.items(): - if key == CONF_DEFAULT_BATTERY_STRATEGY: - self._config[key] = BatteryStrategy(value) - if value is not None: - self._config[key] = value - - if env_battery_overrides := os.getenv(ENV_BATTERY_OVERRIDE): - self._config[CONF_BATTERY_OVERRIDES] = convert_battery_config( - env_battery_overrides - ) - elif CONF_BATTERY_OVERRIDES in params: - self._config[CONF_BATTERY_OVERRIDES] = convert_battery_config( - params[CONF_BATTERY_OVERRIDES] - ) - - LOGGER.debug("Loaded Config: %s", self._config) + if config_path := config.get(CONF_CONFIG): + self._config = load_config_from_file(config_path) + + self._config.update(config) + + # The battery override env var is the only one that isn't passed through from + # the CLI (given its special format), so check for it here: + if battery_overrides_env_var := os.getenv(ENV_BATTERY_OVERRIDES): + self._config[CONF_BATTERY_OVERRIDES] = battery_overrides_env_var + + try: + self._config = CONFIG_SCHEMA(self._config) + except vol.Invalid as err: + raise ConfigError(err) from err + + def __str__(self) -> str: + """Define a string representation of this object.""" + return str(self._config) @property def battery_overrides(self) -> dict[str, BatteryStrategy]: """Return the battery overrides.""" - return cast( - Dict[str, BatteryStrategy], self._config.get(CONF_BATTERY_OVERRIDES) - ) + return cast(Dict[str, BatteryStrategy], self._config[CONF_BATTERY_OVERRIDES]) @property def default_battery_strategy(self) -> BatteryStrategy: """Return the default battery strategy.""" - return cast(BatteryStrategy, self._config.get(CONF_DEFAULT_BATTERY_STRATEGY)) + return cast(BatteryStrategy, self._config[CONF_DEFAULT_BATTERY_STRATEGY]) @property def diagnostics(self) -> bool: """Return whether diagnostics is enabled.""" - return cast(bool, self._config.get(CONF_DIAGNOSTICS, False)) + return cast(bool, self._config[CONF_DIAGNOSTICS]) @property def disable_calculated_data(self) -> bool: """Return whether calculated sensor output is disabled.""" - return cast(bool, self._config.get(CONF_DISABLE_CALCULATED_DATA, False)) + return cast(bool, self._config[CONF_DISABLE_CALCULATED_DATA]) @property def endpoint(self) -> str: """Return the ecowitt2mqtt API endpoint.""" - return cast(str, self._config.get(CONF_ENDPOINT)) + return cast(str, self._config[CONF_ENDPOINT]) @property def hass_discovery(self) -> bool: """Return whether Home Assistant Discovery should be used.""" - return cast(bool, self._config.get(CONF_HASS_DISCOVERY, False)) + return cast(bool, self._config.get(CONF_HASS_DISCOVERY)) @property def hass_discovery_prefix(self) -> str: """Return the Home Assistant Discovery MQTT prefix.""" - return cast(str, self._config.get(CONF_HASS_DISCOVERY_PREFIX)) + return cast(str, self._config[CONF_HASS_DISCOVERY_PREFIX]) @property def hass_entity_id_prefix(self) -> str | None: @@ -214,32 +189,32 @@ def hass_entity_id_prefix(self) -> str | None: @property def input_unit_system(self) -> UnitSystemType: """Return the input unit system.""" - return cast(UnitSystemType, self._config.get(CONF_INPUT_UNIT_SYSTEM)) + return cast(UnitSystemType, self._config[CONF_INPUT_UNIT_SYSTEM]) @property def mqtt_broker(self) -> str: """Return the MQTT broker host/IP address.""" - return cast(str, self._config.get(CONF_MQTT_BROKER)) + return cast(str, self._config[CONF_MQTT_BROKER]) @property - def mqtt_password(self) -> str: + def mqtt_password(self) -> str | None: """Return the MQTT broker password.""" - return cast(str, self._config.get(CONF_MQTT_PASSWORD)) + return self._config.get(CONF_MQTT_PASSWORD) @property def mqtt_port(self) -> int: """Return the MQTT broker port.""" - return cast(int, self._config.get(CONF_MQTT_PORT)) + return cast(int, self._config[CONF_MQTT_PORT]) @property def mqtt_retain(self) -> bool: """Return whether MQTT messages should be retained.""" - return cast(bool, self._config.get(CONF_MQTT_RETAIN, False)) + return cast(bool, self._config[CONF_MQTT_RETAIN]) @property def mqtt_tls(self) -> bool: """Return whether MQTT over TLS is configured.""" - return cast(bool, self._config.get(CONF_MQTT_TLS, False)) + return cast(bool, self._config[CONF_MQTT_TLS]) @property def mqtt_topic(self) -> str | None: @@ -247,26 +222,26 @@ def mqtt_topic(self) -> str | None: return self._config.get(CONF_MQTT_TOPIC) @property - def mqtt_username(self) -> str: + def mqtt_username(self) -> str | None: """Return the MQTT broker username.""" - return cast(str, self._config.get(CONF_MQTT_USERNAME)) + return self._config.get(CONF_MQTT_USERNAME) @property def output_unit_system(self) -> UnitSystemType: """Return the output unit system.""" - return cast(UnitSystemType, self._config.get(CONF_OUTPUT_UNIT_SYSTEM)) + return cast(UnitSystemType, self._config[CONF_OUTPUT_UNIT_SYSTEM]) @property def port(self) -> int: """Return the ecowitt2mqtt API port.""" - return cast(int, self._config.get(CONF_PORT)) + return cast(int, self._config[CONF_PORT]) @property def raw_data(self) -> bool: """Return whether raw data is configured.""" - return cast(bool, self._config.get(CONF_RAW_DATA, False)) + return cast(bool, self._config[CONF_RAW_DATA]) @property def verbose(self) -> bool: """Return whether verbose logging is enabled.""" - return cast(bool, self._config.get(CONF_VERBOSE, False)) + return cast(bool, self._config[CONF_VERBOSE]) diff --git a/ecowitt2mqtt/const.py b/ecowitt2mqtt/const.py index a2d005cd..687d98ef 100644 --- a/ecowitt2mqtt/const.py +++ b/ecowitt2mqtt/const.py @@ -6,7 +6,6 @@ __version__ = "2022.08.4" - LOGGER = logging.getLogger(__package__) # Configuration keys: @@ -118,8 +117,14 @@ DATA_POINT_YEARLY_RAIN: Final = "yearlyrain" DATA_POINT_YRAIN_PIEZO: Final = "yrain_piezo" +# Defaults: +DEFAULT_ENDPOINT: Final = "/data/report" +DEFAULT_HASS_DISCOVERY_PREFIX: Final = "homeassistant" +DEFAULT_MQTT_PORT: Final = 1883 +DEFAULT_PORT: Final = 8080 + # Environment variables: -ENV_BATTERY_OVERRIDE: Final = "ECOWITT2MQTT_BATTERY_OVERRIDE" +ENV_BATTERY_OVERRIDES: Final = "ECOWITT2MQTT_BATTERY_OVERRIDE" ENV_CONFIG: Final = "ECOWITT2MQTT_CONFIG" ENV_DEFAULT_BATTERY_STRATEGY: Final = "ECOWITT2MQTT_DEFAULT_BATTERY_STRATEGY" ENV_DIAGNOSTICS: Final = "ECOWITT2MQTT_DIAGNOSTICS" @@ -160,6 +165,7 @@ # Unit systems: UNIT_SYSTEM_IMPERIAL: UnitSystemType = "imperial" UNIT_SYSTEM_METRIC: UnitSystemType = "metric" +UNIT_SYSTEMS: Final = [UNIT_SYSTEM_IMPERIAL, UNIT_SYSTEM_METRIC] # Degree units DEGREE: Final = "°" diff --git a/ecowitt2mqtt/core.py b/ecowitt2mqtt/core.py index 1cbd7080..6d4b160c 100644 --- a/ecowitt2mqtt/core.py +++ b/ecowitt2mqtt/core.py @@ -3,44 +3,68 @@ import logging import os +import sys from typing import Any -from ecowitt2mqtt.config import Config -from ecowitt2mqtt.const import ( - CONF_VERBOSE, - LEGACY_ENV_LOG_LEVEL, - LOGGER, - __version__ as ecowitt2mqtt_version, -) -from ecowitt2mqtt.helpers.logging import TyperLoggerHandler +import colorlog + +from ecowitt2mqtt.config import Config, ConfigError +from ecowitt2mqtt.const import LEGACY_ENV_LOG_LEVEL, LOGGER, __version__ from ecowitt2mqtt.runtime import Runtime +def configure_logging(verbose: bool) -> None: + """Configure logging.""" + if verbose or os.getenv(LEGACY_ENV_LOG_LEVEL): + log_level = logging.DEBUG + else: + log_level = logging.INFO + + handler = colorlog.StreamHandler() + handler.setFormatter( + colorlog.ColoredFormatter( + "%(log_color)s%(asctime)s | %(levelname)s | %(message)s", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "red,bg_white", + }, + ) + ) + + LOGGER.setLevel(log_level) + LOGGER.addHandler(handler) + + class Ecowitt: # pylint: disable=too-few-public-methods """Define the base application object.""" def __init__(self, params: dict[str, Any]) -> None: """Initialize.""" - if params.get(CONF_VERBOSE) or os.getenv(LEGACY_ENV_LOG_LEVEL): - log_level = logging.DEBUG - else: - log_level = logging.INFO - - logging.basicConfig( - level=log_level, - format="%(asctime)s | %(name)s | %(levelname)s | %(message)s", - handlers=(TyperLoggerHandler(),), - ) + try: + self.config = Config(params) + except ConfigError as err: + LOGGER.error(err) + self.exit(1) - self._config = Config(params) - self._runtime = Runtime(self) + configure_logging(self.config.verbose) - @property - def config(self) -> Config: - """Return the config object.""" - return self._config + LOGGER.debug("Input CLI options/environment variables: %s", params) + LOGGER.debug("Loaded config: %s", self.config) + + self.runtime = Runtime(self) async def async_start(self) -> None: """Start ecowitt2mqtt.""" - LOGGER.info("Starting ecowitt2mqtt (version %s)", ecowitt2mqtt_version) - await self._runtime.async_start() + LOGGER.info("Starting ecowitt2mqtt (version %s)", __version__) + try: + await self.runtime.async_start() + except Exception as err: # pylint: disable=broad-except + LOGGER.error(err) + self.exit(1) + + def exit(self, status_code: int = 0) -> int: + """Stop the application.""" + return sys.exit(status_code) diff --git a/ecowitt2mqtt/helpers/config_validation.py b/ecowitt2mqtt/helpers/config_validation.py new file mode 100644 index 00000000..b34dcc29 --- /dev/null +++ b/ecowitt2mqtt/helpers/config_validation.py @@ -0,0 +1,53 @@ +"""Helpers for config validation using voluptuous.""" +from __future__ import annotations + +from numbers import Number +from typing import Any + +import voluptuous as vol + +from ecowitt2mqtt.helpers.calculator.battery import BatteryStrategy + + +def battery_override( + value: str | tuple[str, str] | dict[str, Any] +) -> dict[str, BatteryStrategy]: + """Validate and coerce one or more battery overrides.""" + try: + if isinstance(value, dict): + return {key: BatteryStrategy(val) for key, val in value.items()} + + if isinstance(value, tuple): + return { + pair[0]: BatteryStrategy(pair[1]) + for assignment in value + if (pair := assignment.split("=")) + } + + return { + pair[0]: BatteryStrategy(pair[1]) + for assignment in value.split(";") + if (pair := assignment.split("=")) + } + except (IndexError, ValueError) as err: + raise vol.Invalid(f"invalid battery override: {value}") from err + + +def boolean(value: Any) -> bool: + """Validate and coerce a boolean value.""" + if isinstance(value, bool): + return value + if isinstance(value, str): + value = value.lower().strip() + if value in ("1", "true", "yes", "on", "enable"): + return True + if value in ("0", "false", "no", "off", "disable"): + return False + elif isinstance(value, Number): + # type ignore: https://github.com/python/mypy/issues/3186 + return value != 0 # type: ignore[comparison-overlap] + raise vol.Invalid(f"invalid boolean value: {value}") + + +optional_string = vol.Any(str, None) +port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) diff --git a/ecowitt2mqtt/helpers/logging.py b/ecowitt2mqtt/helpers/logging.py deleted file mode 100644 index d3dccb28..00000000 --- a/ecowitt2mqtt/helpers/logging.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Define logging helpers.""" -from __future__ import annotations - -from functools import wraps -import logging -import traceback -from typing import Any, Callable, TypeVar - -import typer - -from ecowitt2mqtt.const import LOGGER - -T = TypeVar("T") - - -class TyperLoggerHandler(logging.Handler): - """Define a logging handler that works with Typer.""" - - def emit(self, record: logging.LogRecord) -> None: - """Emit a log record.""" - foreground = None - if record.levelno == logging.CRITICAL: - foreground = typer.colors.BRIGHT_RED - elif record.levelno == logging.DEBUG: - foreground = typer.colors.BRIGHT_BLUE - elif record.levelno == logging.ERROR: - foreground = typer.colors.BRIGHT_RED - elif record.levelno == logging.INFO: - foreground = typer.colors.BRIGHT_GREEN - elif record.levelno == logging.WARNING: - foreground = typer.colors.BRIGHT_YELLOW - typer.secho(self.format(record), fg=foreground) - - -def log_exception( - *, exit_code: int = 1 -) -> Callable[[Callable[..., T]], Callable[..., T]]: - """Define a dectorator to handle exceptions via typer output.""" - - def decorator(func: Callable[..., T]) -> Callable[..., T]: - """Decorate.""" - - @wraps(func) - def wrapper(*args: Any, **kwargs: dict[str, Any]) -> T: - """Wrap.""" - try: - return func(*args, **kwargs) - except Exception as err: # pylint: disable=broad-except - LOGGER.error(err) - LOGGER.debug("".join(traceback.format_tb(err.__traceback__))) - raise typer.Exit(code=exit_code) from err - - return wrapper - - return decorator diff --git a/ecowitt2mqtt/runtime.py b/ecowitt2mqtt/runtime.py index 62787736..f9ceee52 100644 --- a/ecowitt2mqtt/runtime.py +++ b/ecowitt2mqtt/runtime.py @@ -5,7 +5,6 @@ import logging import signal from ssl import SSLContext -import traceback from types import FrameType from typing import TYPE_CHECKING, Any @@ -94,14 +93,13 @@ async def _async_create_mqtt_loop(self) -> None: retry_attempt = 0 if self.ecowitt.config.diagnostics: - LOGGER.debug("*** DIAGNOSTICS COLLECTED") + LOGGER.info("*** DIAGNOSTICS COLLECTED") self.stop() except asyncio.CancelledError: LOGGER.debug("Stopping MQTT process loop") raise except MqttError as err: LOGGER.error("There was an MQTT error: %s", err) - LOGGER.debug("".join(traceback.format_tb(err.__traceback__))) retry_attempt += 1 delay = min(retry_attempt**2, DEFAULT_MAX_RETRY_INTERVAL) diff --git a/pyproject.toml b/pyproject.toml index b4b29edb..68248627 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,14 +67,15 @@ classifiers = [ [tool.poetry.dependencies] "ruamel.yaml" = "^0.17.21" asyncio-mqtt = ">=0.12.1" +colorlog = "^6.6.0" fastapi = "^0.79.0" meteocalc = "^1.1.0" python = "^3.8.0" python-multipart = "^0.0.5" thefuzz = {extras = ["speedup"], version = "^0.19.0"} -typer = {extras = ["all"], version = "^0.6.0"} uvicorn = "^0.18.0" uvloop = "^0.16.0" +voluptuous = "^0.13.1" [tool.poetry.dev-dependencies] aiohttp = "^3.8.1" @@ -85,7 +86,7 @@ pytest-asyncio = "^0.19.0" pytest-cov = "^3.0.0" [tool.poetry.scripts] -ecowitt2mqtt = "ecowitt2mqtt.cli:CLI_APP" +ecowitt2mqtt = "ecowitt2mqtt.__main__:main" [tool.pylint.BASIC] expected-line-ending-format = "LF" diff --git a/tests/conftest.py b/tests/conftest.py index d7d9f068..ccb901bf 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,7 +8,6 @@ import pytest import pytest_asyncio -from typer.testing import CliRunner from ecowitt2mqtt.core import Ecowitt @@ -70,12 +69,6 @@ def raw_config_fixture(): return json.dumps(TEST_CONFIG_JSON) -@pytest.fixture(name="runner") -def runner_fixture(): - """Define a fixture to return a Typer CLI test runner.""" - return CliRunner() - - @pytest_asyncio.fixture(name="setup_asyncio_mqtt") async def setup_asyncio_mqtt_fixture(ecowitt, mock_asyncio_mqtt_client): """Define a fixture to patch asyncio-mqtt properly.""" @@ -94,6 +87,6 @@ async def setup_uvicorn_server_fixture(ecowitt): try: yield finally: - await ecowitt._runtime._server.shutdown() + await ecowitt.runtime._server.shutdown() start_task.cancel() await asyncio.sleep(0.1) diff --git a/tests/publisher/test_hass_discovery.py b/tests/publisher/test_hass_discovery.py index 19c8d302..e69b692e 100644 --- a/tests/publisher/test_hass_discovery.py +++ b/tests/publisher/test_hass_discovery.py @@ -9,6 +9,7 @@ CONF_DEFAULT_BATTERY_STRATEGY, CONF_HASS_DISCOVERY, CONF_HASS_ENTITY_ID_PREFIX, + CONF_VERBOSE, ) from ecowitt2mqtt.helpers.calculator.battery import BatteryStrategy from ecowitt2mqtt.helpers.publisher.factory import get_publisher @@ -47,7 +48,7 @@ async def test_publish( device_data, ecowitt, mock_asyncio_mqtt_client, setup_asyncio_mqtt ): """Test publishing a payload.""" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_has_awaits( @@ -2092,7 +2093,7 @@ async def test_publish_custom_entity_id_prefix( device_data, ecowitt, mock_asyncio_mqtt_client, setup_asyncio_mqtt ): """Test publishing a payload with custom HASS entity ID prefix.""" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_has_awaits( @@ -4140,7 +4141,7 @@ async def test_publish_error_mqtt( ): """Test handling an asyncio-mqtt error when publishing.""" with pytest.raises(MqttError): - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) @@ -4161,7 +4162,7 @@ async def test_publish_numeric_battery_strategy( device_data, ecowitt, mock_asyncio_mqtt_client, setup_asyncio_mqtt ): """Test publishing a payload with numeric battery strategy.""" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_has_awaits( @@ -6197,6 +6198,7 @@ async def test_publish_numeric_battery_strategy( { **TEST_CONFIG_JSON, CONF_HASS_DISCOVERY: True, + CONF_VERBOSE: True, } ], ) @@ -6208,7 +6210,7 @@ async def test_no_entity_description( caplog.set_level(logging.DEBUG) device_data["random"] = "value" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_has_awaits( diff --git a/tests/publisher/test_topic_publisher.py b/tests/publisher/test_topic_publisher.py index 70ad9fcf..b8d428d7 100644 --- a/tests/publisher/test_topic_publisher.py +++ b/tests/publisher/test_topic_publisher.py @@ -21,7 +21,7 @@ async def test_publish_processed( device_data, ecowitt, mock_asyncio_mqtt_client, setup_asyncio_mqtt ): """Test publishing a processed payload to an TopicPublisher.""" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_awaited_with( @@ -45,7 +45,7 @@ async def test_publish_raw( device_data, ecowitt, mock_asyncio_mqtt_client, setup_asyncio_mqtt ): """Test publishing a raw payload to an TopicPublisher.""" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_awaited_with( @@ -79,7 +79,7 @@ async def test_publish_retain( device_data, ecowitt, mock_asyncio_mqtt_client, setup_asyncio_mqtt ): """Test publishing a retained raw payload to an TopicPublisher.""" - await ecowitt._runtime._publisher.async_publish( + await ecowitt.runtime._publisher.async_publish( mock_asyncio_mqtt_client, device_data ) mock_asyncio_mqtt_client.publish.assert_awaited_with( diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index b62bdd14..00000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Define tests for the CLI.""" -import logging - -import pytest - -from ecowitt2mqtt.cli import CLI_APP -from ecowitt2mqtt.helpers.logging import TyperLoggerHandler - - -@pytest.mark.parametrize( - "args,missing_args_str", - [ - ([], "--mqtt-broker"), - (["-b", "127.0.0.1"], "--mqtt-topic or --hass-discovery"), - ], -) -def test_missing_required_options(args, caplog, missing_args_str, runner): - """Test that missing required options are handled.""" - runner.invoke(CLI_APP, args) - assert caplog.messages[0] == f"Missing required option: {missing_args_str}" - - -def test_startup_logging(caplog, config_filepath, runner): - """Test startup logging at various levels.""" - caplog.set_level(logging.INFO) - runner.invoke(CLI_APP, []) - info_log_messages = caplog.messages - - caplog.set_level(logging.DEBUG) - runner.invoke(CLI_APP, ["-v"]) - debug_log_messages = caplog.messages - - # There should be more DEBUG-level logs than INFO-level logs: - assert len(debug_log_messages) > len(info_log_messages) - - -def test_typer_logging_handler(caplog, runner): - """Test the TyperLoggerHandler helper.""" - caplog.set_level(logging.DEBUG) - - handler = TyperLoggerHandler() - logger = logging.getLogger("test") - logger.addHandler(handler) - - logger.critical("Test Critical Message") - logger.debug("Test Debug Message") - logger.error("Test Error Message") - logger.info("Test Info Message") - logger.warning("Test Warning Message") - - assert len(caplog.messages) == 5 diff --git a/tests/test_config.py b/tests/test_config.py index 9eb231fd..6801986e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -10,36 +10,8 @@ CONF_CONFIG, CONF_DEFAULT_BATTERY_STRATEGY, CONF_MQTT_BROKER, - ENV_BATTERY_OVERRIDE, - ENV_DEFAULT_BATTERY_STRATEGY, - ENV_ENDPOINT, - ENV_HASS_DISCOVERY, - ENV_HASS_DISCOVERY_PREFIX, - ENV_HASS_ENTITY_ID_PREFIX, - ENV_INPUT_UNIT_SYSTEM, - ENV_MQTT_BROKER, - ENV_MQTT_PASSWORD, - ENV_MQTT_PORT, - ENV_MQTT_TOPIC, - ENV_MQTT_USERNAME, - ENV_OUTPUT_UNIT_SYSTEM, - ENV_PORT, - ENV_RAW_DATA, - ENV_VERBOSE, - LEGACY_ENV_ENDPOINT, - LEGACY_ENV_HASS_DISCOVERY, - LEGACY_ENV_HASS_DISCOVERY_PREFIX, - LEGACY_ENV_HASS_ENTITY_ID_PREFIX, - LEGACY_ENV_INPUT_UNIT_SYSTEM, - LEGACY_ENV_LOG_LEVEL, - LEGACY_ENV_MQTT_BROKER, - LEGACY_ENV_MQTT_PASSWORD, - LEGACY_ENV_MQTT_PORT, - LEGACY_ENV_MQTT_TOPIC, - LEGACY_ENV_MQTT_USERNAME, - LEGACY_ENV_OUTPUT_UNIT_SYSTEM, - LEGACY_ENV_PORT, - LEGACY_ENV_RAW_DATA, + CONF_VERBOSE, + ENV_BATTERY_OVERRIDES, ) from ecowitt2mqtt.helpers.calculator.battery import BatteryStrategy @@ -103,7 +75,7 @@ def test_battery_overrides_config_file(config_filepath): def test_battery_overrides_env_vars(config): """Test battery configs provided by environment variables.""" os.environ[ - ENV_BATTERY_OVERRIDE + ENV_BATTERY_OVERRIDES ] = "testbatt0=boolean;testbatt1=numeric;testbatt2=percentage" config = Config(config) assert config.battery_overrides == { @@ -111,7 +83,7 @@ def test_battery_overrides_env_vars(config): "testbatt1": BatteryStrategy.NUMERIC, "testbatt2": BatteryStrategy.PERCENTAGE, } - os.environ.pop(ENV_BATTERY_OVERRIDE) + os.environ.pop(ENV_BATTERY_OVERRIDES) @pytest.mark.parametrize( @@ -119,10 +91,7 @@ def test_battery_overrides_env_vars(config): [ { **TEST_CONFIG_JSON, - CONF_BATTERY_OVERRIDES: ( - "testbatt0;boolean", - "testbatt1=numeric", - ), + CONF_BATTERY_OVERRIDES: ("testbatt0;boolean",), }, ], ) @@ -131,10 +100,10 @@ def test_battery_overrides_error(config): with pytest.raises(ConfigError): _ = Config(config) - os.environ[ENV_DEFAULT_BATTERY_STRATEGY] = "some-random-string" - with pytest.raises(ConfigError): + os.environ[ENV_BATTERY_OVERRIDES] = "some-random-string" + with pytest.raises(ConfigError) as err: _ = Config(config) - os.environ.pop(ENV_DEFAULT_BATTERY_STRATEGY) + os.environ.pop(ENV_BATTERY_OVERRIDES) def test_battery_overrides_missing(config): @@ -162,7 +131,9 @@ def test_config_file_empty(config_filepath): """Test an empty config file with no overrides.""" with pytest.raises(ConfigError) as err: _ = Config({CONF_CONFIG: config_filepath}) - assert "Missing required option: --mqtt-broker" in str(err) + assert "Must provide an MQTT topic or enable Home Assistant MQTT Discovery" in str( + err + ) @pytest.mark.parametrize( @@ -204,32 +175,47 @@ def test_default_battery_strategy(config): @pytest.mark.parametrize( - "legacy_env_var,new_env_var,value", + "config", [ - (LEGACY_ENV_ENDPOINT, ENV_ENDPOINT, "/data/output"), - (LEGACY_ENV_HASS_DISCOVERY, ENV_HASS_DISCOVERY, "True"), - (LEGACY_ENV_HASS_DISCOVERY_PREFIX, ENV_HASS_DISCOVERY_PREFIX, "homeassistant"), - (LEGACY_ENV_HASS_ENTITY_ID_PREFIX, ENV_HASS_ENTITY_ID_PREFIX, "ecowitt"), - (LEGACY_ENV_INPUT_UNIT_SYSTEM, ENV_INPUT_UNIT_SYSTEM, "imperial"), - (LEGACY_ENV_LOG_LEVEL, ENV_VERBOSE, "DEBUG"), - (LEGACY_ENV_MQTT_BROKER, ENV_MQTT_BROKER, "127.0.0.1"), - (LEGACY_ENV_MQTT_PASSWORD, ENV_MQTT_PASSWORD, "password"), - (LEGACY_ENV_MQTT_PORT, ENV_MQTT_PORT, "1883"), - (LEGACY_ENV_MQTT_TOPIC, ENV_MQTT_TOPIC, "topic"), - (LEGACY_ENV_MQTT_USERNAME, ENV_MQTT_USERNAME, "username"), - (LEGACY_ENV_OUTPUT_UNIT_SYSTEM, ENV_OUTPUT_UNIT_SYSTEM, "imperial"), - (LEGACY_ENV_PORT, ENV_PORT, "8080"), - (LEGACY_ENV_RAW_DATA, ENV_RAW_DATA, "True"), + { + **TEST_CONFIG_JSON, + CONF_VERBOSE: "This isn't a real value", + }, ], ) -def test_deprecated_env_var(caplog, config, legacy_env_var, new_env_var, value): - """Test logging the usage of a deprecated environment variable.""" - os.environ[legacy_env_var] = value - _ = Config(config) - assert any( - m - for m in caplog.messages - if f"Environment variable {legacy_env_var} is deprecated; use {new_env_var} instead" - in m - ) - os.environ.pop(legacy_env_var) +def test_invalid_boolean_config_validation(config): + """Test an invalid boolean config validation.""" + with pytest.raises(ConfigError): + _ = Config(config) + + +@pytest.mark.parametrize( + "config,verbose_value", + [ + ( + { + **TEST_CONFIG_JSON, + CONF_VERBOSE: "yes", + }, + True, + ), + ( + { + **TEST_CONFIG_JSON, + CONF_VERBOSE: "disable", + }, + False, + ), + ( + { + **TEST_CONFIG_JSON, + CONF_VERBOSE: 5, + }, + True, + ), + ], +) +def test_valid_boolean_config_validation(config, verbose_value): + """Test that various boolean config validations work.""" + config = Config(config) + assert config.verbose is verbose_value diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 00000000..e14110ed --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,64 @@ +"""Test the core Ecowitt object.""" +import logging +from unittest.mock import AsyncMock, patch + +import pytest + +from ecowitt2mqtt.config import Config +from ecowitt2mqtt.const import CONF_VERBOSE +from ecowitt2mqtt.core import Ecowitt +from ecowitt2mqtt.runtime import Runtime + +from tests.common import TEST_CONFIG_JSON + + +@pytest.mark.parametrize( + "config", + [ + { + **TEST_CONFIG_JSON, + CONF_VERBOSE: "yes", + }, + ], +) +def test_ecowitt_create(caplog, config): + """Test the creation of an Ecowitt object. + + This is a just a quick sanity check. + """ + caplog.set_level(logging.DEBUG) + ecowitt = Ecowitt(config) + assert any(m for m in caplog.messages if "Loaded config" in m) + assert isinstance(ecowitt.config, Config) + assert ecowitt.config.verbose is True + assert isinstance(ecowitt.runtime, Runtime) + + +@pytest.mark.parametrize("config", [{}]) +def test_invalid_config(caplog, config): + """Test that an invalid config is caught.""" + with pytest.raises(SystemExit): + _ = Ecowitt(config) + assert any( + m + for m in caplog.messages + if "Must provide an MQTT topic or enable Home Assistant MQTT Discovery" in m + ) + + +@pytest.mark.asyncio +async def test_unhandled_runtime_error(caplog, config): + """Test an unhandled runtime error.""" + ecowitt = Ecowitt(config) + with patch.object( + ecowitt.runtime, + "async_start", + AsyncMock(side_effect=Exception("Something horrible and unexpected happened")), + ): + with pytest.raises(SystemExit): + await ecowitt.async_start() + assert any( + m + for m in caplog.messages + if "Something horrible and unexpected happened" in m + ) diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..1b512568 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,108 @@ +"""Test the main entrypoint.""" +import os +import sys +from unittest.mock import patch + +import pytest + +from ecowitt2mqtt.__main__ import get_cli_arguments, get_env_vars, main +from ecowitt2mqtt.const import ( + CONF_MQTT_BROKER, + CONF_MQTT_TOPIC, + CONF_VERBOSE, + ENV_ENDPOINT, + ENV_HASS_DISCOVERY, + ENV_HASS_DISCOVERY_PREFIX, + ENV_HASS_ENTITY_ID_PREFIX, + ENV_INPUT_UNIT_SYSTEM, + ENV_MQTT_BROKER, + ENV_MQTT_PASSWORD, + ENV_MQTT_PORT, + ENV_MQTT_TOPIC, + ENV_MQTT_USERNAME, + ENV_OUTPUT_UNIT_SYSTEM, + ENV_PORT, + ENV_RAW_DATA, + ENV_VERBOSE, + LEGACY_ENV_ENDPOINT, + LEGACY_ENV_HASS_DISCOVERY, + LEGACY_ENV_HASS_DISCOVERY_PREFIX, + LEGACY_ENV_HASS_ENTITY_ID_PREFIX, + LEGACY_ENV_INPUT_UNIT_SYSTEM, + LEGACY_ENV_LOG_LEVEL, + LEGACY_ENV_MQTT_BROKER, + LEGACY_ENV_MQTT_PASSWORD, + LEGACY_ENV_MQTT_PORT, + LEGACY_ENV_MQTT_TOPIC, + LEGACY_ENV_MQTT_USERNAME, + LEGACY_ENV_OUTPUT_UNIT_SYSTEM, + LEGACY_ENV_PORT, + LEGACY_ENV_RAW_DATA, +) + + +@pytest.mark.parametrize( + "legacy_env_var,new_env_var,value", + [ + (LEGACY_ENV_ENDPOINT, ENV_ENDPOINT, "/data/output"), + (LEGACY_ENV_HASS_DISCOVERY, ENV_HASS_DISCOVERY, "True"), + (LEGACY_ENV_HASS_DISCOVERY_PREFIX, ENV_HASS_DISCOVERY_PREFIX, "homeassistant"), + (LEGACY_ENV_HASS_ENTITY_ID_PREFIX, ENV_HASS_ENTITY_ID_PREFIX, "ecowitt"), + (LEGACY_ENV_INPUT_UNIT_SYSTEM, ENV_INPUT_UNIT_SYSTEM, "imperial"), + (LEGACY_ENV_LOG_LEVEL, ENV_VERBOSE, "DEBUG"), + (LEGACY_ENV_MQTT_BROKER, ENV_MQTT_BROKER, "127.0.0.1"), + (LEGACY_ENV_MQTT_PASSWORD, ENV_MQTT_PASSWORD, "password"), + (LEGACY_ENV_MQTT_PORT, ENV_MQTT_PORT, "1883"), + (LEGACY_ENV_MQTT_TOPIC, ENV_MQTT_TOPIC, "topic"), + (LEGACY_ENV_MQTT_USERNAME, ENV_MQTT_USERNAME, "username"), + (LEGACY_ENV_OUTPUT_UNIT_SYSTEM, ENV_OUTPUT_UNIT_SYSTEM, "imperial"), + (LEGACY_ENV_PORT, ENV_PORT, "8080"), + (LEGACY_ENV_RAW_DATA, ENV_RAW_DATA, "True"), + ], +) +def test_deprecated_env_var(caplog, legacy_env_var, new_env_var, value): + """Test logging the usage of a deprecated environment variable.""" + os.environ[legacy_env_var] = value + _ = get_env_vars() + assert any( + m + for m in caplog.messages + if f"Environment variable {legacy_env_var} is deprecated; use {new_env_var} instead" + in m + ) + os.environ.pop(legacy_env_var) + + +def test_get_cli_arguments(): + """Test getting all set CLI arguments.""" + cli_arguments = get_cli_arguments( + ["--mqtt-broker", "127.0.0.1", "--mqtt-topic", "Test"] + ) + assert cli_arguments == {CONF_MQTT_BROKER: "127.0.0.1", CONF_MQTT_TOPIC: "Test"} + + +def test_get_env_vars(): + """Test getting all set environment variables.""" + os.environ[ENV_VERBOSE] = "TRUE" + env_vars = get_env_vars() + assert env_vars == {CONF_VERBOSE: "TRUE"} + os.environ.pop(ENV_VERBOSE) + + +def test_main(): + """Test the main entrypoint. + + This is effectively a quick sanity check to ensure that the CLI doesn't blow up. + """ + with patch( + "sys.argv", + [ + "ecowitt2mqtt", + "--mqtt-broker", + "127.0.0.1", + "--mqtt-topic", + "Test", + "--diagnostics", + ], + ), patch("ecowitt2mqtt.core.Ecowitt.async_start"): + main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 9c3bae23..a0fbbb89 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,11 +1,6 @@ """Define tests for the API server.""" from __future__ import annotations -import asyncio -import logging -import os -import signal -import subprocess from unittest.mock import AsyncMock from aiohttp import ClientSession @@ -23,7 +18,6 @@ async def test_get_diagnostics( caplog, device_data, ecowitt, setup_asyncio_mqtt, setup_uvicorn_server ): """Test getting diagnostics.""" - caplog.set_level(logging.DEBUG) async with ClientSession() as session: resp = await session.request( "post",