diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b2621f4..97835c6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,6 +3,7 @@ name: Docker Image Build and Push to DockerHub on: push: branches: [ "main" ] + tags: [ "v*" ] pull_request: branches: [ "main" ] workflow_dispatch: @@ -22,19 +23,18 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - # list of Docker images to use as base name for tags images: | ${{ vars.DOCKERHUB_USERNAME }}/ecowitt-controller - # Docker tags to generate tags: | - type=schedule,enable=true,pattern={{date 'YYYYMMDD-hhmmss' tz='Europe/Berlin'}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} type=raw,enable={{is_default_branch}},value=latest - type=sha,enable=true,format=short + type=sha,enable=true,format=short - name: Build and push uses: docker/build-push-action@v5 with: context: "{{defaultContext}}:src" - #dockerfile: ./Dockerfile - push: true + push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.meta.outputs.labels }} tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore index 8a30d25..961d3c9 100644 --- a/.gitignore +++ b/.gitignore @@ -260,6 +260,7 @@ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak +*.bkp # SQL Server files *.mdf @@ -396,3 +397,7 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml +.idea/ +/.$diagrams.drawio.bkp +/.continue/mcpServers +/.vscode diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..010164b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,81 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Ecowitt Controller is a .NET 8 / ASP.NET Core application that bridges Ecowitt weather stations and IoT subdevices (AC1100 smart plugs, WFC01/WFC02 water valves) to MQTT, with Home Assistant auto-discovery support. It receives weather data via HTTP POST from Ecowitt gateways and polls subdevices via their HTTP API, then publishes everything over MQTT. + +## Build & Run Commands + +All commands run from `src/`: + +```bash +# Build +dotnet build Ecowitt.Controller.sln + +# Run +dotnet run --project Ecowitt.Controller/Ecowitt.Controller.csproj + +# Run release +dotnet run -c Release --project Ecowitt.Controller/Ecowitt.Controller.csproj + +# Run tests (NUnit) +dotnet test EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj + +# Run a single test +dotnet test EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj --filter "FullyQualifiedName~TestMethodName" + +# Docker build (from src/) +docker build -t ecowitt-controller . +``` + +Note: the test project casing is `EcoWitt.Controller.Tests` (capital W) while the main project is `Ecowitt.Controller`. + +## Architecture + +The system uses three BackgroundServices communicating through an **in-memory message bus** (SlimMessageBus): + +### Data Flow + +1. **DataController** (`Controller/DataController.cs`) — ASP.NET endpoint at `POST /data/report` receives form-encoded weather data from Ecowitt gateways, publishes `GatewayApiData` onto the bus. + +2. **Dispatcher** (`Service/Orchestrator/Dispatcher*.cs`) — Central orchestrator (partial class split across files). Consumes messages from both HTTP and MQTT services, manages the `DeviceStore`, performs change detection via `DeNoiserHelper`, and emits data/discovery messages. The `ConsumerHttp` partial handles gateway and subdevice API data; the `ConsumerMqtt` partial handles MQTT lifecycle and Home Assistant status events. + +3. **MqttService** (`Service/Mqtt/MqttService*.cs`) — Partial class split across files for consumer, publisher, discovery, and events. Connects to MQTT broker, publishes sensor data and Home Assistant discovery payloads, subscribes to HA status topic for re-emission on HA restart. + +4. **HttpPublishingService** (`Service/Http/HttpPublishingService*.cs`) — Polls Ecowitt gateway HTTP APIs for subdevice data on a configurable interval, publishes `SubdeviceApiAggregate` onto the bus. + +### Message Bus Topics (SlimMessageBus) + +Messages are defined in `Model/Message/` (Config, Data, Event subdirs). The bus wiring is in `Program.cs`. Key flows: +- Dispatcher → MqttService: `MqttConfig`, `DeviceData`, `DeviceDataFull`, `SubdeviceData`, `SubdeviceDataFull`, `HomeAssistantDiscoveryEvent` +- MqttService → Dispatcher: `MqttServiceEvent`, `MqttConnectionEvent`, `HomeAssistantStatusEvent` +- Dispatcher → HttpPublishingService: `HttpConfig` +- HttpPublishingService → Dispatcher: `SubdeviceApiAggregate`, `HttpServiceEvent` + +### Key Model Layer + +- **Device/Subdevice/Sensor** (`Model/Device.cs`, `Model/Subdevice.cs`, `Model/Sensor.cs`) — Domain model. `ISensor` has typed value accessors and change tracking via hash comparison. +- **SensorBuilder** (`Model/Mapping/SensorBuilder*.cs`) — Partial class that maps raw Ecowitt property names (e.g., `tempinf`, `baromrelin`) to typed `Sensor` objects with unit conversion (imperial→metric). This is the main mapping to extend when adding new sensor types. +- **ApiDataExtension** (`Model/Mapping/ApiDataExtension.cs`) — Extension methods that convert API DTOs to domain objects. +- **DiscoveryBuilder** (`Model/Discovery/`) — Builds Home Assistant MQTT discovery payloads. +- **DeNoiserHelper** (`Service/Orchestrator/DeNoiser.cs`) — Filters insignificant sensor value changes using configurable tolerances per sensor type. +- **DeviceStore** (`Service/Orchestrator/DeviceStore.cs`) — Thread-safe in-memory store (`ConcurrentDictionary`) for gateway/subdevice state. + +### Configuration + +Three option classes bound from `appsettings.json` sections in `Model/Configuration/`: +- `ecowitt` → `EcowittOptions` (gateways, polling interval, autodiscovery) +- `mqtt` → `MqttOptions` (broker connection) +- `controller` → `ControllerOptions` (units, precision, publishing interval, HA discovery toggle) + +Config is loaded from `/config/appsettings.json` (Docker) or the content root (bare-metal). + +## Conventions + +- Partial classes are used extensively for service separation (consumers, publishers, events in separate files). +- Logging uses Serilog with structured logging templates (not string interpolation). +- HTTP retry policy uses Polly with decorrelated jitter backoff. +- The Ecowitt gateway HTTP API endpoints used: `get_iot_device_list`, `parse_quick_cmd_iot`. +- SensorType/SensorState/SensorCategory enums align with Home Assistant device classes. \ No newline at end of file diff --git a/README.md b/README.md index 499c57b..d8ed1c9 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,49 @@ -## Supported Devices - -### Gateways -- **GW2000**: The GW2000 is a displayless console/gateway available since April 2022. It offers an extended range of functions, including a browser interface (WebUI) for display and configuration. It supports both Ethernet and WLAN connections, though it's recommended not to use both interfaces simultaneously to avoid blocking the web interface. The GW2000 firmware supports various sensors, including the WFC01 IoT sensor. -- **GW1200**: This gateway supports bidirectional communication necessary for controlling intelligent components like switches and water valves. It supports a WebUI and also supports the WS View Plus app for configuration . -- **GW1x00**: The GW1100 is similar to the GW1200 but does not support a WebUI and no subdevices. It is configured and displayed via the WS View Plus app. The GW1100 includes a temperature/humidity sensor for indoor measurements and supports local data display through applications like PWT (Personal Weather Tablet). -- **Other Compatible Gateways**: WN1980, WS3800, and WS39x0 also support IoT sensors and bidirectional communication . - -### Subdevices -- **AC1100 Smart Plug**: The AC1100 is a switchable socket that can be controlled manually, time-controlled, or based on measured values from the weather station. It requires an IoT-enabled console (e.g., GW2000, GW1200) and the Ecowitt app for automatic operation. It supports different regions with corresponding plugs and maximum wattages . -- **WFC01 Intelligent Water Timer (WittFlow)**: The WFC01 is a timer-controlled or sensor-measurement-dependent water valve. It features a built-in liquid flow sensor and a temperature sensor for the liquid. The valve can be controlled via the Ecowitt app, and it supports various operating modes, including manual, plan, and smart modes . The device is waterproof and dustproof to IP66 standards and built from corrosion-resistant materials . - -### Compatibility -- **Weather Stations**: Fine Offset and its clones (Ecowitt, Froggit, Ambient Weather...) are generally supported if your gateway/weather station supports custom weather station uploads in Ecowitt format. -- **Web API Devices**: If your device offers a web API (e.g., GW2000, GW1200), this tool offers bidirectional communication, allowing you to control actors like the AC1100 smart plug or the WFC01 water valve . -## Why -Ecowitt offers great and reliable weather stations that can be run entirely locally. As a Home Assistant user, I used Ecowitt2Mqtt to collect my weather data. Unfortunately, it doesn't support subdevices (yet), and my Python skills aren't sufficient to extend it. However, I have experience with .NET, IoT, and I enjoy challenges. This project was created to fill that gap and provide a solution for controlling Ecowitt subdevices from Home Assistant. Additionally, the decoupled architecture can serve as a blueprint for future similar projects. -## Features -- **Multi-Gateway, Multi-Subdevices Support:** The system supports multiple gateways and subdevices, allowing for extensive scalability and flexibility . -- **Automatic Discovery:** Newly added sensors and subdevices are automatically detected. This feature can be turned off to allow for manual gateway definitions . -- **Bidirectional Communication:** Enables data retrieval such as battery state and sensor status from subdevices, and also allows for control commands to be sent . -- **MQTT-Based Integration:** Weather data is exposed via MQTT, facilitating integration with other IoT systems . -- **Home Assistant Compatibility:** Supports device discovery messages to the Home Assistant topic, enabling seamless integration with Home Assistant through MQTT . -- **Highly Configurable:** Offers extensive configuration options including discovery settings, polling schedules, HTTP timeouts, and alternative discovery topics . -- **Metric and Freedom Units Support:** Transforms data points to metric units by default, with the option to turn this off if imperial units are preferred . -- **Dynamic Property Discovery:** The system flexibly discovers and publishes new sensor properties by default, ensuring up-to-date data availability . -## Roadmap -- **InfluxDB Storage:** Store states directly in InfluxDB without going through Home Assistant first. This will enable more efficient data storage and retrieval, allowing for advanced data analysis and visualization using tools like Grafana. -- **Calculated Properties:** Implement calculated properties such as dewpoint, wind direction (NESW), windchill, etc. These calculated metrics will provide more comprehensive weather data insights, enhancing the utility of the collected data for various applications. -## Getting started -To begin, you will need a running MQTT broker along with its IP address or hostname and port number. While TLS and credentials are optional, they are highly recommended for enhanced security. -## Ecowitt Controller (this tool) -If you intend to run the Ecowitt Controller in a Docker container, you will need to create a new `.json` file named `appsettings.json`. For a minimal setup, you only need to specify the IP address of your MQTT broker. All other settings will be automatically populated with default values. -### Minimal Configuration - ``` json +# Ecowitt Controller + +A .NET 10 bridge that connects Ecowitt weather stations and IoT subdevices to MQTT, with native Home Assistant auto-discovery. + +## Features + +- Multi-gateway, multi-subdevice support +- Automatic discovery of new sensors and subdevices +- Bidirectional communication with subdevices (AC1100, WFC01, WFC02) +- Home Assistant MQTT discovery (devices, sensors, switches) +- Metric/imperial unit conversion +- Change-detection filtering to reduce MQTT noise + +## Supported Devices + +**Gateways:** GW3000, GW2000, GW1200, GW1100, WN1980, WS3800, WS39x0 + +**Subdevices** (require IoT-capable gateway like GW2000/GW1200): +- **AC1100** — Smart plug with power monitoring +- **WFC01** — Water timer with flow sensor and temperature +- **WFC02** — Water valve with optional flow sensor + +**Weather Stations:** Any Fine Offset compatible station (Ecowitt, Froggit, Ambient Weather, ...) that supports custom Ecowitt protocol uploads. + +## Getting Started + +**Prerequisites:** A running MQTT broker (e.g. Mosquitto). + +### Configuration + +Create an `appsettings.json`. Minimal setup — just point it at your MQTT broker: + +```json { "mqtt": { - "host": "" + "host": "192.168.1.50" } } -``` +``` -For a more detailed configuration, including MQTT credentials and polling intervals, see the full configuration example below. This example also enables debug logging. -### Full Config +Full configuration with all options and their defaults: -``` json +```json { "Serilog": { - "MinimumLevel": "Debug" + "MinimumLevel": "Warning" }, "mqtt": { "host": "", @@ -56,72 +53,87 @@ For a more detailed configuration, including MQTT credentials and polling interv "basetopic": "ecowitt", "clientId": "ecowitt-controller", "reconnect": true, - "reconnectAttemps": 5 + "reconnectAttempts": 2 }, "ecowitt": { - "pollingInterval": 30, //seconds + "pollingInterval": 30, "autodiscovery": true, + "calculateValues": true, "retries": 2, "gateways": [ { "name": "weatherstation_01", "ip": "192.168.1.101" - }, - { - "name": "weatherstation_02", - "ip": "192.168.1.102", - "username": "", - "password": "" } ] }, "controller": { - "precision": 2, //math.round to x digits - "unit": "metric", // or "imperial" - "publishingInterval": 10, //seconds + "precision": 2, + "unit": "metric", "homeassistantdiscovery": true } } +``` +| Section | Key | Default | Description | +|---------|-----|---------|-------------| +| `mqtt` | `host` | — | MQTT broker address (required) | +| `mqtt` | `port` | `1883` | MQTT broker port | +| `mqtt` | `basetopic` | `ecowitt` | Root MQTT topic prefix | +| `mqtt` | `reconnect` | `true` | Auto-reconnect on disconnect | +| `mqtt` | `reconnectAttempts` | `2` | Reconnect retry count | +| `ecowitt` | `pollingInterval` | `30` | Subdevice polling interval (seconds) | +| `ecowitt` | `autodiscovery` | `false` | Auto-discover gateways from incoming data | +| `ecowitt` | `calculateValues` | `true` | Generate calculated sensor values | +| `ecowitt` | `gateways` | `[]` | Manual gateway definitions (name, ip, credentials) | +| `controller` | `precision` | `2` | Decimal places for floating-point values | +| `controller` | `unit` | `metric` | `metric` or `imperial` | +| `controller` | `homeassistantdiscovery` | `true` | Publish HA MQTT discovery messages | + +### Run with Docker + +```bash +docker run -d --name ecowitt-controller \ + -v /path/to/appsettings.json:/config/appsettings.json:ro \ + -p 8080:8080 \ + mplogas/ecowitt-controller:latest ``` -### Run as Docker -To run Ecowitt Controller in a Docker container, mount your config file and forward port 8080: -`docker run -d --name ecowitt-controller -v path/to/appsettings.json:/config/appsettings.json:ro -p 8080:8080 mplogas/ecowitt-controller:latest` -### Run on Bare-Metal -To run Ecowitt Controller on bare-metal, clone the repository and modify the `appsettings.json`. Then, change into the `src` directory and run: +### Run from Source + +```bash +cd src +dotnet run --project Ecowitt.Controller/Ecowitt.Controller.csproj -c Release +``` -`dotnet run -C Release Ecowitt.Controller.csproj` ### Configure Your Weather Station -To configure your Ecowitt weather station to send data to the Ecowitt Controller, follow these steps: - -1. **Access the WebUI or WS View Plus App:** - - For the GW2000, you can use the built-in WebUI available at the device's IP address . - - Alternatively, use the WS View Plus app to configure the station. - -2. **Set Up Custom Server:** - - Navigate to the weather services configuration page. - - Enable the 'customized' data posting option. - - Choose the Ecowitt protocol. - - Enter the IP address or hostname of your Ecowitt-Controller instance. - - Specify the path as `/data/report`. - - Set the port number (default is 8080). - - Define the posting interval (e.g., 30 seconds). - -3. **Save and Apply Settings:** - - Save the configuration and ensure the weather station starts sending data to the specified Ecowitt-Controller instance. + +1. Open your gateway's WebUI or the WS View Plus app +2. Go to weather services and enable the **Customized** upload +3. Set protocol to **Ecowitt**, enter the controller's IP, path `/data/report`, port `8080` +4. Set the posting interval (e.g. 30 seconds) + ### Home Assistant -Ensure your Home Assistant has permission set up to listen for MQTT messages from the Ecowitt Controller topic. The Home Assistant Discovery should automatically pick up new devices and sensors if `homeassistantdiscovery` is enabled in your `appsettings.json`. -## Implementation - -This project is developed using C# and .NET 8, utilizing ASP.NET Core Web API to create an endpoint for the Ecowitt custom weather station. The main components of the implementation include: -- **ASP.NET Core Web API**: Acts as the endpoint to receive data from the weather station. -- **[MQTTnet](https://github.com/dotnet/MQTTnet)**: Facilitates MQTT background services for communication with the weather station. -- **Polling Background Service**: Periodically retrieves updates from subdevices. -- **[SlimMessageBus](https://github.com/zarusz/SlimMessageBus)**: An in-memory message bus that integrates all components for efficient communication.` + +With `homeassistantdiscovery` enabled (default), devices and sensors appear automatically in HA via MQTT discovery. Make sure your HA instance is connected to the same MQTT broker. + +## Documentation + +- [HTTP API](docs/api.md) — Inbound weather data endpoint and outbound gateway polling +- [MQTT Topics](docs/mqtt.md) — Topic structure, payloads, and Home Assistant discovery + +## Tech Stack + +- **ASP.NET Core Web API** — HTTP endpoint for Ecowitt weather data +- **[MQTTnet](https://github.com/dotnet/MQTTnet)** — MQTT client +- **[SlimMessageBus](https://github.com/zarusz/SlimMessageBus)** — In-memory message bus connecting the services +- **[Serilog](https://serilog.net/)** — Structured logging +- **[Polly](https://github.com/App-vNext/Polly)** — HTTP retry policies + ## Contributing -We welcome contributions from the community! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) file for guidelines on how to get involved. + +Contributions welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. -## Contact -For any questions or feedback, please open an issue on GitHub. \ No newline at end of file + +MIT — see [LICENSE](LICENSE). \ No newline at end of file diff --git a/diagrams.drawio b/diagrams.drawio new file mode 100644 index 0000000..de15417 --- /dev/null +++ b/diagrams.drawio @@ -0,0 +1,283 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..a20a3f7 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,165 @@ +# HTTP API + +## Inbound — Weather Station Data + +The controller exposes an HTTP endpoint that Ecowitt gateways post weather data to. + +### `GET /data/report` + +Health check / connectivity test. + +**Response:** `200 OK` +```json +{ "status": "ok" } +``` + +### `POST /data/report` + +Receives weather station data from Ecowitt gateways. + +**Content-Type:** `application/x-www-form-urlencoded` + +The gateway sends all sensor readings as form fields. The controller captures the sender's IP address automatically and serializes the form key/value pairs into a JSON payload for internal processing. + +**Known form fields (non-exhaustive):** +| Field | Description | +|-------|-------------| +| `PASSKEY` | Gateway passkey identifier | +| `stationtype` | Station type string (e.g. `GW2000A_V3.1.3`) | +| `runtime` | Gateway uptime in seconds | +| `dateutc` | Gateway timestamp | +| `freq` | Radio frequency | +| `model` | Gateway model (e.g. `GW2000A`) | +| `tempinf` | Indoor temperature (°F) | +| `tempf` | Outdoor temperature (°F) | +| `humidity` | Outdoor humidity (%) | +| `humidityin` | Indoor humidity (%) | +| `baromrelin` | Relative barometric pressure (inHg) | +| `baromabsin` | Absolute barometric pressure (inHg) | +| `winddir` | Wind direction (°) | +| `windspeedmph` | Wind speed (mph) | +| `windgustmph` | Wind gust (mph) | +| `maxdailygust` | Max daily gust (mph) | +| `solarradiation` | Solar radiation (W/m²) | +| `uv` | UV index | +| `rrain_piezo` | Rain rate piezo (in/hr) | +| `drain_piezo` | Daily rain piezo (in) | +| `wrain_piezo` | Weekly rain piezo (in) | +| `mrain_piezo` | Monthly rain piezo (in) | +| `yrain_piezo` | Yearly rain piezo (in) | +| `soilmoisture1`–`8` | Soil moisture channels (%) | +| `soilad1`–`8` | Soil admittance channels (mS) | +| `lightning_num` | Lightning strike count | +| `lightning` | Lightning distance | +| `lightning_time` | Last lightning strike time | +| `tempf1`–`8` | Extra temperature channels (°F) | +| `humidity1`–`8` | Extra humidity channels (%) | +| `tf_co2` | CO2 sensor temperature (°F) | +| `humi_co2` | CO2 sensor humidity (%) | +| `pm25_co2` | CO2 sensor PM2.5 (µg/m³) | +| `co2` | CO2 concentration (ppm) | +| `wh65batt`, `wh80batt`, `wh90batt`, `wh57batt`, `co2_batt`, etc. | Battery states | +| `ws90cap_volt` | WS90 capacitor voltage | + +All imperial values are automatically converted to metric when `controller.unit` is set to `"metric"` (default). + +**Response:** `200 OK`, `400 Bad Request`, or `500 Internal Server Error` + +## Outbound — Gateway Subdevice Polling + +The controller polls Ecowitt gateways via their local HTTP API to retrieve subdevice data. This runs as a background service on the configured `ecowitt.pollingInterval`. + +### `GET http:///get_iot_device_list` + +Retrieves the list of connected IoT subdevices (AC1100, WFC01, WFC02). + +**Response structure:** +```json +{ + "command": [ + { + "id": 12345, + "model": 1, + "ver": 15, + "rfnet_state": 1, + "battery": 4, + "signal": 3 + } + ] +} +``` + +| Field | Description | +|-------|-------------| +| `id` | Unique subdevice identifier | +| `model` | Subdevice model: `1` = WFC01, `2` = AC1100, `3` = WFC02 | +| `ver` | Firmware version | +| `rfnet_state` | RF network state (`1` = available) | +| `battery` | Battery level | +| `signal` | Signal strength | + +### `POST http:///parse_quick_cmd_iot` + +Reads detailed subdevice data or sends commands. + +**Read device request:** +```json +{ + "command": [ + { "cmd": "read_device", "id": 12345, "model": 1 } + ] +} +``` + +**Response structure (varies by model):** +```json +{ + "command": [ + { + "devicename": "WFC01", + "nickname": "Garden Valve", + "water_status": "0", + "water_running": "0", + "water_total": "1234", + "flow_velocity": "0", + "water_temp": "65.3", + "wfc01batt": "4", + "gw_rssi": "-45", + ... + } + ] +} +``` + +Sensor properties are mapped through `SensorBuilder` (see `Model/Mapping/SensorBuilder.cs`) and vary per subdevice model. + +**Quick run command (not yet active):** +```json +{ + "command": [ + { + "cmd": "quick_run", + "id": 12345, + "model": 1, + "val": 20, + "val_type": 1, + "on_type": 0, + "off_type": 0, + "always_on": 1, + "on_time": 0, + "off_time": 0 + } + ] +} +``` + +**Quick stop command (not yet active):** +```json +{ + "command": [ + { "cmd": "quick_stop", "id": 12345, "model": 1 } + ] +} +``` + +> Note: Subdevice command sending is currently commented out in the Dispatcher. The infrastructure is in place but not wired up. \ No newline at end of file diff --git a/docs/mqtt.md b/docs/mqtt.md new file mode 100644 index 0000000..560ad2c --- /dev/null +++ b/docs/mqtt.md @@ -0,0 +1,173 @@ +# MQTT Topics + +All topics are prefixed with the configured base topic (default: `ecowitt`). Names are sanitized to lowercase with spaces replaced by hyphens. + +## Published Topics + +### Heartbeat +- **`/heartbeat`** — Published every 30 seconds while MQTT is connected. + ```json + { "service": "2024-01-15T12:00:00Z" } + ``` + +### Gateway +- **`/`** — Gateway info, published on first discovery and full updates. + ```json + { + "ip": "192.168.1.101", + "name": "weatherstation-01", + "model": "GW2000A", + "passkey": "...", + "stationType": "GW2000A_V3.1.3", + "runtime": 123456, + "state": "online", + "freq": "868M" + } + ``` + +- **`//availability`** — Gateway availability. Payload is plain text `online` or `offline` (offline if no data received for 5 minutes). + +### Gateway Sensors +- **`//sensors/`** — Sensor data for measurement sensors. + ```json + { "name": "tempinf", "alias": "Indoor Temperature", "value": 22.5, "unit": "°C" } + ``` + +- **`//diag/`** — Sensor data for diagnostic sensors (batteries, RSSI, etc.). Same payload format as above. + +### Subdevices +- **`//subdevices/`** — Subdevice info. + ```json + { + "id": 12345, + "model": 1, + "devicename": "WFC01", + "nickname": "Garden Valve", + "state": "online", + "ver": 15 + } + ``` + +- **`//subdevices//availability`** — Subdevice availability (`online`/`offline`). + +### Subdevice Sensors +- **`//subdevices//sensors/`** — Measurement sensors. +- **`//subdevices//diag/`** — Diagnostic sensors. + +Same payload format as gateway sensors. + +## Subscribed Topics + +### Home Assistant Status +- **`homeassistant/status`** — Listens for `online`/`offline`. On `online`, all gateways and subdevices are re-emitted so HA picks them up after restart. + +### Subdevice Commands (direct MQTT) +- **`/+/subdevices/+/cmd`** — Accepts JSON commands for subdevice control. + ```json + { "cmd": 0, "id": 12345, "duration": 20, "unit": 1, "alwaysOn": false } + ``` + `cmd`: `0` = Start, `1` = Stop. `unit`: `0` = Seconds, `1` = Minutes, `2` = Hours, `3` = Liters. + +### Subdevice Commands (Home Assistant) +- **`/+/subdevices/+/cmd/homeassistant`** — Simplified command topic for HA switches. Payload is plain text `ON` or `OFF`. + +## Home Assistant Discovery + +When `controller.homeassistantdiscovery` is enabled, discovery configs are published as retained messages under the `homeassistant/` prefix. + +### Discovery Topic Pattern +- **`homeassistant/sensor//config`** — Gateway & subdevice availability entities +- **`homeassistant/sensor/_/config`** — Sensor entities +- **`homeassistant/binary_sensor/_/config`** — Binary sensor entities (rain state, running state, etc.) +- **`homeassistant/switch//config`** — Switch entities for controllable subdevices + +### Discovery Payload Structure + +**Gateway device:** +```json +{ + "device": { + "identifiers": ["ec_weatherstation-01"], + "name": "weatherstation-01", + "model": "GW2000A", + "manufacturer": "Ecowitt", + "hw_version": "GW2000A", + "sw_version": "GW2000A_V3.1.3" + }, + "origin": { + "name": "Ecowitt Controller", + "sw": "v2.0.0", + "url": "https://github.com/mplogas/ecowitt-controller" + }, + "name": "Availability", + "unique_id": "ec_weatherstation-01_availability", + "object_id": "ec_weatherstation-01_availability", + "availability_topic": "/weatherstation-01/availability", + "state_topic": "/weatherstation-01/availability", + "retain": false, + "qos": 1 +} +``` + +**Subdevice device** (linked to gateway via `via_device`): +```json +{ + "device": { + "identifiers": ["ec_garden-valve"], + "name": "Garden Valve", + "model": "1", + "manufacturer": "Ecowitt", + "hw_version": "1", + "sw_version": "15", + "via_device": "ec_weatherstation-01" + }, + ... +} +``` + +**Sensor entity:** +```json +{ + "device": { "..." }, + "origin": { "..." }, + "name": "Indoor Temperature", + "unique_id": "ec_weatherstation-01_tempinf_temperature", + "object_id": "ec_weatherstation-01_tempinf_temperature", + "device_class": "temperature", + "state_topic": "/weatherstation-01/sensors/indoor-temperature", + "value_template": "{{ value_json.value }}", + "unit_of_measurement": "°C", + "retain": false, + "qos": 1 +} +``` + +**Binary sensor entity:** +```json +{ + "value_template": "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}", + ... +} +``` + +**Switch entity (subdevice toggle):** +```json +{ + "name": "switch", + "state_topic": "//subdevices//diag/running", + "command_topic": "//subdevices//cmd/homeassistant", + "value_template": "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}", + ... +} +``` + +### Identifier Format +- Device identifiers: `ec_` (sanitized, lowercase, hyphens) +- Entity unique IDs: `ec__` (e.g. `ec_weatherstation-01_availability`) +- Sensor entity IDs: `ec___` (e.g. `ec_weatherstation-01_tempinf_temperature`) + +### Device Class Mapping +Sensor types are mapped to Home Assistant device classes (see `DiscoveryBuilder.BuildDeviceCategory`). Examples: `temperature`, `humidity`, `pressure`, `battery`, `wind_speed`, `precipitation`, `voltage`, `current`, `power`, `signal_strength`, etc. + +### Entity Categories +- **Diagnostic** sensors (batteries, RSSI, heap, runtime, etc.) are published with `"entity_category": "diagnostic"` so they appear under device diagnostics in HA rather than as primary entities. diff --git a/src/Dockerfile b/src/Dockerfile index 2bb2810..1deb7c9 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -1,10 +1,10 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base WORKDIR /app -EXPOSE 8080 +EXPOSE 8080 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ADD Ecowitt.Controller/ /src/Ecowitt.Controller WORKDIR "/src/Ecowitt.Controller" RUN dotnet restore "./Ecowitt.Controller.csproj" diff --git a/src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs b/src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs new file mode 100644 index 0000000..33b8d00 --- /dev/null +++ b/src/EcoWitt.Controller.Tests/ApiDataExtensionTest.cs @@ -0,0 +1,215 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Mapping; + +namespace EcoWitt.Controller.Tests; + +public class ApiDataExtensionTest +{ + private const string GatewayPayload = "[{\"name\":\"tempinf\",\"value\":\"72.5\"},{\"name\":\"humidityin\",\"value\":\"45\"},{\"name\":\"baromrelin\",\"value\":\"29.92\"},{\"name\":\"windspeedmph\",\"value\":\"5.5\"},{\"name\":\"winddir\",\"value\":\"180\"},{\"name\":\"uv\",\"value\":\"3\"}]"; + + private const string WFC01Payload = "{\"command\":[{\"model\":1,\"id\":13398,\"nickname\":\"WFC01-00003456\",\"devicename\":\"MJULtW6rvT1I8dEKz3o2\",\"version\":113,\"water_status\":0,\"warning\":0,\"always_on\":0,\"val_type\":1,\"val\":15,\"run_time\":24,\"wfc01batt\":5,\"rssi\":4,\"gw_rssi\":-52,\"timeutc\":1721337849,\"publish_time\":1719251738,\"water_action\":4,\"water_running\":0,\"plan_status\":0,\"water_total\":\"617.557\",\"happen_water\":\"614.258\",\"flow_velocity\":\"0.00\",\"water_temp\":\"18.8\"}]}"; + + private const string AC1100Payload = "{\"command\":[{\"model\":2,\"id\":10695,\"nickname\":\"AC1100-000029C7\",\"devicename\":\"xTNGzWMorVwEKqvltP30\",\"version\":103,\"ac_status\":1,\"warning\":0,\"always_on\":1,\"val_type\":1,\"val\":3,\"run_time\":0,\"rssi\":3,\"gw_rssi\":-64,\"timeutc\":1721419571,\"publish_time\":1721419571,\"ac_action\":3,\"ac_running\":1,\"plan_status\":1,\"elect_total\":14821,\"happen_elect\":0,\"realtime_power\":0,\"ac_voltage\":232,\"ac_current\":0}]}"; + + [Test] + public void MapGateway_Metric_ConvertsUnits() + { + var data = new GatewayApiData + { + PASSKEY = "ABC123", + Model = "GW2000A", + StationType = "GW2000A_V3.1.3", + Runtime = 1000, + Freq = "868M", + IpAddress = "192.168.1.100", + Payload = GatewayPayload + }; + + var device = data.Map(isMetric: true, calculateValues: false); + + Assert.That(device.IpAddress, Is.EqualTo("192.168.1.100")); + Assert.That(device.Model, Is.EqualTo("GW2000A")); + Assert.That(device.PASSKEY, Is.EqualTo("ABC123")); + Assert.That(device.Sensors.Count, Is.GreaterThan(0)); + + var temp = device.Sensors.FirstOrDefault(s => s.Name == "tempinf"); + Assert.That(temp, Is.Not.Null); + Assert.That(temp!.UnitOfMeasurement, Is.EqualTo("°C")); + // 72.5°F = 22.5°C + Assert.That((double)temp.Value!, Is.EqualTo(22.5).Within(0.1)); + } + + [Test] + public void MapGateway_Imperial_NoConversion() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = GatewayPayload + }; + + var device = data.Map(isMetric: false, calculateValues: false); + + var temp = device.Sensors.FirstOrDefault(s => s.Name == "tempinf"); + Assert.That(temp, Is.Not.Null); + Assert.That(temp!.UnitOfMeasurement, Is.EqualTo("F")); + Assert.That((double)temp.Value!, Is.EqualTo(72.5).Within(0.01)); + } + + [Test] + public void MapGateway_WithCalculatedValues() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = GatewayPayload + }; + + var device = data.Map(isMetric: true, calculateValues: true); + + // should have calculated dewpoint, wind compass etc. + var compass = device.Sensors.FirstOrDefault(s => s.Name == "winddir-comp"); + Assert.That(compass, Is.Not.Null); + Assert.That(compass!.Value, Is.EqualTo("S")); + } + + [Test] + public void MapGateway_EmptyPayload_NoSensors() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = "" + }; + + var device = data.Map(); + Assert.That(device.Sensors, Is.Empty); + } + + [Test] + public void MapGateway_OkPayload_NoSensors() + { + var data = new GatewayApiData + { + Model = "GW2000A", + Payload = "200 OK" + }; + + var device = data.Map(); + Assert.That(device.Sensors, Is.Empty); + } + + [Test] + public void MapSubdevice_WFC01() + { + var data = new SubdeviceApiData + { + Id = 13398, + Model = 1, + Version = 113, + RfnetState = 1, + Battery = 5, + Signal = 4, + GwIp = "192.168.1.100", + Payload = WFC01Payload + }; + + var subdevice = data.Map(isMetric: true); + + Assert.That(subdevice.Id, Is.EqualTo(13398)); + Assert.That(subdevice.Model, Is.EqualTo(SubdeviceModel.WFC01)); + Assert.That(subdevice.Availability, Is.True); + Assert.That(subdevice.Nickname, Is.EqualTo("WFC01-00003456")); + Assert.That(subdevice.Devicename, Is.EqualTo("MJULtW6rvT1I8dEKz3o2")); + Assert.That(subdevice.Sensors.Count, Is.GreaterThan(0)); + + var waterTotal = subdevice.Sensors.FirstOrDefault(s => s.Name == "water_total"); + Assert.That(waterTotal, Is.Not.Null); + Assert.That(waterTotal!.SensorType, Is.EqualTo(SensorType.Water)); + } + + [Test] + public void MapSubdevice_AC1100() + { + var data = new SubdeviceApiData + { + Id = 10695, + Model = 2, + Version = 103, + RfnetState = 1, + Battery = 9, + Signal = 4, + GwIp = "192.168.1.100", + Payload = AC1100Payload + }; + + var subdevice = data.Map(isMetric: true); + + Assert.That(subdevice.Id, Is.EqualTo(10695)); + Assert.That(subdevice.Model, Is.EqualTo(SubdeviceModel.AC1100)); + Assert.That(subdevice.Nickname, Is.EqualTo("AC1100-000029C7")); + Assert.That(subdevice.Sensors.Count, Is.GreaterThan(0)); + + var power = subdevice.Sensors.FirstOrDefault(s => s.Name == "realtime_power"); + Assert.That(power, Is.Not.Null); + Assert.That(power!.SensorType, Is.EqualTo(SensorType.Power)); + + var voltage = subdevice.Sensors.FirstOrDefault(s => s.Name == "ac_voltage"); + Assert.That(voltage, Is.Not.Null); + } + + [Test] + public void MapSubdevice_Unavailable() + { + var data = new SubdeviceApiData + { + Id = 99, + Model = 1, + RfnetState = 0, // offline + GwIp = "192.168.1.100", + Payload = "" + }; + + var subdevice = data.Map(); + Assert.That(subdevice.Availability, Is.False); + } + + [Test] + public void MapSubdevice_EmptyPayload() + { + var data = new SubdeviceApiData + { + Id = 99, + Model = 1, + RfnetState = 1, + GwIp = "192.168.1.100", + Payload = "" + }; + + var subdevice = data.Map(); + Assert.That(subdevice.Sensors, Is.Empty); + } + + [Test] + public void MapSubdevice_WFC01_WithCalculatedValues() + { + var data = new SubdeviceApiData + { + Id = 13398, + Model = 1, + Version = 113, + RfnetState = 1, + GwIp = "192.168.1.100", + Payload = WFC01Payload + }; + + var subdevice = data.Map(isMetric: true, calculateValues: true); + + // happen_water should be recalculated as delta + var happenWater = subdevice.Sensors.FirstOrDefault(s => s.Name == "happen_water"); + Assert.That(happenWater, Is.Not.Null); + // water_total (617.557) - happen_water (614.258) = 3.299 + Assert.That((double)happenWater!.Value!, Is.EqualTo(3.299).Within(0.01)); + } +} diff --git a/src/EcoWitt.Controller.Tests/DeNoiserTest.cs b/src/EcoWitt.Controller.Tests/DeNoiserTest.cs new file mode 100644 index 0000000..57bcfc2 --- /dev/null +++ b/src/EcoWitt.Controller.Tests/DeNoiserTest.cs @@ -0,0 +1,134 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Service.Orchestrator; + +namespace EcoWitt.Controller.Tests; + +public class DeNoiserTest +{ + [Test] + public void Temperature_WithinTolerance_NoChange() + { + var sensor = new Sensor("tempf", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + // tolerance is 0.1 for temperature + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 20.05), Is.False); + } + + [Test] + public void Temperature_BeyondTolerance_Changed() + { + var sensor = new Sensor("tempf", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 20.2), Is.True); + } + + [Test] + public void Humidity_WithinTolerance_NoChange() + { + var sensor = new Sensor("humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity); + // tolerance is 1.0 for humidity + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 50.5), Is.False); + } + + [Test] + public void Humidity_BeyondTolerance_Changed() + { + var sensor = new Sensor("humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 51.5), Is.True); + } + + [Test] + public void Pressure_WithinTolerance_NoChange() + { + var sensor = new Sensor("baromrelin", 1013.0, SensorDataType.Double, "hPa", SensorType.Pressure); + // tolerance is 0.5 for pressure + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 1013.3), Is.False); + } + + [Test] + public void Battery_WithinTolerance_NoChange() + { + var sensor = new Sensor("batt1", 80.0, SensorDataType.Double, "%", SensorType.Battery); + // tolerance is 1.0 for battery + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 80.5), Is.False); + } + + [Test] + public void WindSpeed_BeyondTolerance_Changed() + { + var sensor = new Sensor("windspeedmph", 10.0, SensorDataType.Double, "km/h", SensorType.WindSpeed); + // tolerance is 0.2 for wind speed + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 10.3), Is.True); + } + + [Test] + public void Integer_WithinTolerance_NoChange() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer, sensorType: SensorType.None); + // default integer tolerance is 1.0 + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 5), Is.False); + } + + [Test] + public void Integer_BeyondTolerance_Changed() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer, sensorType: SensorType.None); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 7), Is.True); + } + + [Test] + public void Boolean_Changed() + { + var sensor = new Sensor("ac_running", true, SensorDataType.Boolean, sensorType: SensorType.None, sensorClass: SensorClass.BinarySensor); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, false), Is.True); + } + + [Test] + public void Boolean_Same_NoChange() + { + var sensor = new Sensor("ac_running", true, SensorDataType.Boolean, sensorType: SensorType.None, sensorClass: SensorClass.BinarySensor); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, true), Is.False); + } + + [Test] + public void String_Changed() + { + var sensor = new Sensor("winddir-comp", "N", SensorDataType.String); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, "NE"), Is.True); + } + + [Test] + public void String_Same_NoChange() + { + var sensor = new Sensor("winddir-comp", "N", SensorDataType.String); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, "N"), Is.False); + } + + [Test] + public void BothNull_NoChange() + { + var sensor = new Sensor("test", null, SensorDataType.Double); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, null), Is.False); + } + + [Test] + public void OldNull_NewValue_Changed() + { + var sensor = new Sensor("test", null, SensorDataType.Double); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 5.0), Is.True); + } + + [Test] + public void OldValue_NewNull_Changed() + { + var sensor = new Sensor("test", 5.0, SensorDataType.Double); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, null), Is.True); + } + + [Test] + public void DefaultDoubleTolerance_UsedForUnmappedType() + { + // SensorType.None has no specific tolerance, falls back to 0.01 + var sensor = new Sensor("custom", 1.0, SensorDataType.Double, sensorType: SensorType.None); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 1.005), Is.False); + Assert.That(DeNoiserHelper.HasSignificantChange(sensor, 1.02), Is.True); + } +} diff --git a/src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs b/src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs new file mode 100644 index 0000000..53079b9 --- /dev/null +++ b/src/EcoWitt.Controller.Tests/DiscoveryBuilderTest.cs @@ -0,0 +1,152 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Discovery; + +namespace EcoWitt.Controller.Tests; + +public class DiscoveryBuilderTest +{ + [Test] + public void BuildIdentifier_DefaultType() + { + var id = DiscoveryBuilder.BuildIdentifier("My Gateway"); + Assert.That(id, Is.EqualTo("ec_my-gateway_config")); + } + + [Test] + public void BuildIdentifier_WithType() + { + var id = DiscoveryBuilder.BuildIdentifier("weatherstation 01", "availability"); + Assert.That(id, Is.EqualTo("ec_weatherstation-01_availability")); + } + + [Test] + public void BuildIdentifier_AlreadyClean() + { + var id = DiscoveryBuilder.BuildIdentifier("gw1", "temperature"); + Assert.That(id, Is.EqualTo("ec_gw1_temperature")); + } + + [Test] + public void BuildDevice_MinimalNoModel() + { + var device = DiscoveryBuilder.BuildDevice("testgw"); + Assert.That(device.Name, Is.EqualTo("testgw")); + Assert.That(device.Identifiers, Contains.Item("ec_testgw_config")); + Assert.That(device.Model, Is.Null); + Assert.That(device.ViaDevice, Is.Null); + } + + [Test] + public void BuildDevice_Full() + { + var device = DiscoveryBuilder.BuildDevice("MyGW", "GW2000A", "Ecowitt", "GW2000A", "V3.1.3"); + Assert.That(device.Name, Is.EqualTo("MyGW")); + Assert.That(device.Model, Is.EqualTo("GW2000A")); + Assert.That(device.Manufacturer, Is.EqualTo("Ecowitt")); + Assert.That(device.HwVersion, Is.EqualTo("GW2000A")); + Assert.That(device.SwVersion, Is.EqualTo("V3.1.3")); + } + + [Test] + public void BuildDevice_WithViaDevice() + { + var device = DiscoveryBuilder.BuildDevice("subdev", "WFC01", "Ecowitt", "WFC01", "113", "ec_gw1_config"); + Assert.That(device.ViaDevice, Is.EqualTo("ec_gw1_config")); + } + + [Test] + public void BuildOrigin_Default() + { + var origin = DiscoveryBuilder.BuildOrigin(); + Assert.That(origin.Name, Is.EqualTo("Ecowitt Controller")); + Assert.That(origin.Sw, Is.EqualTo("v2.0.0")); + Assert.That(origin.Url, Does.Contain("github.com")); + } + + [Test] + public void BuildGatewayConfig() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildGatewayConfig(device, origin, "Availability", "ec_gw1_availability", "ecowitt/gw1/availability", "ecowitt/gw1/availability"); + + Assert.That(config.Name, Is.EqualTo("Availability")); + Assert.That(config.UniqueId, Is.EqualTo("ec_gw1_availability")); + Assert.That(config.ObjectId, Is.EqualTo("ec_gw1_availability")); + Assert.That(config.StateTopic, Is.EqualTo("ecowitt/gw1/availability")); + Assert.That(config.Qos, Is.EqualTo(1)); + Assert.That(config.Retain, Is.False); + } + + [Test] + public void BuildSensorConfig() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSensorConfig(device, origin, "Indoor Temperature", "ec_gw1_tempinf_temperature", "temperature", "ecowitt/gw1/sensors/indoor-temperature", unitOfMeasurement: "°C"); + + Assert.That(config.Name, Is.EqualTo("Indoor Temperature")); + Assert.That(config.DeviceClass, Is.EqualTo("temperature")); + Assert.That(config.UnitOfMeasurement, Is.EqualTo("°C")); + Assert.That(config.ValueTemplate, Is.EqualTo("{{ value_json.value }}")); + } + + [Test] + public void BuildSensorConfig_BinarySensor() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSensorConfig(device, origin, "Rain State", "ec_gw1_rain", "none", "ecowitt/gw1/sensors/rain", isBinarySensor: true); + + Assert.That(config, Is.Not.Null); + } + + [Test] + public void BuildSensorConfig_DiagnosticCategory() + { + var device = DiscoveryBuilder.BuildDevice("gw1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSensorConfig(device, origin, "RSSI", "ec_gw1_rssi", "signal_strength", "ecowitt/gw1/diag/rssi", sensorCategory: "diagnostic"); + + Assert.That(config.SensorCategory, Is.EqualTo("diagnostic")); + } + + [Test] + public void BuildSwitchConfig() + { + var device = DiscoveryBuilder.BuildDevice("valve1"); + var origin = DiscoveryBuilder.BuildOrigin(); + var config = DiscoveryBuilder.BuildSwitchConfig(device, origin, "switch", "ec_valve1_switch", "ecowitt/gw1/subdevices/123/diag/running", "ecowitt/gw1/subdevices/123/cmd/homeassistant"); + + Assert.That(config.CommandTopic, Is.EqualTo("ecowitt/gw1/subdevices/123/cmd/homeassistant")); + Assert.That(config.StateTopic, Does.Contain("running")); + } + + // Device class mapping + [TestCase(SensorType.Temperature, "temperature")] + [TestCase(SensorType.Humidity, "humidity")] + [TestCase(SensorType.Pressure, "pressure")] + [TestCase(SensorType.Battery, "battery")] + [TestCase(SensorType.WindSpeed, "wind_speed")] + [TestCase(SensorType.Precipitation, "precipitation")] + [TestCase(SensorType.PrecipitationIntensity, "precipitation_intensity")] + [TestCase(SensorType.Voltage, "voltage")] + [TestCase(SensorType.Current, "current")] + [TestCase(SensorType.Power, "power")] + [TestCase(SensorType.Energy, "energy")] + [TestCase(SensorType.SignalStrength, "signal_strength")] + [TestCase(SensorType.CarbonDioxide, "carbon_dioxide")] + [TestCase(SensorType.Pm25, "pm25")] + [TestCase(SensorType.Pm10, "pm10")] + [TestCase(SensorType.Pm1, "pm1")] + [TestCase(SensorType.Distance, "distance")] + [TestCase(SensorType.Irradiance, "irradiance")] + [TestCase(SensorType.Moisture, "moisture")] + [TestCase(SensorType.Water, "water")] + [TestCase(SensorType.VolumeFlowRate, "volume_flow_rate")] + [TestCase(SensorType.None, "none")] + public void BuildDeviceCategory_MapsCorrectly(SensorType type, string expected) + { + Assert.That(DiscoveryBuilder.BuildDeviceCategory(type), Is.EqualTo(expected)); + } +} diff --git a/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj b/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj index 3d8fd0b..1aa1d37 100644 --- a/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj +++ b/src/EcoWitt.Controller.Tests/EcoWitt.Controller.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable diff --git a/src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs b/src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs new file mode 100644 index 0000000..12ffc5d --- /dev/null +++ b/src/EcoWitt.Controller.Tests/MqttPathBuilderTest.cs @@ -0,0 +1,93 @@ +using Ecowitt.Controller.Service.Mqtt; + +namespace EcoWitt.Controller.Tests; + +public class MqttPathBuilderTest +{ + [Test] + public void BuildMqttGatewayTopic() + { + Assert.That(MqttPathBuilder.BuildMqttGatewayTopic("MyGateway"), Is.EqualTo("mygateway")); + } + + [Test] + public void BuildMqttGatewaySensorTopic() + { + Assert.That(MqttPathBuilder.BuildMqttGatewaySensorTopic("gw1", "Indoor Temperature"), + Is.EqualTo("gw1/sensors/indoor-temperature")); + } + + [Test] + public void BuildMqttGatewayDiagnosticTopic() + { + Assert.That(MqttPathBuilder.BuildMqttGatewayDiagnosticTopic("gw1", "WH90 Battery"), + Is.EqualTo("gw1/diag/wh90-battery")); + } + + [Test] + public void BuildMqttSubdeviceTopic() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceTopic("gw1", "12345"), + Is.EqualTo("gw1/subdevices/12345")); + } + + [Test] + public void BuildMqttSubdeviceSensorTopic() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceSensorTopic("gw1", "12345", "Water Temperature"), + Is.EqualTo("gw1/subdevices/12345/sensors/water-temperature")); + } + + [Test] + public void BuildMqttSubdeviceDiagnosticTopic() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceDiagnosticTopic("gw1", "12345", "RSSI"), + Is.EqualTo("gw1/subdevices/12345/diag/rssi")); + } + + [Test] + public void BuildMqttSubdeviceCommandTopic_Specific() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceCommandTopic("gw1", "12345"), + Is.EqualTo("gw1/subdevices/12345/cmd")); + } + + [Test] + public void BuildMqttSubdeviceCommandTopic_Wildcard() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceCommandTopic(), + Is.EqualTo("+/subdevices/+/cmd")); + } + + [Test] + public void BuildMqttSubdeviceHACommandTopic_Specific() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceHACommandTopic("gw1", "12345"), + Is.EqualTo("gw1/subdevices/12345/cmd/homeassistant")); + } + + [Test] + public void BuildMqttSubdeviceHACommandTopic_Wildcard() + { + Assert.That(MqttPathBuilder.BuildMqttSubdeviceHACommandTopic(), + Is.EqualTo("+/subdevices/+/cmd/homeassistant")); + } + + [Test] + public void Sanitize_SpacesToHyphens() + { + Assert.That(MqttPathBuilder.Sanitize("My Gateway Name"), Is.EqualTo("my-gateway-name")); + } + + [Test] + public void Sanitize_ToLowercase() + { + Assert.That(MqttPathBuilder.Sanitize("GW2000A"), Is.EqualTo("gw2000a")); + } + + [Test] + public void Sanitize_AlreadyClean() + { + Assert.That(MqttPathBuilder.Sanitize("gw1/sensors/tempf"), Is.EqualTo("gw1/sensors/tempf")); + } +} diff --git a/src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs b/src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs new file mode 100644 index 0000000..20542cc --- /dev/null +++ b/src/EcoWitt.Controller.Tests/MqttPayloadBuilderTest.cs @@ -0,0 +1,158 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Service.Mqtt; + +namespace EcoWitt.Controller.Tests; + +public class MqttPayloadBuilderTest +{ + [Test] + public void BuildGatewayPayload_FullModel() + { + var gw = new Device + { + IpAddress = "192.168.1.100", + Name = "weatherstation-01", + Model = "GW2000A", + PASSKEY = "ABC123", + StationType = "GW2000A_V3.1.3", + Runtime = 12345, + Freq = "868M" + }; + + dynamic payload = MqttPayloadBuilder.BuildGatewayPayload(gw); + + Assert.That((string)payload.ip, Is.EqualTo("192.168.1.100")); + Assert.That((string)payload.name, Is.EqualTo("weatherstation-01")); + Assert.That((string)payload.model, Is.EqualTo("GW2000A")); + Assert.That((string)payload.state, Is.EqualTo("online")); + } + + [Test] + public void BuildGatewayPayload_NoModel_MinimalPayload() + { + var gw = new Device + { + IpAddress = "192.168.1.100", + Name = "weatherstation-01", + Model = null + }; + + dynamic payload = MqttPayloadBuilder.BuildGatewayPayload(gw); + + Assert.That((string)payload.ip, Is.EqualTo("192.168.1.100")); + Assert.That((string)payload.name, Is.EqualTo("weatherstation-01")); + // should not have model property (anonymous type without it) + var type = payload.GetType(); + Assert.That(type.GetProperty("model"), Is.Null); + } + + [Test] + public void BuildGatewayPayload_EmptyModel_MinimalPayload() + { + var gw = new Device + { + IpAddress = "192.168.1.100", + Name = "gw1", + Model = "" + }; + + dynamic payload = MqttPayloadBuilder.BuildGatewayPayload(gw); + var type = payload.GetType(); + Assert.That(type.GetProperty("model"), Is.Null); + } + + [Test] + public void BuildSubdevicePayload_Online() + { + var sd = new Subdevice + { + Id = 12345, + Model = SubdeviceModel.WFC01, + Devicename = "WFC01", + Nickname = "Garden Valve", + Availability = true, + Version = 113 + }; + + dynamic payload = MqttPayloadBuilder.BuildSubdevicePayload(sd); + + Assert.That((int)payload.id, Is.EqualTo(12345)); + Assert.That((SubdeviceModel)payload.model, Is.EqualTo(SubdeviceModel.WFC01)); + Assert.That((string)payload.state, Is.EqualTo("online")); + Assert.That((string)payload.nickname, Is.EqualTo("Garden Valve")); + } + + [Test] + public void BuildSubdevicePayload_Offline() + { + var sd = new Subdevice + { + Id = 99, + Model = SubdeviceModel.AC1100, + Availability = false, + Version = 103 + }; + + dynamic payload = MqttPayloadBuilder.BuildSubdevicePayload(sd); + Assert.That((string)payload.state, Is.EqualTo("offline")); + } + + [Test] + public void BuildSensorPayload_DoubleRounding() + { + var sensor = new Sensor("tempf", "Outdoor Temperature", 22.456789, SensorDataType.Double, "°C", SensorType.Temperature); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((double)payload.value, Is.EqualTo(22.46).Within(0.001)); + + dynamic payload1 = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 1); + Assert.That((double)payload1.value, Is.EqualTo(22.5).Within(0.01)); + } + + [Test] + public void BuildSensorPayload_IntegerNotRounded() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((int)payload.value, Is.EqualTo(5)); + } + + [Test] + public void BuildSensorPayload_NullUnitOmitted() + { + var sensor = new Sensor("uv", 5, SensorDataType.Integer, unitOfMeasurement: ""); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((object?)payload.unit, Is.Null); + } + + [Test] + public void BuildSensorPayload_UnitIncluded() + { + var sensor = new Sensor("tempf", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((string)payload.unit, Is.EqualTo("°C")); + } + + [Test] + public void BuildSensorPayload_IncludesNameAndAlias() + { + var sensor = new Sensor("tempinf", "Indoor Temperature", 20.0, SensorDataType.Double, "°C", SensorType.Temperature); + + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: 2); + Assert.That((string)payload.name, Is.EqualTo("tempinf")); + Assert.That((string)payload.alias, Is.EqualTo("Indoor Temperature")); + } + + [Test] + public void BuildSensorPayload_DefaultPrecision() + { + var sensor = new Sensor("tempf", 22.456789, SensorDataType.Double, "°C", SensorType.Temperature); + + // null precision should default to 2 + dynamic payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision: null); + Assert.That((double)payload.value, Is.EqualTo(22.46).Within(0.001)); + } +} diff --git a/src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs b/src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs new file mode 100644 index 0000000..45c8f6c --- /dev/null +++ b/src/EcoWitt.Controller.Tests/SensorBuilderAddonTest.cs @@ -0,0 +1,162 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Mapping; + +namespace EcoWitt.Controller.Tests; + +public class SensorBuilderAddonTest +{ + [Test] + public void CalculateGatewayAddons_DewpointMetric() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("tempinf", "Indoor Temperature", 20.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidityin", "Indoor Humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity)); + device.Sensors.Add(new Sensor("tempf", "Outdoor Temperature", 25.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidity", "Outdoor Humidity", 60.0, SensorDataType.Double, "%", SensorType.Humidity)); + device.Sensors.Add(new Sensor("windspeedmph", "Wind Speed", 15.0, SensorDataType.Double, "km/h", SensorType.WindSpeed)); + device.Sensors.Add(new Sensor("winddir", "Wind Direction", 180, SensorDataType.Integer, "°")); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var indoorDewpoint = device.Sensors.FirstOrDefault(s => s.Name == "dewpointin"); + Assert.That(indoorDewpoint, Is.Not.Null); + Assert.That(indoorDewpoint!.SensorType, Is.EqualTo(SensorType.Temperature)); + // dewpoint = temp - (100 - humidity) / 5 = 20 - (100-50)/5 = 20 - 10 = 10 + Assert.That((double)indoorDewpoint.Value!, Is.EqualTo(10.0).Within(0.5)); + + var outdoorDewpoint = device.Sensors.FirstOrDefault(s => s.Name == "dewpoint"); + Assert.That(outdoorDewpoint, Is.Not.Null); + // 25 - (100-60)/5 = 25 - 8 = 17 + Assert.That((double)outdoorDewpoint!.Value!, Is.EqualTo(17.0).Within(0.5)); + } + + [Test] + public void CalculateGatewayAddons_HeatIndex() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("tempf", "Outdoor Temperature", 30.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidity", "Outdoor Humidity", 70.0, SensorDataType.Double, "%", SensorType.Humidity)); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var heatIndex = device.Sensors.FirstOrDefault(s => s.Name == "heatindex"); + Assert.That(heatIndex, Is.Not.Null); + Assert.That(heatIndex!.SensorType, Is.EqualTo(SensorType.Temperature)); + } + + [Test] + public void CalculateGatewayAddons_WindChill() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("tempf", "Outdoor Temperature", 5.0, SensorDataType.Double, "°C", SensorType.Temperature)); + device.Sensors.Add(new Sensor("humidity", "Outdoor Humidity", 50.0, SensorDataType.Double, "%", SensorType.Humidity)); + device.Sensors.Add(new Sensor("windspeedmph", "Wind Speed", 20.0, SensorDataType.Double, "km/h", SensorType.WindSpeed)); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var windChill = device.Sensors.FirstOrDefault(s => s.Name == "windchill"); + Assert.That(windChill, Is.Not.Null); + Assert.That(windChill!.SensorType, Is.EqualTo(SensorType.Temperature)); + } + + [Test] + public void CalculateGatewayAddons_WindDirectionCompass() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("winddir", "Wind Direction", 180, SensorDataType.Integer, "°")); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var compass = device.Sensors.FirstOrDefault(s => s.Name == "winddir-comp"); + Assert.That(compass, Is.Not.Null); + Assert.That(compass!.Value, Is.EqualTo("S")); + } + + [TestCase(0, "N")] + [TestCase(10, "N")] + [TestCase(11, "NNE")] + [TestCase(45, "NE")] + [TestCase(90, "E")] + [TestCase(135, "SE")] + [TestCase(180, "S")] + [TestCase(225, "SW")] + [TestCase(270, "W")] + [TestCase(315, "NW")] + [TestCase(349, "N")] + [TestCase(359, "N")] + public void CalculateGatewayAddons_WindCompassBoundaries(int degrees, string expected) + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("winddir", "Wind Direction", degrees, SensorDataType.Integer, "°")); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var compass = device.Sensors.FirstOrDefault(s => s.Name == "winddir-comp"); + Assert.That(compass, Is.Not.Null); + Assert.That(compass!.Value, Is.EqualTo(expected)); + } + + [Test] + public void CalculateGatewayAddons_PM25AQI() + { + var device = new Device { IpAddress = "1.2.3.4" }; + device.Sensors.Add(new Sensor("pm25_24h_co2", "CO2 PM2.5 24h Average", 5.0, SensorDataType.Double, "µg/m³", SensorType.Pm25, SensorState.Total)); + + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + + var aqi = device.Sensors.FirstOrDefault(s => s.Name == "pm25_24h_co2-aqi"); + Assert.That(aqi, Is.Not.Null); + Assert.That(aqi!.Value, Is.EqualTo("Good")); + } + + [Test] + public void CalculateGatewayAddons_MissingSensors_NoError() + { + var device = new Device { IpAddress = "1.2.3.4" }; + // no sensors at all + Assert.DoesNotThrow(() => SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true)); + // only indoor temp, no humidity — shouldn't add dewpoint + device.Sensors.Add(new Sensor("tempinf", "Indoor Temperature", 20.0, SensorDataType.Double, "°C", SensorType.Temperature)); + SensorBuilder.CalculateGatewayAddons(ref device, isMetric: true); + Assert.That(device.Sensors.FirstOrDefault(s => s.Name == "dewpointin"), Is.Null); + } + + [Test] + public void CalculateWFC01Addons_WaterDelta() + { + var subdevice = new Subdevice + { + Id = 1, + Model = SubdeviceModel.WFC01, + GwIp = "1.2.3.4" + }; + subdevice.Sensors.Add(new Sensor("water_total", "Total Water", 1000.0, SensorDataType.Double, "L", SensorType.Water, SensorState.TotalIncreasing)); + subdevice.Sensors.Add(new Sensor("happen_water", "Last Planned Consumption", 800.0, SensorDataType.Double, "L", SensorType.Water)); + + SensorBuilder.CalculateWFC01Addons(ref subdevice); + + var happen = subdevice.Sensors.First(s => s.Name == "happen_water"); + // delta: 1000 - 800 = 200... wait, it replaces happen_water with (total - happen) + // so: 1000 - 800 = 200 + Assert.That((double)happen.Value!, Is.EqualTo(200.0).Within(0.01)); + } + + [Test] + public void CalculateWFC01Addons_NonWFC01_Ignored() + { + var subdevice = new Subdevice + { + Id = 1, + Model = SubdeviceModel.AC1100, + GwIp = "1.2.3.4" + }; + subdevice.Sensors.Add(new Sensor("water_total", "Total Water", 1000.0, SensorDataType.Double, "L", SensorType.Water)); + subdevice.Sensors.Add(new Sensor("happen_water", "Last Planned Consumption", 800.0, SensorDataType.Double, "L", SensorType.Water)); + + SensorBuilder.CalculateWFC01Addons(ref subdevice); + + // should not modify since model is AC1100 + var happen = subdevice.Sensors.First(s => s.Name == "happen_water"); + Assert.That((double)happen.Value!, Is.EqualTo(800.0).Within(0.01)); + } +} diff --git a/src/EcoWitt.Controller.Tests/SensorBuilderTest.cs b/src/EcoWitt.Controller.Tests/SensorBuilderTest.cs new file mode 100644 index 0000000..ebdf79b --- /dev/null +++ b/src/EcoWitt.Controller.Tests/SensorBuilderTest.cs @@ -0,0 +1,359 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Mapping; + +namespace EcoWitt.Controller.Tests; + +public class SensorBuilderTest +{ + [Test] + public void BuildSensor_UnknownProperty_ReturnsNull() + { + var result = SensorBuilder.BuildSensor("unknown_property_xyz", "42"); + Assert.That(result, Is.Null); + } + + [Test] + public void BuildSensor_TemperatureIndoor_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("tempinf", "68.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Name, Is.EqualTo("tempinf")); + Assert.That(sensor.Alias, Is.EqualTo("Indoor Temperature")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Temperature)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("°C")); + Assert.That(sensor.DataType, Is.EqualTo(SensorDataType.Double)); + // 68°F = 20°C + Assert.That((double)sensor.Value!, Is.EqualTo(20.0).Within(0.01)); + } + + [Test] + public void BuildSensor_TemperatureIndoor_ImperialNoConversion() + { + var sensor = SensorBuilder.BuildSensor("tempinf", "68.0", isMetric: false); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.UnitOfMeasurement, Is.EqualTo("F")); + Assert.That((double)sensor.Value!, Is.EqualTo(68.0).Within(0.01)); + } + + [Test] + public void BuildSensor_TemperatureOutdoor_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("tempf", "32.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Outdoor Temperature")); + // 32°F = 0°C + Assert.That((double)sensor.Value!, Is.EqualTo(0.0).Within(0.01)); + } + + [Test] + public void BuildSensor_NumberedTemperature() + { + var sensor = SensorBuilder.BuildSensor("tempf3", "50.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Temperature 3")); + // 50°F = 10°C + Assert.That((double)sensor.Value!, Is.EqualTo(10.0).Within(0.01)); + } + + [Test] + public void BuildSensor_Humidity() + { + var sensor = SensorBuilder.BuildSensor("humidity", "65.5"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Outdoor Humidity")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Humidity)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("%")); + Assert.That((double)sensor.Value!, Is.EqualTo(65.5).Within(0.01)); + } + + [Test] + public void BuildSensor_NumberedHumidity() + { + var sensor = SensorBuilder.BuildSensor("humidity5", "45.0"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Humidity 5")); + } + + [Test] + public void BuildSensor_PressureRelative_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("baromrelin", "29.92", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Relative Pressure")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Pressure)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("hPa")); + // 29.92 inHg * 33.86388 = ~1013.25 hPa + Assert.That((double)sensor.Value!, Is.EqualTo(1013.25).Within(0.5)); + } + + [Test] + public void BuildSensor_WindSpeed_MetricConversion() + { + var sensor = SensorBuilder.BuildSensor("windspeedmph", "10.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.WindSpeed)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("km/h")); + // 10 mph * 1.60934 = 16.0934 km/h + Assert.That((double)sensor.Value!, Is.EqualTo(16.09).Within(0.01)); + } + + [Test] + public void BuildSensor_WindGust() + { + var sensor = SensorBuilder.BuildSensor("windgustmph", "25.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Wind Gust")); + } + + [Test] + public void BuildSensor_WindDirection() + { + var sensor = SensorBuilder.BuildSensor("winddir", "180"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.UnitOfMeasurement, Is.EqualTo("°")); + Assert.That(sensor.Value, Is.EqualTo(180)); + } + + [Test] + public void BuildSensor_SolarRadiation() + { + var sensor = SensorBuilder.BuildSensor("solarradiation", "850.5"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Solar Radiation")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Irradiance)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("W/m²")); + } + + [Test] + public void BuildSensor_UvIndex() + { + var sensor = SensorBuilder.BuildSensor("uv", "5"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Value, Is.EqualTo(5)); + } + + [Test] + public void BuildSensor_RainRate_Metric() + { + var sensor = SensorBuilder.BuildSensor("rainratein", "0.5", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.PrecipitationIntensity)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("mm/h")); + // 0.5 in * 25.4 = 12.7 mm + Assert.That((double)sensor.Value!, Is.EqualTo(12.7).Within(0.01)); + } + + [Test] + public void BuildSensor_DailyRain_Metric() + { + var sensor = SensorBuilder.BuildSensor("dailyrainin", "1.0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Precipitation)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("mm")); + Assert.That((double)sensor.Value!, Is.EqualTo(25.4).Within(0.01)); + } + + [Test] + public void BuildSensor_PiezoRainVariants() + { + Assert.That(SensorBuilder.BuildSensor("rrain_piezo", "0.1", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("erain_piezo", "0.2", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("hrain_piezo", "0.3", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("drain_piezo", "0.4", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wrain_piezo", "0.5", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("mrain_piezo", "0.6", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("yrain_piezo", "0.7", true), Is.Not.Null); + } + + [Test] + public void BuildSensor_RainState_Binary() + { + var sensor = SensorBuilder.BuildSensor("srain_piezo", "1"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorClass, Is.EqualTo(SensorClass.BinarySensor)); + Assert.That(sensor.Value, Is.EqualTo(true)); + } + + [Test] + public void BuildSensor_SoilMoisture() + { + var sensor = SensorBuilder.BuildSensor("soilmoisture4", "55.0"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Soil Moisture 4")); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.Moisture)); + } + + [Test] + public void BuildSensor_SoilAdmittance() + { + var sensor = SensorBuilder.BuildSensor("soilad2", "123"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Soil Admittance 2")); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("mS")); + } + + [Test] + public void BuildSensor_PM25Channel() + { + var sensor = SensorBuilder.BuildSensor("pm25_ch1", "12.5", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Pm25)); + } + + [Test] + public void BuildSensor_CO2() + { + var sensor = SensorBuilder.BuildSensor("co2", "450"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.CarbonDioxide)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("ppm")); + } + + [Test] + public void BuildSensor_Lightning() + { + var num = SensorBuilder.BuildSensor("lightning_num", "5"); + Assert.That(num, Is.Not.Null); + Assert.That(num!.Alias, Is.EqualTo("Lightning Strikes")); + + var dist = SensorBuilder.BuildSensor("lightning", "10.5"); + Assert.That(dist, Is.Not.Null); + Assert.That(dist!.SensorType, Is.EqualTo(SensorType.Distance)); + + var time = SensorBuilder.BuildSensor("lightning_time", "1700000000"); + Assert.That(time, Is.Not.Null); + Assert.That(time!.DataType, Is.EqualTo(SensorDataType.DateTime)); + } + + [Test] + public void BuildSensor_Battery_WithMultiplier() + { + var sensor = SensorBuilder.BuildSensor("wh57batt", "3"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Battery)); + Assert.That(sensor.SensorCategory, Is.EqualTo(SensorCategory.Diagnostic)); + // 3 * 20 = 60% + Assert.That(sensor.Value, Is.EqualTo(60)); + } + + [Test] + public void BuildSensor_Battery_WithoutMultiplier() + { + var sensor = SensorBuilder.BuildSensor("batt1", "4"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor.Value, Is.EqualTo(4)); + } + + [Test] + public void BuildSensor_Battery_ClampsTo100() + { + var sensor = SensorBuilder.BuildSensor("wh57batt", "6"); + Assert.That(sensor, Is.Not.Null); + // 6 * 20 = 120 → clamped to 100 + Assert.That(sensor!.Value, Is.EqualTo(100)); + } + + [Test] + public void BuildSensor_Voltage() + { + var sensor = SensorBuilder.BuildSensor("ws90cap_volt", "3.2"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorType, Is.EqualTo(SensorType.Voltage)); + Assert.That(sensor.UnitOfMeasurement, Is.EqualTo("V")); + } + + [Test] + public void BuildSensor_ACSubdeviceSensors() + { + Assert.That(SensorBuilder.BuildSensor("ac_status", "1")!.SensorClass, Is.EqualTo(SensorClass.BinarySensor)); + Assert.That(SensorBuilder.BuildSensor("ac_running", "0")!.Value, Is.EqualTo(false)); + Assert.That(SensorBuilder.BuildSensor("realtime_power", "150")!.SensorType, Is.EqualTo(SensorType.Power)); + Assert.That(SensorBuilder.BuildSensor("ac_voltage", "230")!.SensorType, Is.EqualTo(SensorType.Voltage)); + Assert.That(SensorBuilder.BuildSensor("ac_current", "2")!.SensorType, Is.EqualTo(SensorType.Current)); + Assert.That(SensorBuilder.BuildSensor("elect_total", "5000")!.SensorType, Is.EqualTo(SensorType.Energy)); + } + + [Test] + public void BuildSensor_WFCSubdeviceSensors() + { + Assert.That(SensorBuilder.BuildSensor("water_status", "1")!.SensorClass, Is.EqualTo(SensorClass.BinarySensor)); + Assert.That(SensorBuilder.BuildSensor("water_running", "0")!.Value, Is.EqualTo(false)); + + var total = SensorBuilder.BuildSensor("water_total", "123.4", isMetric: true); + Assert.That(total, Is.Not.Null); + Assert.That(total!.SensorType, Is.EqualTo(SensorType.Water)); + + var flow = SensorBuilder.BuildSensor("flow_velocity", "2.5", isMetric: true); + Assert.That(flow, Is.Not.Null); + Assert.That(flow!.SensorType, Is.EqualTo(SensorType.VolumeFlowRate)); + + var temp = SensorBuilder.BuildSensor("water_temp", "65.3", isMetric: true); + Assert.That(temp, Is.Not.Null); + Assert.That(temp!.SensorType, Is.EqualTo(SensorType.Temperature)); + } + + [Test] + public void BuildSensor_WFC02Sensors() + { + Assert.That(SensorBuilder.BuildSensor("wfc02_total", "500.0", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02_flow_velocity", "1.5", true), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02_status", "1"), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02rssi", "3"), Is.Not.Null); + Assert.That(SensorBuilder.BuildSensor("wfc02batt", "80"), Is.Not.Null); + } + + [Test] + public void BuildSensor_LeafWetness() + { + var sensor = SensorBuilder.BuildSensor("leafwetness_ch3", "42"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Leaf Wetness 3")); + } + + [Test] + public void BuildSensor_LeakChannel() + { + var sensor = SensorBuilder.BuildSensor("leak_ch2", "1"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.Alias, Is.EqualTo("Leak Channel 2")); + } + + // Edge cases + [TestCase("na")] + [TestCase("--")] + [TestCase("")] + [TestCase("n/a")] + [TestCase("nan")] + [TestCase("null")] + [TestCase("none")] + public void BuildSensor_InvalidTokens_ReturnsNull(string value) + { + var sensor = SensorBuilder.BuildSensor("tempinf", value); + Assert.That(sensor, Is.Null); + } + + [Test] + public void BuildSensor_CommaAsDecimal_ParsesCorrectly() + { + var sensor = SensorBuilder.BuildSensor("tempinf", "68,0", isMetric: true); + Assert.That(sensor, Is.Not.Null); + Assert.That((double)sensor!.Value!, Is.EqualTo(20.0).Within(0.01)); + } + + [Test] + public void BuildSensor_DiagnosticCategory() + { + var sensor = SensorBuilder.BuildSensor("gw_rssi", "-64"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorCategory, Is.EqualTo(SensorCategory.Diagnostic)); + Assert.That(sensor.SensorType, Is.EqualTo(SensorType.SignalStrength)); + } + + [Test] + public void BuildSensor_HeapDiagnostic() + { + var sensor = SensorBuilder.BuildSensor("heap", "32000"); + Assert.That(sensor, Is.Not.Null); + Assert.That(sensor!.SensorCategory, Is.EqualTo(SensorCategory.Diagnostic)); + } +} diff --git a/src/EcoWitt.Controller.Tests/SensorMappingTest.cs b/src/EcoWitt.Controller.Tests/SensorMappingTest.cs index ac2ec1a..eb06f46 100644 --- a/src/EcoWitt.Controller.Tests/SensorMappingTest.cs +++ b/src/EcoWitt.Controller.Tests/SensorMappingTest.cs @@ -1,5 +1,4 @@ using Ecowitt.Controller.Model; -using Newtonsoft.Json; namespace EcoWitt.Controller.Tests; @@ -13,27 +12,26 @@ public void Setup() [Test] public void TestSensorCreation() { - // arrange act assert List list = new(); - Sensor sensorBool = new("Test", true, "", SensorType.None, SensorState.Measurement); - Assert.That(sensorBool.DataType, Is.EqualTo(typeof(bool))); + var sensorBool = new Sensor("Test", true, SensorDataType.Boolean, sensorType: SensorType.None, sensorState: SensorState.Measurement); + Assert.That(sensorBool.DataType, Is.EqualTo(SensorDataType.Boolean)); Assert.That(sensorBool.Value, Is.EqualTo(true)); Assert.That(sensorBool.Name, Is.EqualTo("Test")); - Sensor sensorInt = new("Test", 1, "", SensorType.None, SensorState.Measurement); - Assert.That(sensorInt.DataType, Is.EqualTo(typeof(int))); + var sensorInt = new Sensor("Test", 1, SensorDataType.Integer, sensorType: SensorType.None, sensorState: SensorState.Measurement); + Assert.That(sensorInt.DataType, Is.EqualTo(SensorDataType.Integer)); Assert.That(sensorInt.Value, Is.EqualTo(1)); Assert.That(sensorInt.Name, Is.EqualTo("Test")); - Sensor sensorDouble = new("Test", 1.0, "", SensorType.Temperature, SensorState.Measurement); - Assert.That(sensorDouble.DataType, Is.EqualTo(typeof(double))); + var sensorDouble = new Sensor("Test", 1.0, SensorDataType.Double, sensorType: SensorType.Temperature, sensorState: SensorState.Measurement); + Assert.That(sensorDouble.DataType, Is.EqualTo(SensorDataType.Double)); Assert.That(sensorDouble.Value, Is.EqualTo(1.0)); Assert.That(sensorDouble.SensorType, Is.EqualTo(SensorType.Temperature)); Assert.That(sensorDouble.Name, Is.EqualTo("Test")); - Sensor sensorString = new("Test", "10", "GBps", SensorType.DataRate, SensorState.Measurement); - Assert.That(sensorString.DataType, Is.EqualTo(typeof(string))); + var sensorString = new Sensor("Test", "10", SensorDataType.String, unitOfMeasurement: "GBps", sensorType: SensorType.DataRate, sensorState: SensorState.Measurement); + Assert.That(sensorString.DataType, Is.EqualTo(SensorDataType.String)); Assert.That(sensorString.Value, Is.EqualTo("10")); Assert.That(sensorString.UnitOfMeasurement, Is.EqualTo("GBps")); Assert.That(sensorString.Name, Is.EqualTo("Test")); @@ -43,8 +41,5 @@ public void TestSensorCreation() list.Add(sensorDouble); list.Add(sensorString); Assert.That(list.Count, Is.EqualTo(4)); - } - - } \ No newline at end of file diff --git a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs index eaa87c1..13fd8b4 100644 --- a/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs +++ b/src/EcoWitt.Controller.Tests/SubdeviceMappingTest.cs @@ -1,6 +1,7 @@ using System.Diagnostics; -using Ecowitt.Controller.Mapping; using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Mapping; using Newtonsoft.Json; namespace EcoWitt.Controller.Tests; @@ -77,7 +78,7 @@ public void TestDeviceAcCreation() Assert.That(subDevice.Devicename, Is.EqualTo("xTNGzWMorVwEKqvltP30")); Assert.That(subDevice.Nickname, Is.EqualTo("AC1100-000029C7")); Assert.That(subDevice.Availability, Is.EqualTo(true)); - Assert.That(subDevice.Sensors, Has.Count.EqualTo(18)); + Assert.That(subDevice.Sensors, Has.Count.EqualTo(15)); } [Test] @@ -115,6 +116,6 @@ public void TestDeviceWfcCreation() Assert.That(subDevice.Devicename, Is.EqualTo("MJULtW6rvT1I8dEKz3o2")); Assert.That(subDevice.Nickname, Is.EqualTo("WFC01-00003456")); Assert.That(subDevice.Availability, Is.EqualTo(true)); - Assert.That(subDevice.Sensors, Has.Count.EqualTo(18)); + Assert.That(subDevice.Sensors, Has.Count.EqualTo(15)); } } \ No newline at end of file diff --git a/src/Ecowitt.Controller.sln b/src/Ecowitt.Controller.sln index e2e0d50..b2626fb 100644 --- a/src/Ecowitt.Controller.sln +++ b/src/Ecowitt.Controller.sln @@ -7,19 +7,27 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Ecowitt.Controller", "Ecowi EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution", "Solution", "{B3658F9B-BB28-4B44-A07B-793FCE094BB3}" ProjectSection(SolutionItems) = preProject + ..\diagrams.drawio = ..\diagrams.drawio ..\LICENSE = ..\LICENSE ..\README.md = ..\README.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C746A790-DCB6-46A6-A74E-DF1A4FD6994F}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docker", "Docker", "{C746A790-DCB6-46A6-A74E-DF1A4FD6994F}" ProjectSection(SolutionItems) = preProject .dockerignore = .dockerignore + ..\.gitignore = ..\.gitignore docker-compose.yml = docker-compose.yml Dockerfile = Dockerfile EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EcoWitt.Controller.Tests", "EcoWitt.Controller.Tests\EcoWitt.Controller.Tests.csproj", "{A6E36077-BD8E-4296-A628-F6914560FE02}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Docs", "Docs", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}" + ProjectSection(SolutionItems) = preProject + ..\docs\api.md = ..\docs\api.md + ..\docs\mqtt.md = ..\docs\mqtt.md + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/Ecowitt.Controller/Consumer/CommandConsumer.cs b/src/Ecowitt.Controller/Consumer/CommandConsumer.cs deleted file mode 100644 index 5fda56e..0000000 --- a/src/Ecowitt.Controller/Consumer/CommandConsumer.cs +++ /dev/null @@ -1,98 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Store; -using Microsoft.Extensions.Options; -using System.Text.Json; -using SlimMessageBus; - -namespace Ecowitt.Controller.Consumer; - -public class CommandConsumer : IConsumer -{ - private readonly ILogger _logger; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IDeviceStore _deviceStore; - private readonly EcowittOptions _options; - - public CommandConsumer(ILogger logger, IHttpClientFactory httpClientFactory, IDeviceStore store, IOptions options) - { - _logger = logger; - _httpClientFactory = httpClientFactory; - _deviceStore = store; - _options = options.Value; - } - - public async Task OnHandle(SubdeviceApiCommand message) - { - _logger.LogInformation($"Received SubdeviceCommand: {message.Cmd} for device {message.Id}"); - - var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); - if(gw == null) - { - _logger.LogWarning($"Gateway not found for subdevice {message.Id}"); - return; - } - // hey compiler, this can't be null! - var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); - - if (message.Cmd == Command.Start) - { - //var val = message.Duration ?? 20; - //var valType = message.Unit ?? DurationUnit.Minutes; - // // the magic "maganiFator" :D - //if(valType == DurationUnit.Liters) val *= 10; - //var alwaysOn = message.AlwaysOn.HasValue ? 1 : 0; - //await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model, val: val, valType: (int)valType, alwaysOn: alwaysOn); - - //default always-on message for now - await SendCommand(gw.IpAddress, "quick_run", message.Id, (int)subdevice!.Model); - - } else if (message.Cmd == Command.Stop) - { - await SendCommand(gw.IpAddress, "quick_stop", message.Id, (int)subdevice!.Model); - } - else - { - _logger.LogWarning($"Ignoring unsupported command {message.Cmd} for subdevice {message.Id}"); - return; - } - } - - private async Task SendCommand(string ipAddress, string cmd, int id, int model, int val = 0, int valType = 0, int onType = 0, int offType = 0, int alwaysOn = 1, int onTime = 0, int offTime = 0) - { - var client = _httpClientFactory.CreateClient("ecowitt-client"); - client.BaseAddress = new Uri($"http://{ipAddress}"); - - var username = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; - var password = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; - if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) - { - //TODO: authentication header, need to test - //client.DefaultRequestHeaders.Add(); - } - - // [{"on_type":0,"off_type":0,"always_on":0,"on_time":0,"off_time":0,"val_type":1,"val":20,"cmd":"quick_run","id":12345,"model":1}]} - dynamic payload; - switch (cmd) - { - case "quick_run": - payload = new { command = new[] { new { cmd, id, model, val, val_type = valType, on_type = onType, off_type = offType, always_on = alwaysOn, on_time = onTime, off_time = offTime } } }; - break; - case "quick_stop": - payload = new { command = new[] { new { cmd, id, model } } }; - break; - default: - _logger.LogWarning($"Unsupported command type {cmd}. Not sending command to {ipAddress} for subdevice {id}"); - return false; - } - - var sContent = new StringContent(JsonSerializer.Serialize(payload)); - var response = await client.PostAsync("parse_quick_cmd_iot", sContent); - - if (response.IsSuccessStatusCode) return true; - else { - _logger.LogWarning($"Could not send command to {ipAddress} for subdevice {id}"); - return false; - } - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Consumer/DataConsumer.cs b/src/Ecowitt.Controller/Consumer/DataConsumer.cs deleted file mode 100644 index 4b48d92..0000000 --- a/src/Ecowitt.Controller/Consumer/DataConsumer.cs +++ /dev/null @@ -1,157 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mapping; -using Ecowitt.Controller.Store; -using Microsoft.Extensions.Options; -using SlimMessageBus; -using System.Text.Json; - -namespace Ecowitt.Controller.Consumer; - -public class DataConsumer : IConsumer, IConsumer -{ - private readonly ILogger _logger; - private readonly IDeviceStore _deviceStore; - private readonly EcowittOptions _ecowittOptions; - private readonly ControllerOptions _controllerOptions; - - public DataConsumer(ILogger logger, IDeviceStore deviceStore, IOptions ecowittOptions, IOptions controllerOptions) - { - _logger = logger; - _deviceStore = deviceStore; - _ecowittOptions = ecowittOptions.Value; - _controllerOptions = controllerOptions.Value; - } - - public Task OnHandle(GatewayApiData message) - { - _logger.LogDebug($"Received ApiData: {message.Model} ({message.PASSKEY}) \n {message.Payload}"); - var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); - updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.','-'); - - var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); - if(storedGateway == null) - { - updatedGateway.DiscoveryUpdate = true; - - foreach (var sensor in updatedGateway.Sensors) - { - sensor.DiscoveryUpdate = true; - } - if(!_deviceStore.UpsertGateway(updatedGateway)) _logger.LogWarning($"failed to add gateway {updatedGateway.IpAddress} ({updatedGateway.Model}) to the store"); - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } - } - else - { - // no other property should update besides sensors - // and i'm stupid, because TS is required for availability :( - storedGateway.TimestampUtc = updatedGateway.TimestampUtc; - - foreach (var sensor in updatedGateway.Sensors) - { - var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); - if (storedSensor == null) - { - sensor.DiscoveryUpdate = true; - storedGateway.Sensors.Add(sensor); - } - else - { - storedSensor.Value = sensor.Value; - } - } - - var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); - foreach (var sensor in sensorsToRemove) - { - storedGateway.Sensors.Remove(sensor); - } - - if(!_deviceStore.UpsertGateway(storedGateway)) {_logger.LogWarning($"failed to update {storedGateway.IpAddress} ({storedGateway.Model}) in the store");} - else { _logger.LogDebug($"gateway updated: {JsonSerializer.Serialize(storedGateway)})"); } - } - - return Task.CompletedTask; - } - - public Task OnHandle(SubdeviceApiAggregate message) - { - var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); - foreach (var ip in ips) - { - var storedGateway = _deviceStore.GetGateway(ip); - if (storedGateway == null) - { - if (_ecowittOptions.AutoDiscovery) - { - _logger.LogWarning($"Gateway {ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)"); - return Task.CompletedTask; - } - - storedGateway = new Gateway {IpAddress = ip}; - storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.','-'); - storedGateway.DiscoveryUpdate = true; - } - - var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); - foreach (var data in subdeviceApiData) - { - var updatedSubDevice = data.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); - var storedSubDevice = storedGateway.Subdevices.FirstOrDefault(gwsd => gwsd.Id == updatedSubDevice.Id); - if (storedSubDevice == null) - { - updatedSubDevice.DiscoveryUpdate = true; - foreach (var sensor in updatedSubDevice.Sensors) - { - sensor.DiscoveryUpdate = true; - } - storedGateway.Subdevices.Add(updatedSubDevice); - _logger.LogInformation($"subdevice added: {data.Id} ({data.Model})"); - - - } else { - storedSubDevice.TimestampUtc = updatedSubDevice.TimestampUtc; - storedSubDevice.Availability = updatedSubDevice.Availability; - - // no update of other properties - if (storedSubDevice.Version != updatedSubDevice.Version || storedSubDevice.Devicename != updatedSubDevice.Devicename || storedSubDevice.Nickname != updatedSubDevice.Nickname) - { - storedSubDevice.Version = updatedSubDevice.Version; - storedSubDevice.Devicename = updatedSubDevice.Devicename; - storedSubDevice.Nickname = updatedSubDevice.Nickname; - storedSubDevice.DiscoveryUpdate = true; - } - - // update sensors one by one and find out if there are new ones - // if there are new ones, mark the subdevice for discovery update - foreach (var sensor in updatedSubDevice.Sensors) - { - var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); - if (storedSensor == null) - { - sensor.DiscoveryUpdate = true; - storedSubDevice.Sensors.Add(sensor); - } - else - { - storedSensor.Value = sensor.Value; - } - } - - // remove sensors that are not in the update - var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); - foreach (var sensor in sensorsToRemove) - { - storedSubDevice.Sensors.Remove(sensor); - } - - _logger.LogInformation($"subdevice updated: {data.Id} ({data.Model})"); - } - } - - _deviceStore.UpsertGateway(storedGateway); - } - - return Task.CompletedTask; - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Controller/DataController.cs b/src/Ecowitt.Controller/Controller/DataController.cs index 250137c..7e76c0f 100644 --- a/src/Ecowitt.Controller/Controller/DataController.cs +++ b/src/Ecowitt.Controller/Controller/DataController.cs @@ -1,7 +1,7 @@ -using System.Text.Json; -using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; using Microsoft.AspNetCore.Mvc; using SlimMessageBus; +using System.Text.Json; namespace Ecowitt.Controller.Controller; @@ -53,7 +53,7 @@ public async Task PostData([FromForm] GatewayApiData data) else { - _logger.LogInformation($"Received data from IP {ip} ({data.StationType})."); + _logger.LogInformation("Received data from IP {Ip} ({DataStationType}).", ip, data.StationType); data.IpAddress = ip; } @@ -61,7 +61,7 @@ public async Task PostData([FromForm] GatewayApiData data) //write forms key/values into Payload property as json data.Payload = JsonSerializer.Serialize(Request.Form.Select(kvp => new { name = kvp.Key, value = kvp.Value[0] })); - _logger.LogDebug($"Request form keys: {string.Join(", ", Request.Form.Keys)}"); + _logger.LogDebug("Request form keys: {Join}", string.Join(", ", Request.Form.Keys)); await _messageBus.Publish(data); diff --git a/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs b/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs deleted file mode 100644 index d83e28b..0000000 --- a/src/Ecowitt.Controller/Discovery/DiscoveryPublishService.cs +++ /dev/null @@ -1,188 +0,0 @@ -using System.Text.Encodings.Web; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Discovery.Model; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mqtt; -using Ecowitt.Controller.Store; -using Microsoft.Extensions.Options; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Ecowitt.Controller.Discovery; - -public class DiscoveryPublishService : BackgroundService -{ - private readonly ILogger _logger; - private readonly MqttOptions _mqttOptions; - private readonly IDeviceStore _store; - private readonly IMqttClient _mqttClient; - private readonly ControllerOptions _controllerOptions; - - private readonly Origin _origin; - - public DiscoveryPublishService(IOptions mqttOptions, IOptions controllerOption, - ILogger logger, IMqttClient mqttClient, - IDeviceStore deviceStore) - { - _logger = logger; - _mqttOptions = mqttOptions.Value; - _controllerOptions = controllerOption.Value; - _store = deviceStore; - _mqttClient = mqttClient; - _origin = DiscoveryBuilder.BuildOrigin(); - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_controllerOptions.HomeAssistantDiscovery) - { - _logger.LogInformation("Home Assistant discovery is disabled"); - return; - } - - _logger.LogInformation("Starting DiscoveryPublishService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(_controllerOptions.PublishingInterval)); - try - { - while (await timer.WaitForNextTickAsync(stoppingToken)) - { - foreach (var gw in _store.GetGatewaysShort().Select(gwKvp => _store.GetGateway(gwKvp.Key)).OfType()) - { - if (gw.DiscoveryUpdate) - { - gw.DiscoveryUpdate = false; - await PublishGatewayDiscovery(gw); - } - - foreach (var sensor in gw.Sensors.Where(sensor => sensor.DiscoveryUpdate)) - { - sensor.DiscoveryUpdate = false; - await PublishSensorDiscovery(gw, sensor); - } - - foreach (var subdevice in gw.Subdevices) - { - if (subdevice.DiscoveryUpdate) - { - subdevice.DiscoveryUpdate = false; - await PublishSubdeviceDiscovery(gw, subdevice); - await PublishSubdeviceSwitchDiscovery(gw, subdevice); - } - - foreach (var sensor in subdevice.Sensors.Where(s => s.DiscoveryUpdate)) - { - sensor.DiscoveryUpdate = false; - await PublishSensorDiscovery(gw, subdevice, sensor); - } - } - } - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping MqttService"); - } - } - - - - - private async Task PublishGatewayDiscovery(Gateway gw) - { - var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType??"unknown"); - var id = DiscoveryBuilder.BuildIdentifier(gw.Name, "availability"); - //var statetopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayTopic(gw.Name)}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayTopic(gw.Name)}/availability"; - - var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); - - await PublishMessage(Helper.Sanitize($"sensor/{gw.Name}"), config); - - } - private async Task PublishSubdeviceDiscovery(Gateway gw, Ecowitt.Controller.Model.Subdevice subdevice) - { - var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); - var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "availability"); - //var statetopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; - - var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); - - await PublishMessage(Helper.Sanitize($"sensor/{subdevice.Nickname}"), config); - } - - private async Task PublishSubdeviceSwitchDiscovery(Gateway gw, Ecowitt.Controller.Model.Subdevice subdevice) - { - var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); - var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "switch"); - var statetopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/diag/running"; - var valueTemplate = "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}"; - var cmdTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic(gw.Name, subdevice.Id.ToString())}"; - - var config = - DiscoveryBuilder.BuildSwitchConfig(device, _origin, "switch", id, statetopic, cmdTopic, valueTemplate: valueTemplate); - - await PublishMessage(Helper.Sanitize($"switch/{subdevice.Nickname}"), config); - } - - private async Task PublishSensorDiscovery(Gateway gw, ISensor sensor) - { - var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); - - var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayDiagnosticTopic(gw.Name, sensor.Alias)}" : $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewaySensorTopic(gw.Name, sensor.Alias)}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttGatewayTopic(gw.Name)}/availability"; - await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); - } - - private async Task PublishSensorDiscovery(Gateway gw, Ecowitt.Controller.Model.Subdevice subdevice, ISensor sensor) - { - var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); - var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}" : $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}"; - var availabilityTopic = $"{_mqttOptions.BaseTopic}/{Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; - await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); - } - - private async Task PublishSensorDiscovery(Device device, ISensor sensor, string statetopic, string availabilityTopic) - { - var id = DiscoveryBuilder.BuildIdentifier($"{device.Name}_{sensor.Name}", sensor.SensorType.ToString()); - var category = DiscoveryBuilder.BuildDeviceCategory(sensor.SensorType); - - var valueTemplate = sensor.SensorClass == SensorClass.BinarySensor - ? "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}" - : "{{ value_json.value }}"; - //var valueTemplate = "{{ value_json.value }}"; - - var config = sensor.SensorCategory == SensorCategory.Diagnostic - ? DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, sensorCategory: sensor.SensorCategory.ToString().ToLower(), isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor) - : DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor); - - var sensorClassTopic = BuildSensorClassTopic(sensor.SensorClass); - - await PublishMessage(Helper.Sanitize($"{sensorClassTopic}/{device.Name}_{sensor.Name}"), config); - } - - private async Task PublishMessage(string topic, Config config) - { - topic = $"homeassistant/{topic}/config"; - - if(config.DeviceClass != null && config.DeviceClass.Equals("none", StringComparison.InvariantCultureIgnoreCase)) config.DeviceClass = null; - - var payload = JsonSerializer.Serialize(config, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}); - - _logger.LogDebug($"Topic: {topic}"); - _logger.LogDebug($"Payload: {payload}"); - - if (!await _mqttClient.Publish(topic, payload)) - _logger.LogWarning($"Failed to publish message to topic {topic}. Is the client connected?"); - } - - private string BuildSensorClassTopic(SensorClass sc) - { - return sc switch - { - SensorClass.BinarySensor => "binary_sensor", - _ => "sensor" - }; - } -} diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.csproj b/src/Ecowitt.Controller/Ecowitt.Controller.csproj index d4beea9..7161cb7 100644 --- a/src/Ecowitt.Controller/Ecowitt.Controller.csproj +++ b/src/Ecowitt.Controller/Ecowitt.Controller.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable a86b1f49-380d-4c8c-8812-1f07670a6652 @@ -9,26 +9,37 @@ - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Ecowitt.Controller/Ecowitt.Controller.sln b/src/Ecowitt.Controller/Ecowitt.Controller.sln new file mode 100644 index 0000000..13dfddf --- /dev/null +++ b/src/Ecowitt.Controller/Ecowitt.Controller.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ecowitt.Controller", "Ecowitt.Controller.csproj", "{56154C56-94B0-8CD8-9B5A-3C7D1D373C63}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Debug|Any CPU.Build.0 = Debug|Any CPU + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Release|Any CPU.ActiveCfg = Release|Any CPU + {56154C56-94B0-8CD8-9B5A-3C7D1D373C63}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {33608E75-F9C7-4C7D-8B46-A65DAD5ACDF7} + EndGlobalSection +EndGlobal diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs b/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs deleted file mode 100644 index 3cf5748..0000000 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderLogic.cs +++ /dev/null @@ -1,233 +0,0 @@ -using Ecowitt.Controller.Model; -using System.Text.RegularExpressions; - -namespace Ecowitt.Controller.Mapping -{ - public partial class SensorBuilder - { - private static Sensor? BuildWaterFlowSensor(string propertyName, string alias, string propertyValue, bool isMetric = true) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? L2G(value) : value, isMetric ? "L/min" : "gal/min", SensorType.VolumeFlowRate) - : null; - } - - private static Sensor? BuildWaterConsumptionSensor(string propertyName, string alias, string propertyValue, - bool isMetric = true, bool isTotal = false) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), isMetric ? "L" : "gal", SensorType.Water, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) - : null; - } - - private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue) - { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "A", SensorType.Current) - : null; - } - - private static Sensor? BuildPowerSensor(string propertyName, string alias, string propertyValue) - { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "W", SensorType.Power) - : null; - } - - private static Sensor? BuildConsumptionSensor(string propertyName, string alias, string propertyValue, bool isTotal = false) - { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "Wh", SensorType.Energy, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) - : null; - } - - private static Sensor? BuildDistanceSensor(string propertyName, string alias, string propertyValue, - bool isMetric = true) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? value : K2M(value), isMetric ? "km" : "miles", SensorType.Distance) - : null; - } - - private static Sensor? BuildBinarySensor(string propertyName, string alias, string propertyValue, bool isDiag = true) - { - bool value; - switch (propertyValue) - { - case "0": - case "false": - value = false; - break; - case "1": - case "true": - value = true; - break; - default: - return null; - } - - return new Sensor(propertyName, alias, value, sensorClass: SensorClass.BinarySensor, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); - } - - private static Sensor? BuildBatterySensor(string propertyName, string alias, string propertyValue, bool withMultiplier = false ) - { - const int multiplier = 20; // I forsee this will change in the future with new sensors - if (int.TryParse(propertyValue, out var value)) - { - if (withMultiplier) - { - value = value * multiplier; - if(value > 100) value = 100; //fix for the 120% battery level when powered by USB - } - - return new Sensor(propertyName, alias, value, "%", SensorType.Battery, - sensorCategory: SensorCategory.Diagnostic); - } - return null; - } - - private static Sensor? BuildPPMSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isTotal = false) - { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "ppm", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) - : null; - } - - private static Sensor? BuildParticleSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isMetric = true, bool isTotal = false) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "µg/m³", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) - : null; - } - - private static Sensor? BuildVoltageSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "V", SensorType.Voltage, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) - : null; - } - - private static Sensor? BuildRainRateSensor(string propertyName, string alias, string propertyValue, bool isMetric) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, isMetric ? "mm/h" : "in/h", SensorType.PrecipitationIntensity) - : null; - } - - private static Sensor? BuildRainSensor(string propertyName, string alias, string propertyValue, bool isMetric) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, isMetric ? "mm" : "in", SensorType.Precipitation) - : null; - } - - private static Sensor? BuildWindSpeedSensor(string propertyName, string alias, string propertyValue, bool isMetric) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? M2K(value) : value, isMetric ? "km/h" : "mph", SensorType.WindSpeed) - : null; - } - - private static Sensor? BuildPressureSensor(string propertyName, string alias, string propertyValue, bool isMetric) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, isMetric ? IM2HP(value) : value, isMetric ? "hPa" : "inHg", SensorType.Pressure) - : null; - } - - private static Sensor? BuildHumiditySensor(string propertyName, string alias, string propertyValue) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, "%", SensorType.Humidity) - : null; - } - - private static Sensor? BuildTemperatureSensor(string propertyName, string alias, string propertyValue, bool isMetric, bool startMetric = false) - { - if (double.TryParse(propertyValue, out var value)) - { - var unit = isMetric ? "°C" : "F"; - if (startMetric != isMetric) - { - value = startMetric ? C2F(value) : F2C(value); - } - - return new Sensor(propertyName, alias, value, unit, SensorType.Temperature); - } - - return null; - } - - private static Sensor? BuildDoubleSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) - { - return double.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias,value, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) - : null; - } - - private static Sensor? BuildDateTimeSensor(string propertyName, string alias, string propertyValue) - { - return long.TryParse(propertyValue, out var ts) - ? new Sensor(propertyName, alias, DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime) - : null; - } - - private static Sensor? BuildIntSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) - { - return int.TryParse(propertyValue, out var value) - ? new Sensor(propertyName, alias, value, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) - : null; - } - - private static Sensor BuildStringSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) - { - return new Sensor(propertyName, alias, propertyValue, unitOfMeasurement: string.Empty, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); - } - - // well, that (https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-source-generators?pivots=dotnet-8-0) doesn't work - //[GeneratedRegex(@"(\D*)(\d+)", RegexOptions.IgnoreCase)] - //private static partial Regex SensorNumberRegex(); - // so we're doing it old school - private static int GetNumber(string propertyName) - { - const string pattern = @"^[a-zA-Z_0-9]*(\d+)$"; - var m = Regex.Match(propertyName, pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); - return m.Success ? int.Parse(m.Groups[1].Value) : -1; - } - - private static double K2M(double result) - { - return result * 0.621371; - } - - private static double IM2HP(double im) - { - return im * 33.86388; - } - - private static double F2C(double fahrenheit) - { - return ((fahrenheit - 32) * 5 / 9); - } - - private static double C2F(double celsius) - { - return celsius * 9 / 5 + 32; - } - - private static double M2K(double mph) - { - return mph * 1.60934; - } - - private static double I2M(double inches) - { - return inches * 25.4; - } - - private static double L2G(double liters) - { - return liters * 0.264172; - } - } -} diff --git a/src/Ecowitt.Controller/Model/GatewayApiData.cs b/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs similarity index 81% rename from src/Ecowitt.Controller/Model/GatewayApiData.cs rename to src/Ecowitt.Controller/Model/Api/GatewayApiData.cs index 9c8288e..56a7389 100644 --- a/src/Ecowitt.Controller/Model/GatewayApiData.cs +++ b/src/Ecowitt.Controller/Model/Api/GatewayApiData.cs @@ -1,13 +1,13 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Model.Api; public class GatewayApiData { - public string PASSKEY { get; set; } - public string StationType { get; set; } + public string PASSKEY { get; set; } = string.Empty; + public string StationType { get; set; } = string.Empty; public int Runtime { get; set; } public DateTime DateUtc { get; set; } - public string Freq { get; set; } - public string Model { get; set; } + public string Freq { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; public string? IpAddress { get; set; } public DateTime TimestampUtc { get; set; } = DateTime.UtcNow; diff --git a/src/Ecowitt.Controller/Model/SubdeviceApiCommand.cs b/src/Ecowitt.Controller/Model/Api/SubdeviceApiCommand.cs similarity index 89% rename from src/Ecowitt.Controller/Model/SubdeviceApiCommand.cs rename to src/Ecowitt.Controller/Model/Api/SubdeviceApiCommand.cs index 9fc2357..77270b7 100644 --- a/src/Ecowitt.Controller/Model/SubdeviceApiCommand.cs +++ b/src/Ecowitt.Controller/Model/Api/SubdeviceApiCommand.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Model.Api; public class SubdeviceApiCommand { diff --git a/src/Ecowitt.Controller/Model/SubdeviceApiData.cs b/src/Ecowitt.Controller/Model/Api/SubdeviceApiData.cs similarity index 90% rename from src/Ecowitt.Controller/Model/SubdeviceApiData.cs rename to src/Ecowitt.Controller/Model/Api/SubdeviceApiData.cs index 978b8e9..67e92e6 100644 --- a/src/Ecowitt.Controller/Model/SubdeviceApiData.cs +++ b/src/Ecowitt.Controller/Model/Api/SubdeviceApiData.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Model; +namespace Ecowitt.Controller.Model.Api; public class SubdeviceApiData { diff --git a/src/Ecowitt.Controller/Configuration/ControllerOptions.cs b/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs similarity index 71% rename from src/Ecowitt.Controller/Configuration/ControllerOptions.cs rename to src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs index b37e85e..5a5f036 100644 --- a/src/Ecowitt.Controller/Configuration/ControllerOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/ControllerOptions.cs @@ -1,10 +1,9 @@ -namespace Ecowitt.Controller.Configuration; +namespace Ecowitt.Controller.Model.Configuration; public class ControllerOptions { public int Precision { get; set; } = 2; public Units Units { get; set; } = Units.Metric; - public int PublishingInterval { get; set; } = 60; public bool HomeAssistantDiscovery { get; set; } = true; } diff --git a/src/Ecowitt.Controller/Configuration/EcowittOptions.cs b/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs similarity index 83% rename from src/Ecowitt.Controller/Configuration/EcowittOptions.cs rename to src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs index bcd3cb3..bafea4b 100644 --- a/src/Ecowitt.Controller/Configuration/EcowittOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/EcowittOptions.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Configuration; +namespace Ecowitt.Controller.Model.Configuration; public class EcowittOptions { @@ -11,7 +11,6 @@ public class EcowittOptions public class GatewayOptions { - public string Passkey { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Username { get; set; } = string.Empty; public string Password { get; set; } = string.Empty; diff --git a/src/Ecowitt.Controller/Configuration/MqttOptions.cs b/src/Ecowitt.Controller/Model/Configuration/MqttOptions.cs similarity index 88% rename from src/Ecowitt.Controller/Configuration/MqttOptions.cs rename to src/Ecowitt.Controller/Model/Configuration/MqttOptions.cs index 2f787f7..be9e266 100644 --- a/src/Ecowitt.Controller/Configuration/MqttOptions.cs +++ b/src/Ecowitt.Controller/Model/Configuration/MqttOptions.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Configuration; +namespace Ecowitt.Controller.Model.Configuration; public class MqttOptions { @@ -10,4 +10,5 @@ public class MqttOptions public string ClientId { get; set; } = "ecowitt-controller"; public bool Reconnect { get; set; } = true; public int ReconnectAttempts { get; set; } = 2; + } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Gateway.cs b/src/Ecowitt.Controller/Model/Device.cs similarity index 76% rename from src/Ecowitt.Controller/Model/Gateway.cs rename to src/Ecowitt.Controller/Model/Device.cs index 448d1b6..9972b16 100644 --- a/src/Ecowitt.Controller/Model/Gateway.cs +++ b/src/Ecowitt.Controller/Model/Device.cs @@ -1,13 +1,11 @@ -using System.Collections.Concurrent; - namespace Ecowitt.Controller.Model; -public class Gateway +public class Device { // important properties public string IpAddress { get; set; } public string Name { get; set; } - public DateTime TimestampUtc { get; set; } + public DateTime TimestampUtc { get; set; } //= DateTime.UtcNow; public List Subdevices { get; set; } = new(); public bool DiscoveryUpdate { get; set; } @@ -23,6 +21,4 @@ public class Gateway // sensors public List Sensors { get; set; } = new List(); - - public ConcurrentBag ConcurrentSensors { get; set; } = new ConcurrentBag(); } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Discovery/Model/Availability.cs b/src/Ecowitt.Controller/Model/Discovery/Availability.cs similarity index 90% rename from src/Ecowitt.Controller/Discovery/Model/Availability.cs rename to src/Ecowitt.Controller/Model/Discovery/Availability.cs index 7f79a59..d96338e 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Availability.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Availability.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Availability { diff --git a/src/Ecowitt.Controller/Discovery/Model/Config.cs b/src/Ecowitt.Controller/Model/Discovery/Config.cs similarity index 97% rename from src/Ecowitt.Controller/Discovery/Model/Config.cs rename to src/Ecowitt.Controller/Model/Discovery/Config.cs index fbe6033..cb69f0f 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Config.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Config.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Config { diff --git a/src/Ecowitt.Controller/Discovery/Model/Device.cs b/src/Ecowitt.Controller/Model/Discovery/Device.cs similarity index 93% rename from src/Ecowitt.Controller/Discovery/Model/Device.cs rename to src/Ecowitt.Controller/Model/Discovery/Device.cs index 01df009..61629f6 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Device.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Device.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Device { diff --git a/src/Ecowitt.Controller/Discovery/DiscoveryBuilder.cs b/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs similarity index 98% rename from src/Ecowitt.Controller/Discovery/DiscoveryBuilder.cs rename to src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs index c119f4a..84a067d 100644 --- a/src/Ecowitt.Controller/Discovery/DiscoveryBuilder.cs +++ b/src/Ecowitt.Controller/Model/Discovery/DiscoveryBuilder.cs @@ -1,7 +1,4 @@ -using Ecowitt.Controller.Discovery.Model; -using Ecowitt.Controller.Model; - -namespace Ecowitt.Controller.Discovery; +namespace Ecowitt.Controller.Model.Discovery; public static class DiscoveryBuilder { @@ -54,7 +51,7 @@ public static Origin BuildOrigin() return new Origin { Name = "Ecowitt Controller", - Sw = "v0.0.1", + Sw = "v2.0.0", Url = "https://github.com/mplogas/ecowitt-controller" }; } diff --git a/src/Ecowitt.Controller/Discovery/Model/Enums.cs b/src/Ecowitt.Controller/Model/Discovery/Enums.cs similarity index 80% rename from src/Ecowitt.Controller/Discovery/Model/Enums.cs rename to src/Ecowitt.Controller/Model/Discovery/Enums.cs index 4b8098d..69f6473 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Enums.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Enums.cs @@ -1,4 +1,4 @@ -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public enum EntityCategory { diff --git a/src/Ecowitt.Controller/Discovery/Model/Origin.cs b/src/Ecowitt.Controller/Model/Discovery/Origin.cs similarity index 85% rename from src/Ecowitt.Controller/Discovery/Model/Origin.cs rename to src/Ecowitt.Controller/Model/Discovery/Origin.cs index 3d0c059..f8007de 100644 --- a/src/Ecowitt.Controller/Discovery/Model/Origin.cs +++ b/src/Ecowitt.Controller/Model/Discovery/Origin.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Ecowitt.Controller.Discovery.Model; +namespace Ecowitt.Controller.Model.Discovery; public class Origin { diff --git a/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs similarity index 87% rename from src/Ecowitt.Controller/Mapping/ApiDataExtension.cs rename to src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs index aa98c40..619c22d 100644 --- a/src/Ecowitt.Controller/Mapping/ApiDataExtension.cs +++ b/src/Ecowitt.Controller/Model/Mapping/ApiDataExtension.cs @@ -1,7 +1,8 @@ -using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Serilog; using System.Text.Json; -namespace Ecowitt.Controller.Mapping; +namespace Ecowitt.Controller.Model.Mapping; public static class ApiDataExtension { @@ -11,9 +12,9 @@ public static class ApiDataExtension /// /// /// - public static Model.Subdevice Map(this SubdeviceApiData subdeviceApiData, bool isMetric = true, bool calculateValues = true) + public static Subdevice Map(this SubdeviceApiData subdeviceApiData, bool isMetric = true, bool calculateValues = true) { - var result = new Model.Subdevice + var result = new Subdevice { Id = subdeviceApiData.Id, Model = (SubdeviceModel)subdeviceApiData.Model, @@ -53,6 +54,8 @@ public static Model.Subdevice Map(this SubdeviceApiData subdeviceApiData, bool i if (sensor != null) { result.Sensors.Add(sensor); + Log.Debug("Mapped sensor {SensorName} with value {SensorValue} for subdevice {SubdeviceId}", + sensor.Name, sensor.Value, result.Id); } } @@ -76,9 +79,9 @@ public static Model.Subdevice Map(this SubdeviceApiData subdeviceApiData, bool i /// /// /// - public static Gateway Map(this GatewayApiData gatewayApiData, bool isMetric = true, bool calculateValues = true) + public static Device Map(this GatewayApiData gatewayApiData, bool isMetric = true, bool calculateValues = true) { - var result = new Gateway + var result = new Device { PASSKEY = gatewayApiData.PASSKEY, Model = gatewayApiData.Model, diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs similarity index 62% rename from src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs index 524f091..eac7522 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilderAddon.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Addon.cs @@ -1,7 +1,4 @@ -using System.Transactions; -using Ecowitt.Controller.Model; - -namespace Ecowitt.Controller.Mapping +namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder { @@ -9,75 +6,76 @@ public static void CalculateWFC01Addons(ref Model.Subdevice subdevice) { if (subdevice.Model == SubdeviceModel.WFC01) { - var waterTotal = subdevice.Sensors.FirstOrDefault(s => - s.Name.Equals("water_total", StringComparison.InvariantCultureIgnoreCase)); - var waterHappen = subdevice.Sensors.FirstOrDefault(s => - s.Name.Equals("happen_water", StringComparison.InvariantCultureIgnoreCase)); + var waterTotal = subdevice.Sensors.FirstOrDefault(s => s.Name.Equals("water_total", StringComparison.InvariantCultureIgnoreCase)); + var waterHappen = subdevice.Sensors.FirstOrDefault(s => s.Name.Equals("happen_water", StringComparison.InvariantCultureIgnoreCase)); if (waterTotal != null && waterHappen != null) { - waterHappen.Value = (double)waterTotal.Value - (double)waterHappen.Value; + // re-calc delta + var newVal = waterTotal is Sensor wt ? wt.AsDouble() - (waterHappen is Sensor wh ? wh.AsDouble() : 0d) : 0d; + if (waterHappen is Sensor whSensor) + { + whSensor.Value = newVal; + } } } } - public static void CalculateGatewayAddons(ref Model.Gateway gateway, bool isMetric) + public static void CalculateGatewayAddons(ref Model.Device device, bool isMetric) { - var tempin = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("tempinf", StringComparison.InvariantCultureIgnoreCase)); - var humidityin = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("humidityin", StringComparison.InvariantCultureIgnoreCase)); + var tempin = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempinf", StringComparison.InvariantCultureIgnoreCase)) as Sensor; + var humidityin = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidityin", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (tempin != null && humidityin != null) { - var dewPoint = BuildTemperatureSensor("dewpointin", "Indoor Dewpoint", - isMetric - ? CalculateDewPointMetric((double)tempin.Value, (double)humidityin.Value).ToString() - : CalculateDewPointImperial((double)tempin.Value, (double)humidityin.Value).ToString(), isMetric, isMetric); - if (dewPoint != null) gateway.Sensors.Add(dewPoint); + var dewPointVal = isMetric ? CalculateDewPointMetric(tempin.AsDouble(), humidityin.AsDouble()) : CalculateDewPointImperial(tempin.AsDouble(), humidityin.AsDouble()); + var dewPoint = BuildTemperatureSensor("dewpointin", "Indoor Dewpoint", dewPointVal.ToString(), isMetric, isMetric); + if (dewPoint != null) device.Sensors.Add(dewPoint); } - var temp = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("tempf", StringComparison.InvariantCultureIgnoreCase)); - var humidity = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("humidity", StringComparison.InvariantCultureIgnoreCase)); + var temp = device.Sensors.FirstOrDefault(s => s.Name.Equals("tempf", StringComparison.InvariantCultureIgnoreCase)) as Sensor; + var humidity = device.Sensors.FirstOrDefault(s => s.Name.Equals("humidity", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (temp != null && humidity != null) { - var dewPoint = BuildTemperatureSensor("dewpoint", "Outdoor Dewpoint", - isMetric - ? CalculateDewPointMetric((double)temp.Value, (double)humidity.Value).ToString() - : CalculateDewPointImperial((double)temp.Value, (double)humidity.Value).ToString(), isMetric, isMetric); - var heatIndex = BuildTemperatureSensor("heatindex", "Heat Index", - isMetric - ? CalculateHeatIndexMetric((double)temp.Value, (double)humidity.Value).ToString() - : CalculateHeatIndexImperial((double)temp.Value, (double)humidity.Value).ToString(), isMetric, isMetric); - - if (dewPoint != null) gateway.Sensors.Add(dewPoint); - if (heatIndex != null) gateway.Sensors.Add(heatIndex); + var dewPointVal = isMetric ? CalculateDewPointMetric(temp.AsDouble(), humidity.AsDouble()) : CalculateDewPointImperial(temp.AsDouble(), humidity.AsDouble()); + var heatIndexVal = isMetric ? CalculateHeatIndexMetric(temp.AsDouble(), humidity.AsDouble()) : CalculateHeatIndexImperial(temp.AsDouble(), humidity.AsDouble()); + var dewPoint = BuildTemperatureSensor("dewpoint", "Outdoor Dewpoint", dewPointVal.ToString(), isMetric, isMetric); + var heatIndex = BuildTemperatureSensor("heatindex", "Heat Index", heatIndexVal.ToString(), isMetric, isMetric); + if (dewPoint != null) device.Sensors.Add(dewPoint); + if (heatIndex != null) device.Sensors.Add(heatIndex); } - var windspeed = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("windspeedmph", StringComparison.InvariantCultureIgnoreCase)); + var windspeed = device.Sensors.FirstOrDefault(s => s.Name.Equals("windspeedmph", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (temp != null && windspeed != null) { - var windChill = BuildTemperatureSensor("windchill", "Wind Chill", - isMetric - ? CalculateWindChillMetric((double)temp.Value, (double)windspeed.Value).ToString() - : CalculateWindChillImperial((double)temp.Value, (double)windspeed.Value).ToString(), isMetric, isMetric); - if (windChill != null) gateway.Sensors.Add(windChill); + var windChillVal = isMetric ? CalculateWindChillMetric(temp.AsDouble(), windspeed.AsDouble()) : CalculateWindChillImperial(temp.AsDouble(), windspeed.AsDouble()); + var windChill = BuildTemperatureSensor("windchill", "Wind Chill", windChillVal.ToString(), isMetric, isMetric); + if (windChill != null) device.Sensors.Add(windChill); } - var winddirection = gateway.Sensors.FirstOrDefault(s => s.Name.Equals("winddir", StringComparison.InvariantCultureIgnoreCase)); + var winddirection = device.Sensors.FirstOrDefault(s => s.Name.Equals("winddir", StringComparison.InvariantCultureIgnoreCase)) as Sensor; if (winddirection != null) { - var compass = BuildStringSensor("winddir-comp", "Wind Direction (Compass)", - CalculateWindDirection((int)winddirection.Value).ToString()); - gateway.Sensors.Add(compass); + var compass = BuildStringSensor("winddir-comp", "Wind Direction (Compass)", CalculateWindDirection(winddirection.AsInt())); + device.Sensors.Add(compass); } var sensorsToAdd = new List(); - var pm25 = gateway.Sensors.Where(s => s.Name.StartsWith("pm25_avg_24h") || s.Name.StartsWith("pm25_24h")); - sensorsToAdd.AddRange(pm25.Select(sensor => BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm25Aqi24h((double)sensor.Value)))); + var pm25 = device.Sensors.Where(s => s.Name.StartsWith("pm25_avg_24h") || s.Name.StartsWith("pm25_24h")); + sensorsToAdd.AddRange(pm25.Select(sensor => + { + var s = sensor as Sensor; + return BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm25Aqi24h(s?.AsDouble() ?? 0)); + })); - var pm10 = gateway.Sensors.Where(s => s.Name.StartsWith("pm10_avg_24h") || s.Name.StartsWith("pm10_24h")); - sensorsToAdd.AddRange(pm10.Select(sensor => BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm10Aqi24h((double)sensor.Value)))); + var pm10 = device.Sensors.Where(s => s.Name.StartsWith("pm10_avg_24h") || s.Name.StartsWith("pm10_24h")); + sensorsToAdd.AddRange(pm10.Select(sensor => + { + var s = sensor as Sensor; + return BuildStringSensor($"{sensor.Name}-aqi", $"{sensor.Alias} AQI", CalculatePm10Aqi24h(s?.AsDouble() ?? 0)); + })); - gateway.Sensors.AddRange(sensorsToAdd); + device.Sensors.AddRange(sensorsToAdd.Where(s => s != null)); } // shout out to wikipedia for the formulas! <3 diff --git a/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs new file mode 100644 index 0000000..9129877 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.Builder.cs @@ -0,0 +1,249 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Serilog; + +namespace Ecowitt.Controller.Model.Mapping +{ + public partial class SensorBuilder + { + private static readonly HashSet InvalidTokens = new(StringComparer.InvariantCultureIgnoreCase) + { + "-","--","na","n/a","nan","null","none","" + }; + + private static bool TryParseDouble(string? raw, out double value, string? prop = null) + { + value = default; + if (string.IsNullOrWhiteSpace(raw)) return false; + raw = raw.Trim(); + if (InvalidTokens.Contains(raw)) return false; + if (raw.Contains(',') && !raw.Contains('.') && raw.IndexOf(',') == raw.LastIndexOf(',')) + { + raw = raw.Replace(',', '.'); + } + var ok = double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out value); + if (!ok) + { + Log.Debug("Could not parse double value '{Raw}' for sensor property {Property}", raw, prop ?? ""); + } + return ok; + } + + private static bool TryParseInt(string? raw, out int value, string? prop = null) + { + value = default; + if (string.IsNullOrWhiteSpace(raw)) return false; + raw = raw.Trim(); + if (InvalidTokens.Contains(raw)) return false; + if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) return true; + if (double.TryParse(raw, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture, out var dbl)) + { + if (dbl % 1 == 0 && dbl <= int.MaxValue && dbl >= int.MinValue) + { + value = (int)dbl; + return true; + } + } + Log.Debug("Could not parse int value '{Raw}' for sensor property {Property}", raw, prop ?? ""); + return false; + } + + private static bool IsInvalidString(string? raw) => string.IsNullOrWhiteSpace(raw) || InvalidTokens.Contains(raw.Trim()); + + private static Sensor? BuildWaterFlowSensor(string propertyName, string alias, string propertyValue, bool isMetric = true) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), SensorDataType.Double, isMetric ? "L/min" : "gal/min", SensorType.VolumeFlowRate) + : null; + } + + private static Sensor? BuildWaterConsumptionSensor(string propertyName, string alias, string propertyValue, + bool isMetric = true, bool isTotal = false) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? value : L2G(value), SensorDataType.Double, isMetric ? "L" : "gal", SensorType.Water, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) + : null; + } + + private static Sensor? BuildCurrentSensor(string propertyName, string alias, string propertyValue, bool isMilliAmp = false) + { + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, isMilliAmp ? "mA" : "A", SensorType.Current) + : null; + } + + + private static Sensor? BuildPowerSensor(string propertyName, string alias, string propertyValue) + { + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, "W", SensorType.Power) + : null; + } + + private static Sensor? BuildConsumptionSensor(string propertyName, string alias, string propertyValue, bool isTotal = false) + { + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, "Wh", SensorType.Energy, isTotal ? SensorState.TotalIncreasing : SensorState.Measurement) + : null; + } + + private static Sensor? BuildDistanceSensor(string propertyName, string alias, string propertyValue, + bool isMetric = true) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? value : K2M(value), SensorDataType.Double, isMetric ? "km" : "miles", SensorType.Distance) + : null; + } + + private static Sensor? BuildBinarySensor(string propertyName, string alias, string propertyValue, bool isDiag = true) + { + if (propertyValue == null) return null; + bool value; + switch (propertyValue.Trim().ToLowerInvariant()) + { + case "0": + case "false": + case "off": + case "no": + value = false; break; + case "1": + case "true": + case "on": + case "yes": + value = true; break; + default: + Log.Debug("Could not parse boolean value '{Raw}' for sensor property {Property}", propertyValue, propertyName); + return null; + } + return new Sensor(propertyName, alias, value, SensorDataType.Boolean, sensorType: SensorType.None, sensorClass: SensorClass.BinarySensor, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); + } + + private static Sensor? BuildBatterySensor(string propertyName, string alias, string propertyValue, bool withMultiplier = false ) + { + if (!TryParseInt(propertyValue, out var value, propertyName)) return null; + if (withMultiplier) + { + value *= 20; // multiplier + } + if (value > 100) value = 100; + if (value < 0) value = 0; + return new Sensor(propertyName, alias, value, SensorDataType.Integer, "%", SensorType.Battery, sensorCategory: SensorCategory.Diagnostic); + } + + private static Sensor? BuildPPMSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isTotal = false) + { + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, "ppm", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) + : null; + } + + private static Sensor? BuildParticleSensor(string propertyName, string alias, string propertyValue, SensorType sensorType, bool isMetric = true, bool isTotal = false) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, "µg/m³", sensorType, isTotal ? SensorState.Total : SensorState.Measurement) + : null; + } + + private static Sensor? BuildVoltageSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, "V", SensorType.Voltage, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) + : null; + } + + private static Sensor? BuildRainRateSensor(string propertyName, string alias, string propertyValue, bool isMetric) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, SensorDataType.Double, isMetric ? "mm/h" : "in/h", SensorType.PrecipitationIntensity) + : null; + } + + private static Sensor? BuildRainSensor(string propertyName, string alias, string propertyValue, bool isMetric) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? I2M(value) : value, SensorDataType.Double, isMetric ? "mm" : "in", SensorType.Precipitation) + : null; + } + + private static Sensor? BuildWindSpeedSensor(string propertyName, string alias, string propertyValue, bool isMetric) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? M2K(value) : value, SensorDataType.Double, isMetric ? "km/h" : "mph", SensorType.WindSpeed) + : null; + } + + private static Sensor? BuildPressureSensor(string propertyName, string alias, string propertyValue, bool isMetric) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, isMetric ? IM2HP(value) : value, SensorDataType.Double, isMetric ? "hPa" : "inHg", SensorType.Pressure) + : null; + } + + private static Sensor? BuildHumiditySensor(string propertyName, string alias, string propertyValue) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, "%", SensorType.Humidity) + : null; + } + + private static Sensor? BuildTemperatureSensor(string propertyName, string alias, string propertyValue, bool isMetric, bool startMetric = false) + { + if (!TryParseDouble(propertyValue, out var value, propertyName)) return null; + var unit = isMetric ? "°C" : "F"; + if (startMetric != isMetric) + { + value = startMetric ? C2F(value) : F2C(value); + } + return new Sensor(propertyName, alias, value, SensorDataType.Double, unit, SensorType.Temperature); + } + + private static Sensor? BuildDoubleSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) + { + return TryParseDouble(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Double, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) + : null; + } + + private static Sensor? BuildDateTimeSensor(string propertyName, string alias, string propertyValue) + { + if (long.TryParse(propertyValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var ts)) + { + return new Sensor(propertyName, alias, DateTimeOffset.FromUnixTimeSeconds(ts).UtcDateTime, SensorDataType.DateTime); + } + if (DateTime.TryParse(propertyValue, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var dt)) + { + return new Sensor(propertyName, alias, DateTime.SpecifyKind(dt, DateTimeKind.Utc), SensorDataType.DateTime); + } + Log.Debug("Could not parse datetime value '{Raw}' for sensor property {Property}", propertyValue, propertyName); + return null; + } + + private static Sensor? BuildIntSensor(string propertyName, string alias, string propertyValue, string unit = "", SensorType type = SensorType.None, bool isDiag = false) + { + return TryParseInt(propertyValue, out var value, propertyName) + ? new Sensor(propertyName, alias, value, SensorDataType.Integer, unit, type, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config) + : null; + } + + private static Sensor? BuildStringSensor(string propertyName, string alias, string propertyValue, bool isDiag = false) + { + if (IsInvalidString(propertyValue)) return null; + return new Sensor(propertyName, alias, propertyValue, SensorDataType.String, string.Empty, sensorCategory: isDiag ? SensorCategory.Diagnostic : SensorCategory.Config); + } + + private static int GetNumber(string propertyName) + { + const string pattern = @"^[a-zA-Z_0-9]*(\d+)$"; + var m = Regex.Match(propertyName, pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + return m.Success ? int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture) : -1; + } + + private static double K2M(double result) => result * 0.621371; + private static double IM2HP(double im) => im * 33.86388; + private static double F2C(double fahrenheit) => (fahrenheit - 32) * 5 / 9; + private static double C2F(double celsius) => celsius * 9 / 5 + 32; + private static double M2K(double mph) => mph * 1.60934; + private static double I2M(double inches) => inches * 25.4; + private static double L2G(double liters) => liters * 0.264172; + } +} diff --git a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs similarity index 78% rename from src/Ecowitt.Controller/Mapping/SensorBuilder.cs rename to src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs index e17f654..3e3e179 100644 --- a/src/Ecowitt.Controller/Mapping/SensorBuilder.cs +++ b/src/Ecowitt.Controller/Model/Mapping/SensorBuilder.cs @@ -1,110 +1,6 @@ -using Ecowitt.Controller.Model; -using Serilog; +using Serilog; -/* all currently available sensors. their naming scheme is not consistent, so we have to map them manually -PASSKEY -stationtype -runtime -dateutc -tempinf -humidityin -baromrelin -baromabsin -tempf -humidity -winddir -windspeedmph -windgustmph -maxdailygust -solarradiation -uv -rainratein -eventrainin -hourlyrainin -dailyrainin -weeklyrainin -monthlyrainin -yearlyrainin -totalrainin -srain_piezo -rrain_piezo -erain_piezo -hrain_piezo -drain_piezo -wrain_piezo -mrain_piezo -yrain_piezo -ws90cap_volt -ws90_ver -tempf1,….,,tempf8 -humidity1,….,humidity8 -soilmoisture1,….., soilmoisture8 -soilad1,…., soilad8 -pm25_ch1, …., pm25_ch4 -pm25_avg_24h_ch1,…..,pm25_avg_24h_ch4 -tf_co2 -humi_co2 -pm1_co2 -pm1_24h_co2 -pm25_co2 -pm25_24h_co2 -pm4_co2 -pm4_24h_co2 -pm10_co2 -pm10_24h_co2 -co2 -co2_24h -lightning_num -lightning -lightning_time -leak_ch1 …., leak_ch4 -tf_ch1, …., tf_ch8 -leafwetness_ch1,…, leafwetness_ch8 -console_batt -wh65batt -wh80batt -wh26batt -batt1,….,batt8 -soilbatt1,…,soilbatt8 -pm25batt1, …, pm25batt4 -wh57batt -leakbatt1, …,leakbatt4 -tf_batt1, …, tf_batt8 -co2_batt -leaf_batt1, …, leaf_batt8 -wh90batt -freq -model -interval -ac_status -warning -always_on -val_type -val -run_time -rssi -gw_rssi -timeutc -publish_time -ac_action -ac_running -plan_status -elect_total -happen_elect -realtime_power -ac_voltage -ac_current -water_status -water_action -water_running -water_total -happen_water -flow_velocity -water_temp -wfc01batt -*/ - -namespace Ecowitt.Controller.Mapping +namespace Ecowitt.Controller.Model.Mapping { public partial class SensorBuilder { @@ -128,13 +24,12 @@ public partial class SensorBuilder return BuildWindSpeedSensor(propertyName, "Wind Speed", propertyValue, isMetric); case "windgustmph": return BuildWindSpeedSensor(propertyName, "Wind Gust", propertyValue, isMetric); - //return BuildDoubleSensor(propertyName, "Wind Gust", propertyValue, "km/h", SensorType.WindSpeed); case "maxdailygust": return BuildWindSpeedSensor(propertyName, "Max Daily Gust", propertyValue, isMetric); case "winddir": return BuildIntSensor(propertyName, "Wind Direction", propertyValue, "°"); case "solarradiation": - return BuildIntSensor(propertyName, "Solar Radiation", propertyValue, "W/m²", SensorType.Irradiance); + return BuildDoubleSensor(propertyName, "Solar Radiation", propertyValue, "W/m²", SensorType.Irradiance); case "uv": return BuildIntSensor(propertyName, "UV Index", propertyValue); case "srain_piezo": @@ -250,7 +145,6 @@ public partial class SensorBuilder case "leak_ch2": case "leak_ch3": case "leak_ch4": - // maybe it's bool, I don't have this sensor number = GetNumber(propertyName); return BuildIntSensor(propertyName, $"Leak Channel {number}", propertyValue); case "tf_ch1": @@ -271,20 +165,15 @@ public partial class SensorBuilder case "leafwetness_ch6": case "leafwetness_ch7": case "leafwetness_ch8": - // maybe it's double, I don't have this sensor number = GetNumber(propertyName); return BuildIntSensor(propertyName, $"Leaf Wetness {number}", propertyValue, "%"); case "console_batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "Console Battery", propertyValue); case "wh65batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "WH65 Battery", propertyValue); case "wh80batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "WH80 Battery", propertyValue); case "wh26batt": - // maybe it's voltage, I don't have this sensor return BuildBatterySensor(propertyName, "WH26 Battery", propertyValue); case "batt1": case "batt2": @@ -294,10 +183,8 @@ public partial class SensorBuilder case "batt6": case "batt7": case "batt8": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Battery {number}", propertyValue); - //return BuildVoltageSensor(propertyName, $"Battery {number}", propertyValue, isDiag: true); case "soilbatt1": case "soilbatt2": case "soilbatt3": @@ -307,12 +194,11 @@ public partial class SensorBuilder case "soilbatt7": case "soilbatt8": number = GetNumber(propertyName); - return BuildVoltageSensor(propertyName, $"Soil Battery {number}", propertyValue, isDiag: true); + return BuildVoltageSensor(propertyName, $"Soil Battery {number}", propertyValue, true); case "pm25batt1": case "pm25batt2": case "pm25batt3": case "pm25batt4": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"PM2.5 Battery {number}", propertyValue); case "wh57batt": @@ -321,7 +207,6 @@ public partial class SensorBuilder case "leakbatt2": case "leakbatt3": case "leakbatt4": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Leak Battery {number}", propertyValue); case "tf_batt1": @@ -332,7 +217,6 @@ public partial class SensorBuilder case "tf_batt6": case "tf_batt7": case "tf_batt8": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Temperature Battery {number}", propertyValue); case "co2_batt": @@ -345,7 +229,6 @@ public partial class SensorBuilder case "leaf_batt6": case "leaf_batt7": case "leaf_batt8": - // maybe it's voltage, I don't have this sensor number = GetNumber(propertyName); return BuildBatterySensor(propertyName, $"Leaf Battery {number}", propertyValue); case "wh90batt": @@ -379,7 +262,7 @@ public partial class SensorBuilder case "ac_voltage": return BuildVoltageSensor(propertyName, "AC Voltage", propertyValue); case "ac_current": - return BuildCurrentSensor(propertyName, "AC Current", propertyValue); + return BuildCurrentSensor(propertyName, "AC Current", propertyValue, true); case "water_status": return BuildBinarySensor(propertyName, "Water Status", propertyValue); case "water_action": @@ -387,11 +270,12 @@ public partial class SensorBuilder case "water_running": return BuildBinarySensor(propertyName, "Running", propertyValue); case "water_total": + case "wfc02_total": return BuildWaterConsumptionSensor(propertyName, "Total Water", propertyValue, isMetric, true); case "happen_water": - //new Sensor("Daily Consumption", isMetric ? (double?)device.water_total - (double?)device.happen_water : L2G(device.water_total) - L2G(device.happen_water), isMetric ? "L" : "gal", SensorType.Volume, SensorState.Measurement)); return BuildWaterConsumptionSensor(propertyName, "Last Planned Consumption", propertyValue, isMetric); case "flow_velocity": + case "wfc02_flow_velocity": return BuildWaterFlowSensor(propertyName, "Flow Velocity", propertyValue, isMetric); case "water_temp": return BuildTemperatureSensor(propertyName, "Water Temperature", propertyValue, isMetric, true); @@ -399,29 +283,26 @@ public partial class SensorBuilder return BuildBatterySensor(propertyName, "WFC01 Battery", propertyValue, true); case "heap": return BuildIntSensor(propertyName, "Gateway Heap", propertyValue, "byte", isDiag: true); - case "PASSKEY": - case "stationtype": - case "runtime": - case "dateutc": - case "freq": - case "model": - case "ws90_ver": - case "interval": - case "rssi": - case "timeutc": - case "publish_time": - case "id": - case "nickname": - case "devicename": - case "version": + case "wfc02_status": + return BuildIntSensor(propertyName, "WFC02 Status", propertyValue, isDiag: true); + case "wfc02rssi": + return BuildIntSensor(propertyName, "WFC02 RSSI", propertyValue, "/5", SensorType.SignalStrength, true); + case "wfc02batt": + return BuildBatterySensor(propertyName, "WFC02 Battery", propertyValue, true); + case "capacity": + return BuildIntSensor(propertyName, "Capacity", propertyValue, "byte", isDiag: true); + case "transition": + return BuildIntSensor(propertyName, "Transition", propertyValue, isDiag: true); + case "flowmeter": + return BuildBinarySensor(propertyName, "Flowmeter Available", propertyValue, isDiag: true); + case "valve": + return BuildBinarySensor(propertyName, "Valve Available", propertyValue, isDiag: true); + case "wfc02_position": + return BuildIntSensor(propertyName, "wfc02-position", propertyValue, isDiag: true); default: - Log.Information($"Ignored property {propertyName}."); + Log.Information("Ignored property {PropertyName}.", propertyName); return null; } } - - } - - } diff --git a/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs new file mode 100644 index 0000000..1baa7f4 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Config/HttpConfig.cs @@ -0,0 +1,36 @@ +namespace Ecowitt.Controller.Model.Message.Config; + +public class HttpConfig +{ + public List Hosts { get; set; } = new List(); + public int PollingInterval { get; set; } = 5; + public bool AutoDiscovery { get; set; } +} + +public class HttpHost +{ + public HttpHost() + { + + } + public HttpHost(string host) + { + Host = host; + } + + public HttpHost(string host, string user, string password) + { + Host = host; + User = user; + Password = password; + } + + public string Host { get; set; } + public int Port { get; set; } = 80; + public string User { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string Protocol { get; set; } = "http"; // or "https" + public string BaseUrl => (Protocol == "http" && Port == 80) || (Protocol == "https" && Port == 443) + ? $"{Protocol}://{Host}" + : $"{Protocol}://{Host}:{Port}"; +} diff --git a/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs b/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs new file mode 100644 index 0000000..71ae573 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Config/MqttConfig.cs @@ -0,0 +1,22 @@ +using Ecowitt.Controller.Model.Configuration; + +namespace Ecowitt.Controller.Model.Message.Config; + +public class MqttConfig +{ + public bool Enabled { get; set; } = true; + public string Host { get; set; } + public int Port { get; set; } = 1883; + public string User { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + public string BaseTopic { get; set; } = "ecowitt"; + public string ClientId { get; set; } = "ecowitt-controller"; + public bool Reconnect { get; set; } = true; + public int ReconnectAttempts { get; set; } = 2; + public int Precision { get; set; } = 2; + public Units Units { get; set; } = Units.Metric; + public bool HomeAssistantDiscovery { get; set; } = true; + + public string CmdTopic { get; } = "cmd"; + public string HeartbeatTopic { get; } = "heartbeat"; +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs new file mode 100644 index 0000000..0b9e5f7 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/DeviceData.cs @@ -0,0 +1,9 @@ +namespace Ecowitt.Controller.Model.Message.Data; + +public class DeviceData +{ + public string GatewayId { get; set; } = string.Empty; + public string GatewayName { get; set; } = string.Empty; + public List ChangedSensors { get; set; } = new(); + public DateTime Timestamp { get; set; } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs b/src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs new file mode 100644 index 0000000..9623ba8 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/DeviceDataFull.cs @@ -0,0 +1,7 @@ +namespace Ecowitt.Controller.Model.Message.Data +{ + public class DeviceDataFull : DeviceData + { + public Device Device { get; set; } + } +} diff --git a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceApiAggregate.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceApiAggregate.cs new file mode 100644 index 0000000..da8aaae --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceApiAggregate.cs @@ -0,0 +1,8 @@ +using Ecowitt.Controller.Model.Api; + +namespace Ecowitt.Controller.Model.Message.Data; + +public class SubdeviceApiAggregate +{ + public List Subdevices { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs new file mode 100644 index 0000000..2f2a9f7 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceData.cs @@ -0,0 +1,12 @@ +namespace Ecowitt.Controller.Model.Message.Data +{ + public class SubdeviceData + { + public string GatewayId { get; set; } = string.Empty; + public string GatewayName { get; set; } = string.Empty; + public int SubdeviceId { get; set; } + public List ChangedSensors { get; set; } = new(); + public DateTime Timestamp { get; set; } + + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs new file mode 100644 index 0000000..dbbcf5e --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Data/SubdeviceDataFull.cs @@ -0,0 +1,7 @@ +namespace Ecowitt.Controller.Model.Message.Data +{ + public class SubdeviceDataFull : SubdeviceData + { + public Subdevice Subdevice { get; set; } + } +} diff --git a/src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs new file mode 100644 index 0000000..f2330c9 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/DiscoveryRemovalEvent.cs @@ -0,0 +1,7 @@ +namespace Ecowitt.Controller.Model.Message.Event; + +public class DiscoveryRemovalEvent +{ + public string DeviceName { get; set; } = string.Empty; + public List Sensors { get; set; } = new(); +} diff --git a/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs new file mode 100644 index 0000000..08378f2 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantDiscoveryEvent.cs @@ -0,0 +1,6 @@ +namespace Ecowitt.Controller.Model.Message.Event; + +public class HomeAssistantDiscoveryEvent(Model.Device device) +{ + public Device Device { get; set; } = device; +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantStatusEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantStatusEvent.cs new file mode 100644 index 0000000..bd40f41 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/HomeAssistantStatusEvent.cs @@ -0,0 +1,13 @@ +namespace Ecowitt.Controller.Model.Message.Event; + +public class HomeAssistantStatusEvent +{ + public HomeAssistantStatusType Status { get; set; } +} + +public enum HomeAssistantStatusType +{ + Unknown, + Online, + Offline +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Event/HttpServiceEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/HttpServiceEvent.cs new file mode 100644 index 0000000..7bebe93 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/HttpServiceEvent.cs @@ -0,0 +1,16 @@ +namespace Ecowitt.Controller.Model.Message.Event; + +public class HttpServiceEvent +{ + public HttpServiceEventType EventType { get; set; } = HttpServiceEventType.Unknown; + public string? Message { get; set; } + +} + +public enum HttpServiceEventType +{ + Unknown, + Started, + Stopped, + Error +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Event/MqttConnectionEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/MqttConnectionEvent.cs new file mode 100644 index 0000000..cf1e035 --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/MqttConnectionEvent.cs @@ -0,0 +1,15 @@ +namespace Ecowitt.Controller.Model.Message.Event; + +public class MqttConnectionEvent +{ + public MqttConnectionEventType EventType { get; set; } + public string? Message { get; set; } +} + +public enum MqttConnectionEventType +{ + Connected, + Disconnected, + Error, + MessageReceived +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/Message/Event/MqttServiceEvent.cs b/src/Ecowitt.Controller/Model/Message/Event/MqttServiceEvent.cs new file mode 100644 index 0000000..c7fb45c --- /dev/null +++ b/src/Ecowitt.Controller/Model/Message/Event/MqttServiceEvent.cs @@ -0,0 +1,17 @@ +namespace Ecowitt.Controller.Model.Message.Event +{ + public class MqttServiceEvent + { + public MqttServiceEventType EventType { get; set; } = MqttServiceEventType.Unknown; + public string? Message { get; set; } + } + + public enum MqttServiceEventType + { + Unknown, + Started, + Stopped, + Heartbeat, + Error + } +} diff --git a/src/Ecowitt.Controller/Model/Sensor.cs b/src/Ecowitt.Controller/Model/Sensor.cs index 1539aaa..47cf59d 100644 --- a/src/Ecowitt.Controller/Model/Sensor.cs +++ b/src/Ecowitt.Controller/Model/Sensor.cs @@ -1,75 +1,166 @@ -using System.Text.Json.Serialization; + + +using Newtonsoft.Json; namespace Ecowitt.Controller.Model; -public interface ISensor +public enum SensorDataType { - public string Name { get; } - public string Alias { get; } - public DateTime TimestampUtc { get; set; } - public SensorType SensorType { get; } - public SensorState SensorState { get; } - public SensorClass SensorClass { get; } - public SensorCategory SensorCategory { get; } - public string UnitOfMeasurement { get; } - public object Value { get; set; } - [JsonIgnore] - public Type DataType { get; } - public bool DiscoveryUpdate { get; set; } + Integer, + Double, + Boolean, + String, + DateTime } -public interface ISensor : ISensor +public interface ISensor { - new T Value { get; set; } + string Name { get; } + string Alias { get; } + DateTime TimestampUtc { get; } + SensorType SensorType { get; } + SensorState SensorState { get; } + SensorClass SensorClass { get; } + SensorCategory SensorCategory { get; } + string UnitOfMeasurement { get; } + SensorDataType DataType { get; } + object? Value { get; set; } + bool DiscoveryUpdate { get; set; } + bool HasChanged { get; } + void ResetChangeFlag(); } -public class Sensor : ISensor +public sealed class Sensor : ISensor { + private int _lastValueHash; + private object? _value; + public string Name { get; } public string Alias { get; } - public DateTime TimestampUtc { get; set; } + public DateTime TimestampUtc { get; private set; } public SensorType SensorType { get; } public SensorState SensorState { get; } public SensorClass SensorClass { get; } public SensorCategory SensorCategory { get; } public string UnitOfMeasurement { get; } - public T Value { get; set; } - - object ISensor.Value + public SensorDataType DataType { get; } + public bool DiscoveryUpdate { get; set; } + public bool HasChanged { get; private set; } + + public object? Value { - get => Value; - set => Value = (T)value; + get => _value; + set + { + var newHash = value?.GetHashCode() ?? 0; + HasChanged = _lastValueHash != newHash; + _lastValueHash = newHash; + _value = value; + if (HasChanged) + { + TimestampUtc = DateTime.UtcNow; + } + } } - public Type DataType => typeof(T); - public bool DiscoveryUpdate { get; set; } - - public Sensor(string name, T value, string unitOfMeasurement = "", SensorType sensorType = SensorType.None, SensorState sensorState = SensorState.Measurement, - SensorClass sensorClass = SensorClass.Sensor, SensorCategory sensorCategory = SensorCategory.Config) + [JsonConstructor] + private Sensor(string name, + string alias, + object? value, + SensorDataType dataType, + string unitOfMeasurement, + SensorType sensorType, + SensorState sensorState, + SensorClass sensorClass, + SensorCategory sensorCategory, + bool discoveryUpdate, + DateTime timestampUtc) { Name = name; - Alias = name; + Alias = alias; + DataType = dataType; SensorType = sensorType; SensorState = sensorState; SensorClass = sensorClass; SensorCategory = sensorCategory; UnitOfMeasurement = unitOfMeasurement; - Value = value; - TimestampUtc = DateTime.UtcNow; + DiscoveryUpdate = discoveryUpdate; + TimestampUtc = timestampUtc == default ? DateTime.UtcNow : DateTime.SpecifyKind(timestampUtc, DateTimeKind.Utc); + + // set value & hash without flagging change + _value = CoerceValue(value, dataType); + _lastValueHash = _value?.GetHashCode() ?? 0; + HasChanged = false; } - public Sensor(string name, string alias, T value, string unitOfMeasurement = "", SensorType sensorType = SensorType.None, SensorState sensorState = SensorState.Measurement, - SensorClass sensorClass = SensorClass.Sensor, SensorCategory sensorCategory = SensorCategory.Config) + public Sensor(string name, + string alias, + object? value, + SensorDataType dataType, + string unitOfMeasurement = "", + SensorType sensorType = SensorType.None, + SensorState sensorState = SensorState.Measurement, + SensorClass sensorClass = SensorClass.Sensor, + SensorCategory sensorCategory = SensorCategory.Config) { Name = name; Alias = alias; + DataType = dataType; SensorType = sensorType; SensorState = sensorState; SensorClass = sensorClass; SensorCategory = sensorCategory; UnitOfMeasurement = unitOfMeasurement; - Value = value; TimestampUtc = DateTime.UtcNow; + Value = value; // invokes setter => sets hash & timestamp + } + + public Sensor(string name, + object? value, + SensorDataType dataType, + string unitOfMeasurement = "", + SensorType sensorType = SensorType.None, + SensorState sensorState = SensorState.Measurement, + SensorClass sensorClass = SensorClass.Sensor, + SensorCategory sensorCategory = SensorCategory.Config) + : this(name, name, value, dataType, unitOfMeasurement, sensorType, sensorState, sensorClass, sensorCategory) + { } + + public void ResetChangeFlag() => HasChanged = false; + + // Helper typed accessors (optional usage) + public double AsDouble() => DataType switch + { + SensorDataType.Double => Convert.ToDouble(Value), + SensorDataType.Integer => Convert.ToDouble(Value), + _ => throw new InvalidCastException($"Sensor {Name} value is not numeric") + }; + public int AsInt() => DataType == SensorDataType.Integer ? Convert.ToInt32(Value) : (int)Math.Round(AsDouble()); + public bool AsBool() => DataType == SensorDataType.Boolean ? (bool)(Value ?? false) : throw new InvalidCastException($"Sensor {Name} value is not boolean"); + public string AsString() => Value?.ToString() ?? string.Empty; + public DateTime AsDateTime() => DataType == SensorDataType.DateTime ? (DateTime)(Value ?? DateTime.MinValue) : throw new InvalidCastException($"Sensor {Name} value is not DateTime"); + + private static object? CoerceValue(object? raw, SensorDataType dt) + { + if (raw == null) return null; + try + { + return dt switch + { + SensorDataType.Integer => Convert.ToInt32(raw), + SensorDataType.Double => Convert.ToDouble(raw), + SensorDataType.Boolean => Convert.ToBoolean(raw), + SensorDataType.String => raw.ToString(), + SensorDataType.DateTime => raw is DateTime dtv + ? DateTime.SpecifyKind(dtv, DateTimeKind.Utc) + : DateTime.SpecifyKind(Convert.ToDateTime(raw), DateTimeKind.Utc), + _ => raw + }; + } + catch + { + return raw; // fallback, avoid hard failure + } } } @@ -186,7 +277,6 @@ public enum SensorType WindSpeed } - /// /// from: https://developers.home-assistant.io/blog/2021/09/20/state_class_total/ /// There are 3 defined state classes: diff --git a/src/Ecowitt.Controller/Model/Subdevice.cs b/src/Ecowitt.Controller/Model/Subdevice.cs index a609818..828f961 100644 --- a/src/Ecowitt.Controller/Model/Subdevice.cs +++ b/src/Ecowitt.Controller/Model/Subdevice.cs @@ -18,5 +18,6 @@ public enum SubdeviceModel { Unknown = 0, WFC01 = 1, - AC1100 = 2 + AC1100 = 2, + WFC02 = 3 } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Model/SubdeviceApiAggregate.cs b/src/Ecowitt.Controller/Model/SubdeviceApiAggregate.cs deleted file mode 100644 index d662f03..0000000 --- a/src/Ecowitt.Controller/Model/SubdeviceApiAggregate.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Ecowitt.Controller.Model; - -public class SubdeviceApiAggregate -{ - public List Subdevices { get; set; } = new(); -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Mqtt/MqttClient.cs b/src/Ecowitt.Controller/Mqtt/MqttClient.cs deleted file mode 100644 index 21d0327..0000000 --- a/src/Ecowitt.Controller/Mqtt/MqttClient.cs +++ /dev/null @@ -1,153 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Microsoft.Extensions.Options; -using MQTTnet; -using MQTTnet.Client; - -namespace Ecowitt.Controller.Mqtt; - -public interface IMqttClient : IDisposable -{ - Task Connect(); - Task Disconnect(); - Task Subscribe(string topic); - Task Publish(string topic, string message); - - event EventHandler? OnClientConnected; - event EventHandler? OnClientDisconnected; - event EventHandler? OnMessageReceived; -} - -public class MqttMessageReceivedEventArgs : EventArgs -{ - public MqttMessageReceivedEventArgs(string payload, string topic, string clientId) - { - Payload = payload; - Topic = topic; - ClientId = clientId; - } - - public string Payload { get; } - public string Topic { get; } - public string ClientId { get; } -} - -public class MqttClient : IMqttClient -{ - private readonly MQTTnet.Client.IMqttClient _client; - private readonly ILogger _logger; - public event EventHandler? OnClientConnected; - public event EventHandler? OnClientDisconnected; - public event EventHandler? OnMessageReceived; - - private readonly MqttOptions _options; - - - public MqttClient(ILogger logger, MqttFactory factory, IOptions options) - { - _logger = logger; - _options = options.Value; - _client = factory.CreateMqttClient(); - _client.ConnectedAsync += ClientOnConnectedAsync; - _client.DisconnectedAsync += ClientOnDisconnectedAsync; - _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; - } - - public async Task Connect() - { - if (!_client.IsConnected) - { - var optionsBuilder = new MqttClientOptionsBuilder() - .WithTcpServer(_options.Host, _options.Port) - .WithClientId(_options.ClientId) - .WithCleanSession(); - // allowing empty passwords - if (!string.IsNullOrWhiteSpace(_options.User)/* && !string.IsNullOrWhiteSpace(_options.Password)*/) - optionsBuilder.WithCredentials(_options.User, _options.Password); - - if (!_client.IsConnected) await _client.ConnectAsync(optionsBuilder.Build()); - } - else _logger.LogWarning("Can't connect. Client already connected."); - } - - public async Task Disconnect() - { - if (_client.IsConnected) await _client.DisconnectAsync(); - else _logger.LogWarning("Can't disconnect. Client not connected."); - } - - public async Task Subscribe(string topic) - { - var topicFilter = new MqttTopicFilterBuilder().WithTopic(topic).Build(); - - if (_client.IsConnected) await _client.SubscribeAsync(topicFilter); - else _logger.LogWarning($"Can't subscribe to topic {topic}. Client not connected."); - } - - public async Task Publish(string topic, string message) - { - var mqttPayload = new MqttApplicationMessageBuilder() - .WithTopic(topic) - .WithPayload(message) - .Build(); - - if (_client.IsConnected) - { - var result = await _client.PublishAsync(mqttPayload); - if (result.IsSuccess) - { - _logger.LogDebug($"Message {message} published to topic {topic}"); - return true; - } - else _logger.LogWarning($"Failed to publish message {message} to topic {topic}. Reason: {result.ReasonCode}"); - } - else _logger.LogWarning($"Can't publish message {message} to topic {topic}. Client not connected."); - return false; - } - - private Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) - { - var payload = arg.ApplicationMessage.ConvertPayloadToString(); - _logger.LogDebug($"Message received for topic {arg.ApplicationMessage.Topic}: {payload}"); - - OnMessageReceived?.Invoke(this, - new MqttMessageReceivedEventArgs(payload, arg.ApplicationMessage.Topic, arg.ClientId)); - - return Task.CompletedTask; - } - - private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) - { - _logger.LogInformation($"MQTT client disconnected. Reason: {arg.Reason}"); - OnClientDisconnected?.Invoke(this, EventArgs.Empty); - - if (_options.Reconnect) - { - for (var i = 0; i < _options.ReconnectAttempts; i++) - { - try - { - await Connect(); - break; - } - catch (Exception e) - { - _logger.LogError(e, $"Reconnect attempt {i + 1} failed."); - } - } - } - else _logger.LogWarning("Reconnect is disabled, no reconnect attempt"); - } - - private Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) - { - _logger.LogInformation("MQTT client connected."); - OnClientConnected?.Invoke(this, EventArgs.Empty); - return Task.CompletedTask; - } - - public void Dispose() - { - if (_client.IsConnected) _client.DisconnectAsync().GetAwaiter().GetResult(); - _client.Dispose(); - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Mqtt/MqttService.cs deleted file mode 100644 index 8b270d6..0000000 --- a/src/Ecowitt.Controller/Mqtt/MqttService.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Microsoft.Extensions.Options; -using SlimMessageBus; -using System.Text.Json; - -namespace Ecowitt.Controller.Mqtt; - -public class MqttService : BackgroundService -{ - private const string CmdTopic = "cmd"; - private const string HeartbeatTopic = "heartbeat"; - private readonly ILogger _logger; - private readonly IMessageBus _messageBus; - private readonly IMqttClient _mqttClient; - private readonly MqttOptions _mqttConfig; - private readonly ControllerOptions _controllerConfig; - - public MqttService(IOptions mqttConfig, IOptions controllerConfig, ILogger logger, IMqttClient mqttClient, - IMessageBus messageBus) - { - _logger = logger; - _mqttConfig = mqttConfig.Value; - _controllerConfig = controllerConfig.Value; - _messageBus = messageBus; - - _mqttClient = mqttClient; - _mqttClient.OnMessageReceived += OnMessageReceived; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Starting MqttService"); - - await Connect(); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(60)); - try - { - while(await timer.WaitForNextTickAsync(stoppingToken)) - { - // because i can't figure out how to escape the special chars for the heartbeat payload :( - await _mqttClient.Publish($"{_mqttConfig.BaseTopic}/{HeartbeatTopic}", JsonSerializer.Serialize(new { service = DateTime.UtcNow })); - _logger.LogInformation("Sent heartbeat"); - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping MqttService"); - } - } - - public override void Dispose() - { - _mqttClient.Dispose(); - } - - //public async Task StopAsync(CancellationToken cancellationToken) - //{ - // await _mqttClient.Disconnect(); - // _logger.LogInformation("Disconnected"); - - // await base.StopAsync(cancellationToken); - //} - - private async void OnMessageReceived(object? sender, MqttMessageReceivedEventArgs e) - { - _logger.LogDebug("Received message on topic {Topic} with payload {Payload}", e.Topic, e.Payload); - if (e.Topic.EndsWith("homeassistant")) - { - if (int.TryParse(e.Topic.Split('/')[3], out var result)) - { - var cmd = e.Payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" - await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); - } - else - { - _logger.LogWarning("Invalid subdevice id in topic {Topic}", e.Topic); - } - } - else - { - var cmd = JsonSerializer.Deserialize(e.Payload); - await _messageBus.Publish(cmd); - } - - } - - private async Task Connect() - { - await _mqttClient.Connect(); - _logger.LogInformation("Connected"); - - await _mqttClient.Subscribe($"{_mqttConfig.BaseTopic}/{Helper.BuildMqttSubdeviceCommandTopic()}"); - if (_controllerConfig.HomeAssistantDiscovery) - { - await _mqttClient.Subscribe($"{_mqttConfig.BaseTopic}/{Helper.BuildMqttSubdeviceHACommandTopic()}"); - } - _logger.LogInformation("subscribed to all topics"); - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Program.cs b/src/Ecowitt.Controller/Program.cs index 39f4291..1df0bd2 100644 --- a/src/Ecowitt.Controller/Program.cs +++ b/src/Ecowitt.Controller/Program.cs @@ -1,13 +1,15 @@ using System.Net; using System.Reflection; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Consumer; -using Ecowitt.Controller.Discovery; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mqtt; -using Ecowitt.Controller.Store; -using Ecowitt.Controller.Subdevice; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Configuration; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; +using Ecowitt.Controller.Service.Http; +using Ecowitt.Controller.Service.Mqtt; +using Ecowitt.Controller.Service.Orchestrator; using MQTTnet; +using Newtonsoft.Json; using Polly; using Polly.Contrib.WaitAndRetry; using Polly.Extensions.Http; @@ -45,7 +47,8 @@ public static async Task Main(string[] args) builder.Services.Configure(configuration.GetSection("mqtt")); builder.Services.Configure(configuration.GetSection("controller")); - builder.Services.AddHttpClient("ecowitt-client").AddPolicyHandler(GetRetryPolicy(2)); + var ecowittRetries = configuration.GetSection("ecowitt").GetValue("retries"); + builder.Services.AddHttpClient("ecowitt-client").AddPolicyHandler(GetRetryPolicy(ecowittRetries > 0 ? ecowittRetries : 2)); builder.Services.AddSerilog((services, lc) => lc .ReadFrom.Services(services) @@ -55,52 +58,78 @@ public static async Task Main(string[] args) .MinimumLevel.Warning() .ReadFrom.Configuration(builder.Configuration)); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + var sp = builder.Services.BuildServiceProvider(); builder.Services.AddSlimMessageBus(smb => { smb.WithProviderMemory(cfg => { cfg.EnableMessageSerialization = true; }); - smb.AddJsonSerializer(); - smb.Produce(x => x.DefaultTopic("api-data")); - smb.Produce(x => x.DefaultTopic("subdevice-data")); - smb.Produce(x => x.DefaultTopic("subdevice-command")); - smb.Consume(x => x - .Topic("api-data") - .WithConsumer() - ); - smb.Consume(x => x - .Topic("subdevice-data") - .WithConsumer() - ); - smb.Consume(x => x - .Topic("subdevice-command") - .WithConsumer() - ); + smb.AddJsonSerializer(jsonSerializerSettings: JsonSettings); + smb.WithDependencyResolver(sp); + + // statemachine -> mqttservice + smb.Produce(x => x.DefaultTopic("config-mqtt")); + smb.Produce(x => x.DefaultTopic("home-assistant-discovery")); + smb.Produce(x => x.DefaultTopic("discovery-removal")); + smb.Produce(x => x.DefaultTopic("device-data")); + smb.Produce(x => x.DefaultTopic("device-data-full")); + smb.Produce(x => x.DefaultTopic("subdevice-data")); + smb.Produce(x => x.DefaultTopic("subdevice-data-full")); + smb.Consume(x => x.Topic("config-mqtt").WithConsumer()); + smb.Consume(x => x.Topic("home-assistant-discovery").WithConsumer()); + smb.Consume(x => x.Topic("discovery-removal").WithConsumer()); + smb.Consume(x => x.Topic("device-data").WithConsumer()); + smb.Consume(x => x.Topic("device-data-full").WithConsumer()); + smb.Consume(x => x.Topic("subdevice-data").WithConsumer()); + smb.Consume(x => x.Topic("subdevice-data-full").WithConsumer()); + + // mqttservice -> statemachine + smb.Produce(x => x.DefaultTopic("mqtt-service-event")); + smb.Produce(x => x.DefaultTopic("mqtt-connection-event")); + smb.Produce(x => x.DefaultTopic("home-assistant-status")); + smb.Consume(x => x.Topic("mqtt-service-event").WithConsumer()); + smb.Consume(x => x.Topic("mqtt-connection-event").WithConsumer()); + smb.Consume(x => x.Topic("home-assistant-status").WithConsumer()); + + // controller -> statemachine + smb.Produce(x => x.DefaultTopic("gw-api-data")); + smb.Consume(x => x.Topic("gw-api-data").WithConsumer()); + + // statemachine -> HttpPublishingService + smb.Produce(x => x.DefaultTopic("config-http")); + smb.Consume(x => x.Topic("config-http").WithConsumer()); + + // HttpPublishingService -> statemachine + smb.Produce(x => x.DefaultTopic("subdevice-api-data")); + smb.Produce(x => x.DefaultTopic("http-service-event")); + smb.Consume(x => x.Topic("subdevice-api-data").WithConsumer()); + smb.Consume(x => x.Topic("http-service-event").WithConsumer()); + + // mqttservice -> statemachine (subdevice commands) + smb.Produce(x => x.DefaultTopic("subdevice-api-command")); + smb.Consume(x => x.Topic("subdevice-api-command").WithConsumer()); + smb.AddServicesFromAssembly(Assembly.GetExecutingAssembly()); }); - builder.Services.AddTransient(); - builder.Services.AddSingleton(); + builder.Services.AddHostedService(s => s.GetRequiredService()); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); - builder.Services.AddHostedService(); + builder.Services.AddTransient(); + builder.Services.AddHostedService(s => s.GetRequiredService()); + builder.Services.AddHostedService(s => s.GetRequiredService()); builder.Services.AddControllers(); - //builder.Services.AddEndpointsApiExplorer(); - //builder.Services.AddSwaggerGen(); var app = builder.Build(); app.UseSerilogRequestLogging(options => { - // Customize the message template options.MessageTemplate = "Handled {RequestPath}"; - - // Emit debug-level events instead of the defaults options.GetLevel = (httpContext, elapsed, ex) => LogEventLevel.Debug; - - // Attach additional properties to the request completion event options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); @@ -108,12 +137,6 @@ public static async Task Main(string[] args) }; }); - //if (app.Environment.IsDevelopment()) - //{ - // app.UseSwagger(); - // app.UseSwaggerUI(); - //} - app.MapControllers(); await app.RunAsync(); @@ -126,7 +149,12 @@ private static IAsyncPolicy GetRetryPolicy(int retries) return HttpPolicyExtensions .HandleTransientHttpError() .OrResult(msg => msg.StatusCode == HttpStatusCode.NotFound) - //.WaitAndRetryAsync(retries, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); .WaitAndRetryAsync(delay); } + + private static readonly JsonSerializerSettings JsonSettings = new() + { + TypeNameHandling = TypeNameHandling.Auto, + NullValueHandling = NullValueHandling.Ignore + }; } \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs new file mode 100644 index 0000000..b7ec588 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Consumer.cs @@ -0,0 +1,15 @@ +using Ecowitt.Controller.Model.Message.Config; + +namespace Ecowitt.Controller.Service.Http +{ + public partial class HttpPublishingService + { + public Task OnHandle(HttpConfig message) + { + _logger.LogDebug("HttpConfig received"); + _config = message ?? throw new ArgumentNullException(nameof(message), "HttpConfig cannot be null"); + + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs new file mode 100644 index 0000000..55badc7 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.Events.cs @@ -0,0 +1,64 @@ +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; + +namespace Ecowitt.Controller.Service.Http +{ + public partial class HttpPublishingService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting SubdeviceService"); + + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_config.PollingInterval)); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + foreach (var host in _config.Hosts) + { + try + { + var data = await GetSubdeviceData(host, stoppingToken); + if(data.Count == 0) + { + _logger.LogWarning("No subdevice data received from {HostBaseUrl}", host.BaseUrl); + } + + var aggregate = new SubdeviceApiAggregate(); + aggregate.Subdevices.AddRange(data); + await _messageBus.Publish(aggregate,cancellationToken: stoppingToken); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to get subdevicedata for {HostBaseUrl}", host.BaseUrl); + } + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Stopping SubdeviceService"); + } + } + + public async Task StartedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new HttpServiceEvent { EventType = HttpServiceEventType.Started }, cancellationToken: cancellationToken); + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task StoppedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new HttpServiceEvent { EventType = HttpServiceEventType.Stopped }, cancellationToken: cancellationToken); + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs new file mode 100644 index 0000000..4cfb72a --- /dev/null +++ b/src/Ecowitt.Controller/Service/Http/HttpPublishingService.cs @@ -0,0 +1,190 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Message.Config; +using SlimMessageBus; +using System.Text.Json; + +namespace Ecowitt.Controller.Service.Http; + +public partial class HttpPublishingService : BackgroundService, IHostedLifecycleService, IConsumer +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + private readonly IMessageBus _messageBus; + private HttpConfig _config = new HttpConfig(); + + public HttpPublishingService(ILogger logger, IMessageBus messageBus, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _messageBus = messageBus; + _httpClientFactory = httpClientFactory; + } + + + private async Task> GetSubdeviceData(HttpHost host, CancellationToken cancellationToken) + { + _logger.LogInformation("Polling subdevices from {HostHost}", host.Host); + var subdevices = new List(); + try + { + subdevices.AddRange(await GetSubdevicesOverview(host, cancellationToken)); + foreach (var subdevice in subdevices) + { + var payload = await GetSubDeviceApiPayload(host, subdevice.Id, subdevice.Model, cancellationToken); + subdevice.Payload = payload; + await Task.Delay(350, cancellationToken); // delay to not overload the gateway + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception while trying to get subdevices from {HostHost}", host.Host); + } + + return subdevices; + + } + + private async Task> GetSubdevicesOverview(HttpHost host, CancellationToken cancellationToken) + { + var subdevices = new List(); + using var client = CreateHttpClient(host); + + try + { + var response = await client.GetAsync("get_iot_device_list", cancellationToken); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + var jsonDocument = JsonDocument.Parse(content); + var elements = jsonDocument.RootElement.GetProperty("command"); + foreach (var element in elements.EnumerateArray()) + { + + var subdevice = new SubdeviceApiData + { + Id = element.GetProperty("id").GetInt32(), + Model = element.GetProperty("model").GetInt32(), + Version = element.GetProperty("ver").GetInt32(), + RfnetState = element.GetProperty("rfnet_state").GetInt32(), + Battery = element.GetProperty("battery").GetInt32(), + Signal = element.GetProperty("signal").GetInt32(), + GwIp = host.Host, + TimestampUtc = DateTime.UtcNow + }; + _logger.LogInformation("Subdevice: {SubdeviceId} ({SubdeviceModel})", subdevice.Id, subdevice.Model); + subdevices.Add(subdevice); + } + } + else + { + _logger.LogWarning("Failed to get subdevices from {HostHost}", host.Host); + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception while trying to get subdevices from {HostHost}", host.Host); + } + + + return subdevices; + } + + private async Task GetSubDeviceApiPayload(HttpHost host, int subdeviceId, int model, CancellationToken cancellationToken) + { + using var client = CreateHttpClient(host); + try + { + var payload = new { command = new[] { new { cmd = "read_device", id = subdeviceId, model } } }; + var sContent = new StringContent(JsonSerializer.Serialize(payload)); + var response = await client.PostAsync("parse_quick_cmd_iot", sContent, cancellationToken); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(cancellationToken); + } + else + { + _logger.LogWarning("Could not get payload from {HostHost} for subdevice {SubdeviceId}", host.Host, subdeviceId); + } + } + catch (Exception e) + { + _logger.LogError(e, "Exception while trying to get payload from {HostHost} for subdevice {SubdeviceId}", host.Host, subdeviceId); + } + + return string.Empty; + } + + public async Task SendSubdeviceCommand(string gatewayIp, SubdeviceApiCommand command, SubdeviceModel model) + { + var host = _config.Hosts.FirstOrDefault(h => h.Host == gatewayIp); + if (host == null) + { + _logger.LogWarning("No host config found for gateway {GatewayIp}", gatewayIp); + return false; + } + + using var client = CreateHttpClient(host); + try + { + object payload; + switch (command.Cmd) + { + case Command.Start: + var val = command.Duration ?? 0; + var valType = (int)(command.Unit ?? DurationUnit.Minutes); + var alwaysOn = command.AlwaysOn == true || !command.Duration.HasValue ? 1 : 0; + payload = new + { + command = new[] + { + new + { + always_on = alwaysOn, val_type = valType, val, + position = 100, cmd = "quick_run", + id = command.Id, model = (int)model + } + } + }; + break; + case Command.Stop: + payload = new { command = new[] { new { cmd = "quick_stop", id = command.Id, model = (int)model } } }; + break; + default: + _logger.LogWarning("Unsupported command {Cmd} for subdevice {Id}", command.Cmd, command.Id); + return false; + } + + var json = JsonSerializer.Serialize(payload); + _logger.LogDebug("Sending command payload: {Json}", json); + var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + var response = await client.PostAsync("parse_quick_cmd_iot", content); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Sent {Cmd} command to subdevice {Id} on {GatewayIp}", command.Cmd, command.Id, gatewayIp); + return true; + } + + _logger.LogWarning("Failed to send command to subdevice {Id} on {GatewayIp}: {StatusCode}", command.Id, gatewayIp, response.StatusCode); + return false; + } + catch (Exception e) + { + _logger.LogError(e, "Exception sending command to subdevice {Id} on {GatewayIp}", command.Id, gatewayIp); + return false; + } + } + + private HttpClient CreateHttpClient(HttpHost host) + { + var client = _httpClientFactory.CreateClient("ecowitt-client"); + client.BaseAddress = new Uri(host.BaseUrl); + + if (!string.IsNullOrWhiteSpace(host.User) && !string.IsNullOrWhiteSpace(host.Password)) + { + var credentials = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"{host.User}:{host.Password}")); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", credentials); + } + return client; + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Mqtt/Helper.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttPathBuilder.cs similarity index 95% rename from src/Ecowitt.Controller/Mqtt/Helper.cs rename to src/Ecowitt.Controller/Service/Mqtt/MqttPathBuilder.cs index 0e9a2d8..4827306 100644 --- a/src/Ecowitt.Controller/Mqtt/Helper.cs +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttPathBuilder.cs @@ -1,8 +1,6 @@ -using System.Text; +namespace Ecowitt.Controller.Service.Mqtt; -namespace Ecowitt.Controller.Mqtt; - -public static class Helper +public static class MqttPathBuilder { public static string BuildMqttGatewayTopic(string gwName) { diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs new file mode 100644 index 0000000..5510b97 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttPayloadBuilder.cs @@ -0,0 +1,58 @@ +using Ecowitt.Controller.Model; + +namespace Ecowitt.Controller.Service.Mqtt; + +public static class MqttPayloadBuilder +{ + public static dynamic BuildSubdevicePayload(Model.Subdevice subdevice) + { + return new + { + id = subdevice.Id, + model = subdevice.Model, + devicename = subdevice.Devicename, + nickname = subdevice.Nickname, + state = subdevice.Availability ? "online" : "offline", + ver = subdevice.Version + }; + } + + public static dynamic BuildSensorPayload(ISensor s, int? precision) + { + object? valueOut = s.Value; + if (s.DataType == SensorDataType.Double) + { + try { valueOut = Math.Round(Convert.ToDouble(s.Value), precision ?? 2); } catch { /* ignore */ } + } + return new + { + name = s.Name, + alias = s.Alias, + value = valueOut, + unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null + }; + } + + public static dynamic BuildGatewayPayload(Device gw) + { + if (string.IsNullOrWhiteSpace(gw.Model)) + { + return new + { + ip = gw.IpAddress, + name = gw.Name + }; + } + return new + { + ip = gw.IpAddress, + name = gw.Name, + model = gw.Model, + passkey = gw.PASSKEY, + stationType = gw.StationType, + runtime = gw.Runtime, + state = "online", + freq = gw.Freq + }; + } +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs new file mode 100644 index 0000000..2f28318 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Consumer.cs @@ -0,0 +1,148 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; +using MQTTnet.Client; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + public async Task OnHandle(MqttConfig message) + { + if (_client != null) + { + try + { + if (_mqttConfig is { HomeAssistantDiscovery: true }) await UnsubscribeHomeAssistant(); + + if (_client.IsConnected) await _client.DisconnectAsync(); + _client.ApplicationMessageReceivedAsync -= ClientOnApplicationMessageReceivedAsync; + _client.DisconnectedAsync -= ClientOnDisconnectedAsync; + _client.ConnectedAsync -= ClientOnConnectedAsync; + _client.Dispose(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while disconnecting, unregistering or disposing MQTT client."); + return; + } + } + + _mqttConfig = message; + + if (!message.Enabled) + { + _logger.LogInformation("MQTT is disabled"); + return; + } + + _client = _factory.CreateMqttClient(); + _client.ConnectedAsync += ClientOnConnectedAsync; + _client.DisconnectedAsync += ClientOnDisconnectedAsync; + _client.ApplicationMessageReceivedAsync += ClientOnApplicationMessageReceivedAsync; + var optionsBuilder = new MqttClientOptionsBuilder() + .WithTcpServer(_mqttConfig.Host, _mqttConfig.Port) + .WithClientId(_mqttConfig.ClientId) + .WithCleanSession(); + if (!string.IsNullOrWhiteSpace(_mqttConfig.User)) + optionsBuilder.WithCredentials(_mqttConfig.User, _mqttConfig.Password); + + await _client.ConnectAsync(optionsBuilder.Build()); + await _client.SubscribeAsync($"{_mqttConfig.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceCommandTopic()}"); + + if (_mqttConfig.HomeAssistantDiscovery) await SubscribeHomeAssistant(); + } + + public async Task OnHandle(HomeAssistantDiscoveryEvent message) + { + await EmitHomeAssistantDiscovery(message.Device); + } + + public async Task OnHandle(DiscoveryRemovalEvent message) + { + foreach (var sensor in message.Sensors) + { + var sensorClassTopic = BuildSensorClassTopic(sensor.SensorClass); + var topic = $"homeassistant/{MqttPathBuilder.Sanitize($"{sensorClassTopic}/{message.DeviceName}_{sensor.Name}")}/config"; + await RemoveDiscoveryMessage(topic); + _logger.LogInformation("Removed discovery for {DeviceName}/{SensorName}", message.DeviceName, sensor.Name); + } + } + + public async Task OnHandle(DeviceData message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + if (string.IsNullOrWhiteSpace(message.GatewayName)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + await PublishSensors(message.ChangedSensors, message.GatewayName); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(message.GatewayName), message.Timestamp); + } + + public async Task OnHandle(DeviceDataFull message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + var gateway = message.Device; + if (string.IsNullOrWhiteSpace(gateway.Name)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + await PublishGateway(gateway); + await PublishSensors(gateway.Sensors, gateway.Name); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttGatewayTopic(gateway.Name), message.Timestamp); + } + + public async Task OnHandle(SubdeviceData message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + if (string.IsNullOrWhiteSpace(message.GatewayName)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + await PublishSubdeviceSensors(message.ChangedSensors, message.GatewayName, message.SubdeviceId); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(message.GatewayName, message.SubdeviceId.ToString()), DateTime.UtcNow); + } + + public async Task OnHandle(SubdeviceDataFull message) + { + if (_client == null || !_client.IsConnected) + { + _logger.LogWarning("MQTT client is not connected. Cannot publish device data."); + return; + } + + if (string.IsNullOrWhiteSpace(message.GatewayName)) + { + _logger.LogWarning("Gateway name is empty. Cannot publish device data."); + return; + } + + var subdevice = message.Subdevice; + await PublishSubdevice(subdevice, message.GatewayName); + await PublishSubdeviceSensors(message.Subdevice.Sensors, message.GatewayName, message.SubdeviceId); + await PublishAvailabilityMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(message.GatewayName, message.SubdeviceId.ToString()), DateTime.UtcNow); + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs new file mode 100644 index 0000000..a4f404c --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.DiscoveryPublisher.cs @@ -0,0 +1,124 @@ + +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Discovery; +using MQTTnet; +using MQTTnet.Protocol; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Device = Ecowitt.Controller.Model.Device; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + private async Task PublishGatewayDiscovery(Device gw) + { + var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); + var id = DiscoveryBuilder.BuildIdentifier(gw.Name, "availability"); + //var statetopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayTopic(gw.Name)}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayTopic(gw.Name)}/availability"; + + var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"sensor/{gw.Name}"), config); + } + + private async Task PublishSubdeviceDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) + { + var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); + var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "availability"); + //var statetopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; + + var config = DiscoveryBuilder.BuildGatewayConfig(device, _origin, "Availability", id, availabilityTopic, availabilityTopic); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"sensor/{subdevice.Nickname}"), config); + } + + private async Task PublishSubdeviceSwitchDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice) + { + var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); + var id = DiscoveryBuilder.BuildIdentifier(subdevice.Nickname, "switch"); + var statetopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/diag/running"; + var valueTemplate = "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}"; + var cmdTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic(gw.Name, subdevice.Id.ToString())}"; + + var config = + DiscoveryBuilder.BuildSwitchConfig(device, _origin, "switch", id, statetopic, cmdTopic, valueTemplate: valueTemplate); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"switch/{subdevice.Nickname}"), config); + } + + private async Task PublishSensorDiscovery(Device gw, ISensor sensor) + { + var device = gw.Model == null ? DiscoveryBuilder.BuildDevice(gw.Name) : DiscoveryBuilder.BuildDevice(gw.Name, gw.Model, "Ecowitt", gw.Model, gw.StationType ?? "unknown"); + + var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayDiagnosticTopic(gw.Name, sensor.Alias)}" : $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewaySensorTopic(gw.Name, sensor.Alias)}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttGatewayTopic(gw.Name)}/availability"; + await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); + } + + private async Task PublishSensorDiscovery(Device gw, Ecowitt.Controller.Model.Subdevice subdevice, ISensor sensor) + { + var device = DiscoveryBuilder.BuildDevice(subdevice.Nickname, subdevice.Model.ToString(), "Ecowitt", subdevice.Model.ToString(), subdevice.Version.ToString(), DiscoveryBuilder.BuildIdentifier(gw.Name)); + var statetopic = sensor.SensorCategory == SensorCategory.Diagnostic ? $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}" : $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias)}"; + var availabilityTopic = $"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString())}/availability"; + await PublishSensorDiscovery(device, sensor, statetopic, availabilityTopic); + } + + private async Task PublishSensorDiscovery(Model.Discovery.Device device, ISensor sensor, string statetopic, string availabilityTopic) + { + var id = DiscoveryBuilder.BuildIdentifier($"{device.Name}_{sensor.Name}", sensor.SensorType.ToString()); + var category = DiscoveryBuilder.BuildDeviceCategory(sensor.SensorType); + + var valueTemplate = sensor.SensorClass == SensorClass.BinarySensor + ? "{% if (value_json.value == true) -%} ON {%- else -%} OFF {%- endif %}" + : "{{ value_json.value }}"; + //var valueTemplate = "{{ value_json.value }}"; + + var config = sensor.SensorCategory == SensorCategory.Diagnostic + ? DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, sensorCategory: sensor.SensorCategory.ToString().ToLower(), isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor) + : DiscoveryBuilder.BuildSensorConfig(device, _origin, sensor.Alias, id, category, statetopic, valueTemplate: valueTemplate, unitOfMeasurement: sensor.UnitOfMeasurement, isBinarySensor: sensor.SensorClass == SensorClass.BinarySensor); + + var sensorClassTopic = BuildSensorClassTopic(sensor.SensorClass); + + await PublishDiscoveryMessage(MqttPathBuilder.Sanitize($"{sensorClassTopic}/{device.Name}_{sensor.Name}"), config); + } + + private async Task PublishDiscoveryMessage(string topic, Config config) + { + if (_client is { IsConnected: true }) + { + topic = $"homeassistant/{topic}/config"; + + if (config.DeviceClass != null && + config.DeviceClass.Equals("none", StringComparison.InvariantCultureIgnoreCase)) + config.DeviceClass = null; + + var payload = JsonSerializer.Serialize(config, + new JsonSerializerOptions + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + + await Publish(topic, payload, true); + } + } + + public async Task RemoveDiscoveryMessage(string topic, bool retain = false) + { + await Publish(topic, string.Empty, true); + } + + private string BuildSensorClassTopic(SensorClass sc) + { + return sc switch + { + SensorClass.BinarySensor => "binary_sensor", + _ => "sensor" + }; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs new file mode 100644 index 0000000..326fcdd --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Events.cs @@ -0,0 +1,119 @@ +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Message.Event; +using MQTTnet; +using MQTTnet.Client; +using System.Text.Json; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + public async Task StartedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Started }, cancellationToken: cancellationToken); + } + + public Task StartingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public async Task StoppedAsync(CancellationToken cancellationToken) + { + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Stopped }, cancellationToken: cancellationToken); + } + + public Task StoppingAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task ClientOnApplicationMessageReceivedAsync(MqttApplicationMessageReceivedEventArgs arg) + { + var payload = arg.ApplicationMessage.ConvertPayloadToString(); + var topic = arg.ApplicationMessage.Topic; + _logger.LogDebug("Message received for topic {Topic}: {Payload}", topic, payload); + + if (topic.Equals(HaStatusTopic, StringComparison.OrdinalIgnoreCase)) + { + // home assistant status topic + if (payload.Equals("online", StringComparison.OrdinalIgnoreCase)) + await _messageBus.Publish(new HomeAssistantStatusEvent() { Status = HomeAssistantStatusType.Online }); + else if (payload.Equals("offline", StringComparison.OrdinalIgnoreCase)) await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Offline }); + else await _messageBus.Publish(new HomeAssistantStatusEvent { Status = HomeAssistantStatusType.Unknown }); + } + else if (topic.EndsWith("cmd/homeassistant")) + { + // commands coming from home assistant + if (int.TryParse(topic.Split('/')[3], out var result)) + { + var cmd = payload.Equals("ON", StringComparison.InvariantCultureIgnoreCase) ? Command.Start : Command.Stop; //I know, everything that's not "ON" is "OFF" + await _messageBus.Publish(new SubdeviceApiCommand() { Cmd = cmd, Id = result }); + } + else + { + _logger.LogWarning("Invalid subdevice id in topic {Topic}", topic); + } + } + else + { + // direct commands via mqtt + try + { + var cmd = JsonSerializer.Deserialize(payload); + if (cmd == null) + { + _logger.LogWarning("Failed to deserialize command from topic {Topic}", topic); + return; + } + await _messageBus.Publish(cmd); + } + catch (Exception e) + { + _logger.LogError(e, "Failed to deserialize command from topic {Topic}: {Payload}", topic, payload); + } + } + } + + private async Task ClientOnDisconnectedAsync(MqttClientDisconnectedEventArgs arg) + { + _logger.LogInformation("MQTT client disconnected."); + await _messageBus.Publish(new MqttConnectionEvent { EventType = MqttConnectionEventType.Disconnected }); + if (_mqttConfig is { Reconnect: true } && !_isConnecting) + { + _logger.LogInformation("Attempting to reconnect to MQTT broker..."); + _isConnecting = true; + try + { + for (var i = 0; i < _mqttConfig.ReconnectAttempts; i++) + { + try + { + var result = await _client?.ConnectAsync(_client.Options)!; + if (result.ResultCode == MqttClientConnectResultCode.Success) break; + else _logger.LogWarning("Failed to reconnect to MQTT broker. Attempt {I} of {MqttConfigReconnectAttempts}. Reason: {MqttClientConnectResultCode}", i + 1, _mqttConfig.ReconnectAttempts, result.ResultCode); + } + catch (Exception ex) + { + _logger.LogError("Exception during MQTT reconnect attempt {I} of {MqttConfigReconnectAttempts}: {ExMessage}", i + 1, _mqttConfig.ReconnectAttempts, ex.Message); + } + await Task.Delay(TimeSpan.FromSeconds(2)); + } + } + finally + { + _isConnecting = false; + } + } + } + + private async Task ClientOnConnectedAsync(MqttClientConnectedEventArgs arg) + { + _logger.LogInformation("MQTT client connected."); + await _messageBus.Publish(new MqttConnectionEvent + { + EventType = MqttConnectionEventType.Connected + }); + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs new file mode 100644 index 0000000..0a209ee --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.Publisher.cs @@ -0,0 +1,85 @@ +using Ecowitt.Controller.Model; +using MQTTnet; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using MQTTnet.Protocol; + +namespace Ecowitt.Controller.Service.Mqtt +{ + public partial class MqttService + { + private async Task PublishSubdevice(Subdevice subdevice, string gatewayName) + { + var payload = MqttPayloadBuilder.BuildSubdevicePayload(subdevice); + await PublishMessage(MqttPathBuilder.BuildMqttSubdeviceTopic(gatewayName, subdevice.Id.ToString()), payload); + } + + private async Task PublishSubdeviceSensors(List sensors, string gatewayName, int subdeviceId) + { + var precision = _mqttConfig?.Precision ?? 2; + foreach (var sensor in sensors) + { + var payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision); + var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? MqttPathBuilder.BuildMqttSubdeviceDiagnosticTopic(gatewayName, subdeviceId.ToString(), sensor.Alias) : MqttPathBuilder.BuildMqttSubdeviceSensorTopic(gatewayName, subdeviceId.ToString(), sensor.Alias); + await PublishMessage(topic, payload); + } + } + + private async Task PublishGateway(Device gateway) + { + var payload = MqttPayloadBuilder.BuildGatewayPayload(gateway); + await PublishMessage(MqttPathBuilder.BuildMqttGatewayTopic(gateway.Name), payload); + } + + private async Task PublishSensors(List sensors, string gatewayName ) + { + var precision = _mqttConfig?.Precision ?? 2; + foreach (var sensor in sensors) + { + var payload = MqttPayloadBuilder.BuildSensorPayload(sensor, precision); + var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? MqttPathBuilder.BuildMqttGatewayDiagnosticTopic(gatewayName, sensor.Alias) : MqttPathBuilder.BuildMqttGatewaySensorTopic(gatewayName, sensor.Alias); + await PublishMessage(topic, payload); + } + } + + private async Task PublishMessage(string topic, dynamic payload) + { + if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}", + JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }))) + _logger.LogWarning("Failed to publish message to topic {MqttConfigBaseTopic}/{Topic}. Is the client connected?", _mqttConfig?.BaseTopic, topic); + } + + private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) + { + var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; + + if (!await Publish($"{_mqttConfig?.BaseTopic}/{topic}/availability", available)) + _logger.LogWarning("Failed to publish message to topic {MqttConfigBaseTopic}/{Topic}. Is the client connected?", _mqttConfig?.BaseTopic, topic); + } + + private async Task Publish(string topic, string message, bool retain = false) + { + var mqttPayload = new MqttApplicationMessageBuilder() + .WithTopic(topic) + .WithPayload(message) + .WithRetainFlag(retain) + .WithQualityOfServiceLevel(MqttQualityOfServiceLevel.AtLeastOnce) + .Build(); + + if (_client is { IsConnected: true }) + { + var result = await _client.PublishAsync(mqttPayload); + if (result.IsSuccess) + { + _logger.LogInformation("Published message to topic {topic}", topic); + _logger.LogDebug("Message {message} ", message); + return true; + } + else _logger.LogWarning("Failed to publish message {Message} to topic {Topic}. Reason: {MqttClientPublishReasonCode}", message, topic, result.ReasonCode); + } + else _logger.LogWarning("Can't publish message {Message} to topic {Topic}. Client not connected.", message, topic); + return false; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs new file mode 100644 index 0000000..ad8878a --- /dev/null +++ b/src/Ecowitt.Controller/Service/Mqtt/MqttService.cs @@ -0,0 +1,111 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Discovery; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; +using MQTTnet; +using MQTTnet.Client; +using SlimMessageBus; + +namespace Ecowitt.Controller.Service.Mqtt; + +public partial class MqttService : BackgroundService, IHostedLifecycleService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer +{ + private readonly ILogger _logger; + private readonly MqttFactory _factory; + private readonly IMessageBus _messageBus; + private IMqttClient? _client; + private bool _isConnecting; + private MqttConfig? _mqttConfig; + private readonly Origin _origin; + private const string HaStatusTopic = "homeassistant/status"; + + + public MqttService(ILogger logger, MqttFactory factory, IMessageBus messageBus) + { + _logger = logger; + _factory = factory; + _messageBus = messageBus; + _origin = DiscoveryBuilder.BuildOrigin(); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(30)); + try + { + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + if (_client is { IsConnected: true } && _mqttConfig != null) + { + await Publish($"{_mqttConfig.BaseTopic}/{_mqttConfig.HeartbeatTopic}", + JsonSerializer.Serialize(new { service = DateTime.UtcNow })); + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Heartbeat }, cancellationToken: stoppingToken); + _logger.LogDebug("Sent heartbeat"); + } + } + } + catch (OperationCanceledException) + { + _logger.LogInformation("Stopping MqttService"); + await _messageBus.Publish(new MqttServiceEvent { EventType = MqttServiceEventType.Stopped }, cancellationToken: stoppingToken); + } + } + + + + private async Task SubscribeHomeAssistant() + { + if (_client == null || !_client.IsConnected || _isConnecting) + { + _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot subscribe to Home Assistant state."); + return; + } + + await _client.SubscribeAsync(HaStatusTopic); + await _client.SubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Subscribed to Home Assistant"); + } + + private async Task UnsubscribeHomeAssistant() + { + if (_client == null || !_client.IsConnected || _isConnecting) + { + _logger.LogWarning("MQTT client is not connected or is currently connecting. Cannot unsubscribe from Home Assistant state."); + return; + } + + await _client.UnsubscribeAsync(HaStatusTopic); + await _client.UnsubscribeAsync($"{_mqttConfig?.BaseTopic}/{MqttPathBuilder.BuildMqttSubdeviceHACommandTopic()}"); + _logger.LogInformation("Unsubscribed from Home Assistant"); + } + + private async Task EmitHomeAssistantDiscovery(Model.Device gateway) + { + await PublishGatewayDiscovery(gateway); + foreach (var sensor in gateway.Sensors) + { + await PublishSensorDiscovery(gateway, sensor); + } + + foreach (var subdevice in gateway.Subdevices) + { + await PublishSubdeviceDiscovery(gateway, subdevice); + if (subdevice.Model is SubdeviceModel.WFC01 or SubdeviceModel.AC1100 or SubdeviceModel.WFC02) + await PublishSubdeviceSwitchDiscovery(gateway, subdevice); + foreach (var sensor in subdevice.Sensors) + { + await PublishSensorDiscovery(gateway, subdevice, sensor); + } + } + } + + + + + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs new file mode 100644 index 0000000..b96a2fb --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeNoiser.cs @@ -0,0 +1,70 @@ +using Ecowitt.Controller.Model; +using Serilog; + +namespace Ecowitt.Controller.Service.Orchestrator +{ + public static class DeNoiserHelper + { + // Base tolerances per SensorType (can be extended) + private static readonly Dictionary TypeTolerance = new() + { + { SensorType.Temperature, 0.1 }, + { SensorType.Humidity, 1.0 }, + { SensorType.Pressure, 0.5 }, + { SensorType.Battery, 1.0 }, + { SensorType.WindSpeed, 0.2 }, + { SensorType.PrecipitationIntensity, 0.1 }, + { SensorType.Distance, 1.0 } + }; + + private const double DefaultDoubleTolerance = 0.01; + private const double DefaultIntegerTolerance = 1.0; + + public static bool HasSignificantChange(ISensor sensor, object? newValue) + { + if (sensor.Value == null && newValue == null) return false; + if (sensor.Value == null || newValue == null) return true; // one is null => changed + + try + { + switch (sensor.DataType) + { + case SensorDataType.Double: + { + var oldVal = Convert.ToDouble(sensor.Value); + var newVal = Convert.ToDouble(newValue); + var tol = GetTolerance(sensor.SensorType, DefaultDoubleTolerance); + var changed = Math.Abs(oldVal - newVal) >= tol; + Log.Verbose("DeNoiser double check {Name}: old={Old} new={New} tol={Tol} changed={Changed}", sensor.Name, oldVal, newVal, tol, changed); + return changed; + } + case SensorDataType.Integer: + { + var oldVal = Convert.ToDouble(sensor.Value); // cast to double for diff + var newVal = Convert.ToDouble(newValue); + var tol = GetTolerance(sensor.SensorType, DefaultIntegerTolerance); + var changed = Math.Abs(oldVal - newVal) >= tol; + Log.Verbose("DeNoiser int check {Name}: old={Old} new={New} tol={Tol} changed={Changed}", sensor.Name, oldVal, newVal, tol, changed); + return changed; + } + case SensorDataType.Boolean: + case SensorDataType.String: + case SensorDataType.DateTime: + var eq = Equals(sensor.Value, newValue); + Log.Verbose("DeNoiser equality check {Name}: old={Old} new={New} changed={Changed}", sensor.Name, sensor.Value, newValue, !eq); + return !eq; + default: + return !Equals(sensor.Value, newValue); + } + } + catch (Exception ex) + { + Log.Debug(ex, "DeNoiser parse error for sensor {Name} (treating as changed)", sensor.Name); + return true; + } + } + + private static double GetTolerance(SensorType type, double fallback) + => TypeTolerance.TryGetValue(type, out var t) ? t : fallback; + } +} diff --git a/src/Ecowitt.Controller/Store/DeviceStore.cs b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs similarity index 51% rename from src/Ecowitt.Controller/Store/DeviceStore.cs rename to src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs index a943b69..a1bf081 100644 --- a/src/Ecowitt.Controller/Store/DeviceStore.cs +++ b/src/Ecowitt.Controller/Service/Orchestrator/DeviceStore.cs @@ -1,20 +1,20 @@ using System.Collections.Concurrent; using Ecowitt.Controller.Model; -namespace Ecowitt.Controller.Store; +namespace Ecowitt.Controller.Service.Orchestrator; public interface IDeviceStore { - Gateway? GetGateway(string ipAddress); - Gateway? GetGatewayBySubdeviceId(int id); - bool UpsertGateway(Gateway data); + Device? GetGateway(string ipAddress); + Device? GetGatewayBySubdeviceId(int id); + bool UpsertGateway(Device data); void Clear(); - Dictionary GetGatewaysShort(); + Dictionary GetGatewaysShort(); } public class DeviceStore : IDeviceStore { - private readonly ConcurrentDictionary _gateways = new(); + private readonly ConcurrentDictionary _gateways = new(); private readonly ILogger _logger; public DeviceStore(ILogger logger) @@ -22,31 +22,31 @@ public DeviceStore(ILogger logger) _logger = logger; } - public Dictionary GetGatewaysShort() + public Dictionary GetGatewaysShort() { - return _gateways.ToArray().ToDictionary(gateway => gateway.Key, gateway => gateway.Value.Model); + return _gateways.ToDictionary(gateway => gateway.Key, gateway => gateway.Value.Model); } - public Gateway? GetGateway(string ipAddress) + public Device? GetGateway(string ipAddress) { return _gateways.TryGetValue(ipAddress, out var gateway) ? gateway : null; } - public Gateway? GetGatewayBySubdeviceId(int id) + public Device? GetGatewayBySubdeviceId(int id) { return _gateways.Values.FirstOrDefault(gw => gw.Subdevices.Any(sd => sd.Id == id)); } - public bool UpsertGateway(Gateway data) + public bool UpsertGateway(Device data) { if (_gateways.TryGetValue(data.IpAddress, out var gateway)) { - _logger.LogInformation($"Updateing Gateway {data.IpAddress} ({data.Model})"); + _logger.LogInformation("Updating gateway {DataIpAddress} ({DataModel})", data.IpAddress, data.Model); return _gateways.TryUpdate(data.IpAddress, data, gateway); } else { - _logger.LogInformation($"Adding Gateway {data.IpAddress} ({data.Model})"); + _logger.LogInformation("Adding Gateway {DataIpAddress} ({DataModel})", data.IpAddress, data.Model); return _gateways.TryAdd(data.IpAddress, data); } } diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs new file mode 100644 index 0000000..407e801 --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerHttp.cs @@ -0,0 +1,260 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using System.Text.Json; +using SlimMessageBus; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; +using Ecowitt.Controller.Model.Mapping; +using Ecowitt.Controller.Model.Configuration; + +namespace Ecowitt.Controller.Service.Orchestrator +{ + public partial class Dispatcher : IConsumer + { + public async Task OnHandle(SubdeviceApiCommand message) + { + _logger.LogInformation("Received SubdeviceCommand: {MessageCmd} for device {MessageId}", message.Cmd, message.Id); + + var gw = _deviceStore.GetGatewayBySubdeviceId(message.Id); + if (gw == null) + { + _logger.LogWarning("Gateway not found for subdevice {MessageId}", message.Id); + return; + } + + var subdevice = gw.Subdevices.FirstOrDefault(sd => sd.Id == message.Id); + if (subdevice == null) + { + _logger.LogWarning("Subdevice {MessageId} not found in gateway {GwIp}", message.Id, gw.IpAddress); + return; + } + + if (message.Cmd is not (Command.Start or Command.Stop)) + { + _logger.LogWarning("Ignoring unsupported command {MessageCmd} for subdevice {MessageId}", message.Cmd, message.Id); + return; + } + + // HA sends bare ON/OFF without duration — default to always-on + if (message.Cmd == Command.Start && !message.Duration.HasValue) + { + message.AlwaysOn = true; + } + + await _httpPublishingService.SendSubdeviceCommand(gw.IpAddress, message, subdevice.Model); + } + + public async Task OnHandle(GatewayApiData message) + { + _logger.LogDebug("Received ApiData: {MessageModel} ({MessagePasskey}) \n {MessagePayload}", message.Model, message.PASSKEY, message.Payload); + var updatedGateway = message.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); + updatedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == updatedGateway.IpAddress)?.Name ?? updatedGateway.IpAddress.Replace('.', '-'); + + var storedGateway = _deviceStore.GetGateway(updatedGateway.IpAddress); + if (storedGateway == null) + { + updatedGateway.DiscoveryUpdate = true; + + foreach (var sensor in updatedGateway.Sensors) + { + sensor.DiscoveryUpdate = true; + } + + var firstGateway = _deviceStore.GetGatewaysShort().Count == 0; + if (_deviceStore.UpsertGateway(updatedGateway)) + { + _logger.LogDebug("gateway added: {Serialize})", JsonSerializer.Serialize(storedGateway)); + if (_ecowittOptions is { AutoDiscovery: true, Gateways.Count: 0 } && firstGateway) + { + await EmitHttpConfig(); + } + await EmitHomeAssistantDiscovery(updatedGateway); + await EmitGatewayFull(updatedGateway); + } + else _logger.LogWarning("failed to add gateway {UpdatedGatewayIpAddress} ({UpdatedGatewayModel}) to the store", updatedGateway.IpAddress, updatedGateway.Model); + } + else + { + // no other property should update besides sensors - it seems fw isn't reported by the GW + storedGateway.TimestampUtc = updatedGateway.TimestampUtc; + var changedSensors = new List(); + var emitDiscovery = false; + foreach (var sensor in updatedGateway.Sensors) + { + var storedSensor = storedGateway.Sensors.FirstOrDefault(s => s.Name == sensor.Name); + if (storedSensor == null) + { + sensor.DiscoveryUpdate = true; + storedGateway.Sensors.Add(sensor); + changedSensors.Add(sensor); + emitDiscovery = true; + } + else if (DeNoiserHelper.HasSignificantChange(storedSensor, sensor.Value)) + { + storedSensor.Value = sensor.Value; + changedSensors.Add(storedSensor); + } + } + + if(changedSensors.Count > 0) await EmitGatewayChanged(changedSensors, storedGateway.IpAddress, storedGateway.Name); + else _logger.LogInformation("no changes for gateway {StoredGatewayIpAddress} ({StoredGatewayModel})", storedGateway.IpAddress, storedGateway.Model); + + var sensorsToRemove = storedGateway.Sensors.Where(s => updatedGateway.Sensors.All(gs => gs.Name != s.Name)).ToList(); + if (sensorsToRemove.Count > 0) + { + emitDiscovery = true; + await EmitDiscoveryRemoval(storedGateway.Name, sensorsToRemove); + } + foreach (var sensor in sensorsToRemove) + { + storedGateway.Sensors.Remove(sensor); + } + + if (!_deviceStore.UpsertGateway(storedGateway)) + { + _logger.LogWarning("failed to update {StoredGatewayIpAddress} ({StoredGatewayModel}) in the store", storedGateway.IpAddress, storedGateway.Model); + } + else + { + if(emitDiscovery) await EmitHomeAssistantDiscovery(storedGateway); + _logger.LogDebug("gateway updated: {Serialize})", JsonSerializer.Serialize(storedGateway)); + } + } + + //LogStorageState(); + } + + public async Task OnHandle(SubdeviceApiAggregate message) + { + var ips = message.Subdevices.DistinctBy(sd => sd.GwIp).Select(sd => sd.GwIp); + foreach (var ip in ips) + { + var storedGateway = _deviceStore.GetGateway(ip); + if (storedGateway == null) + { + if (_ecowittOptions.AutoDiscovery) + { + _logger.LogWarning("Gateway {Ip} not found while in autodiscovery mode. Not updating subdevices. (Try turning off autodiscovery)", ip); + return; + } + + storedGateway = new Device { IpAddress = ip }; + storedGateway.Name = _ecowittOptions.Gateways.FirstOrDefault(g => g.Ip == storedGateway.IpAddress)?.Name ?? storedGateway.IpAddress.Replace('.', '-'); + storedGateway.TimestampUtc = DateTime.UtcNow; + storedGateway.DiscoveryUpdate = true; + _deviceStore.UpsertGateway(storedGateway); + await EmitHomeAssistantDiscovery(storedGateway); + } + + var subdeviceApiData = message.Subdevices.Where(sd => sd.GwIp == ip); + foreach (var data in subdeviceApiData) + { + var updatedSubDevice = data.Map(_controllerOptions.Units == Units.Metric, _ecowittOptions.CalculateValues); + var storedSubDevice = storedGateway.Subdevices.FirstOrDefault(gwsd => gwsd.Id == updatedSubDevice.Id); + if (storedSubDevice == null) + { + updatedSubDevice.DiscoveryUpdate = true; + foreach (var sensor in updatedSubDevice.Sensors) + { + sensor.DiscoveryUpdate = true; + } + storedGateway.Subdevices.Add(updatedSubDevice); + _logger.LogInformation("subdevice added: {DataId} ({DataModel})", data.Id, data.Model); + + await EmitHomeAssistantDiscovery(storedGateway); + _deviceStore.UpsertGateway(storedGateway); + await EmitSubdeviceFull(updatedSubDevice); + } + else + { + storedSubDevice.TimestampUtc = updatedSubDevice.TimestampUtc; + storedSubDevice.Availability = updatedSubDevice.Availability; + + // no update of other properties + if (storedSubDevice.Version != updatedSubDevice.Version || storedSubDevice.Devicename != updatedSubDevice.Devicename || storedSubDevice.Nickname != updatedSubDevice.Nickname) + { + storedSubDevice.Version = updatedSubDevice.Version; + storedSubDevice.Devicename = updatedSubDevice.Devicename; + storedSubDevice.Nickname = updatedSubDevice.Nickname; + storedSubDevice.DiscoveryUpdate = true; + + await EmitSubdeviceFull(storedSubDevice); + } + + // update sensors one by one and find out if there are new ones + // if there are new ones, mark the subdevice for discovery update + var changedSensors = new List(); + var flushData = false; + foreach (var sensor in updatedSubDevice.Sensors) + { + var storedSensor = storedSubDevice.Sensors.FirstOrDefault(s => s.Name == sensor.Name); + if (storedSensor == null) + { + sensor.DiscoveryUpdate = true; + storedSubDevice.Sensors.Add(sensor); + changedSensors.Add(sensor); + flushData = true; + } + else if (DeNoiserHelper.HasSignificantChange(storedSensor, sensor.Value)) + { + storedSensor.Value = sensor.Value; + changedSensors.Add(storedSensor); + } + } + + if (changedSensors.Count > 0) await EmitSubdeviceChanged(changedSensors, storedGateway.IpAddress, storedSubDevice.Id); + else _logger.LogInformation("no changes for subdevice {DataId} ({DataModel})", data.Id, data.Model); + + var sensorsToRemove = storedSubDevice.Sensors.Where(s => updatedSubDevice.Sensors.All(us => us.Name != s.Name)).ToList(); + if (sensorsToRemove.Count > 0) + { + flushData = true; + await EmitDiscoveryRemoval(storedSubDevice.Nickname, sensorsToRemove); + } + foreach (var sensor in sensorsToRemove) + { + storedSubDevice.Sensors.Remove(sensor); + } + + if (flushData) + { + _deviceStore.UpsertGateway(storedGateway); + await EmitHomeAssistantDiscovery(storedGateway); + } + + _logger.LogInformation("subdevice updated: {DataId} ({DataModel})", data.Id, data.Model); + } + } + } + + //LogStorageState(); + } + + + public Task OnHandle(HttpServiceEvent message) + { + switch (message.EventType) + { + case HttpServiceEventType.Started: + _logger.LogInformation("HTTP Service started"); + _lastHttpServiceState = HttpServiceEventType.Started; + break; + case HttpServiceEventType.Stopped: + _logger.LogWarning("HTTP Service stopped"); + _lastHttpServiceState = HttpServiceEventType.Stopped; + break; + case HttpServiceEventType.Error: + _logger.LogError("HTTP Service error: {MessageMessage}", message.Message); + _lastHttpServiceState = HttpServiceEventType.Error; + break; + case HttpServiceEventType.Unknown: + default: + _logger.LogWarning("Unknown HTTP Service event: {HttpServiceEventType}", message.EventType); + _lastHttpServiceState = HttpServiceEventType.Unknown; + break; + } + + return Task.CompletedTask; + } + } +} diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs new file mode 100644 index 0000000..dd034cb --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.ConsumerMqtt.cs @@ -0,0 +1,91 @@ +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Message.Event; + +namespace Ecowitt.Controller.Service.Orchestrator +{ + public partial class Dispatcher + { + public Task OnHandle(MqttServiceEvent message) + { + switch (message.EventType) + { + case MqttServiceEventType.Started: + _logger.LogInformation("MQTT Service started"); + _lastMqttServiceState = MqttServiceEventType.Started; + //_mqttServiceStarted.TrySetResult(true); + break; + case MqttServiceEventType.Stopped: + _logger.LogWarning("MQTT Service stopped"); + _lastMqttServiceState = MqttServiceEventType.Stopped; + break; + case MqttServiceEventType.Error: + _logger.LogError("MQTT Service error: {MessageMessage}", message.Message); + _lastMqttServiceState = MqttServiceEventType.Error; + break; + case MqttServiceEventType.Heartbeat: + _logger.LogDebug("MQTT Service heartbeat received"); + _lastMqttServiceState = MqttServiceEventType.Heartbeat; + break; + case MqttServiceEventType.Unknown: + default: + _logger.LogWarning("Unknown MQTT Service event: {MqttServiceEventType}", message.EventType); + break; + } + + return Task.CompletedTask; + } + + public Task OnHandle(MqttConnectionEvent message) + { + switch (message.EventType) + { + case MqttConnectionEventType.Connected: + _logger.LogInformation("MQTT Client connected"); + break; + case MqttConnectionEventType.Disconnected: + _logger.LogWarning("MQTT Client disconnected"); + break; + case MqttConnectionEventType.Error: + _logger.LogInformation("MQTT Client error {MessageMessage}", message.Message); + break; + case MqttConnectionEventType.MessageReceived: + default: + _logger.LogInformation("MQTT Connection event received: {MqttConnectionEventType}", message.EventType); + break; + } + + return Task.CompletedTask; + } + + public async Task OnHandle(HomeAssistantStatusEvent message) + { + switch (message.Status) + { + case HomeAssistantStatusType.Online: + _logger.LogWarning("HomeAssistant is online"); + foreach (var gateway in _deviceStore.GetGatewaysShort().Select(kvp => _deviceStore.GetGateway(kvp.Key)).OfType()) + { + await EmitHomeAssistantDiscovery(gateway); + await EmitGatewayFull(gateway); + foreach (var subdevice in gateway.Subdevices) + { + await EmitSubdeviceFull(subdevice); + } + + _logger.LogInformation("Re-emitted device {deviceIp}", gateway.IpAddress); + } + break; + case HomeAssistantStatusType.Offline: + _logger.LogWarning("HomeAssistant is offline"); + break; + case HomeAssistantStatusType.Unknown: + default: + _logger.LogInformation("Unknown HomeAssistant status received"); + break; + } + } + + + + } +} diff --git a/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs new file mode 100644 index 0000000..54e3e5a --- /dev/null +++ b/src/Ecowitt.Controller/Service/Orchestrator/Dispatcher.cs @@ -0,0 +1,169 @@ +using System.Text.Json; +using Ecowitt.Controller.Model; +using Ecowitt.Controller.Model.Api; +using Ecowitt.Controller.Model.Configuration; +using Ecowitt.Controller.Model.Message.Config; +using Ecowitt.Controller.Model.Message.Data; +using Ecowitt.Controller.Model.Message.Event; +using Ecowitt.Controller.Service.Http; +using Microsoft.Extensions.Options; +using SlimMessageBus; + +namespace Ecowitt.Controller.Service.Orchestrator; + +public partial class Dispatcher : BackgroundService, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer, IConsumer +{ + private readonly ILogger _logger; + private readonly IDeviceStore _deviceStore; + private readonly EcowittOptions _ecowittOptions; + private readonly ControllerOptions _controllerOptions; + private readonly MqttOptions _mqttOptions; + private readonly IMessageBus _messageBus; + private readonly HttpPublishingService _httpPublishingService; + private MqttServiceEventType _lastMqttServiceState = MqttServiceEventType.Unknown; + private HttpServiceEventType _lastHttpServiceState = HttpServiceEventType.Unknown; + + public Dispatcher(ILogger logger, IDeviceStore deviceStore, IMessageBus messageBus, IOptions mqttOptions, IOptions ecowittOptions, IOptions controllerOptions, HttpPublishingService httpPublishingService) + { + _logger = logger; + _deviceStore = deviceStore; + _mqttOptions = mqttOptions.Value; + _ecowittOptions = ecowittOptions.Value; + _controllerOptions = controllerOptions.Value; + _messageBus = messageBus; + _httpPublishingService = httpPublishingService; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Starting Orchestrator"); + + _logger.LogInformation("Emitting initial configuration"); + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + await EmitMqttConfig(); + if(_ecowittOptions.Gateways.Count > 0) await EmitHttpConfig(); + + + } + + private async Task EmitHomeAssistantDiscovery(Device device) + { + var discoveryEvent = new HomeAssistantDiscoveryEvent(device); + await _messageBus.Publish(discoveryEvent); + } + + private async Task EmitDiscoveryRemoval(string deviceName, List sensors) + { + if (sensors.Count == 0) return; + await _messageBus.Publish(new DiscoveryRemovalEvent { DeviceName = deviceName, Sensors = sensors }); + } + + private async Task EmitHttpConfig() + { + var httpConfig = new HttpConfig + { + Hosts = _ecowittOptions.Gateways.Select(gw => new HttpHost(gw.Ip, gw.Username, gw.Password)).ToList(), + PollingInterval = _ecowittOptions.PollingInterval, + AutoDiscovery = _ecowittOptions.AutoDiscovery + }; + + await _messageBus.Publish(httpConfig); + } + + private async Task EmitHttpConfig(List hosts) + { + var httpConfig = new HttpConfig + { + Hosts = hosts, + PollingInterval = _ecowittOptions.PollingInterval, + AutoDiscovery = _ecowittOptions.AutoDiscovery + }; + + await _messageBus.Publish(httpConfig); + } + + private async Task EmitMqttConfig() + { + var mqttConfig = new MqttConfig + { + Host = _mqttOptions.Host, + Port = _mqttOptions.Port, + ClientId = _mqttOptions.ClientId, + BaseTopic = _mqttOptions.BaseTopic, + ReconnectAttempts = _mqttOptions.ReconnectAttempts, + HomeAssistantDiscovery = _controllerOptions.HomeAssistantDiscovery, + Precision = _controllerOptions.Precision, + Units = _controllerOptions.Units + }; + if (!string.IsNullOrWhiteSpace(_mqttOptions.User)) + { + mqttConfig.User = _mqttOptions.User; + mqttConfig.Password = _mqttOptions.Password; + } + + await _messageBus.Publish(mqttConfig); + } + + private void LogStorageState() + { + foreach (var gw in _deviceStore.GetGatewaysShort()) + { + _logger.LogInformation("Gateway {GwKey} - {GwValue}", gw.Key, gw.Value); + var gateway = _deviceStore.GetGateway(gw.Key); + _logger.LogDebug("Storage dump: \n {Serialize}", JsonSerializer.Serialize(gateway)); + } + } + + private async Task EmitGatewayFull(Device device) + { + await _messageBus.Publish(new DeviceDataFull + { + Device = device, + GatewayId = device.IpAddress, + GatewayName = device.Name, + Timestamp = device.TimestampUtc + }); + } + + private async Task EmitSubdeviceFull(Subdevice subdevice) + { + var gateway = _deviceStore.GetGateway(subdevice.GwIp); + var gatewayName = gateway != null ? gateway.Name : subdevice.GwIp; + + await _messageBus.Publish(new SubdeviceDataFull + { + Subdevice = subdevice, + GatewayId = subdevice.GwIp, + GatewayName = gatewayName, + SubdeviceId = subdevice.Id, + Timestamp = subdevice.TimestampUtc + }); + } + + private async Task EmitGatewayChanged(List sensorsChanged, string gatewayId, string gatewayName) + { + await _messageBus.Publish(new DeviceData() + { + ChangedSensors = sensorsChanged, + GatewayId = gatewayId, + GatewayName = gatewayName, + Timestamp = DateTime.UtcNow + }); + } + + private async Task EmitSubdeviceChanged(List sensorsChanged, string gatewayId, int subdeviceId) + { + var gateway = _deviceStore.GetGatewayBySubdeviceId(subdeviceId); + var gatewayName = gateway != null ? gateway.Name : gatewayId; + + await _messageBus.Publish(new SubdeviceData() + { + ChangedSensors = sensorsChanged, + GatewayId = gatewayId, + SubdeviceId = subdeviceId, + GatewayName = gatewayName, + Timestamp = DateTime.UtcNow + }); + } + +} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Store/DataPublishService.cs b/src/Ecowitt.Controller/Store/DataPublishService.cs deleted file mode 100644 index 2d4c6f9..0000000 --- a/src/Ecowitt.Controller/Store/DataPublishService.cs +++ /dev/null @@ -1,151 +0,0 @@ -using System.Text.Encodings.Web; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Mqtt; -using Microsoft.Extensions.Options; -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace Ecowitt.Controller.Store; - -public class DataPublishService : BackgroundService -{ - private readonly ILogger _logger; - private readonly MqttOptions _mqttOptions; - private readonly IDeviceStore _store; - private readonly IMqttClient _mqttClient; - private readonly ControllerOptions _controllerOptions; - - public DataPublishService(IOptions mqttOptions, IOptions controllerOption, ILogger logger, IMqttClient mqttClient, - IDeviceStore deviceStore) - { - _logger = logger; - _mqttOptions = mqttOptions.Value; - _controllerOptions = controllerOption.Value; - _store = deviceStore; - _mqttClient = mqttClient; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Starting MqttPublishService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(_controllerOptions.PublishingInterval)); - try - { - while(await timer.WaitForNextTickAsync(stoppingToken)) - { - foreach (var gwKvp in _store.GetGatewaysShort()) - { - var gw = _store.GetGateway(gwKvp.Key); - if(gw == null) continue; - - // to sent messages according to ideas outlines in mqtt.md the following approach could work - // 1. send gateway state & hw_info to base// as json payload - // 2. send gateway sensors to base//sensors/ as json payload - // 3. send subdevice state & hw_info to base// as json payload - // 4. send subdevice sensors to base///sensors/ as json payload - - // note: HA Discovery seems to break for mqtt topics with spaces in them - - var payload = BuildGatewayPayload(gw); - - await PublishMessage(Helper.BuildMqttGatewayTopic(gw.Name), payload); - await PublishAvailabilityMessage(Helper.BuildMqttGatewayTopic(gw.Name), gw.TimestampUtc); - - foreach (var sensor in gw.Sensors) - { - payload = BuildSensorPayload(sensor); - var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? Helper.BuildMqttGatewayDiagnosticTopic(gw.Name, sensor.Alias) : Helper.BuildMqttGatewaySensorTopic(gw.Name, sensor.Alias); - await PublishMessage(topic, payload); - - } - - if (gw.Subdevices.Count == 0) continue; - foreach (var subdevice in gw.Subdevices) - { - payload = BuildSubdevicePayload(subdevice); - await PublishMessage(Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString()), payload); - await PublishAvailabilityMessage(Helper.BuildMqttSubdeviceTopic(gw.Name, subdevice.Id.ToString()), subdevice.TimestampUtc); - - foreach (var sensor in subdevice.Sensors) - { - payload = BuildSensorPayload(sensor); - var topic = sensor.SensorCategory == SensorCategory.Diagnostic ? Helper.BuildMqttSubdeviceDiagnosticTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias) : Helper.BuildMqttSubdeviceSensorTopic(gw.Name, subdevice.Id.ToString(), sensor.Alias); - await PublishMessage(topic, payload); - } - } - } - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping MqttService"); - } - } - - private dynamic BuildSubdevicePayload(Model.Subdevice subdevice) - { - return new - { - id = subdevice.Id, - model = subdevice.Model, - devicename = subdevice.Devicename, - nickname = subdevice.Nickname, - state = subdevice.Availability ? "online" : "offline", - ver = subdevice.Version - }; - } - - private dynamic BuildSensorPayload(ISensor s) - { - _logger.LogDebug($"Sensor {s.Name} datatype: {s.DataType}"); - return new - { - name = s.Name, - alias = s.Alias, - value = s.DataType == typeof(double) ? Math.Round(Convert.ToDouble(s.Value), _controllerOptions.Precision) : s.Value, - unit = !string.IsNullOrWhiteSpace(s.UnitOfMeasurement) ? s.UnitOfMeasurement : null - //type = s.SensorType != SensorType.None ? s.SensorType.ToString() : null - }; - } - - private dynamic BuildGatewayPayload(Gateway gw) - { - if (string.IsNullOrWhiteSpace(gw.Model)) - { - return new - { - ip = gw.IpAddress, - name = gw.Name - }; - } - - return new - { - ip = gw.IpAddress, - name = gw.Name, - model = gw.Model, - passkey = gw.PASSKEY, - stationType = gw.StationType, - runtime = gw.Runtime, - state = (DateTime.UtcNow - gw.TimestampUtc).TotalSeconds < _controllerOptions.PublishingInterval * 3 ? "online" : "offline", - freq = gw.Freq - }; - } - - private async Task PublishMessage(string topic, dynamic payload) - { - if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}", - JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping}))) - _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); - } - - private async Task PublishAvailabilityMessage(string topic, DateTime timestamp) - { - var available = DateTime.UtcNow.Subtract(timestamp) < TimeSpan.FromSeconds(300) ? "online" : "offline"; - - if (!await _mqttClient.Publish($"{_mqttOptions.BaseTopic}/{topic}/availability", available)) - _logger.LogWarning($"Failed to publish message to topic {_mqttOptions.BaseTopic}/{topic}. Is the client connected?"); - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs b/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs deleted file mode 100644 index 2e86b63..0000000 --- a/src/Ecowitt.Controller/Subdevice/SubdeviceService.cs +++ /dev/null @@ -1,176 +0,0 @@ -using System.Text.Json; -using Ecowitt.Controller.Configuration; -using Ecowitt.Controller.Model; -using Ecowitt.Controller.Store; -using Microsoft.Extensions.Options; -using SlimMessageBus; - -namespace Ecowitt.Controller.Subdevice; - -public class SubdeviceService : BackgroundService -{ - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger _logger; - private readonly IMessageBus _messageBus; - private readonly EcowittOptions _options; - private readonly IDeviceStore _store; - - public SubdeviceService(ILogger logger, IMessageBus messageBus, - IHttpClientFactory httpClientFactory, IDeviceStore store, IOptions options) - { - _logger = logger; - _messageBus = messageBus; - _httpClientFactory = httpClientFactory; - _store = store; - _options = options.Value; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Starting SubdeviceService"); - - using PeriodicTimer timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.PollingInterval)); - try - { - while(await timer.WaitForNextTickAsync(stoppingToken)) - { - await SubDevicePolling(stoppingToken, _options.AutoDiscovery); - } - } - catch (OperationCanceledException) - { - _logger.LogInformation("Stopping SubdeviceService"); - } - } - - private async Task SubDevicePolling(CancellationToken cancellationToken, bool autoDiscovery = true) - { - var subdevices = new SubdeviceApiAggregate(); - if (autoDiscovery) - { - // TODO: remove reference to _store and replace it with req/resp through smb - // TODO: GWxx are Ecowitt models, what about Froggit etc? - foreach (var gwKvp in _store.GetGatewaysShort().Where(kvp => kvp.Value.StartsWith("GW12") || kvp.Value.StartsWith("GW20"))) - { - subdevices.Subdevices.AddRange(await GetSubdeviceData(gwKvp.Key, cancellationToken)); - } - } - else - { - foreach (var gateway in _options.Gateways) - { - subdevices.Subdevices.AddRange(await GetSubdeviceData(gateway.Ip, cancellationToken)); - } - } - - if(subdevices.Subdevices.Count > 0) await _messageBus.Publish(subdevices, cancellationToken: cancellationToken); - } - - private async Task> GetSubdeviceData(string ipAddress, CancellationToken cancellationToken) - { - _logger.LogInformation($"Polling subdevices from {ipAddress}"); - var subdevices = new List(); - try - { - subdevices.AddRange(await GetSubdevicesOverview(ipAddress, cancellationToken)); - foreach (var subdevice in subdevices) - { - var payload = await GetSubDeviceApiPayload(ipAddress, subdevice.Id, subdevice.Model, cancellationToken); - subdevice.Payload = payload; - await Task.Delay(350, cancellationToken); // delay to not overload the gateway - } - } - catch (Exception e) - { - _logger.LogError(e, $"Exception while trying to get subdevices from {ipAddress}"); - } - - return subdevices; - - } - - private async Task > GetSubdevicesOverview(string ipAddress, CancellationToken cancellationToken) - { - var subdevices = new List(); - using var client = CreateHttpClient(ipAddress); - - try - { - var response = await client.GetAsync("get_iot_device_list", cancellationToken); - if (response.IsSuccessStatusCode) - { - var content = await response.Content.ReadAsStringAsync(); - var jsonDocument = JsonDocument.Parse(content); - var elements = jsonDocument.RootElement.GetProperty("command"); - foreach (var element in elements.EnumerateArray()) - { - - var subdevice = new SubdeviceApiData - { - Id = element.GetProperty("id").GetInt32(), - Model = element.GetProperty("model").GetInt32(), - Version = element.GetProperty("ver").GetInt32(), - RfnetState = element.GetProperty("rfnet_state").GetInt32(), - Battery = element.GetProperty("battery").GetInt32(), - Signal = element.GetProperty("signal").GetInt32(), - GwIp = ipAddress, - TimestampUtc = DateTime.UtcNow - }; - _logger.LogInformation($"Subdevice: {subdevice.Id} ({subdevice.Model})"); - subdevices.Add(subdevice); - } - } - else - { - _logger.LogWarning($"Failed to get subdevices from {ipAddress}"); - } - } - catch (Exception e) - { - _logger.LogError(e, $"Exception while trying to get subdevices from {ipAddress}"); - } - - - return subdevices; - } - - private async Task GetSubDeviceApiPayload(string ipAddress, int subdeviceId, int model, CancellationToken cancellationToken) - { - using var client = CreateHttpClient(ipAddress); - try - { - var payload = new { command = new[] { new { cmd = "read_device", id = subdeviceId, model } } }; - var sContent = new StringContent(JsonSerializer.Serialize(payload)); - var response = await client.PostAsync("parse_quick_cmd_iot", sContent, cancellationToken); - if (response.IsSuccessStatusCode) - { - return await response.Content.ReadAsStringAsync(cancellationToken); - } - else - { - _logger.LogWarning($"Could not get payload from {ipAddress} for subdevice {subdeviceId}"); - } - } - catch (Exception e) - { - _logger.LogError(e, $"Exception while trying to get payload from {ipAddress} for subdevice {subdeviceId}"); - } - - return string.Empty; - } - - private HttpClient CreateHttpClient(string ipAddress) - { - var client = _httpClientFactory.CreateClient("ecowitt-client"); - client.BaseAddress = new Uri($"http://{ipAddress}"); - - var username = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Username; - var password = _options.Gateways.FirstOrDefault(gw => gw.Ip == ipAddress)?.Password; - if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) - { - //TODO: authentication header, need to test - //client.DefaultRequestHeaders.Add(); - } - return client; - } -} \ No newline at end of file diff --git a/src/Ecowitt.Controller/appsettings.json b/src/Ecowitt.Controller/appsettings.json index f345f52..ada5a09 100644 --- a/src/Ecowitt.Controller/appsettings.json +++ b/src/Ecowitt.Controller/appsettings.json @@ -3,7 +3,6 @@ "MinimumLevel": { "Default": "Information", "Override": { - "Ecowitt.Controller.Consumer": "Debug" } } }, @@ -13,25 +12,24 @@ "user": "", "password": "", "port": 1883, - "basetopic": "ec-dev", + "basetopic": "ecowitt", "clientId": "ecowitt-controller", "reconnect": true, "reconnectAttemps": 5 }, "ecowitt": { - "pollingInterval": 3, + "pollingInterval": 30, "autodiscovery": false, "gateways": [ { - "name": "garten", - "ip": "192.168.103.162" + "name": "", + "ip": "" } ] }, "controller": { "precision": 2, "unit": "metric", - "publishingInterval": 5, "homeassistantdiscovery": true } } diff --git a/src/Ecowitt.Controller/ha_discovery_payload.md b/src/Ecowitt.Controller/ha_discovery_payload.md deleted file mode 100644 index 8658223..0000000 --- a/src/Ecowitt.Controller/ha_discovery_payload.md +++ /dev/null @@ -1,184 +0,0 @@ -### setting up a gateway -topic `homeassistant/sensor/gw2000/config` - -payload: -```json -{ - "device": { - "hw_version": "gw2000_v1.2.3", - "identifiers": [ - "gw2000_testgateway" - ], - "manufacturer": "Ecowitt", - "model": "GW2000", - "name": "Testgateway", - "sw_version": "1.2.3" - }, - "name": "Availability", - "availability_topic": "ecowitt/gw2000/availability", - "object_id": "ecowitt_gw2000_availability_state", - "state_topic": "ecowitt/gw2000/state", - "unique_id": "ecowitt_gw2000_availability_state", - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` - -``` json -{ - "Device": { - "Identifiers": [ - "ec_GW2000A_192-168-103-162" - ], - "Manufacturer": "Ecowitt", - "Model": "GW2000A", - "Name": "192-168-103-162", - "Hw_Version": "GW2000A_V3.1.3", - "Sw_Version": "5844040" - }, - "Origin": { - "Name": "Ecowitt Controller", - "Sw": "0.1", - "Url": "https://github.com/mplogas/ecowitt-controller" - }, - "Name": "Availability", - "Retain": false, - "Qos": 1, - "Availability_Topic": "ecowitt-dev/192-168-103-162/availability", - "State_Topic": "ecowitt-dev/192-168-103-162", - "Unique_Id": "ecowitt-controller_192-168-103-162_availability_state", - "Object_Id": "ecowitt-controller_192-168-103-162_availability_state" -} -``` - - -### setting up a sensor for the gateway -topic `homeassistant/sensor/gw2000/uv/config` - -payload: -```json -{ - "device": { - "hw_version": "gw2000_v1.2.3", - "identifiers": [ - "gw2000_testgateway" - ], - "manufacturer": "Ecowitt", - "model": "GW2000", - "name": "Testgateway", - "sw_version": "1.2.3" - }, - "name": "uv", - "retain": false, - "availability_topic": "ecowitt/gw2000/availability", - "object_id": "ecowitt_gw2000_uv_state", - "state_topic": "ecowitt/gw2000/state", - "value_template": "{{ value_json.uv }}", - "unique_id": "ecowitt_gateway_1234567890_uv_state", - "icon": "mdi:weather-sunny", - "qos": 1, - "state_class": "measurement", - "unit_of_measurement": "UV index", - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` -### subdevice -topic `homeassistant/sensor/wfc01/config` - -payload: -```json -{ - "device": { - "hw_version": "wfc01_v1.0.0", - "identifiers": [ - "wfc01_012345" - ], - "manufacturer": "Ecowitt", - "model": "WFC01", - "name": "WittFlow 001", - "sw_version": "1.0.1", - "via_device": "gw2000_testgateway" - }, - "name": "availability", - "retain": false, - "availability": [ - { - "topic": "ecowitt/gw2000/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - }, - { - "topic": "ecowitt/wfc01/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - } - ], - "availability_mode": "all", - "state_topic": "ecowitt/wfc01/state", - "object_id": "ecowitt_wfc01_availability_state", - "unique_id": "ecowitt_wfc01_availability_state", - "qos": 1, - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` - -### subdevice toggle -topic `homeassistant/switch/wfc01/config` - -payload: -```json -{ - "device": { - "hw_version": "wfc01_v1.0.0", - "identifiers": [ - "wfc01_012345" - ], - "manufacturer": "Ecowitt", - "model": "WFC01", - "name": "WittFlow 001", - "sw_version": "1.0.1", - "via_device": "gw2000_testgateway" - }, - "name": "toggle", - "retain": false, - "availability": [ - { - "topic": "ecowitt/gw2000/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - }, - { - "topic": "ecowitt/wfc01/availability", - "payload_available": "online", - "payload_not_available": "offline", - "value_template": "{{ value_json.state }}" - } - ], - "availability_mode": "all", - "command_topic": "ecowitt/wfc01/cmd", - "state_topic": "ecowitt/wfc01/state", - "object_id": "ecowitt_wfc01_toggle_state", - "unique_id": "ecowitt_wfc01_toggle_state", - "qos": 1, - "icon": "mdi:water", - "origin": { - "name": "Ecowitt Controller", - "sw": "0.0.1", - "url": "https://github.com/mplogas/ecowitt-controller" - } -} -``` \ No newline at end of file diff --git a/src/Ecowitt.Controller/mqtt.md b/src/Ecowitt.Controller/mqtt.md deleted file mode 100644 index 46126b5..0000000 --- a/src/Ecowitt.Controller/mqtt.md +++ /dev/null @@ -1,17 +0,0 @@ -# MQTT - -## MQTT topic considerations - -#### Requirements -- easily accessible for HA Discovery -- Gateways, Subdevices and Sensors should be easily accessible through topic filter, eg. `ecowitt/+/subdevices/#` or `ecowitt/+/sensors/#` or `ecowitt/+/subdevices/+/sensors/#` -- sensor topics should be filtered with `/sensors/+/temperature` or `/sensors/+/humidity` -- sensor payload should be simple json, eg. `{"value": 23.4, "unit": "°C"}` -- state payload should be `{"state": "online"}` or `{"state": "offline"}` and part of the payload to gateways or subdevices - -#### Topic Templates -- `ecowitt/` - Gateway hw_info & state -- `ecowitt//subdevices/` - Subdevice hw_info & state -- `ecowitt//sensors//` - Gateway Sensor data -- `ecowitt//subdevices//sensors//` - Subdevice Sensor data - diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 011cde4..faa72a0 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -10,7 +10,7 @@ services: - 9001:9001 volumes: - type: bind - source: H:\docker\mosquitto + source: ~/docker/config/mosquitto target: /mosquitto/ homeassistant-dev: container_name: homeassistant-dev @@ -22,13 +22,9 @@ services: - 8123:8123 volumes: - type: bind - source: H:\docker\homeassistant + source: ~/docker/config/homeassistant target: /config - type: bind source: /etc/localtime target: /etc/localtime read_only: true - # - type: bind - # source: /run/dbus - # target: /run/dbus - # read_only: true