Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions COMPONENT_SUPPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# BTHome Component Support for Shelly Exporters

## Overview

This implementation adds support for reading BTHome sensor components from Shelly devices. The feature is designed to be highly encapsulated and reusable across different Shelly device exporters.

## Architecture

### 1. Utilities.Components Library

**Location**: `Utilities/Components/`

**Key Classes**:
- `BtHomeDevice`: Represents a BTHome device with its associated sensors
- `BtHomeSensor`: Represents an individual sensor
- `BtHomeComponentsHandler`: Handles parsing and managing BTHome component data

**Design Principles**:
- **Separation of Concerns**: Device logic separated from sensor logic
- **Simplicity**: Direct concrete classes without unnecessary abstractions
- **Encapsulation**: All component logic contained within the Utilities library
- **User-Controlled Naming**: Relies on user-provided sensor names without type assumptions

### 2. Logical Structure

```
BtHomeDevice
├── Properties: Id, Name, Address, Rssi, Battery, LastUpdatedTimestamp
└── Sensors: List<BtHomeSensor>
└── BtHomeSensor
├── Properties: Id, Name, Value, LastUpdatedTimestamp, ObjectId, Index
└── Method: GetLabels(deviceName)
```

### 3. Prometheus Metrics Integration

**New Metric Type**: `LabeledGaugeMetric` in `Utilities/Metrics/`
- Supports Prometheus labels for richer metrics
- Format: `devicename_component{device_name="DeviceName", sensor_name="SensorName"} value`

### 4. Configuration

Added `enableComponentMetrics` boolean to `TargetDevice` configuration:
```csharp
public bool enableComponentMetrics = false; // Set to true to enable BTHome component metrics
```

## Features

### Simultaneous API Requests
- Makes concurrent requests to both `/rpc/Shelly.GetStatus` and `/rpc/Shelly.GetComponents`
- Improves performance by reducing total request time

### Dynamic Metric Discovery
- Components are discovered dynamically at runtime
- No need to predefine component structure

### Device-Sensor Mapping Logic
1. Finds all `bthomedevice:*` entries with valid names
2. Maps their MAC addresses to device names
3. Finds `bthomesensor:*` entries with valid names
4. Links sensors to devices via MAC address
5. Only exposes metrics for sensors that have both device name and sensor name

## Usage

### Configuration Example
```yaml
logLevel: Information
listenPort: 8080
targets:
- name: my_device
url: 192.168.0.10
enableComponentMetrics: true
```

### Sample Metrics Output
```
shellyPro4Pm_my_device_component{device_name="Keller",sensor_name="Temperature"} 20.2
shellyPro4Pm_my_device_component{device_name="Keller",sensor_name="Humidity"} 73
shellyPro4Pm_my_device_component{device_name="Keller",sensor_name="Battery"} 100
shellyPro4Pm_my_device_component{device_name="Lüftung",sensor_name="Temperature"} 20.4
shellyPro4Pm_my_device_component{device_name="Lüftung",sensor_name="Humidity"} 63
shellyPro4Pm_my_device_component{device_name="Lüftung",sensor_name="Battery"} 100
```

## Extensibility

### Adding to Other Exporters

To add component support to other Shelly exporters:

1. **Add dependency**: Reference the `Utilities.Components` namespace
2. **Update TargetDevice**: Add `enableComponentMetrics` property
3. **Update Connection class**:
```csharp
readonly BtHomeComponentsHandler? componentsHandler;

// In constructor:
if (enableComponentMetrics)
{
string? password = target.RequiresAuthentication() ? target.password : null;
componentsHandler = new BtHomeComponentsHandler(targetUrl, requestTimeoutTime, password);
}

// In UpdateMetricsIfNecessary, make simultaneous requests:
Task<bool>? componentsTask = componentsHandler?.UpdateComponentsFromDevice();

// Add methods:
public string GetComponentMetrics(string metricPrefix) =>
componentsHandler?.GenerateMetrics(metricPrefix) ?? "";
```
4. **Update Program.cs**:
```csharp
// In CollectAllMetrics():
if (device.HasComponentsEnabled())
{
allMetrics += device.GetComponentMetrics($"exporterName_{device.GetTargetName()}");
}
```

### Component Request URL

All Shelly devices that support components use the same endpoint:
```
POST /rpc
{
"method": "Shelly.GetComponents"
}
```

### Benefits

The simplified design provides:
- **Simplicity**: No unnecessary abstractions or interfaces (YAGNI principle)
- **Reliability**: No assumptions about sensor types based on user-provided names
- **Maintainability**: Straightforward, concrete implementation
- **Performance**: Direct method calls without interface overhead
- **Flexibility**: Users control sensor naming without system-imposed type constraints

## Error Handling

- Component request failures don't affect power meter metrics
- Invalid component data is logged but doesn't stop the exporter
- Missing device names or sensor names result in skipped metrics
- Graceful degradation when components are disabled

## Performance Considerations

- Simultaneous requests reduce total update time
- Components are cached and only updated when power metrics are updated
- Minimal memory overhead for component storage
- Thread-safe component data access

## Supported Sensor Types

The system uses user-provided sensor names directly in Prometheus labels without making assumptions about sensor types. This approach:
- Respects user naming preferences
- Avoids incorrect type classification
- Provides maximum flexibility
- Ensures reliable metric generation regardless of naming conventions
21 changes: 16 additions & 5 deletions ShellyPro4PmExporter/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Globalization;
using Serilog;
using Utilities;
using Utilities.Components;
using Utilities.Configs;
using Utilities.Metrics;
using Utilities.Networking;
Expand All @@ -15,7 +16,7 @@ internal static class Program
const int defaultPort = 10037;
static int listenPort = defaultPort;

static readonly Dictionary<ShellyPro4PmConnection, List<GaugeMetric>> deviceToMetricsDictionary = new(1);
static readonly Dictionary<ShellyPro4PmConnection, List<BaseMetric>> deviceToMetricsDictionary = new(1);

static async Task Main()
{
Expand Down Expand Up @@ -65,7 +66,10 @@ static bool WriteExampleConfig()
config.targets.Add(new TargetDevice("Your Name for the device - like \"power_sockets\" - keep it formatted like that, lowercase with underscores",
"Address (usually 192.168.X.X - the IP of your device)",
"Password (leave empty if not used)",
targetMeters));
targetMeters)
{
enableComponentMetrics = false // Set to true to enable BTHome component metrics
});

Configuration.WriteConfig(configName, config);
return true;
Expand All @@ -89,7 +93,7 @@ static void SetupDevicesFromConfig(Config<TargetDevice> config)

static void SetupMetrics()
{
foreach ((ShellyPro4PmConnection device, List<GaugeMetric> deviceMetrics) in deviceToMetricsDictionary)
foreach ((ShellyPro4PmConnection device, List<BaseMetric> deviceMetrics) in deviceToMetricsDictionary)
{
string deviceName = device.GetTargetName();
string metricPrefix = "shellyPro4Pm_" + deviceName + "_";
Expand Down Expand Up @@ -186,18 +190,25 @@ static async Task<string> CollectAllMetrics()
{
string allMetrics = "";

foreach ((ShellyPro4PmConnection device, List<GaugeMetric> deviceMetrics) in deviceToMetricsDictionary)
foreach ((ShellyPro4PmConnection device, List<BaseMetric> deviceMetrics) in deviceToMetricsDictionary)
{
if (!await device.UpdateMetricsIfNecessary())
{
log.Error("Failed to update metrics for target device: {targetName}", device.GetTargetName());
continue;
}

foreach (GaugeMetric metric in deviceMetrics)
// Collect regular metrics
foreach (BaseMetric metric in deviceMetrics)
{
allMetrics += metric.GetMetric();
}

// Collect dynamic component metrics if enabled
if (device.HasComponentsEnabled())
{
allMetrics += device.GetComponentMetrics($"shellyPro4Pm_{device.GetTargetName()}");
}
}

return allMetrics;
Expand Down
47 changes: 41 additions & 6 deletions ShellyPro4PmExporter/ShellyPro4PmConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Globalization;
using System.Text.Json;
using Serilog;
using Utilities.Components;
using Utilities.Networking.RequestHandling.WebSockets;

namespace ShellyPro4PmExporter;
Expand All @@ -18,6 +19,7 @@ public class ShellyPro4PmConnection
readonly TimeSpan minimumTimeBetweenRequests = TimeSpan.FromSeconds(0.8);

readonly MeterReading[] meterReadings;
readonly BtHomeComponentsHandler? componentsHandler;

readonly WebSocketHandler requestHandler;

Expand All @@ -36,6 +38,13 @@ public ShellyPro4PmConnection(TargetDevice target)
{
requestHandler.SetAuth(target.password);
}

// Setup components handler if enabled
if (target.enableComponentMetrics)
{
string? password = target.RequiresAuthentication() ? target.password : null;
componentsHandler = new BtHomeComponentsHandler(targetUrl, requestTimeoutTime, password);
}

int targetMeterCount = target.targetMeters.Length;
meterReadings = new MeterReading[targetMeterCount];
Expand All @@ -58,6 +67,15 @@ public MeterReading[] GetCurrentMeterReadings()
return meterReadings;
}

public IReadOnlyCollection<BtHomeDevice>? GetBtHomeDevices()
=> componentsHandler?.GetDevices();

public bool HasComponentsEnabled()
=> componentsHandler != null;

public string GetComponentMetrics(string metricPrefix)
=> componentsHandler?.GenerateMetrics(metricPrefix) ?? "";

public async Task<bool> UpdateMetricsIfNecessary()
{
if (DateTime.UtcNow - lastSuccessfulRequest < minimumTimeBetweenRequests)
Expand All @@ -68,21 +86,38 @@ public async Task<bool> UpdateMetricsIfNecessary()
log.Debug("Updating metrics");

requestStopWatch.Restart();
string? requestResponse = await requestHandler.Request();

// Make simultaneous requests
Task<string?> statusTask = requestHandler.Request();
Task<bool>? componentsTask = componentsHandler?.UpdateComponentsFromDevice();

// Wait for status request (required)
string? statusResponse = await statusTask;
TimeSpan requestTime = requestStopWatch.Elapsed;
log.Debug("Metrics request took: {requestTime} ms", requestTime.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture));
log.Debug("Status request took: {requestTime} ms", requestTime.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture));

if (string.IsNullOrEmpty(requestResponse))
if (string.IsNullOrEmpty(statusResponse))
{
log.Error("Request response null or empty - could not update metrics");
log.Error("Status request response null or empty - could not update metrics");
return false;
}

if (!UpdateMetrics(requestResponse))
// Update status metrics
if (!UpdateMetrics(statusResponse))
{
log.Error("Failed to update metrics");
log.Error("Failed to update status metrics");
return false;
}

// Wait for and update components if enabled
if (componentsTask != null)
{
bool componentsUpdated = await componentsTask;
if (!componentsUpdated)
{
log.Warning("Failed to update component metrics, but continuing");
}
}

log.Debug("Updating metrics completed");
lastSuccessfulRequest = DateTime.UtcNow;
Expand Down
1 change: 1 addition & 0 deletions ShellyPro4PmExporter/TargetDevice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public class TargetDevice
public string url;
public string password;
public float requestTimeoutTime = 3;
public bool enableComponentMetrics = false;

public TargetMeter[] targetMeters;

Expand Down
Loading