| ------- | --------------- | -------- | --------- | ------------------------------------------------------------------- |
| sensors | temperature | Number | yes | Current Temperature in °C |
| | state | Contact | yes | Valve status: OPEN or CLOSED (position = 0) |
-| | open | Contact | yes | ON: "window is open" was detected, OFF: window is closed |
| | lastUpdate | DateTime | yes | Timestamp of the last update (any sensor value changed) |
| control | targetTemp | Number | no | Temperature in °C: 4=Low/Min; 5..30=target temperature;31=Hi/Max |
| | position | Dimmer | no | Set valve to manual mode (0..100%) disables auto-temp) |
public static final String SHELLY_API_MIN_FWCOIOT = "v1.6";// v1.6.0+
public static final String SHELLY_API_FWCOIOT2 = "v1.8";// CoAP 2 with FW 1.8+
public static final String SHELLY_API_FW_110 = "v1.10"; // FW 1.10 or newer detected, activates some add feature
- public static final String SHELLY2_API_MIN_FWVERSION = "v0.10.2"; // Gen 2 minimum FW
+ public static final String SHELLY2_API_MIN_FWVERSION = "v0.10.1"; // Gen 2 minimum FW
// Alarm types/messages
public static final String ALARM_TYPE_NONE = "NONE";
public static final int DIGITS_LUX = 0;
public static final int DIGITS_PERCENT = 1;
- public static final int SHELLY_API_TIMEOUT_MS = 15000;
+ public static final int SHELLY_API_TIMEOUT_MS = 10000;
public static final int UPDATE_STATUS_INTERVAL_SECONDS = 3; // check for updates every x sec
public static final int UPDATE_SKIP_COUNT = 20; // update every x triggers or when a key was pressed
public static final int UPDATE_MIN_DELAY = 15;// update every x triggers or when a key was pressed
public String response = "";
public int httpCode = -1;
public String httpReason = "";
- public String authResponse = "";
+ public String authChallenge = "";
public ShellyApiResult() {
}
import static org.openhab.binding.shelly.internal.ShellyBindingConstants.SHELLY_API_TIMEOUT_MS;
import static org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.*;
+import static org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.*;
import static org.openhab.binding.shelly.internal.util.ShellyUtils.*;
import java.nio.charset.StandardCharsets;
+import java.text.MessageFormat;
import java.util.Base64;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
+import javax.ws.rs.core.HttpHeaders;
+
import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
import org.eclipse.jetty.http.HttpHeader;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
+import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthChallenge;
+import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRsp;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2RpcBaseMessage;
import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
public class ShellyHttpClient {
private final Logger logger = LoggerFactory.getLogger(ShellyHttpClient.class);
- public static final String HTTP_HEADER_AUTH = "Authorization";
+ public static final String HTTP_HEADER_AUTH = HttpHeaders.AUTHORIZATION;
public static final String HTTP_AUTH_TYPE_BASIC = "Basic";
+ public static final String HTTP_AUTH_TYPE_DIGEST = "Digest";
public static final String CONTENT_TYPE_JSON = "application/json; charset=UTF-8";
public static final String CONTENT_TYPE_FORM_URLENC = "application/x-www-form-urlencoded";
this.thingName = thingName;
setConfig(thingName, config);
this.httpClient = httpClient;
+ this.httpClient.setConnectTimeout(SHELLY_API_TIMEOUT_MS);
}
public void initialize() throws ShellyApiException {
boolean timeout = false;
while (retries > 0) {
try {
- apiResult = innerRequest(HttpMethod.GET, uri, "");
+ apiResult = innerRequest(HttpMethod.GET, uri, null, "");
if (timeout) {
logger.debug("{}: API timeout #{}/{} recovered ({})", thingName, timeoutErrors, timeoutsRecovered,
apiResult.getUrl());
}
public String httpPost(String uri, String data) throws ShellyApiException {
- return innerRequest(HttpMethod.POST, uri, data).response;
+ return innerRequest(HttpMethod.POST, uri, null, data).response;
+ }
+
+ public String httpPost(@Nullable Shelly2AuthChallenge auth, String data) throws ShellyApiException {
+ return innerRequest(HttpMethod.POST, SHELLYRPC_ENDPOINT, auth, data).response;
}
- private ShellyApiResult innerRequest(HttpMethod method, String uri, String data) throws ShellyApiException {
+ private ShellyApiResult innerRequest(HttpMethod method, String uri, @Nullable Shelly2AuthChallenge auth,
+ String data) throws ShellyApiException {
Request request = null;
String url = "http://" + config.deviceIp + uri;
ShellyApiResult apiResult = new ShellyApiResult(method.toString(), url);
request = httpClient.newRequest(url).method(method.toString()).timeout(SHELLY_API_TIMEOUT_MS,
TimeUnit.MILLISECONDS);
- if (!config.password.isEmpty() && !getString(data).contains("\"auth\":{")) {
- String value = config.userId + ":" + config.password;
- request.header(HTTP_HEADER_AUTH,
- HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(value.getBytes()));
+ if (!uri.equals(SHELLY_URL_DEVINFO) && !config.password.isEmpty()) { // not for /shelly or no password
+ // configured
+ // Add Auth info
+ // Gen 1: Basic Auth
+ // Gen 2: Digest Auth
+ String authHeader = "";
+ if (auth != null) { // only if we received an Auth challenge
+ authHeader = formatAuthResponse(uri,
+ buildAuthResponse(uri, auth, SHELLY2_AUTHDEF_USER, config.password));
+ } else {
+ if (!uri.equals(SHELLYRPC_ENDPOINT)) {
+ String bearer = config.userId + ":" + config.password;
+ authHeader = HTTP_AUTH_TYPE_BASIC + " " + Base64.getEncoder().encodeToString(bearer.getBytes());
+ }
+ }
+ if (!authHeader.isEmpty()) {
+ request.header(HTTP_HEADER_AUTH, authHeader);
+ }
}
fillPostData(request, data);
logger.trace("{}: HTTP {} for {} {}\n{}", thingName, method, url, data, request.getHeaders());
apiResult.httpCode = message.error.code;
apiResult.response = message.error.message;
if (getInteger(message.error.code) == HttpStatus.UNAUTHORIZED_401) {
- apiResult.authResponse = getString(message.error.message).replaceAll("\\\"", "\"");
+ apiResult.authChallenge = getString(message.error.message).replaceAll("\\\"", "\"");
}
}
}
HttpFields headers = contentResponse.getHeaders();
- String auth = headers.get(HttpHeader.WWW_AUTHENTICATE);
- if (!getString(auth).isEmpty()) {
- apiResult.authResponse = auth;
+ String authChallenge = headers.get(HttpHeader.WWW_AUTHENTICATE);
+ if (!getString(authChallenge).isEmpty()) {
+ apiResult.authChallenge = authChallenge;
}
// validate response, API errors are reported as Json
return apiResult;
}
+ protected @Nullable Shelly2AuthRsp buildAuthResponse(String uri, @Nullable Shelly2AuthChallenge challenge,
+ String user, String password) throws ShellyApiException {
+ if (challenge == null) {
+ return null; // not required
+ }
+ if (!SHELLY2_AUTHTTYPE_DIGEST.equalsIgnoreCase(challenge.authType)
+ || !SHELLY2_AUTHALG_SHA256.equalsIgnoreCase(challenge.algorithm)) {
+ throw new IllegalArgumentException("Unsupported Auth type/algorithm requested by device");
+ }
+ Shelly2AuthRsp response = new Shelly2AuthRsp();
+ response.username = user;
+ response.realm = challenge.realm;
+ response.nonce = challenge.nonce;
+ response.cnonce = Long.toHexString((long) Math.floor(Math.random() * 10e8));
+ response.nc = "00000001";
+ response.authType = challenge.authType;
+ response.algorithm = challenge.algorithm;
+ String ha1 = sha256(response.username + ":" + response.realm + ":" + password);
+ String ha2 = sha256(HttpMethod.POST + ":" + uri);// SHELLY2_AUTH_NOISE;
+ response.response = sha256(
+ ha1 + ":" + response.nonce + ":" + response.nc + ":" + response.cnonce + ":" + "auth" + ":" + ha2);
+ return response;
+ }
+
+ protected String formatAuthResponse(String uri, @Nullable Shelly2AuthRsp rsp) {
+ return rsp != null ? MessageFormat.format(HTTP_AUTH_TYPE_DIGEST
+ + " username=\"{0}\", realm=\"{1}\", uri=\"{2}\", nonce=\"{3}\", cnonce=\"{4}\", nc=\"{5}\", qop=\"auth\",response=\"{6}\", algorithm=\"{7}\", ",
+ rsp.username, rsp.realm, uri, rsp.nonce, rsp.cnonce, rsp.nc, rsp.response, rsp.algorithm) : "";
+ }
+
/**
* Fill in POST data, set http headers
*
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor.ShellySensorBat;
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor.ShellySensorHum;
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor.ShellySensorLux;
-import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRequest;
-import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthResponse;
+import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthRsp;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DevConfigCover;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DevConfigInput;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DevConfigSwitch;
protected final ShellyStatusSensor sensorData = new ShellyStatusSensor();
protected final ArrayList<ShellyRollerStatus> rollerStatus = new ArrayList<>();
protected @Nullable ShellyThingInterface thing;
- protected @Nullable Shelly2AuthRequest authReq;
+ protected @Nullable Shelly2AuthRsp authReq;
public Shelly2ApiClient(String thingName, ShellyThingInterface thing) {
super(thingName, thing);
return request;
}
- protected Shelly2AuthRequest buildAuthRequest(Shelly2AuthResponse authParm, String user, String realm,
- String password) throws ShellyApiException {
- Shelly2AuthRequest authReq = new Shelly2AuthRequest();
- authReq.username = "admin";
- authReq.realm = realm;
- authReq.nonce = authParm.nonce;
- authReq.cnonce = (long) Math.floor(Math.random() * 10e8);
- authReq.nc = authParm.nc != null ? authParm.nc : 1;
- authReq.authType = SHELLY2_AUTHTTYPE_DIGEST;
- authReq.algorithm = SHELLY2_AUTHALG_SHA256;
- String ha1 = sha256(authReq.username + ":" + authReq.realm + ":" + password);
- String ha2 = SHELLY2_AUTH_NOISE;
- authReq.response = sha256(
- ha1 + ":" + authReq.nonce + ":" + authReq.nc + ":" + authReq.cnonce + ":" + "auth" + ":" + ha2);
- return authReq;
- }
-
protected String mapValue(Map<String, String> map, @Nullable String key) {
String value;
boolean known = key != null && !key.isEmpty() && map.containsKey(key);
* @author Markus Michels - Initial contribution
*/
public class Shelly2ApiJsonDTO {
+ public static final String SHELLYRPC_ENDPOINT = "/rpc";
+
public static final String SHELLYRPC_METHOD_CLASS_SHELLY = "Shelly";
public static final String SHELLYRPC_METHOD_CLASS_SWITCH = "Switch";
public Object params;
public String event;
public Object result;
- public Shelly2AuthRequest auth;
+ public Shelly2AuthRsp auth;
public Shelly2RpcMessageError error;
}
public Shelly2RpcMessageError error;
}
+ public static String SHELLY2_AUTHDEF_USER = "admin";
public static String SHELLY2_AUTHTTYPE_DIGEST = "digest";
public static String SHELLY2_AUTHTTYPE_STRING = "string";
public static String SHELLY2_AUTHALG_SHA256 = "SHA-256";
// = ':auth:'+HexHash("dummy_method:dummy_uri");
public static String SHELLY2_AUTH_NOISE = "6370ec69915103833b5222b368555393393f098bfbfbb59f47e0590af135f062";
- public static class Shelly2AuthRequest {
- public String username;
- public Long nonce;
- public Long cnonce;
- public Integer nc;
- public String realm;
- public String algorithm;
- public String response;
+ public static class Shelly2AuthChallenge { // on 401 message contains the auth info
@SerializedName("auth_type")
public String authType;
+ public String nonce;
+ public String nc;
+ public String realm;
+ public String algorithm;
}
- public static class Shelly2AuthResponse { // on 401 message contains the auth info
- @SerializedName("auth_type")
- public String authType;
- public Long nonce;
- public Integer nc;
+ public static class Shelly2AuthRsp {
+ public String username;
+ public String nonce;
+ public String cnonce;
+ public String nc;
public String realm;
public String algorithm;
+ public String response;
+ @SerializedName("auth_type")
+ public String authType;
}
// BTHome samples
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusLight;
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusRelay;
import org.openhab.binding.shelly.internal.api1.Shelly1ApiJsonDTO.ShellyStatusSensor;
-import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthResponse;
+import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2AuthChallenge;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2ConfigParms;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2DeviceConfigSta;
import org.openhab.binding.shelly.internal.api2.Shelly2ApiJsonDTO.Shelly2DeviceConfig.Shelly2GetConfigResult;
import org.openhab.binding.shelly.internal.config.ShellyThingConfiguration;
import org.openhab.binding.shelly.internal.handler.ShellyThingInterface;
import org.openhab.binding.shelly.internal.handler.ShellyThingTable;
+import org.openhab.binding.shelly.internal.util.ShellyVersionDTO;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.thing.ThingStatusDetail;
import org.slf4j.Logger;
protected boolean initialized = false;
private boolean discovery = false;
private Shelly2RpcSocket rpcSocket = new Shelly2RpcSocket();
- private Shelly2AuthResponse authInfo = new Shelly2AuthResponse();
+ private @Nullable Shelly2AuthChallenge authInfo;
/**
* Regular constructor - called by Thing handler
ShellySettingsDevice device = getDeviceInfo();
profile.settings.device = device;
+ if (!getString(device.fw).isEmpty()) {
+ profile.fwDate = substringBefore(device.fw, "/");
+ profile.fwVersion = profile.status.update.oldVersion = "v" + substringAfter(device.fw, "/");
+ }
+
profile.hostname = device.hostname;
profile.deviceType = device.type;
profile.mac = device.mac;
if (ourId != -1) {
startScript(ourId, false);
enableScript(script, false);
+ deleteScript(ourId);
logger.debug("{}: Script {} was disabledd, id={}", thingName, script, ourId);
}
return;
}
if (upload && ourId != -1) {
// Delete existing script
- logger.debug("{}: Delete existing script", thingName);
- apiRequest(new Shelly2RpcRequest().withMethod(SHELLYRPC_METHOD_SCRIPT_DELETE).withId(ourId));
+ deleteScript(ourId);
}
if (upload) {
if (!running) {
running = startScript(ourId, true);
- }
- if (!discovery) {
- logger.info("{}: Script {} {}", thingName, script,
- running ? "was successfully (re)started" : "failed to start");
+ logger.debug("{}: Script {} {}", thingName, script,
+ running ? "was successfully started" : "failed to start");
}
} catch (ShellyApiException e) {
ShellyApiResult res = e.getApiResult();
apiRequest(SHELLYRPC_METHOD_SCRIPT_SETCONFIG, params, String.class);
return true;
} catch (ShellyApiException e) {
+ logger.debug("{}: Unable to enable script {}", thingName, script, e);
return false;
}
}
+ private boolean deleteScript(int id) {
+ if (id == -1) {
+ throw new IllegalArgumentException("Invalid Script Id");
+ }
+ try {
+ logger.debug("{}: Delete existing script with id{}", thingName, id);
+ apiRequest(new Shelly2RpcRequest().withMethod(SHELLYRPC_METHOD_SCRIPT_DELETE).withId(id));
+ return true;
+ } catch (ShellyApiException e) {
+ logger.debug("{}: Unable to delete script with id {}", thingName, id);
+ }
+ return false;
+ }
+
@Override
public void onConnect(String deviceIp, boolean connected) {
if (thing == null && thingTable != null) {
if (message.error != null) {
if (message.error.code == HttpStatus.UNAUTHORIZED_401 && !getString(message.error.message).isEmpty()) {
// Save nonce for notification
- Shelly2AuthResponse auth = gson.fromJson(message.error.message, Shelly2AuthResponse.class);
+ Shelly2AuthChallenge auth = gson.fromJson(message.error.message, Shelly2AuthChallenge.class);
if (auth != null && auth.realm == null) {
logger.debug("{}: Authentication data received: {}", thingName, message.error.message);
authInfo = auth;
status.update.hasUpdate = ds.sys.availableUpdates.stable != null;
if (ds.sys.availableUpdates.stable != null) {
status.update.newVersion = "v" + getString(ds.sys.availableUpdates.stable.version);
+ status.hasUpdate = new ShellyVersionDTO().compare(profile.fwVersion, status.update.newVersion) < 0;
}
if (ds.sys.availableUpdates.beta != null) {
status.update.betaVersion = "v" + getString(ds.sys.availableUpdates.beta.version);
+ status.hasUpdate = new ShellyVersionDTO().compare(profile.fwVersion, status.update.betaVersion) < 0;
}
}
- if (ds.sys.wakeUpReason != null && ds.sys.wakeUpReason.boot != null) {
+ if (ds.sys.wakeUpReason != null && ds.sys.wakeUpReason.boot != null)
+
+ {
List<Object> values = new ArrayList<>();
String boot = getString(ds.sys.wakeUpReason.boot);
String cause = getString(ds.sys.wakeUpReason.cause);
return relayStatus;
}
- @SuppressWarnings("null")
@Override
public void setRelayTurn(int id, String turnMode) throws ShellyApiException {
ShellyDeviceProfile profile = getProfile();
Shelly2RpcBaseMessage req = buildRequest(method, params);
try {
reconnect(); // make sure WS is connected
-
- if (authInfo.realm != null) {
- req.auth = buildAuthRequest(authInfo, config.userId, config.serviceName, config.password);
- }
json = rpcPost(gson.toJson(req));
} catch (ShellyApiException e) {
ShellyApiResult res = e.getApiResult();
- String auth = getString(res.authResponse);
+ String auth = getString(res.authChallenge);
if (res.isHttpAccessUnauthorized() && !auth.isEmpty()) {
String[] options = auth.split(",");
+ authInfo = new Shelly2AuthChallenge();
for (String o : options) {
String key = substringBefore(o, "=").stripLeading().trim();
String value = substringAfter(o, "=").replaceAll("\"", "").trim();
switch (key) {
case "Digest qop":
+ authInfo.authType = SHELLY2_AUTHTTYPE_DIGEST;
break;
case "realm":
authInfo.realm = value;
break;
case "nonce":
- authInfo.nonce = Long.parseLong(value, 16);
+ // authInfo.nonce = Long.parseLong(value, 16);
+ authInfo.nonce = value;
break;
case "algorithm":
authInfo.algorithm = value;
break;
}
}
- authInfo.nc = 1;
- req.auth = buildAuthRequest(authInfo, config.userId, authInfo.realm, config.password);
json = rpcPost(gson.toJson(req));
} else {
throw e;
}
private String rpcPost(String postData) throws ShellyApiException {
- return httpPost("/rpc", postData);
+ return httpPost(authInfo, postData);
}
private void reconnect() throws ShellyApiException {
try {
disconnect(); // for safety
- URI uri = new URI("ws://" + deviceIp + "/rpc");
+ URI uri = new URI("ws://" + deviceIp + SHELLYRPC_ENDPOINT);
ClientUpgradeRequest request = new ClientUpgradeRequest();
request.setHeader(HttpHeaders.HOST, deviceIp);
request.setHeader("Origin", "http://" + deviceIp);
e.event, e.data.name);
}
}
+ } else {
+ handler.onNotifyEvent(fromJson(gson, receivedMessage, Shelly2RpcNotifyEvent.class));
}
}
}
private final ShellyChannelCache cache;
private final int cacheCount = UPDATE_SETTINGS_INTERVAL_SECONDS / UPDATE_STATUS_INTERVAL_SECONDS;
- private final boolean gen2;
+ private boolean gen2 = false;
private final boolean blu;
protected boolean autoCoIoT = false;
* @param mode Device mode (e.g. relay, roller)
*/
protected void changeThingType(String thingType, String mode) {
- ThingTypeUID thingTypeUID = ShellyThingCreator.getThingTypeUID(thingType, "", mode);
+ String deviceType = substringBefore(thingType, "-");
+ ThingTypeUID thingTypeUID = ShellyThingCreator.getThingTypeUID(thingType, deviceType, mode);
if (!thingTypeUID.equals(THING_TYPE_SHELLYUNKNOWN)) {
logger.debug("{}: Changing thing type to {}", getThing().getLabel(), thingTypeUID);
Map<String, String> properties = editProperties();
- properties.replace(PROPERTY_DEV_TYPE, thingType);
+ properties.replace(PROPERTY_DEV_TYPE, deviceType);
properties.replace(PROPERTY_DEV_MODE, mode);
updateProperties(properties);
changeThingType(thingTypeUID, getConfig());
<label>ShellyPlus 2 Relay</label>
<description>@text/thing-type.shelly.shellyplus2-relay.description</description>
<channel-groups>
- <channel-group id="relay1" typeId="relayChannel"/>
- <channel-group id="meter1" typeId="meter"/>
- <channel-group id="relay2" typeId="relayChannel"/>
- <channel-group id="meter2" typeId="meter"/>
+ <channel-group id="relay1" typeId="relayChannel">
+ <label>@text/channel-group-type.shelly.relayChannel1.label</label>
+ </channel-group>
+ <channel-group id="meter1" typeId="meter">
+ <label>@text/channel-group-type.shelly.meter1.label</label>
+ </channel-group>
+ <channel-group id="relay2" typeId="relayChannel">
+ <label>@text/channel-group-type.shelly.relayChannel2.label</label>
+ </channel-group>
+ <channel-group id="meter2" typeId="meter">
+ <label>@text/channel-group-type.shelly.meter1.label</label>
+ </channel-group>
<channel-group id="device" typeId="deviceStatus"/>
</channel-groups>