[ring] Add support for snapshots from camera devices (#20152)

* Initial framework to get Snapshot images

Signed-off-by: Paul Smedley <paul@smedley.id.au>
This commit is contained in:
Paul Smedley
2026-02-04 20:59:43 +01:00
committed by GitHub
parent 3a64fe3208
commit 9e234d69cc
14 changed files with 277 additions and 26 deletions
+17 -9
View File
@@ -55,9 +55,13 @@ If hardware ID is not specified, the MAC address of the system running OpenHAB i
### Device Status (Video Doorbell Binding Thing, Stickup Cam Binding Thing, Other Binding Thing only):
| Channel Type ID | Item Type | Description |
|------------------|-----------|---------------------|
| battery | Number | Battery level in % |
| Channel Type ID | Item Type | Description |
|--------------------|-----------|-------------------------|
| battery | Number | Battery level in % |
| snapshot | Image | Image Snapshot * |
| snapshot-timestamp | DateTime | Timestamp of Snapshot * |
*) Video Doorbell and Stickup Cam only
## Full Example
@@ -87,14 +91,18 @@ String RingEventKind "Ring Event Kind" { ch
String RingEventDeviceID "Ring Device ID" { channel="ring:account:ringAccount:event#doorbotId" }
String RingEventDeviceDescription "Ring Device Description" { channel="ring:account:ringAccount:event#doorbotDescription" }
Switch RingDoorbellEnabled "Ring Doorbell Polling Enabled" { channel="ring:doorbell:<ring_device_id>:control#enabled" }
Number RingDoorbellBattery "Ring Doorbell Battery [%s]%" { channel="ring:doorbell:<ring_device_id>:status#battery"}
Switch RingDoorbellEnabled "Ring Doorbell Polling Enabled" { channel="ring:doorbell:<ring_device_id>:control#enabled" }
Number RingDoorbellBattery "Ring Doorbell Battery [%s]%" { channel="ring:doorbell:<ring_device_id>:status#battery"}
Image RingDoorbellSnapshot "Ring Doorbell Snapshot" { channel="ring:doorbell:<ring_device_id>:status#snapshot"}
DateTime RingDoorbellSnapshotTimeStamp "Ring Doorbell Snapshot Timestamp" { channel="ring:doorbell:<ring_device_id>:status#snapshot-timestamp"}
Switch RingChimeEnabled "Ring Chime Polling Enabled" { channel="ring:chime:<ring_device_id>:control#enabled" }
Switch RingStickupEnabled "Ring Stickup Polling Enabled" { channel="ring:stickupcam:<ring_device_id>:control#enabled" }
Number RingStickupBattery "Ring Stickup Battery [%s]%" { channel="ring:stickupcam:<ring_device_id>:status#battery"}
Switch RingStickupEnabled "Ring Stickup Polling Enabled" { channel="ring:stickupcam:<ring_device_id>:control#enabled" }
Number RingStickupBattery "Ring Stickup Battery [%s]%" { channel="ring:stickupcam:<ring_device_id>:status#battery"}
Image RingStickupSnapshot "Ring Stickup Snapshot" { channel="ring:stickupcam:<ring_device_id>:status#snapshot"}
DateTime RingStickupSnapshotTimeStamp "Ring Stickup Snapshot Timestamp" { channel="ring:stickupcam:<ring_device_id>:status#snapshot-timestamp"}
Switch RingOtherEnabled "Ring Other Polling Enabled" { channel="ring:other:<ring_device_id>:control#enabled" }
Number RingOtherBattery "Ring Other Battery [%s]%" { channel="ring:other:<ring_device_id>:status#battery"}
Switch RingOtherEnabled "Ring Other Polling Enabled" { channel="ring:other:<ring_device_id>:control#enabled" }
Number RingOtherBattery "Ring Other Battery [%s]%" { channel="ring:other:<ring_device_id>:status#battery"}
```
@@ -42,6 +42,9 @@ public class RingBindingConstants {
public static final String CHANNEL_STATUS_BATTERY = "status#battery";
public static final String CHANNEL_STATUS_SNAPSHOT = "status#snapshot";
public static final String CHANNEL_STATUS_SNAPSHOT_TIMESTAMP = "status#snapshot-timestamp";
public static final String CHANNEL_EVENT_URL = "event#url";
public static final String CHANNEL_EVENT_CREATED_AT = "event#createdAt";
public static final String CHANNEL_EVENT_KIND = "event#kind";
@@ -34,6 +34,8 @@ public class ApiConstants {
public static final String URL_RECORDING_END = "/share/play?disable_redirect=true";
public static final String URL_DOORBELLS = API_BASE + "/clients_api/doorbots";
public static final String URL_CHIMES = API_BASE + "/clients_api/chimes";
public static final String URL_SNAPSHOT_TIMESTAMPS = API_BASE + "/clients_api/snapshots/timestamps";
public static final String URL_SNAPSHOTS = API_BASE + "/clients_api/snapshots/image/";
public static final String URL_RECORDING = "/clients_api/dings/{0}/recording";
@@ -46,6 +46,7 @@ import org.openhab.binding.ring.internal.api.ProfileTO;
import org.openhab.binding.ring.internal.api.RingDevicesTO;
import org.openhab.binding.ring.internal.api.RingEventTO;
import org.openhab.binding.ring.internal.api.SessionTO;
import org.openhab.binding.ring.internal.api.SessionTimestampTO;
import org.openhab.binding.ring.internal.api.TokenTO;
import org.openhab.binding.ring.internal.data.ParamBuilder;
import org.openhab.binding.ring.internal.data.Tokens;
@@ -237,7 +238,48 @@ public class RestClient {
}
/**
* Get get the Ring devices
* Get the timestamp of the last camera snapshot
*
* @param id the device id of the Ring cameras
* @return a long of the timestamp of the last snapsnot
* @throws AuthenticationException when request is invalid.
*/
public long getSnapshotTimestamp(String deviceId, Tokens tokens) throws AuthenticationException {
String input = "{\"doorbot_ids\":[" + deviceId + "]}";
String jsonResult = postRequest(ApiConstants.URL_SNAPSHOT_TIMESTAMPS, input, Map.of(), tokens);
SessionTimestampTO sessionTimestamp = Objects
.requireNonNull(gson.fromJson(jsonResult, SessionTimestampTO.class));
if (sessionTimestamp.data.length > 0) {
return sessionTimestamp.data[0].timestamp;
} else {
return -1;
}
}
/**
* Get the image from the camera
*
* @param id the device id of the Ring cameras
* @return a byte array of the camera image
* @throws AuthenticationException when request is invalid.
*/
public byte[] getSnapshot(String deviceId, Tokens tokens) throws AuthenticationException {
try {
ContentResponse response = httpClient.newRequest(ApiConstants.URL_SNAPSHOTS + deviceId)
.header(HttpHeader.AUTHORIZATION.asString(), "Bearer " + tokens.accessToken()).send();
if (response.getStatus() == 200) {
return response.getContent();
} else {
throw new AuthenticationException("Failed to download snapshot: " + response.getStatus());
}
} catch (ExecutionException | InterruptedException | TimeoutException e) {
throw new AuthenticationException("Failed to download snapshot.");
}
}
/**
* Get the Ring devices
*
* @param tokens the tokens previously retrieved when authenticating.
* @return the RingDevices instance filled with all available data.
@@ -31,4 +31,22 @@ public interface RingAccount {
@Nullable
RingDevice getDevice(String id);
/**
* Get the timestamp of the last camera snapshot
*
* @param id the device id of the Ring cameras
* @return a long of the timestamp of the last snapsnot
* @throws AuthenticationException when request is invalid.
*/
long getSnapshotTimestamp(String id);
/**
* Get the image from the camera
*
* @param id the device id of the Ring cameras
* @return a byte array of the camera image
* @throws AuthenticationException when request is invalid.
*/
byte[] getSnapshot(String id);
}
@@ -24,6 +24,7 @@ import org.openhab.binding.ring.internal.handler.ChimeHandler;
import org.openhab.binding.ring.internal.handler.DoorbellHandler;
import org.openhab.binding.ring.internal.handler.OtherDeviceHandler;
import org.openhab.binding.ring.internal.handler.StickupcamHandler;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
@@ -62,6 +63,7 @@ public class RingHandlerFactory extends BaseThingHandlerFactory {
private final HttpClient httpClient;
private final RingVideoServlet servlet;
private TimeZoneProvider timeZoneProvider;
private int httpPort;
public final Gson gson = new Gson();
@@ -69,7 +71,7 @@ public class RingHandlerFactory extends BaseThingHandlerFactory {
@Activate
public RingHandlerFactory(@Reference NetworkAddressService networkAddressService,
@Reference RingVideoServlet servlet, @Reference HttpClientFactory httpClientFactory,
ComponentContext componentContext) throws Exception {
@Reference TimeZoneProvider timeZoneProvider, ComponentContext componentContext) throws Exception {
super.activate(componentContext);
httpPort = HttpServiceUtil.getHttpServicePort(componentContext.getBundleContext());
if (httpPort == -1) {
@@ -77,6 +79,7 @@ public class RingHandlerFactory extends BaseThingHandlerFactory {
}
this.servlet = servlet;
this.networkAddressService = networkAddressService;
this.timeZoneProvider = timeZoneProvider;
logger.debug("Using OH HTTP port {}", httpPort);
@@ -107,11 +110,11 @@ public class RingHandlerFactory extends BaseThingHandlerFactory {
return null;
}
} else if (thingTypeUID.equals(THING_TYPE_DOORBELL)) {
return new DoorbellHandler(thing);
return new DoorbellHandler(thing, timeZoneProvider);
} else if (thingTypeUID.equals(THING_TYPE_CHIME)) {
return new ChimeHandler(thing);
} else if (thingTypeUID.equals(THING_TYPE_STICKUPCAM)) {
return new StickupcamHandler(thing);
return new StickupcamHandler(thing, timeZoneProvider);
} else if (thingTypeUID.equals(THING_TYPE_OTHERDEVICE)) {
return new OtherDeviceHandler(thing);
}
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2010-2026 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.ring.internal.api;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* @author Paul Smedley - Initial contribution
*/
@NonNullByDefault
public class SessionTimestampTO {
@SerializedName("timestamps")
public @NonNullByDefault({}) Data[] data;
public static class Data {
public int doorbot_id;
public long timestamp;
}
}
@@ -281,9 +281,8 @@ public class AccountHandler extends BaseBridgeHandler implements RingAccount {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, ex.getMessage());
}
} catch (JsonParseException e) {
logger.debug("Invalid response from api.ring.com when initializing Ring Account handler{}", e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Invalid response from api.ring.com");
"@text/offline.comm-error.invalid-response");
}
logger.debug("doLogin RT: {}", getRefreshTokenFromFile());
try {
@@ -291,12 +290,10 @@ public class AccountHandler extends BaseBridgeHandler implements RingAccount {
updateStatus(ThingStatus.ONLINE);
} catch (AuthenticationException ae) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"AuthenticationException response from ring.com");
logger.debug("RestClient reported AuthenticationException in finally block: {}", ae.getMessage());
"@text/offline.comm-error.auth-exception");
} catch (JsonParseException pe1) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"JsonParseException response from ring.com");
logger.debug("RestClient reported JsonParseException in finally block: {}", pe1.getMessage());
}
}
@@ -549,7 +546,7 @@ public class AccountHandler extends BaseBridgeHandler implements RingAccount {
stopSessionRefresh();
stopAutomaticRefresh();
ExecutorService service = this.videoExecutorService;
if (service != null) {
if (!service.isShutdown()) {
service.shutdownNow();
}
this.videoExecutorService = null;
@@ -567,6 +564,28 @@ public class AccountHandler extends BaseBridgeHandler implements RingAccount {
return registry.getRingDevice(id);
}
@Override
public long getSnapshotTimestamp(String id) {
try {
return restClient.getSnapshotTimestamp(id, tokens);
} catch (AuthenticationException ae) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.comm-error.invalid-response");
return -1;
}
}
@Override
public byte[] getSnapshot(String id) {
try {
return restClient.getSnapshot(id, tokens);
} catch (AuthenticationException ae) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"@text/offline.comm-error.auth-exception");
}
return new byte[0];
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(RingDiscoveryService.class);
@@ -13,11 +13,18 @@
package org.openhab.binding.ring.internal.handler;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_BATTERY;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_SNAPSHOT;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_SNAPSHOT_TIMESTAMP;
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.api.RingDeviceTO;
import org.openhab.binding.ring.internal.device.Doorbell;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.Command;
@@ -33,9 +40,12 @@ import org.openhab.core.types.Command;
@NonNullByDefault
public class DoorbellHandler extends RingDeviceHandler {
private int lastBattery = -1;
private long lastSnapshotTimestamp = -1;
private TimeZoneProvider timeZoneProvider;
public DoorbellHandler(Thing thing) {
public DoorbellHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
super(thing);
this.timeZoneProvider = timeZoneProvider;
}
@Override
@@ -70,5 +80,16 @@ public class DoorbellHandler extends RingDeviceHandler {
logger.debug("Battery Level Unchanged for {} - {} vs {}", getThing().getUID().getId(),
deviceTO.health.batteryPercentage, lastBattery);
}
long timestamp = getSnapshotTimestamp();
if (timestamp > lastSnapshotTimestamp) {
logger.debug("timestamp = {} != lastSnapshotTimestamp {}, update snapshot channel", timestamp,
lastSnapshotTimestamp);
lastSnapshotTimestamp = timestamp;
ChannelUID channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_SNAPSHOT);
updateState(channelUID, new RawType(getSnapshot(), "image/jpeg"));
channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_SNAPSHOT_TIMESTAMP);
updateState(channelUID, new DateTimeType(ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestamp),
timeZoneProvider.getTimeZone())));
}
}
}
@@ -31,7 +31,6 @@ import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BridgeHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
@@ -79,16 +78,32 @@ public abstract class RingDeviceHandler extends AbstractRingHandler {
}
protected @Nullable RingDevice getDevice() {
Bridge bridge = getBridge();
if (bridge != null) {
BridgeHandler bridgeHandler = bridge.getHandler();
if (bridgeHandler instanceof RingAccount ringAccount) {
if (getBridge() instanceof Bridge bridge) {
if (bridge.getHandler() instanceof RingAccount ringAccount) {
return ringAccount.getDevice(config.id);
}
}
return null;
}
protected long getSnapshotTimestamp() {
if (getBridge() instanceof Bridge bridge) {
if (bridge.getHandler() instanceof RingAccount ringAccount) {
return ringAccount.getSnapshotTimestamp(config.id);
}
}
return -1;
}
protected byte[] getSnapshot() {
if (getBridge() instanceof Bridge bridge) {
if (bridge.getHandler() instanceof RingAccount ringAccount) {
return ringAccount.getSnapshot(config.id);
}
}
return new byte[0];
}
/**
* Link the device, and update the device with the status CONFIGURED.
*
@@ -13,11 +13,18 @@
package org.openhab.binding.ring.internal.handler;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_BATTERY;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_SNAPSHOT;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_SNAPSHOT_TIMESTAMP;
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.api.RingDeviceTO;
import org.openhab.binding.ring.internal.device.Stickupcam;
import org.openhab.core.i18n.TimeZoneProvider;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.RawType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.Command;
@@ -33,9 +40,12 @@ import org.openhab.core.types.Command;
@NonNullByDefault
public class StickupcamHandler extends RingDeviceHandler {
private int lastBattery = -1;
private long lastSnapshotTimestamp = -1;
private TimeZoneProvider timeZoneProvider;
public StickupcamHandler(Thing thing) {
public StickupcamHandler(Thing thing, TimeZoneProvider timeZoneProvider) {
super(thing);
this.timeZoneProvider = timeZoneProvider;
}
@Override
@@ -71,5 +81,17 @@ public class StickupcamHandler extends RingDeviceHandler {
deviceTO.health.batteryPercentage, lastBattery);
}
long timestamp = getSnapshotTimestamp();
if (timestamp > lastSnapshotTimestamp) {
logger.debug("timestamp = {} != lastSnapshotTimestamp {}, update snapshot channel", timestamp,
lastSnapshotTimestamp);
lastSnapshotTimestamp = timestamp;
ChannelUID channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_SNAPSHOT);
updateState(channelUID, new RawType(getSnapshot(), "image/jpeg"));
channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_SNAPSHOT_TIMESTAMP);
updateState(channelUID, new DateTimeType(ZonedDateTime.ofInstant(java.time.Instant.ofEpochMilli(timestamp),
timeZoneProvider.getTimeZone())));
}
}
}
@@ -83,5 +83,15 @@ channel-type.ring.enabled.label = Enable Polling
channel-type.ring.enabled.description = Account Polling Enabled (on=yes, off=no)
channel-type.ring.kind.label = Event Type
channel-type.ring.kind.description = The kind of event, usually 'motion' or 'ding'
channel-type.ring.snapshot-timestamp.label = Snapshot DateTime
channel-type.ring.snapshot-timestamp.description = The date and time the snapshot was created
channel-type.ring.snapshot-timestamp.state.pattern = %1$tF %1$tR
channel-type.ring.snapshot.label = Snapshot
channel-type.ring.snapshot.description = Snapshot image from camera
channel-type.ring.url.label = URL to recorded video
channel-type.ring.url.description = The URL to a recorded video (only when subscribed)
# thing status descriptions
offline.comm-error.invalid-response = Invalid response from api.ring.com
offline.comm-error.auth-exception = Authentication issue from api.ring.com
@@ -126,6 +126,9 @@
<channel-group id="control" typeId="controlGroup"/>
<channel-group id="status" typeId="deviceStatus"/>
</channel-groups>
<properties>
<property name="thingTypeVersion">1</property>
</properties>
<representation-property>id</representation-property>
<config-description>
<parameter name="id" type="text">
@@ -155,6 +158,9 @@
<channel-group id="control" typeId="controlGroup"/>
<channel-group id="status" typeId="deviceStatus"/>
</channel-groups>
<properties>
<property name="thingTypeVersion">1</property>
</properties>
<representation-property>id</representation-property>
<config-description>
<parameter name="id" type="text">
@@ -188,6 +194,8 @@
<description>Device Status Information</description>
<channels>
<channel id="battery" typeId="battery"/>
<channel id="snapshot" typeId="snapshot"/>
<channel id="snapshot-timestamp" typeId="snapshot-timestamp"/>
</channels>
</channel-group-type>
@@ -269,4 +277,24 @@
</tags>
<state readOnly="true"></state>
</channel-type>
<channel-type id="snapshot">
<item-type>Image</item-type>
<label>Snapshot</label>
<description>Snapshot image from camera</description>
<tags>
<tag>Status</tag>
<tag>Info</tag>
</tags>
<state readOnly="true"></state>
</channel-type>
<channel-type id="snapshot-timestamp">
<item-type>DateTime</item-type>
<label>Snapshot DateTime</label>
<description>The date and time the snapshot was created</description>
<tags>
<tag>Status</tag>
<tag>Timestamp</tag>
</tags>
<state pattern="%1$tF %1$tR" readOnly="true"/>
</channel-type>
</thing:thing-descriptions>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
<thing-type uid="ring:stickupcam">
<instruction-set targetVersion="1">
<add-channel id="snapshot" groupIds="status">
<type>ring:snapshot</type>
</add-channel>
<add-channel id="snapshot-timestamp" groupIds="status">
<type>ring:snapshot-timestamp</type>
</add-channel>
</instruction-set>
</thing-type>
<thing-type uid="ring:doorbell">
<instruction-set targetVersion="1">
<add-channel id="snapshot" groupIds="status">
<type>ring:snapshot</type>
</add-channel>
<add-channel id="snapshot-timestamp" groupIds="status">
<type>ring:snapshot-timestamp</type>
</add-channel>
</instruction-set>
</thing-type>
</update:update-descriptions>