190 lines
8.0 KiB
Markdown
190 lines
8.0 KiB
Markdown
# Architecture and adding products
|
|
|
|
## Design goals
|
|
|
|
The exporter separates transport, product decoding, Prometheus collection, configuration, and HTTP serving so additional Shelly models do not require changes throughout the application.
|
|
|
|
The Gen1 implementation uses a superset response structure with optional fields. This matches the Shelly API, where `/status` contains common fields plus product- and mode-specific blocks.
|
|
|
|
## Request flow
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
P[Prometheus] -->|GET /metrics| H[HTTP server]
|
|
H --> R[Prometheus registry]
|
|
R --> G[Collector group]
|
|
G -->|concurrent| C1[Device collector A]
|
|
G -->|concurrent| C2[Device collector B]
|
|
C1 --> S1[Status client]
|
|
C2 --> S2[Status client]
|
|
S1 -->|GET /status| D1[Shelly device A]
|
|
S2 -->|GET /status| D2[Shelly device B]
|
|
```
|
|
|
|
There is no background polling loop or value cache. Each `/metrics` request produces one status request per configured device.
|
|
|
|
## Package responsibilities
|
|
|
|
| Package/file | Responsibility |
|
|
| --- | --- |
|
|
| [`main.go`](../main.go) | Parses `-config`, installs signal handling, and starts the application. |
|
|
| [`configuration`](../configuration/configuration.go) | Strict YAML loading, defaults, and product-independent validation. |
|
|
| [`application`](../application/application.go) | Creates clients and collectors, owns the Prometheus registry, and serves HTTP endpoints. |
|
|
| [`shelly`](../shelly/client.go) | Gen1 HTTP `/status` client, timeout, Basic Authentication, status checking, and JSON decoding. |
|
|
| [`collector/factory.go`](../collector/factory.go) | Normalizes product identifiers and maps them to collector constructors. |
|
|
| [`collector/group.go`](../collector/group.go) | Presents all device collectors as one registered collector and runs them concurrently. |
|
|
| [`collector/base.go`](../collector/base.go) | Shared `shelly_up`, request duration, and request error metrics. |
|
|
| [`collector/gen1_status.go`](../collector/gen1_status.go) | Optional superset of known Gen1 `/status` response blocks. |
|
|
| [`collector/gen1_collector.go`](../collector/gen1_collector.go) | Descriptor definitions and conversion from Gen1 status data to Prometheus metrics. |
|
|
|
|
## Startup lifecycle
|
|
|
|
1. `configuration.Load` reads and strictly parses YAML.
|
|
2. Product-independent validation checks the listener, timeout, unique names, and URLs.
|
|
3. `application.New` creates one HTTP status client per configured device.
|
|
4. `collector.NewDeviceCollector` normalizes the configured product and resolves its factory.
|
|
5. All device collectors are wrapped in `collector.Group` and registered once in a private Prometheus registry.
|
|
6. `/metrics` and `/-/healthy` handlers are installed.
|
|
7. `Application.Run` opens the listener and serves until cancellation or a server error.
|
|
|
|
A private registry prevents application metrics from being mixed with process-global collectors registered by dependencies.
|
|
|
|
## Collection behavior
|
|
|
|
`collector.Group.Collect` starts one goroutine per configured device and waits for all of them. Each Gen1 collector then:
|
|
|
|
1. calls `StatusClient.GetStatus`;
|
|
2. emits scrape health, duration, and cumulative error count;
|
|
3. stops for that device if HTTP or JSON decoding failed;
|
|
4. emits common status metrics;
|
|
5. processes relays, inputs, meters, EM meters, rollers, lights, thermostats, and sensors;
|
|
6. skips every absent optional field.
|
|
|
|
The base error counter uses an atomic integer because Prometheus can execute overlapping collections. Device-specific values are const metrics built from the latest response and do not retain mutable state.
|
|
|
|
## Why status fields use pointers
|
|
|
|
In JSON, a missing field and a present zero value have different meanings. For example:
|
|
|
|
```json
|
|
{}
|
|
```
|
|
|
|
means that a capability may not exist, while:
|
|
|
|
```json
|
|
{"power": 0}
|
|
```
|
|
|
|
means that the device supports power measurement and currently reports zero watts.
|
|
|
|
Numeric and boolean status fields therefore use pointers in `gen1Status`. Collection helpers emit a metric only for a non-nil pointer. The custom `numberOrBool` decoder handles the Gen1 `overpower` field because different product families represent it as either a boolean state or a numeric threshold.
|
|
|
|
## Metric conventions
|
|
|
|
- Use the `shelly_` namespace.
|
|
- Use Prometheus base-unit suffixes such as `_seconds`, `_bytes`, `_watts`, and `_volts`.
|
|
- Use `_total` only for counters.
|
|
- All product metrics include `device`; indexed response arrays additionally include `index`.
|
|
- Boolean values are gauges with `0` or `1`.
|
|
- Text is exposed through labels on an `_info` gauge with value `1`.
|
|
- Reuse an existing metric when a new product has the same semantic value.
|
|
- Avoid labels containing frequently changing or unbounded data.
|
|
- Preserve native cumulative device values instead of accumulating scrape deltas in exporter memory.
|
|
|
|
### Energy counter rules
|
|
|
|
Gen1 uses two different cumulative representations:
|
|
|
|
- `meters[].total` is watt-minutes and is divided by 60 before being exported as `shelly_meter_energy_watt_hours_total`.
|
|
- `emeters[].total` and `total_returned` are already Wh and are exported unchanged.
|
|
|
|
Both are Prometheus counters. Reboots or explicit device resets can decrease them; Prometheus counter functions handle the reset.
|
|
|
|
## Extending the Gen1 schema
|
|
|
|
Use this path when a product already supports `GET /status` but returns a field the generic collector does not understand.
|
|
|
|
### 1. Capture an official response fixture
|
|
|
|
Use the official Shelly documentation and, when available, a real device response. Remove passwords and private network identifiers before committing fixtures.
|
|
|
|
### 2. Add optional response fields
|
|
|
|
Extend [`gen1_status.go`](../collector/gen1_status.go). Use pointers for scalar fields:
|
|
|
|
```go
|
|
type gen1Status struct {
|
|
// Existing fields...
|
|
Voltage *float64 `json:"voltage"`
|
|
}
|
|
```
|
|
|
|
For a nested or repeated block, introduce a dedicated type rather than an anonymous map. A typed structure catches incompatible API changes during JSON decoding.
|
|
|
|
### 3. Register descriptors
|
|
|
|
Add each metric once in `registerDescriptors`:
|
|
|
|
```go
|
|
c.add("supply_voltage_volts", "Device supply voltage in volts.")
|
|
```
|
|
|
|
`add` automatically prefixes the name with `shelly_` and prepends the `device` label. Pass only additional label names.
|
|
|
|
### 4. Emit only present values
|
|
|
|
Use the existing helpers:
|
|
|
|
```go
|
|
c.optionalGauge(channel, "supply_voltage_volts", status.Voltage)
|
|
c.optionalBool(channel, "feature_enabled", status.FeatureEnabled)
|
|
```
|
|
|
|
Use `counter` only for a cumulative, monotonically increasing source value:
|
|
|
|
```go
|
|
if status.Events != nil && *status.Events >= 0 {
|
|
c.counter(channel, "events_total", *status.Events)
|
|
}
|
|
```
|
|
|
|
### 5. Register the product identifier
|
|
|
|
If the new model is known to use this schema, append its normalized identifier to `gen1Products`. Unlisted compatible devices can already use `product: gen1` without registration.
|
|
|
|
### 6. Add fixture-based tests
|
|
|
|
Add a representative JSON fixture in the collector tests and gather through `prometheus.NewPedanticRegistry`. Test:
|
|
|
|
- expected metric values and labels;
|
|
- metric type, particularly counters;
|
|
- unit conversions;
|
|
- valid zero values;
|
|
- absence of a metric when the source field is absent;
|
|
- inconsistent JSON forms if the device family has firmware variations.
|
|
|
|
Run:
|
|
|
|
```sh
|
|
gofmt -w collector
|
|
go test ./...
|
|
go vet ./...
|
|
go build ./...
|
|
```
|
|
|
|
## Adding a different protocol generation
|
|
|
|
Do not model a Gen2/Gen3 RPC response as a Gen1 `/status` response. The current `shelly.StatusClient` always appends `/status` and implements Basic Authentication, so supporting an RPC generation requires a transport extension as well as a collector.
|
|
|
|
A clean implementation should:
|
|
|
|
1. define a product-independent request/client interface or a protocol client factory;
|
|
2. choose the protocol client from product metadata during application construction;
|
|
3. implement the RPC endpoint and its authentication scheme separately;
|
|
4. add a collector with generation-appropriate response structures;
|
|
5. register product identifiers through the existing collector factory;
|
|
6. retain the shared collector group and scrape health metrics.
|
|
|
|
This keeps generation-specific endpoint paths and authentication out of the application HTTP server.
|