Skip to content

Commit

Permalink
feat: add support for gen 2 devices (3EM pro)
Browse files Browse the repository at this point in the history
  • Loading branch information
easimon committed Jun 27, 2023
1 parent 3836646 commit 6f7eb82
Show file tree
Hide file tree
Showing 26 changed files with 1,142 additions and 392 deletions.
33 changes: 24 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ the exporter does not require Shelly cloud (but runs fine alongside).

## Supported devices

At the moment, this exporter is tested only with *Shelly Plug* and *Shelly Plug S*, since this is what I own.
The exporter supports Gen1 and Gen2 devices, but is tested only on *Shelly Plug*, *Shelly Plug S* and
*Shelly Pro 3EM*, since this is what I own.
Other series *might* be supported as well, since the HTTP API is similar (not identical) across the whole Shelly family.
If you have a different Shelly device, please tell me if it works or not.

Expand All @@ -25,12 +26,26 @@ items and their defaults, see the [application.yaml](./src/main/resources/applic
By default, Shelly devices allow unauthenticated access to the local HTTP API. If you protected the local API with a
username and password, it needs to be the same across all devices. This is not the Shelly cloud username and password.

| Environment variable | Description | Default | Required |
|-----------------------------------|---------------------------------------------------------|---------|----------|
| SHELLY_DEVICES_HOSTS | Comma-separated list of hostnames and/or IP addresses | (none) | yes |
| SHELLY_AUTH_USERNAME | Shelly HTTP API username (must be same for all devices) | (none) | no |
| SHELLY_AUTH_PASSWORD | Shelly HTTP API password (must be same for all devices) | (none) | no |
| SHELLY_DEVICES_DISCOVERY_INTERVAL | Interval to start a device discovery | 5 min | no |
Shelly has two generations of devices, first and second Generation (Gen 1, Gen2). This exporter supports both to some
extent, but has no auto-detection for the device generation. Instead your need to define two sets of hosts, username,
password. Since the API of these two generations is completely different, make sure to add your devices to the correct
environment variable `SHELLY_DEVICES_HOSTS` or `SHELLY_GEN2DEVICES_HOSTS`. Refer to
[https://shelly-api-docs.shelly.cloud/](https://shelly-api-docs.shelly.cloud/) to find if your devices are Gen1 or
Gen2. If you do not own devices of one of the generations, just leave the corresponding `*_HOSTS` empty.

While it is possible to define `SHELLY_GEN2AUTH_USERNAME`, there is no way to alter the authentication username
on the Shelly devices itself, instead it's hard-coded to 'admin'.

| Environment variable | Description | Default | Required |
|---------------------------------------|-------------------------------------------------------------------------|---------|----------|
| SHELLY_DEVICES_HOSTS | Comma-separated list of hostnames and/or IP addresses for Gen 1 devices | (none) | no |
| SHELLY_AUTH_USERNAME | Shelly Gen 1 HTTP API username (must be same for all Gen 1 devices) | (none) | no |
| SHELLY_AUTH_PASSWORD | Shelly Gen 1 HTTP API password (must be same for all Gen 1 devices) | (none) | no |
| SHELLY_DEVICES_DISCOVERY_INTERVAL | Interval to start a device discovery for Gen 1 devices | 5 min | no |
| SHELLY_GEN2DEVICES_HOSTS | Comma-separated list of hostnames and/or IP addresses for Gen 2 devices | (none) | no |
| SHELLY_GEN2AUTH_USERNAME | Shelly Gen 2 HTTP API username (defaults to admin, do not change) | admin | no |
| SHELLY_GEN2AUTH_PASSWORD | Shelly Gen 2 HTTP API password (must be same for all Gen 2 devices) | (none) | no |
| SHELLY_GEN2DEVICES_DISCOVERY_INTERVAL | Interval to start a device discovery for Gen 2 devices | 5 min | no |

#### Device discovery

Expand All @@ -41,8 +56,8 @@ reappear after the discovery interval. Discovery failures are logged with a reas
- device not responding
- authentication failure (username/password incorrect)

If you have the possibility to create a single DNS A record that contains all Shelly device addresses, this is a
supported scenario (and actually how I run the exporter):
If you have the possibility to create a single DNS A record that contains all Shelly device addresses (or two, one per
device generation, this is a supported scenario (and actually how I run the exporter):

```bash
$ dig shellies
Expand Down
14 changes: 14 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
Expand All @@ -53,6 +63,10 @@
<artifactId>caffeine</artifactId>
</dependency>

<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package click.dobel.shelly.exporter

import click.dobel.shelly.exporter.client.api.Settings
import click.dobel.shelly.exporter.client.api.Shelly
import click.dobel.shelly.exporter.client.api.Status
import click.dobel.shelly.exporter.client.api.gen1.Settings
import click.dobel.shelly.exporter.client.api.gen1.Shelly
import click.dobel.shelly.exporter.client.api.gen1.Status
import click.dobel.shelly.exporter.client.api.gen2.Gen2ShellyConfig
import click.dobel.shelly.exporter.client.api.gen2.Gen2ShellyDeviceInfo
import click.dobel.shelly.exporter.client.api.gen2.Gen2ShellyStatus
import click.dobel.shelly.exporter.config.ShellyConfigProperties
import click.dobel.shelly.exporter.metrics.ShellyMetrics
import click.dobel.shelly.exporter.metrics.ValueFilteringCollectorRegistry
Expand All @@ -20,7 +23,6 @@ import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.annotation.EnableScheduling
import java.util.concurrent.TimeUnit


@Configuration
@EnableCaching
@EnableScheduling
Expand All @@ -32,8 +34,14 @@ class ShellyExporterConfiguration {
private val API_CLASSES = listOf(
Shelly::class,
Status::class,
Settings::class
Settings::class,

Gen2ShellyStatus::class,
Gen2ShellyDeviceInfo::class,
Gen2ShellyConfig::class
)

const val SCRAPE_FAILURE_VALUE = Double.NaN
}

@Bean
Expand Down Expand Up @@ -68,7 +76,7 @@ class ShellyExporterConfiguration {
}

@Bean
fun collectorRegistry(configProperties: ShellyConfigProperties): CollectorRegistry {
return ValueFilteringCollectorRegistry(configProperties.metrics.failureValue, true)
fun collectorRegistry(): CollectorRegistry {
return ValueFilteringCollectorRegistry(SCRAPE_FAILURE_VALUE, true)
}
}
102 changes: 102 additions & 0 deletions src/main/kotlin/click/dobel/shelly/exporter/client/HttpClientBase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package click.dobel.shelly.exporter.client

import click.dobel.shelly.exporter.config.ShellyConfigProperties
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials
import org.apache.hc.client5.http.config.ConnectionConfig
import org.apache.hc.client5.http.impl.classic.HttpClients
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder
import org.apache.hc.core5.http.io.SocketConfig
import org.apache.hc.core5.pool.PoolConcurrencyPolicy
import org.apache.hc.core5.util.TimeValue
import org.apache.hc.core5.util.Timeout
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory
import org.springframework.web.client.RestTemplate

fun url(
address: String,
path: String
): String = "http://${address}${slash(path)}"

private fun slash(
path: String
): String = if (path.startsWith("/")) path else "/$path"

private fun <T> T.runIf(condition: Boolean, block: T.() -> T): T {
return if (condition)
block()
else
this
}

/*
fun createRestTemplate(
auth: ShellyConfigProperties.Auth,
httpParams: ShellyConfigProperties.HttpParams,
builder: RestTemplateBuilder,
): RestTemplate {
return builder
.setConnectTimeout(httpParams.connectTimeout)
.setReadTimeout(httpParams.requestTimeout)
.runIf(auth.isEnabled) {
basicAuthentication(
auth.username,
auth.password
)
}
.build()
}
*/

fun RestTemplateBuilder.createRestTemplate(
auth: ShellyConfigProperties.Auth,
httpParams: ShellyConfigProperties.HttpParams,
): RestTemplate {
val httpClient = httpClient(
auth,
httpParams,
)

return this
.requestFactory { -> HttpComponentsClientHttpRequestFactory(httpClient) }
.build()
}

private fun httpClient(
auth: ShellyConfigProperties.Auth,
httpParams: ShellyConfigProperties.HttpParams,
) = HttpClients
.custom()
.runIf(auth.isEnabled) {
val credentials = UsernamePasswordCredentials(
auth.username,
auth.password.toCharArray()
)
setDefaultCredentialsProvider { _, _ -> credentials }
}
.disableCookieManagement()
.disableRedirectHandling()
.disableAuthCaching()
.disableConnectionState()
.setConnectionReuseStrategy { _, _, _ -> false }
.setConnectionManager(
PoolingHttpClientConnectionManagerBuilder.create()
.setMaxConnTotal(httpParams.maxConnectionsTotal)
.setMaxConnPerRoute(httpParams.maxConnectionsPerRoute)
.setPoolConcurrencyPolicy(PoolConcurrencyPolicy.LAX)
.setDefaultSocketConfig(
SocketConfig.custom()
.setSoTimeout(Timeout.of(httpParams.requestTimeout))
.build()
)
.setDefaultConnectionConfig(
ConnectionConfig.custom()
.setConnectTimeout(Timeout.of(httpParams.connectTimeout))
.setSocketTimeout(Timeout.of(httpParams.requestTimeout))
.setTimeToLive(TimeValue.of(httpParams.timeToLive))
.setValidateAfterInactivity(Timeout.of(httpParams.validationPeriod))
.build()
)
.build()
)
.build()
73 changes: 27 additions & 46 deletions src/main/kotlin/click/dobel/shelly/exporter/client/ShellyClient.kt
Original file line number Diff line number Diff line change
@@ -1,64 +1,45 @@
package click.dobel.shelly.exporter.client

import click.dobel.shelly.exporter.client.api.Settings
import click.dobel.shelly.exporter.client.api.Shelly
import click.dobel.shelly.exporter.client.api.Status
import click.dobel.shelly.exporter.config.ShellyConfigProperties
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Component
import mu.KLoggable
import org.springframework.web.client.RestTemplate

@Component
class ShellyClient(
configProperties: ShellyConfigProperties,
restTemplateBuilder: RestTemplateBuilder
abstract class ShellyClient(
loggable: KLoggable
) {

@Cacheable("Status", sync = true)
fun status(address: String) = get<Status>(address, "status")

@Cacheable("Shelly", sync = true)
fun shelly(address: String) = get<Shelly>(address, "shelly")
companion object {
const val RETRIES = 3
}

@Cacheable("Settings", sync = true)
fun settings(address: String) = get<Settings>(address, "settings")
protected abstract val restTemplate: RestTemplate
protected val logger = loggable.logger

private inline fun <reified T : Any> get(
protected inline fun <reified T : Any> get(
address: String,
path: String
): T? {
return try {
restTemplate.getForObject(url(address, path))
} catch (ex: Exception) {
val url = url(address, path)
return runCatching {
retry(RETRIES) {
restTemplate.getForObject<T>(url)
}
}.getOrElse { ex ->
logger.warn { "GET ${url}: HTTP Request failure: ${ex.message}" }
null
}
}

private fun url(
address: String,
path: String
): String = "http://${address}${slash(path)}"

private fun slash(
path: String
): String = if (path.startsWith("/")) path else "/$path"

private val restTemplate: RestTemplate = restTemplateBuilder
.setConnectTimeout(configProperties.devices.connectTimeout)
.setReadTimeout(configProperties.devices.requestTimeout)
.runIf(configProperties.hasAuth) {
basicAuthentication(
configProperties.auth.username,
configProperties.auth.password
)
inline fun <T> retry(
retries: Int = 1,
call: () -> T
): T? {
retryLoop@ for (i in 0..retries) {
val result = runCatching(call)
if (result.isFailure && i < retries) {
continue@retryLoop
}
return result.getOrThrow()
}
.build()

private inline fun <T> T.runIf(condition: Boolean, block: T.() -> T): T {
return if (condition)
block()
else
this
error("Retries finished unexpectedly. Retries < 1?.")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package click.dobel.shelly.exporter.client

import click.dobel.shelly.exporter.client.api.gen1.Settings
import click.dobel.shelly.exporter.client.api.gen1.Shelly
import click.dobel.shelly.exporter.client.api.gen1.Status
import click.dobel.shelly.exporter.config.ShellyConfigProperties
import mu.KLogging
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Component

@Component
class ShellyGen1Client(
configProperties: ShellyConfigProperties,
restTemplateBuilder: RestTemplateBuilder
) : ShellyClient(this) {
companion object : KLogging()

@Cacheable("Status", sync = true)
fun status(address: String) = get<Status>(address, "status")

@Cacheable("Shelly", sync = true)
fun shelly(address: String) = get<Shelly>(address, "shelly")

@Cacheable("Settings", sync = true)
fun settings(address: String) = get<Settings>(address, "settings")

override val restTemplate = restTemplateBuilder.createRestTemplate(
configProperties.auth,
configProperties.httpParams,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package click.dobel.shelly.exporter.client

import click.dobel.shelly.exporter.client.api.gen2.Gen2ShellyConfig
import click.dobel.shelly.exporter.client.api.gen2.Gen2ShellyDeviceInfo
import click.dobel.shelly.exporter.client.api.gen2.Gen2ShellyStatus
import click.dobel.shelly.exporter.config.ShellyConfigProperties
import mu.KLogging
import org.springframework.boot.web.client.RestTemplateBuilder
import org.springframework.cache.annotation.Cacheable
import org.springframework.stereotype.Component


@Component
class ShellyGen2Client(
configProperties: ShellyConfigProperties,
restTemplateBuilder: RestTemplateBuilder
) : ShellyClient(this) {

companion object : KLogging()

@Cacheable("Gen2ShellyStatus", sync = true)
fun status(address: String) = get<Gen2ShellyStatus>(address, "rpc/Shelly.GetStatus")

@Cacheable("Gen2ShellyDeviceInfo", sync = true)
fun deviceInfo(address: String) = get<Gen2ShellyDeviceInfo>(address, "rpc/Shelly.GetDeviceInfo")

@Cacheable("Gen2ShellyConfig", sync = true)
fun config(address: String) = get<Gen2ShellyConfig>(address, "rpc/Shelly.GetConfig")

override val restTemplate = restTemplateBuilder.createRestTemplate(
configProperties.gen2auth,
configProperties.httpParams,
)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package click.dobel.shelly.exporter.client.api
package click.dobel.shelly.exporter.client.api.gen1

import com.fasterxml.jackson.annotation.JsonProperty

Expand Down
Loading

0 comments on commit 6f7eb82

Please sign in to comment.