[ring] Initial Contribution (#18668)

* Initial contribution

Co-authored-by: Ben Rosenblum <rosenblumb@gmail.com>
Co-authored-by: morph166955 <53797132+morph166955@users.noreply.github.com>
Signed-off-by: Paul Smedley <paul@smedley.id.au>
pull/18768/head
Paul Smedley 2025-06-07 17:30:57 +09:30 committed by GitHub
parent 8eb0e0f913
commit b5c1b3a9d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 4548 additions and 0 deletions

View File

@ -329,6 +329,7 @@
/bundles/org.openhab.binding.resol/ @ramack
/bundles/org.openhab.binding.revogi/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.rfxcom/ @martinvw @paulianttila
/bundles/org.openhab.binding.ring/ @morph166955
/bundles/org.openhab.binding.rme/ @kgoderis
/bundles/org.openhab.binding.robonect/ @reyem
/bundles/org.openhab.binding.roku/ @mlobstein

View File

@ -1621,6 +1621,11 @@
<artifactId>org.openhab.binding.rfxcom</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.ring</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.rme</artifactId>

View File

@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@ -0,0 +1,98 @@
# Ring Binding
This is an experimental binding to the Ring.com API.
It currently supports a Ring account and is able to discover Ring Video Doorbells, Stick Up Cameras, Chimes, and other devices.
They need to be registered in the Ring account before they will be detected.
It currently does *not* support live video streaming, but you can view recorded videos, if this service is enabled in the Ring account.
## Supported Things
The binding currently supports Ring Video Doorbell, Stick Up Cameras, Chimes, and others.
*Other* is identified as any of the non-traditional types such as the intercom.
## Discovery
Auto-discovery is supported by this binding.
After (manually) adding a Ring Account bridge, registered doorbells and chimes will be auto discovered.
## Account Configuration
Account configuration is necessary.
The easiest way to do this is from the UI.
Just add a new thing, select the Ring binding, then Ring Account Binding Thing, and enter username and password.
Optionally, you can also specify a unique hardware ID and refresh interval for how often to check ring.com for events.
If hardware ID is not specified, the MAC address of the system running OpenHAB is used.
| Parameter | Description | Default |
|---------------------|---------------------------------------------------------------------|-------------|
| username | The user name you use to subscribe to the Ring services. | N/A |
| password | The password you use to subscribe to the Ring services. | N/A |
| twofactorCode | 2 factor authentication code (Where enabled) | N/A |
| hardwareId | A unique hardware id | N/A |
| refreshInterval | Refresh interval | 5 |
| videoStoragePath | Video Download Path | N/A |
| videoRetentionCount | Number of videos to keep | 10 |
## Channels
### Control group (all things):
| Channel Type ID | Item Type | Description |
|-----------------|-----------|---------------------------------------|
| enabled | Switch | Enable polling of this device/account |
### Events group (Ring Account Binding Thing only):
| Channel Type ID | Item Type | Description |
|--------------------|-----------|----------------------------------------------------------------------------------------------|
| url | String | The URL to a recorded video (only when subscribed on ring.com) |
| createdAt | DateTime | The date and time the event was created |
| kind | String | The kind of event, usually 'motion' or 'ding' |
| doorbotId | String | The internal id of the doorbot that generated the currently selected event |
| doorbotDescription | String | The description of the doorbot that generated the currently selected event (e.g. Front Door) |
### 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 % |
## Full Example
NOTE 1: Replace <ring_device_id> with a valid ring device ID when manually configuring.
The easiest way to currently get that is to define the account bridge and pull the device ID from the last event channel.
NOTE 2: Text configuration for the Things ONLY works if you DO NOT have 2 factor authentication enabled.
If you are using 2 factor authentication, Things MUST be set up through Main UI.
ring.things:
```java
ring:account:ringAccount "Ring Account" [ username="user@domain.com", password="XXXXXXX", hardwareId="AA-BB-CC-DD-EE-FF", refreshInterval=5 ]
ring:doorbell:<ring_device_id> "Ring Doorbell" [ refreshInterval=5, offOffset=0 ]
ring:chime:<ring_device_id> "Ring Chime" [ refreshInterval=5, offOffset=0 ]
ring:stickupcam:<ring_device_id> "Ring Stickup Camera" [ refreshInterval=5, offOffset=0 ]
ring:other:<ring_device_id> "Ring Other Device" [ refreshInterval=5, offOffset=0 ]
```
ring.items:
```java
Switch RingAccountEnabled "Ring Account Polling Enabled" { channel="ring:account:ringAccount:control#enabled" }
String RingEventVideoURL "Ring Event URL" { channel="ring:account:ringAccount:event#url" }
DateTime RingEventCreated "Ring Event Created" { channel="ring:account:ringAccount:event#createdAt" }
String RingEventKind "Ring Event Kind" { channel="ring:account:ringAccount:event#kind" }
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 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 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"}
```

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.ring</artifactId>
<name>openHAB Add-ons :: Bundles :: Ring Binding</name>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.ring-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-ring" description="Ring Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.ring/${project.version}</bundle>
</feature>
</features>

View File

@ -0,0 +1,55 @@
/*
* Copyright (c) 2010-2025 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;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link RingBinding} class defines common constants, which are
* used across the whole binding.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class RingBindingConstants {
public static final String BINDING_ID = "ring";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_ACCOUNT = new ThingTypeUID(BINDING_ID, "account");
public static final ThingTypeUID THING_TYPE_DOORBELL = new ThingTypeUID(BINDING_ID, "doorbell");
public static final ThingTypeUID THING_TYPE_CHIME = new ThingTypeUID(BINDING_ID, "chime");
public static final ThingTypeUID THING_TYPE_STICKUPCAM = new ThingTypeUID(BINDING_ID, "stickupcam");
public static final ThingTypeUID THING_TYPE_OTHERDEVICE = new ThingTypeUID(BINDING_ID, "otherdevice");
// List of all Channel ids
public static final String CHANNEL_CONTROL_STATUS = "control#status";
public static final String CHANNEL_CONTROL_ENABLED = "control#enabled";
public static final String CHANNEL_STATUS_BATTERY = "status#battery";
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";
public static final String CHANNEL_EVENT_DOORBOT_ID = "event#doorbotId";
public static final String CHANNEL_EVENT_DOORBOT_DESCRIPTION = "event#doorbotDescription";
public static final String SERVLET_VIDEO_PATH = "/ring/video";
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_DOORBELL,
THING_TYPE_CHIME, THING_TYPE_STICKUPCAM, THING_TYPE_OTHERDEVICE);
}

View File

@ -0,0 +1,118 @@
/*
* Copyright (c) 2010-2025 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.handler;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link AbstractRingHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public abstract class AbstractRingHandler extends BaseThingHandler {
public Gson gson;
// Current status
protected OnOffType status = OnOffType.OFF;
protected OnOffType enabled = OnOffType.ON;
protected final Logger logger = LoggerFactory.getLogger(AbstractRingHandler.class);
// Scheduler
protected @Nullable ScheduledFuture<?> refreshJob;
protected AbstractRingHandler(Thing thing, Gson gson) {
super(thing);
this.gson = gson;
}
@Override
public void initialize() {
logger.debug("Initializing AbstractRingHandler");
}
/**
* Refresh the state of channels that may have changed by (re-)initialization.
*/
protected abstract void refreshState();
/**
* Called every minute
*/
protected abstract void minuteTick();
private void refresh() {
try {
minuteTick();
} catch (final Exception e) {
logger.debug("AbstractHandler - Exception occurred during execution of startAutomaticRefresh(): {}",
e.getMessage(), e);
}
}
/**
* Check every 60 seconds if one of the alarm times is reached.
*/
protected void startAutomaticRefresh(final int refreshInterval) {
refreshJob = scheduler.scheduleWithFixedDelay(this::refresh, 0, refreshInterval, TimeUnit.SECONDS);
refreshState();
}
protected void stopAutomaticRefresh() {
ScheduledFuture<?> job = refreshJob;
if (job != null) {
job.cancel(true);
}
refreshJob = null;
}
/**
* Dispose off the refreshJob nicely.
*/
@Override
public void dispose() {
stopAutomaticRefresh();
}
@Override
public void handleRemoval() {
updateStatus(ThingStatus.OFFLINE);
final String id = getThing().getUID().getId();
final RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
try {
registry.removeRingDevice(id);
} catch (final DeviceNotFoundException e) {
logger.debug("Exception occurred during execution of handleRemoval(): {}", e.getMessage(), e);
} finally {
updateStatus(ThingStatus.REMOVED);
}
}
}

View File

@ -0,0 +1,31 @@
/*
* Copyright (c) 2010-2025 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.handler;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link AccountConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Ben Rosenblum - Initial contribution
*/
@NonNullByDefault
public class AccountConfiguration {
public String username = "";
public String password = "";
public String hardwareId = "";
public String twofactorCode = "";
public int videoRetentionCount;
public String videoStoragePath = "";
public int refreshInterval;
}

View File

@ -0,0 +1,610 @@
/*
* Copyright (c) 2010-2025 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.handler;
import static org.openhab.binding.ring.RingBindingConstants.*;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.RestClient;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.RingVideoServlet;
import org.openhab.binding.ring.internal.data.Profile;
import org.openhab.binding.ring.internal.data.RingDevices;
import org.openhab.binding.ring.internal.data.RingEventTO;
import org.openhab.binding.ring.internal.errors.AuthenticationException;
import org.openhab.binding.ring.internal.errors.DuplicateIdException;
import org.openhab.binding.ring.internal.utils.RingUtils;
import org.openhab.core.OpenHAB;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.osgi.service.http.HttpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonParseException;
/**
* The {@link AccountHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Wim Vissers - Initial contribution
* @author Peter Mietlowski - oAuth upgrade and additional maintenance
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class AccountHandler extends BaseBridgeHandler implements RingAccount {
private @Nullable ScheduledFuture<?> jobTokenRefresh = null;
private @Nullable ScheduledFuture<?> eventRefresh = null;
private @Nullable Runnable runnableVideo = null;
private @Nullable RingVideoServlet ringVideoServlet;
private final HttpService httpService;
private final String thingId;
// Current status
protected OnOffType status = OnOffType.OFF;
protected OnOffType enabled = OnOffType.ON;
protected final Logger logger = LoggerFactory.getLogger(AccountHandler.class);
// Scheduler
protected @Nullable ScheduledFuture<?> refreshJob;
/**
* The user profile retrieved when authenticating.
*/
private Profile userProfile = new Profile();
/**
* The registry.
*/
private final RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
/**
* The RestClient is used to connect to the Ring Account.
*/
private RestClient restClient = new RestClient();
/**
* The list with events.
*/
private List<RingEventTO> lastEvents = List.of();
/**
* The index to the current event.
*/
private int eventIndex = 0;
private @Nullable ExecutorService videoExecutorService;
/*
* The number of video files to keep when auto-downloading
*/
private int videoRetentionCount;
/*
* The path of where to save video files
*/
private String videoStoragePath = "";
private final NetworkAddressService networkAddressService;
private final int httpPort;
public AccountHandler(Bridge bridge, NetworkAddressService networkAddressService, HttpService httpService,
int httpPort) {
super(bridge);
this.httpPort = httpPort;
this.networkAddressService = networkAddressService;
this.httpService = httpService;
this.videoExecutorService = Executors.newCachedThreadPool();
this.thingId = this.getThing().getUID().getId();
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof RefreshType) {
boolean eventListOk = lastEvents.size() > eventIndex;
switch (channelUID.getId()) {
case CHANNEL_EVENT_URL:
if (eventListOk) {
String videoFile = restClient.downloadEventVideo(lastEvents.get(eventIndex), userProfile,
videoStoragePath, videoRetentionCount);
String localIP = networkAddressService.getPrimaryIpv4HostAddress();
if (videoFile.endsWith(".mp4")) {
updateState(channelUID,
new StringType("http://" + localIP + ":" + httpPort + "/ring/video/" + videoFile));
} else {
updateState(channelUID, new StringType(videoFile));
}
}
break;
case CHANNEL_EVENT_CREATED_AT:
if (eventListOk) {
updateState(channelUID, lastEvents.get(eventIndex).getCreatedAt());
}
break;
case CHANNEL_EVENT_KIND:
if (eventListOk) {
updateState(channelUID, new StringType(lastEvents.get(eventIndex).kind));
}
break;
case CHANNEL_EVENT_DOORBOT_ID:
if (eventListOk) {
updateState(channelUID, new StringType(lastEvents.get(eventIndex).doorbot.id));
}
break;
case CHANNEL_EVENT_DOORBOT_DESCRIPTION:
if (eventListOk) {
updateState(channelUID, new StringType(lastEvents.get(eventIndex).doorbot.description));
}
break;
case CHANNEL_CONTROL_ENABLED:
updateState(channelUID, enabled);
break;
default:
logger.debug("Command received for an unknown channel: {}", channelUID.getId());
break;
}
} else if (command instanceof OnOffType xcommand) {
switch (channelUID.getId()) {
case CHANNEL_CONTROL_ENABLED:
if (!enabled.equals(xcommand)) {
enabled = xcommand;
updateState(channelUID, enabled);
if (enabled.equals(OnOffType.ON)) {
Configuration config = getThing().getConfiguration();
int refreshInterval = ConfigParser.valueAsOrElse(config.get("refreshInterval"),
BigDecimal.class, BigDecimal.valueOf(500)).intValue();
startAutomaticRefresh(refreshInterval);
} else {
stopAutomaticRefresh();
}
}
break;
default:
logger.debug("Command received for an unknown channel: {}", channelUID.getId());
break;
}
} else {
logger.debug("Command {} is not supported for channel: {}", command, channelUID.getId());
}
}
private void refresh() {
try {
minuteTick();
} catch (final Exception e) {
logger.debug("AbstractHandler - Exception occurred during execution of startAutomaticRefresh(): {}",
e.getMessage(), e);
}
}
protected void startAutomaticRefresh(final int refreshInterval) {
refreshJob = scheduler.scheduleWithFixedDelay(this::refresh, 0, refreshInterval, TimeUnit.SECONDS);
}
protected void stopAutomaticRefresh() {
ScheduledFuture<?> job = refreshJob;
if (job != null) {
job.cancel(true);
}
refreshJob = null;
}
private void saveRefreshTokenToFile(String refreshToken) {
String folderName = OpenHAB.getUserDataFolder() + "/ring";
String thingId = this.thingId;
File folder = new File(folderName);
String fileName = folderName + "/ring." + thingId + ".refreshToken";
if (!folder.exists()) {
logger.debug("Creating directory {}", folderName);
folder.mkdirs();
}
try {
Files.write(Paths.get(fileName), refreshToken.getBytes());
} catch (IOException ex) {
logger.debug("IOException when writing refreshToken to file {}", ex.getMessage());
}
logger.debug("saveRefreshTokenToFile Successful {}", RingUtils.sanitizeData(refreshToken));
}
private String getRefreshTokenFromFile() {
String refreshToken = "";
String folderName = OpenHAB.getUserDataFolder() + "/ring";
String thingId = this.thingId;
String fileName = folderName + "/ring." + thingId + ".refreshToken";
File file = new File(fileName);
if (!file.exists()) {
return refreshToken;
}
try {
final byte[] contents = Files.readAllBytes(Paths.get(fileName));
refreshToken = new String(contents);
} catch (IOException ex) {
logger.debug("IOException when reading refreshToken from file {}", ex.getMessage());
}
logger.debug("getRefreshTokenFromFile successful {}", RingUtils.sanitizeData(refreshToken));
return refreshToken;
}
public void doLogin(String username, String password, String twofactorCode) {
logger.debug("doLogin U:{} P:{} 2:{}", RingUtils.sanitizeData(username), RingUtils.sanitizeData(password),
RingUtils.sanitizeData(twofactorCode));
String hardwareId = getHardwareId();
String refreshToken = getRefreshTokenFromFile();
logger.debug("doLogin H:{} RT:{}", hardwareId, RingUtils.sanitizeData(refreshToken));
try {
userProfile = restClient.getAuthenticatedProfile(username, password, refreshToken, twofactorCode,
hardwareId);
saveRefreshTokenToFile(userProfile.getRefreshToken());
} catch (AuthenticationException ex) {
logger.debug("AuthenticationException when initializing Ring Account handler{}", ex.getMessage());
String message = ex.getMessage();
if ((message != null) && message.startsWith("Two factor")) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, ex.getMessage());
} else {
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");
}
logger.debug("doLogin RT: {}", getRefreshTokenFromFile());
try {
refreshRegistry();
updateStatus(ThingStatus.ONLINE);
} catch (DuplicateIdException dup) {
logger.debug("Ring device with duplicate id detected, ignoring device");
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());
} catch (JsonParseException pe1) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"JsonParseException response from ring.com");
logger.debug("RestClient reported JsonParseException in finally block: {}", pe1.getMessage());
}
}
public String getHardwareId() {
AccountConfiguration config = getConfigAs(AccountConfiguration.class);
String hardwareId = config.hardwareId;
logger.debug("getHardwareId H:{}", hardwareId);
Configuration updatedConfiguration = getThing().getConfiguration();
try {
if (hardwareId.isEmpty()) {
hardwareId = getLocalMAC();
if (("".equals(hardwareId)) || hardwareId.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Hardware ID missing, check thing config");
return hardwareId;
}
logger.debug("getHardwareId getLocalMac H:{}", hardwareId);
// write hardwareId to thing config
config.hardwareId = hardwareId;
updatedConfiguration.put("hardwareId", config.hardwareId);
updateConfiguration(updatedConfiguration);
}
} catch (IOException e) {
logger.debug("getHardwareId failed to get local mac address {}", e.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Initialization failed: " + e.getMessage());
}
return hardwareId;
}
@Override
public void initialize() {
logger.debug("Initializing Ring Account handler");
AccountConfiguration config = getConfigAs(AccountConfiguration.class);
int refreshInterval = config.refreshInterval;
String username = config.username;
String password = config.password;
String hardwareId = getHardwareId();
String refreshToken = getRefreshTokenFromFile();
String twofactorCode = config.twofactorCode;
videoRetentionCount = config.videoRetentionCount;
videoStoragePath = !config.videoStoragePath.isEmpty() ? config.videoStoragePath
: OpenHAB.getConfigFolder() + "/html/ring/video";
logger.debug("AccountHandler - initialize - VSP: {} OH: {}", config.videoStoragePath,
OpenHAB.getConfigFolder());
restClient = new RestClient();
if ((!refreshToken.isEmpty()) || !(username.isEmpty() && password.isEmpty())) {
try {
Configuration updatedConfiguration = getThing().getConfiguration();
logger.debug("Logging in with refresh token: {}", RingUtils.sanitizeData(refreshToken));
userProfile = restClient.getAuthenticatedProfile(username, password, refreshToken, twofactorCode,
hardwareId);
saveRefreshTokenToFile(userProfile.getRefreshToken());
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING, "Retrieving device list");
config.twofactorCode = "";
updatedConfiguration.put("twofactorCode", config.twofactorCode);
updateConfiguration(updatedConfiguration);
if (this.ringVideoServlet == null) {
this.ringVideoServlet = new RingVideoServlet(httpService, videoStoragePath);
}
// Note: When initialization can NOT be done set the status with more details for further
// analysis. See also class ThingStatusDetail for all available status details.
// Add a description to give user information to understand why thing does not work
// as expected. E.g.
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
// "Can not access device as username and/or password are invalid");
startAutomaticRefresh(refreshInterval);
startSessionRefresh(refreshInterval);
} catch (AuthenticationException ex) {
logger.debug("AuthenticationException when initializing Ring Account handler {}", ex.getMessage());
String message = ex.getMessage();
if ((message != null) && message.startsWith("Two factor")) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, ex.getMessage());
} else {
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");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
"Please login via CLI or by updating the Thing properties");
}
}
private void refreshRegistry() throws JsonParseException, AuthenticationException, DuplicateIdException {
logger.debug("AccountHandler - refreshRegistry");
RingDevices ringDevices = restClient.getRingDevices(userProfile, this);
registry.addRingDevices(ringDevices.getRingDevices());
}
protected void minuteTick() {
try {
// Init the devices
refreshRegistry();
updateStatus(ThingStatus.ONLINE);
} catch (AuthenticationException | JsonParseException e) {
logger.debug(
"AuthenticationException in AccountHandler.minuteTick() when trying refreshRegistry, attempting to reconnect {}",
e.getMessage());
AccountConfiguration config = getConfigAs(AccountConfiguration.class);
String username = config.username;
String password = config.password;
String hardwareId = getHardwareId();
String refreshToken = getRefreshTokenFromFile();
if ((!refreshToken.isEmpty()) || !(username.isEmpty() && password.isEmpty())) {
try {
userProfile = restClient.getAuthenticatedProfile(username, password, refreshToken, "", hardwareId);
updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, "Retrieving device list");
} catch (AuthenticationException ex) {
logger.debug("RestClient reported AuthenticationException trying getAuthenticatedProfile: {}",
ex.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Invalid credentials");
} catch (JsonParseException e1) {
logger.debug("RestClient reported JsonParseException trying getAuthenticatedProfile: {}",
e1.getMessage());
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Invalid response from api.ring.com");
} finally {
try {
refreshRegistry();
updateStatus(ThingStatus.ONLINE);
} catch (DuplicateIdException ignored) {
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());
} catch (JsonParseException pe1) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"JsonParseException response from ring.com");
logger.debug("RestClient reported JsonParseException in finally block: {}", pe1.getMessage());
}
}
}
} catch (DuplicateIdException ignored) {
updateStatus(ThingStatus.ONLINE);
}
}
protected void getVideo(RingEventTO event) {
logger.debug("AccountHandler - getVideo - Event id: {}", event.id);
logger.debug("AccountHandler - getVideo - VSP: {}", videoStoragePath);
String videoFile = restClient.downloadEventVideo(event, userProfile, videoStoragePath, videoRetentionCount);
String localIP = networkAddressService.getPrimaryIpv4HostAddress();
if (videoFile.endsWith(".mp4")) {
updateState(new ChannelUID(thing.getUID(), CHANNEL_EVENT_URL),
new StringType("http://" + localIP + ":" + httpPort + "/ring/video/" + videoFile));
} else {
updateState(new ChannelUID(thing.getUID(), CHANNEL_EVENT_URL), new StringType(videoFile));
}
}
protected void eventTick() {
try {
long id = lastEvents.isEmpty() ? 0 : lastEvents.get(0).id;
lastEvents = restClient.getHistory(userProfile, 1);
if (!lastEvents.isEmpty()) {
logger.debug("AccountHandler - eventTick - Event id: {} lastEvents: {}", id,
lastEvents.get(0).id == id);
if (lastEvents.get(0).id != id) {
logger.debug("AccountHandler - eventTick - New Event {}", lastEvents.get(0).id);
updateState(new ChannelUID(thing.getUID(), CHANNEL_EVENT_CREATED_AT),
lastEvents.get(0).getCreatedAt());
updateState(new ChannelUID(thing.getUID(), CHANNEL_EVENT_KIND),
new StringType(lastEvents.get(0).kind));
updateState(new ChannelUID(thing.getUID(), CHANNEL_EVENT_DOORBOT_ID),
new StringType(lastEvents.get(0).doorbot.id));
updateState(new ChannelUID(thing.getUID(), CHANNEL_EVENT_DOORBOT_DESCRIPTION),
new StringType(lastEvents.get(0).doorbot.description));
runnableVideo = () -> getVideo(lastEvents.get(0));
ExecutorService service = videoExecutorService;
if (service != null) {
service.submit(runnableVideo);
}
}
} else {
logger.debug("AccountHandler - eventTick - lastEvents null");
}
} catch (AuthenticationException ex) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"AuthenticationException response from ring.com");
logger.debug(
"RestClient reported AuthenticationExceptionfrom api.ring.com when retrying refreshRegistry for the second time: {}",
ex.getMessage());
} catch (JsonParseException ignored) {
logger.debug(
"RestClient reported JsonParseException api.ring.com when retrying refreshRegistry for the second time: {}",
ignored.getMessage());
}
}
private void refreshToken() {
try {
refreshRegistry();
Configuration config = getThing().getConfiguration();
String hardwareId = (String) config.get("hardwareId");
userProfile = restClient.getAuthenticatedProfile("", "", userProfile.getRefreshToken(), "", hardwareId);
} catch (AuthenticationException | DuplicateIdException e) {
logger.debug(
"AccountHandler - startSessionRefresh - Exception occurred during execution of refreshRegistry(): {}",
e.getMessage(), e);
}
}
private void refreshEvent() {
try {
eventTick();
} catch (final Exception e) {
logger.debug(
"AccountHandler - startSessionRefresh - Exception occurred during execution of eventTick(): {}",
e.getMessage(), e);
}
}
/**
* Refresh the profile every 20 minutes
*/
protected void startSessionRefresh(int refreshInterval) {
logger.debug("startSessionRefresh {}", refreshInterval);
jobTokenRefresh = scheduler.scheduleWithFixedDelay(this::refreshToken, 90, 600, TimeUnit.SECONDS);
eventRefresh = scheduler.scheduleWithFixedDelay(this::refreshEvent, refreshInterval, refreshInterval,
TimeUnit.SECONDS);
}
protected void stopSessionRefresh() {
ScheduledFuture<?> job = jobTokenRefresh;
if (job != null) {
job.cancel(true);
}
jobTokenRefresh = null;
job = eventRefresh;
if (job != null) {
job.cancel(true);
}
eventRefresh = null;
}
String getLocalMAC() throws IOException {
// get local ip from OH system settings
String localIP = networkAddressService.getPrimaryIpv4HostAddress();
if ((localIP == null) || (localIP.isBlank())) {
logger.debug("No local IP selected in openHAB system configuration");
return "";
}
// get MAC address
InetAddress ip = InetAddress.getByName(localIP);
NetworkInterface network = NetworkInterface.getByInetAddress(ip);
if (network != null) {
byte[] mac = network.getHardwareAddress();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < mac.length; i++) {
sb.append(String.format("%02X%s", mac[i], (i < mac.length - 1) ? "-" : ""));
}
String localMAC = sb.toString();
logger.debug("Local IP address='{}', local MAC address = '{}'", localIP, localMAC);
return localMAC;
}
return "";
}
@Override
public @Nullable RestClient getRestClient() {
return restClient;
}
@Override
public @Nullable Profile getProfile() {
return userProfile;
}
@Override
public String getThingId() {
return thingId;
}
/**
* Dispose of the refreshJob nicely.
*/
@Override
public void dispose() {
stopSessionRefresh();
stopAutomaticRefresh();
ExecutorService service = this.videoExecutorService;
if (service != null) {
service.shutdownNow();
}
this.videoExecutorService = null;
super.dispose();
}
}

View File

@ -0,0 +1,99 @@
/*
* Copyright (c) 2010-2025 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.handler;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.data.Chime;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.binding.ring.internal.errors.IllegalDeviceClassException;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.config.core.Configuration;
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.types.Command;
import com.google.gson.Gson;
/**
* The handler for a Ring Chime.
*
* @author Ben Rosenblum - Initial contribution
*
*/
@NonNullByDefault
public class ChimeHandler extends RingDeviceHandler {
public ChimeHandler(Thing thing, Gson gson) {
super(thing, gson);
}
@Override
public void initialize() {
logger.debug("Initializing Chime handler");
super.initialize();
RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
String id = getThing().getUID().getId();
if (registry.isInitialized()) {
try {
linkDevice(id, Chime.class);
updateStatus(ThingStatus.ONLINE);
} catch (DeviceNotFoundException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' not found");
} catch (IllegalDeviceClassException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' of wrong type");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
"Waiting for RingAccount to initialize");
}
// Note: When initialization can NOT be done set the status with more details for further
// analysis. See also class ThingStatusDetail for all available status details.
// Add a description to give user information to understand why thing does not work
// as expected. E.g.
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
// "Can not access device as username and/or password are invalid");
if (this.refreshJob == null) {
Configuration config = getThing().getConfiguration();
int refreshInterval = ConfigParser
.valueAsOrElse(config.get("refreshInterval"), BigDecimal.class, BigDecimal.valueOf(500)).intValue();
startAutomaticRefresh(refreshInterval);
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
// Do Nothing
}
@Override
protected void refreshState() {
// Do Nothing
}
@Override
protected void minuteTick() {
logger.debug("ChimeHandler - minuteTick - device {}", getThing().getUID().getId());
if (device == null) {
initialize();
}
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright (c) 2010-2025 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.handler;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_BATTERY;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.data.Doorbell;
import org.openhab.binding.ring.internal.data.RingDeviceTO;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.binding.ring.internal.errors.IllegalDeviceClassException;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.DecimalType;
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.types.Command;
import com.google.gson.Gson;
/**
* The handler for a Ring Video Doorbell.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*
*/
@NonNullByDefault
public class DoorbellHandler extends RingDeviceHandler {
private int lastBattery = -1;
public DoorbellHandler(Thing thing, Gson gson) {
super(thing, gson);
}
@Override
public void initialize() {
logger.debug("Initializing Doorbell handler");
super.initialize();
RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
String id = getThing().getUID().getId();
if (registry.isInitialized()) {
try {
linkDevice(id, Doorbell.class);
updateStatus(ThingStatus.ONLINE);
} catch (DeviceNotFoundException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' not found");
} catch (IllegalDeviceClassException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' of wrong type");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
"Waiting for RingAccount to initialize");
}
// Note: When initialization can NOT be done set the status with more details for further
// analysis. See also class ThingStatusDetail for all available status details.
// Add a description to give user information to understand why thing does not work
// as expected. E.g.
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
// "Can not access device as username and/or password are invalid");
if (this.refreshJob == null) {
Configuration config = getThing().getConfiguration();
int refreshInterval = ConfigParser
.valueAsOrElse(config.get("refreshInterval"), BigDecimal.class, BigDecimal.valueOf(500)).intValue();
startAutomaticRefresh(refreshInterval);
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
// Do Nothing
}
@Override
protected void refreshState() {
// Do Nothing
}
@Override
protected void minuteTick() {
logger.debug("DoorbellHandler - minuteTick - device {}", getThing().getUID().getId());
if (device == null) {
initialize();
}
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if ((deviceTO != null) && (deviceTO.health.batteryPercentage != lastBattery)) {
logger.debug("Battery Level: {}", deviceTO.health.batteryPercentage);
ChannelUID channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_BATTERY);
updateState(channelUID, new DecimalType(deviceTO.health.batteryPercentage));
lastBattery = deviceTO.health.batteryPercentage;
} else if (deviceTO != null) {
logger.debug("Battery Level Unchanged for {} - {} vs {}", getThing().getUID().getId(),
deviceTO.health.batteryPercentage, lastBattery);
}
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright (c) 2010-2025 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.handler;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_BATTERY;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.data.OtherDevice;
import org.openhab.binding.ring.internal.data.RingDeviceTO;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.binding.ring.internal.errors.IllegalDeviceClassException;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.DecimalType;
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.types.Command;
import com.google.gson.Gson;
/**
* The handler for a Ring Other Device.
*
* @author Ben Rosenblum - Initial Contribution
*
*/
@NonNullByDefault
public class OtherDeviceHandler extends RingDeviceHandler {
private int lastBattery = -1;
public OtherDeviceHandler(Thing thing, Gson gson) {
super(thing, gson);
}
@Override
public void initialize() {
logger.debug("Initializing Other Device handler");
super.initialize();
RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
String id = getThing().getUID().getId();
if (registry.isInitialized()) {
try {
linkDevice(id, OtherDevice.class);
updateStatus(ThingStatus.ONLINE);
} catch (DeviceNotFoundException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' not found");
} catch (IllegalDeviceClassException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' of wrong type");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
"Waiting for RingAccount to initialize");
}
// Note: When initialization can NOT be done set the status with more details for further
// analysis. See also class ThingStatusDetail for all available status details.
// Add a description to give user information to understand why thing does not work
// as expected. E.g.
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
// "Can not access device as username and/or password are invalid");
if (this.refreshJob == null) {
Configuration config = getThing().getConfiguration();
int refreshInterval = ConfigParser
.valueAsOrElse(config.get("refreshInterval"), BigDecimal.class, BigDecimal.valueOf(500)).intValue();
startAutomaticRefresh(refreshInterval);
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
// Do Nothing
}
@Override
protected void refreshState() {
// Do Nothing
}
@Override
protected void minuteTick() {
logger.debug("OtherDeviceHandler - minuteTick - device {}", getThing().getUID().getId());
if (device == null) {
initialize();
}
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if ((deviceTO != null) && (deviceTO.health.batteryPercentage != lastBattery)) {
logger.debug("Battery Level: {}", deviceTO.health.batteryPercentage);
ChannelUID channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_BATTERY);
updateState(channelUID, new DecimalType(deviceTO.health.batteryPercentage));
lastBattery = deviceTO.health.batteryPercentage;
} else if (deviceTO != null) {
logger.debug("Battery Level Unchanged for {} - {} vs {}", getThing().getUID().getId(),
deviceTO.health.batteryPercentage, lastBattery);
}
}
}

View File

@ -0,0 +1,129 @@
/*
* Copyright (c) 2010-2025 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.handler;
import static org.openhab.binding.ring.RingBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.data.RingDevice;
import org.openhab.binding.ring.internal.data.RingDeviceTO;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.binding.ring.internal.errors.IllegalDeviceClassException;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.IncreaseDecreaseType;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.UpDownType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import com.google.gson.Gson;
/**
* The {@link RingDeviceHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public abstract class RingDeviceHandler extends AbstractRingHandler {
/**
* The RingDevice instance linked to this thing.
*/
protected @Nullable RingDevice device;
public RingDeviceHandler(Thing thing, Gson gson) {
super(thing, gson);
}
/**
* Link the device, and update the device with the status CONFIGURED.
*
* @param id the device id
* @param deviceClass the expected class
* @throws DeviceNotFoundException when device is not found in the RingDeviceRegistry.
* @throws IllegalDeviceClassException when the registered device is of the wrong type.
*/
protected void linkDevice(String id, Class<?> deviceClass)
throws DeviceNotFoundException, IllegalDeviceClassException {
device = RingDeviceRegistry.getInstance().getRingDevice(id);
if (device != null) {
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if (deviceClass.equals(device.getClass())) {
device.setRegistrationStatus(RingDeviceRegistry.Status.CONFIGURED);
device.setRingDeviceHandler(this);
if (deviceTO != null) {
thing.setProperty("Description", deviceTO.description);
thing.setProperty("Kind", deviceTO.kind);
thing.setProperty("Device ID", deviceTO.deviceId);
}
} else {
throw new IllegalDeviceClassException("Class '" + deviceClass.getName() + "' expected but '"
+ device.getClass().getName() + "' found.");
}
}
}
/**
* Handle generic commands, common to all Ring Devices.
*/
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (command instanceof Number || command instanceof RefreshType || command instanceof IncreaseDecreaseType
|| command instanceof UpDownType) {
switch (channelUID.getId()) {
case CHANNEL_CONTROL_ENABLED:
updateState(channelUID, enabled);
break;
case CHANNEL_STATUS_BATTERY:
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if (deviceTO != null) {
updateState(channelUID, new DecimalType(deviceTO.health.batteryPercentage));
}
break;
default:
logger.debug("Command received for an unknown channel: {}", channelUID.getId());
break;
}
refreshState();
} else if (command instanceof OnOffType xcommand) {
switch (channelUID.getId()) {
case CHANNEL_CONTROL_ENABLED:
if (!enabled.equals(xcommand)) {
enabled = xcommand;
updateState(channelUID, enabled);
if (enabled.equals(OnOffType.ON)) {
Configuration config = getThing().getConfiguration();
int refreshInterval = (int) config.get("refreshInterval");
startAutomaticRefresh(refreshInterval);
} else {
stopAutomaticRefresh();
}
}
break;
default:
logger.debug("Command received for an unknown channel: {}", channelUID.getId());
break;
}
} else {
logger.debug("Command {} is not supported for channel: {}", command, channelUID.getId());
}
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright (c) 2010-2025 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.handler;
import static org.openhab.binding.ring.RingBindingConstants.CHANNEL_STATUS_BATTERY;
import java.math.BigDecimal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.data.RingDeviceTO;
import org.openhab.binding.ring.internal.data.Stickupcam;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.binding.ring.internal.errors.IllegalDeviceClassException;
import org.openhab.core.config.core.ConfigParser;
import org.openhab.core.config.core.Configuration;
import org.openhab.core.library.types.DecimalType;
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.types.Command;
import com.google.gson.Gson;
/**
* The handler for a Ring Video Stickup Cam.
*
* @author Chris Milbert - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*
*/
@NonNullByDefault
public class StickupcamHandler extends RingDeviceHandler {
private int lastBattery = -1;
public StickupcamHandler(Thing thing, Gson gson) {
super(thing, gson);
}
@Override
public void initialize() {
logger.debug("Initializing Stickupcam handler");
super.initialize();
RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
String id = getThing().getUID().getId();
if (registry.isInitialized()) {
try {
linkDevice(id, Stickupcam.class);
updateStatus(ThingStatus.ONLINE);
} catch (DeviceNotFoundException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' not found");
} catch (IllegalDeviceClassException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Device with id '" + id + "' of wrong type");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
"Waiting for RingAccount to initialize");
}
// Note: When initialization can NOT be done set the status with more details for further
// analysis. See also class ThingStatusDetail for all available status details.
// Add a description to give user information to understand why thing does not work
// as expected. E.g.
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
// "Can not access device as username and/or password are invalid");
if (this.refreshJob == null) {
Configuration config = getThing().getConfiguration();
int refreshInterval = ConfigParser
.valueAsOrElse(config.get("refreshInterval"), BigDecimal.class, BigDecimal.valueOf(500)).intValue();
startAutomaticRefresh(refreshInterval);
}
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
// Do Nothing
}
@Override
protected void refreshState() {
// Do Nothing
}
@Override
protected void minuteTick() {
logger.debug("StickupcamHandler - minuteTick - device {}", getThing().getUID().getId());
if (device == null) {
initialize();
}
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if ((deviceTO != null) && (deviceTO.health.batteryPercentage != lastBattery)) {
logger.debug("Battery Level: {}", deviceTO.battery);
ChannelUID channelUID = new ChannelUID(thing.getUID(), CHANNEL_STATUS_BATTERY);
updateState(channelUID, new DecimalType(deviceTO.health.batteryPercentage));
lastBattery = deviceTO.health.batteryPercentage;
} else if (deviceTO != null) {
logger.debug("Battery Level Unchanged for {} - {} vs {}", getThing().getUID().getId(),
deviceTO.health.batteryPercentage, lastBattery);
}
}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright (c) 2010-2025 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;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class ApiConstants {
public static final int API_VERSION = 11;
// API resources
public static final String API_USER_AGENT = "OpenHAB Ring Binding";
public static final String API_OAUTH_ENDPOINT = "https://oauth.ring.com/oauth/token";
public static final String API_BASE = "https://api.ring.com";
public static final String URL_SESSION = API_BASE + "/clients_api/session";
public static final String URL_DEVICES = API_BASE + "/clients_api/ring_devices";
public static final String URL_HISTORY = API_BASE + "/clients_api/doorbots/history";
public static final String URL_RECORDING_START = API_BASE + "/clients_api/dings/";
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_RECORDING = "/clients_api/dings/{0}/recording";
// JSON data names for ring devices
public static final String DEVICES_DOORBOTS = "doorbots";
public static final String DEVICES_CHIMES = "chimes";
public static final String DEVICES_STICKUP_CAMS = "stickup_cams";
public static final String DEVICES_OTHERDEVICE = "other";
}

View File

@ -0,0 +1,631 @@
/*
* Copyright (c) 2010-2025 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;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.lang.reflect.Type;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.StringJoiner;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.data.DataFactory;
import org.openhab.binding.ring.internal.data.ParamBuilder;
import org.openhab.binding.ring.internal.data.Profile;
import org.openhab.binding.ring.internal.data.RingDevices;
import org.openhab.binding.ring.internal.data.RingEventTO;
import org.openhab.binding.ring.internal.errors.AuthenticationException;
import org.openhab.binding.ring.internal.utils.RingUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import com.google.gson.reflect.TypeToken;
/**
* @author Wim Vissers - Initial contribution
* @author Pete Mietlowski - Updated authentication routines
* @author Chris Milbert - Stickupcam contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class RestClient {
public static final Type RING_EVENT_LIST_TYPE = new TypeToken<List<RingEventTO>>() {
}.getType();
private static final int CONNECTION_TIMEOUT = 12000;
private final Logger logger = LoggerFactory.getLogger(RestClient.class);
private final Gson gson = new Gson();
private static final String METHOD_POST = "POST";
private static final String METHOD_GET = "GET";
// The factory to create data elements
// private DataElementFactory factory;
/**
* Create a new client with the given server and port address.
*/
public RestClient() {
logger.debug("Creating Ring client for API version {} on endPoint {}", ApiConstants.API_VERSION,
ApiConstants.API_BASE);
}
/**
* Post data to given url
*
* @param resourceUrl
* @param data
* @param oauthToken
* @return the servers response
* @throws AuthenticationException
*
*/
private String postRequest(String resourceUrl, String data, String oauthToken) throws AuthenticationException {
String result = "";
logger.trace("RestClient - postRequest: {} - {} - {}", resourceUrl, data, oauthToken);
try {
byte[] postData = data.getBytes(StandardCharsets.UTF_8);
StringBuilder output = new StringBuilder();
URL url = new URI(resourceUrl).toURL();
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setDoInput(true);
conn.setUseCaches(false);
conn.setRequestProperty("User-Agent", ApiConstants.API_USER_AGENT);
conn.setRequestProperty("Authorization", "Bearer " + oauthToken);
conn.setHostnameVerifier((hostname, session) -> true);
// SSL setting
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] { new javax.net.ssl.X509TrustManager() {
@Override
public X509Certificate @Nullable [] getAcceptedIssuers() {
return null;
}
@Override
public void checkServerTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
@Override
public void checkClientTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
} }, null);
conn.setSSLSocketFactory(context.getSocketFactory());
conn.setRequestMethod(METHOD_POST);
conn.setRequestProperty("X-API-LANG", "en");
conn.setRequestProperty("Content-length", "gzip, deflate");
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECTION_TIMEOUT);
OutputStream out = conn.getOutputStream();
out.write(postData);
logger.debug("RestApi postRequest: {}, response code: {}, message {}.", resourceUrl, conn.getResponseCode(),
conn.getResponseMessage());
switch (conn.getResponseCode()) {
case 200, 201:
break;
case 400, 401:
throw new AuthenticationException("Invalid username or password");
case 429:
throw new AuthenticationException("Account ratelimited");
default:
logger.warn("Unhandled http response code: {}", conn.getResponseCode());
throw new AuthenticationException("Failed : HTTP error code : " + conn.getResponseCode());
}
BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream())));
String line;
while ((line = br.readLine()) != null) {
output.append(line);
}
conn.disconnect();
result = output.toString();
logger.trace("RestApi postRequest response: {}.", result);
} catch (IOException | KeyManagementException | NoSuchAlgorithmException | URISyntaxException ex) {
logger.error("RestApi error in postRequest!", ex);
}
return result;
}
/**
* Get data from given url
*
* @param resourceUrl
* @param profile
* @return the servers response
* @throws AuthenticationException
*/
private String getRequest(String resourceUrl, Profile profile) throws AuthenticationException {
String result = "";
logger.trace("RestClient - getRequest: {}", resourceUrl);
try {
StringBuilder output = new StringBuilder();
URL url = new URI(resourceUrl).toURL();
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setDoInput(true);
conn.setUseCaches(false);
conn.setRequestProperty("User-Agent", ApiConstants.API_USER_AGENT);
conn.setHostnameVerifier((hostname, session) -> true);
// SSL setting
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] { new javax.net.ssl.X509TrustManager() {
@Override
public X509Certificate @Nullable [] getAcceptedIssuers() {
return null;
}
@Override
public void checkServerTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
@Override
public void checkClientTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
} }, null);
conn.setSSLSocketFactory(context.getSocketFactory());
conn.setRequestMethod(METHOD_GET);
conn.setRequestProperty("cache-control", "no-cache");
conn.setRequestProperty("Content-type", "application/x-www-form-urlencoded");
conn.setRequestProperty("authorization", "Bearer " + profile.getAccessToken());
conn.setDoOutput(true);
conn.setConnectTimeout(12000);
switch (conn.getResponseCode()) {
case 200, 201:
break;
case 400, 401:
// break;
throw new AuthenticationException("Invalid request");
case 429:
throw new AuthenticationException("Account ratelimited");
default:
logger.warn("Unhandled http response code: {}", conn.getResponseCode());
throw new AuthenticationException("Failed : HTTP error code : " + conn.getResponseCode());
}
if (conn.getResponseCode() != 200) {
logger.debug("RestApi getRequest: {}, response code: {}, message {}.", resourceUrl,
conn.getResponseCode(), conn.getResponseMessage());
}
BufferedReader br = new BufferedReader(new InputStreamReader((conn.getInputStream())));
String line;
while ((line = br.readLine()) != null) {
output.append(line);
}
conn.disconnect();
result = output.toString();
if (!result.startsWith("[{\"id\"")) { // Ignore ding results
logger.trace("RestApi getRequest response: {}.", result);
}
} catch (IOException | KeyManagementException | NoSuchAlgorithmException | URISyntaxException ex) {
logger.debug("RestApi error in getRequest!", ex);
// ex.printStackTrace();
}
return result;
}
/**
* Get a (new) authenticated profile.
*
* @param username the username of the Ring account.
* @param password the password for the Ring account.
* @param hardwareId a hardware ID (must be unique for every piece of hardware used).
* @return a Profile instance with available data stored in it.
* @throws AuthenticationException
* @throws JsonParseException
*/
public Profile getAuthenticatedProfile(String username, String password, String refreshToken, String twofactorCode,
String hardwareId) throws AuthenticationException, JsonParseException {
String refToken = refreshToken;
logger.debug("RestClient - getAuthenticatedProfile U:{} - P:{} - R:{} - 2:{} - H:{}",
RingUtils.sanitizeData(username), RingUtils.sanitizeData(password),
RingUtils.sanitizeData(refreshToken), RingUtils.sanitizeData(twofactorCode),
RingUtils.sanitizeData(hardwareId));
if (!twofactorCode.isBlank()) {
logger.debug("RestClient - getAuthenticatedProfile - valid 2fa - run getAuthCode");
refToken = getAuthCode(twofactorCode, username, password, hardwareId);
}
JsonObject oauthToken = getOauthToken(username, password, refToken);
String jsonResult = postRequest(ApiConstants.URL_SESSION, DataFactory.getSessionParams(hardwareId),
oauthToken.get("access_token").getAsString());
JsonObject obj = JsonParser.parseString(jsonResult).getAsJsonObject();
return new Profile((JsonObject) obj.get("profile"), oauthToken.get("refresh_token").getAsString(),
oauthToken.get("access_token").getAsString());
}
/**
* Get a (new) oAuth token.
*
* @param username the username of the Ring account.
* @param password the password for the Ring account.
* @return a JsonObject with the available data stored in it (access_token, refresh_token)
* @throws AuthenticationException
* @throws JsonParseException
*/
private JsonObject getOauthToken(String username, String password, String refreshToken)
throws AuthenticationException, JsonParseException {
logger.debug("RestClient - getOauthToken {} - {} - {}", RingUtils.sanitizeData(username),
RingUtils.sanitizeData(password), RingUtils.sanitizeData(refreshToken));
String result = null;
JsonObject oauthToken = new JsonObject();
String resourceUrl = ApiConstants.API_OAUTH_ENDPOINT;
try {
Map<String, String> map = new HashMap<String, String>();
map.put("client_id", "ring_official_android");
map.put("scope", "client");
if (refreshToken.isBlank()) {
logger.debug("RestClient - getOauthToken - refreshToken null or empty {}",
RingUtils.sanitizeData(refreshToken));
map.put("grant_type", "password");
map.put("username", username);
map.put("password", password);
} else {
logger.debug("RestClient - getOauthToken - refreshToken NOT null or empty {}",
RingUtils.sanitizeData(refreshToken));
map.put("grant_type", "refresh_token");
map.put("refresh_token", refreshToken);
}
URL url = new URI(resourceUrl).toURL();
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setDoInput(true);
conn.setUseCaches(false);
conn.setRequestProperty("User-Agent", ApiConstants.API_USER_AGENT);
conn.setHostnameVerifier((hostname, session) -> true);
// SSL setting
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] { new javax.net.ssl.X509TrustManager() {
@Override
public X509Certificate @Nullable [] getAcceptedIssuers() {
return null;
}
@Override
public void checkServerTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
@Override
public void checkClientTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
} }, null);
conn.setSSLSocketFactory(context.getSocketFactory());
conn.setRequestMethod(METHOD_POST);
conn.setRequestProperty("Content-type", "application/x-www-form-urlencoded; charset: UTF-8");
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECTION_TIMEOUT);
StringJoiner sj = new StringJoiner("&");
for (Map.Entry<String, String> entry : map.entrySet()) {
sj.add(URLEncoder.encode(entry.getKey(), "UTF-8") + "=" + URLEncoder.encode(entry.getValue(), "UTF-8"));
}
byte[] out = sj.toString().getBytes(StandardCharsets.UTF_8);
int length = out.length;
conn.setFixedLengthStreamingMode(length);
conn.connect();
OutputStream os = conn.getOutputStream();
os.write(out);
logger.debug("RestClient getOauthToken: {}, response code: {}, message {}.", resourceUrl,
conn.getResponseCode(), conn.getResponseMessage());
switch (conn.getResponseCode()) {
case 200, 201:
break;
case 400:
throw new AuthenticationException("Two factor authentication enabled, enter code");
case 412:
if (conn.getResponseMessage().startsWith("Precondition")) {
throw new AuthenticationException("Two factor authentication enabled, enter code");
}
case 401:
throw new AuthenticationException("Invalid username or password.");
case 429:
throw new AuthenticationException("Account ratelimited");
default:
logger.warn("Unhandled http response code: {}", conn.getResponseCode());
throw new AuthenticationException("Failed : HTTP error code : " + conn.getResponseCode());
}
result = readFullyAsString(conn.getInputStream(), "UTF-8");
conn.disconnect();
oauthToken = JsonParser.parseString(result).getAsJsonObject();
logger.debug("RestClient response: {}.", RingUtils.sanitizeData(result));
} catch (IOException | KeyManagementException | NoSuchAlgorithmException | URISyntaxException ex) {
logger.error("RestApi: Error in getOauthToken!", ex);
}
return oauthToken;
}
public String readFullyAsString(InputStream inputStream, String encoding) throws IOException {
return readFully(inputStream).toString(encoding);
}
private ByteArrayOutputStream readFully(InputStream inputStream) throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int length = 0;
while ((length = inputStream.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos;
}
/**
* Post data to given url
*
* @param authCode
* @param username
* @param password
* @param hardwareId
* @return the servers response
* @throws AuthenticationException
*
*/
private String getAuthCode(String authCode, String username, String password, String hardwareId)
throws AuthenticationException {
logger.debug("RestClient - getAuthCode A:{} - U:{} - P:{} - H:{}", RingUtils.sanitizeData(authCode),
RingUtils.sanitizeData(username), RingUtils.sanitizeData(password), RingUtils.sanitizeData(hardwareId));
String result = "";
String resourceUrl = ApiConstants.API_OAUTH_ENDPOINT;
try {
ParamBuilder pb = new ParamBuilder(false);
pb.add("client_id", "ring_official_android");
pb.add("scope", "client");
pb.add("grant_type", "password");
pb.add("password", password);
pb.add("username", username);
URL url = new URI(resourceUrl).toURL();
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setDoInput(true);
conn.setUseCaches(false);
conn.setRequestProperty("X-API-LANG", "en");
conn.setRequestProperty("Content-length", "gzip, deflate");
conn.setRequestProperty("2fa-support", "true");
conn.setRequestProperty("2fa-code", authCode);
conn.setRequestProperty("hardware_id", hardwareId);
conn.setRequestProperty("User-Agent", ApiConstants.API_USER_AGENT);
conn.setHostnameVerifier((hostname, session) -> true);
// SSL setting
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] { new javax.net.ssl.X509TrustManager() {
@Override
public X509Certificate @Nullable [] getAcceptedIssuers() {
return null;
}
@Override
public void checkServerTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
@Override
public void checkClientTrusted(X509Certificate @Nullable [] chain, @Nullable String authType)
throws CertificateException {
}
} }, null);
conn.setSSLSocketFactory(context.getSocketFactory());
conn.setRequestMethod(METHOD_POST);
conn.setDoOutput(true);
conn.setConnectTimeout(CONNECTION_TIMEOUT);
byte[] out = pb.toString().getBytes(StandardCharsets.UTF_8);
conn.connect();
OutputStream os = conn.getOutputStream();
os.write(out);
logger.info("RestApi getAuthCode: {}, response code: {}, message {}.", resourceUrl, conn.getResponseCode(),
conn.getResponseMessage());
switch (conn.getResponseCode()) {
case 200, 201:
break;
case 400:
throw new AuthenticationException("2 factor enabled, enter code");
case 412:
if (conn.getResponseMessage().startsWith("Verification Code")) {
throw new AuthenticationException("2 factor enabled, enter code");
}
case 401:
throw new AuthenticationException("Invalid username or password.");
case 429:
throw new AuthenticationException("Account ratelimited");
default:
logger.warn("Unhandled http response code: {}", conn.getResponseCode());
throw new AuthenticationException("Failed : HTTP error code : " + conn.getResponseCode());
}
result = readFullyAsString(conn.getInputStream(), "UTF-8");
conn.disconnect();
JsonObject refToken = JsonParser.parseString(result).getAsJsonObject();
result = refToken.get("refresh_token").getAsString();
logger.debug("RestClient - getAuthCode response: {}.", RingUtils.sanitizeData(result));
} catch (IOException | KeyManagementException | NoSuchAlgorithmException | URISyntaxException ex) {
logger.error("Error getting auth code!", ex);
} catch (JsonParseException e) {
logger.error("Error parsing refToken", e);
}
return result;
}
/**
* Get the RingDevices instance, given the authenticated Profile.
*
* @param profile the Profile previously retrieved when authenticating.
* @return the RingDevices instance filled with all available data.
* @throws AuthenticationException when request is invalid.
* @throws JsonParseException when response is invalid JSON.
*/
public RingDevices getRingDevices(Profile profile, RingAccount ringAccount)
throws JsonParseException, AuthenticationException {
logger.debug("RestClient - getRingDevices");
String jsonResult = getRequest(ApiConstants.URL_DEVICES, profile);
JsonObject obj = JsonParser.parseString(jsonResult).getAsJsonObject();
return new RingDevices(obj, ringAccount);
}
/**
* Get a List with the last recorded events, newest on top.
*
* @param profile the Profile previously retrieved when authenticating.
* @param limit the maximum number of events.
* @return
* @throws AuthenticationException
* @throws JsonParseException
*/
public synchronized List<RingEventTO> getHistory(Profile profile, int limit)
throws AuthenticationException, JsonParseException {
String jsonResult = getRequest(ApiConstants.URL_HISTORY + "?limit=" + limit, profile);
if (!jsonResult.isBlank()) {
return Objects.requireNonNull(gson.fromJson(jsonResult, RING_EVENT_LIST_TYPE));
} else {
return List.of();
}
}
public String downloadEventVideo(RingEventTO event, Profile profile, String filePath, int retentionCount) {
try {
Path path = Paths.get(filePath);
try {
Files.createDirectories(path.toAbsolutePath());
} catch (IOException e) {
logger.error("RingVideo: Unable to create folder {}, cannot download.: {}", filePath, e.getMessage());
return "";
}
if (retentionCount > 0 && Files.exists(path)) {
// get FileSystem object
FileSystem fs = path.getFileSystem();
String sep = fs.getSeparator();
String filename = event.doorbot.description.replace(" ", "") + "-" + event.kind + "-"
+ event.getCreatedAt().toString().replace(":", "-") + ".mp4";
String fullfilepath = filePath + (filePath.endsWith(sep) ? "" : sep) + filename;
logger.info("fullfilepath = {}", fullfilepath);
path = Paths.get(fullfilepath);
boolean urlFound = false;
if (Files.notExists(path)) {
long eventId = event.id;
StringBuilder vidUrl = new StringBuilder();
vidUrl.append(ApiConstants.URL_RECORDING_START).append(eventId)
.append(ApiConstants.URL_RECORDING_END);
for (int i = 0; i < 10; i++) {
try {
String jsonResult = getRequest(vidUrl.toString(), profile);
JsonObject obj = JsonParser.parseString(jsonResult).getAsJsonObject();
if (obj.get("url").getAsString().startsWith("http")) {
URL url = new URI(obj.get("url").getAsString()).toURL();
InputStream in = url.openStream();
Files.copy(in, Paths.get(fullfilepath), StandardCopyOption.REPLACE_EXISTING);
in.close();
logger.info("fullfilepath.length() = {}", fullfilepath.length());
if (!fullfilepath.isEmpty()) {
urlFound = true;
break;
}
}
} catch (AuthenticationException | URISyntaxException e) {
logger.debug("RingVideo: Error downloading file: {}", e.getMessage());
} finally {
Thread.sleep(15000);
}
}
}
if (urlFound) {
File directory = new File(filePath);
File[] logFiles = directory.listFiles();
long oldestDate = Long.MAX_VALUE;
File oldestFile = null;
if (logFiles != null && logFiles.length > retentionCount) {
// delete oldest files after there's more than the specified number of files
for (File f : logFiles) {
if (f.lastModified() < oldestDate) {
oldestDate = f.lastModified();
oldestFile = f;
}
}
if (oldestFile != null) {
oldestFile.delete();
}
}
return filename;
} else {
return "Video not available on ring.com";
}
} else if (retentionCount == 0) {
return "videoRetentionCount = 0, Auto downloading disabled";
} else {
return "";
}
} catch (IOException | InterruptedException e) {
logger.warn("RingVideo: Unable to process request: {}", e.getMessage());
return "";
}
}
}

View File

@ -0,0 +1,50 @@
/*
* Copyright (c) 2010-2025 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;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.data.Profile;
/**
* The AccountHandler implements this interface to facilitate the
* use of the common services.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public interface RingAccount {
/**
* Get the linked REST client.
*
* @return the REST client.
*/
public @Nullable RestClient getRestClient();
/**
* Get the linked user profile.
*
* @return the user profile.
*/
public @Nullable Profile getProfile();
/**
* Get the Account Handler Thing ID
* *
*
* @return the ring account thing id.
*/
public String getThingId();
}

View File

@ -0,0 +1,184 @@
/*
* Copyright (c) 2010-2025 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;
import java.util.Collection;
import java.util.concurrent.ConcurrentHashMap;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.data.RingDevice;
import org.openhab.binding.ring.internal.data.RingDeviceTO;
import org.openhab.binding.ring.internal.errors.DeviceNotFoundException;
import org.openhab.binding.ring.internal.errors.DuplicateIdException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* Singleton registry of found devices.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class RingDeviceRegistry {
private final Gson gson = new Gson();
/**
* static Singleton instance.
*/
private static final RingDeviceRegistry INSTANCE = new RingDeviceRegistry();
/**
* The logger.
*/
private final Logger logger = LoggerFactory.getLogger(RingDeviceRegistry.class);
/**
* Will be set after initialization.
*/
private boolean initialized;
/**
* Key: device id.
* Value: the RingDevice implementation object.
*/
private ConcurrentHashMap<String, RingDevice> devices = new ConcurrentHashMap<>();
/**
* Return a singleton instance of RingDeviceRegistry.
*/
public static RingDeviceRegistry getInstance() {
return INSTANCE;
}
/**
* Add a new ring device.
*/
public void addRingDevice(RingDevice ringDevice) throws DuplicateIdException {
RingDeviceTO deviceTO = gson.fromJson(ringDevice.getJsonObject(), RingDeviceTO.class);
if (deviceTO != null) {
if (devices.containsKey(deviceTO.id)) {
throw new DuplicateIdException("Ring device with duplicate id " + deviceTO.id + " ignored");
} else {
ringDevice.setRegistrationStatus(Status.ADDED);
devices.put(deviceTO.id, ringDevice);
}
}
}
/**
* Add a new ring device collection.
*/
public synchronized void addRingDevices(Collection<RingDevice> ringDevices) {
for (RingDevice device : ringDevices) {
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if (deviceTO != null) {
logger.debug("RingDeviceRegistry - addRingDevices - Trying: {}", deviceTO.id);
try {
addRingDevice(device);
} catch (DuplicateIdException e) {
logger.debug(
"RingDeviceRegistry - addRingDevices - Ring device with duplicate id {} ignored. Updating Json.",
deviceTO.id);
devices.get(deviceTO.id).setJsonObject(device.getJsonObject());
}
}
}
initialized = true;
}
/**
* Return true after the registry is filled with devices.
*
* @return
*/
public boolean isInitialized() {
return initialized;
}
/**
* Get the device registered with the given id.
*
* @param id the device id.
* @return the RingDevice instance from the registry.
* @throws DeviceNotFoundException
*/
public @Nullable RingDevice getRingDevice(String id) throws DeviceNotFoundException {
if (devices.containsKey(id)) {
return devices.get(id);
} else {
throw new DeviceNotFoundException("Device with id '" + id + "' not found");
}
}
/**
* Remove the device registered with the given id.
*
* @param id the device id.
* @throws DeviceNotFoundException
*/
public void removeRingDevice(String id) throws DeviceNotFoundException {
if (devices.containsKey(id)) {
devices.remove(id);
} else {
throw new DeviceNotFoundException("Device with id '" + id + "' not found");
}
}
/**
* Get a collection with RingDevices with the given status.
*
* @param filter the registration status to filter on.
* @return the (possibly empty) collection.
*/
public Collection<RingDevice> getRingDevices(Status filterStatus) {
return devices.values().stream().filter(d -> d.getRegistrationStatus().equals(filterStatus)).toList();
}
/**
* Set the registration status.
*
* @param id the id of the RingDevice.
* @param status the new registration status.
*/
public void setStatus(String id, Status status) {
RingDevice result = devices.get(id);
if (result != null) {
result.setRegistrationStatus(status);
}
}
/**
* The registry status of the device.
*
* @author Wim Vissers
*
*/
public enum Status {
/**
* When first added to the registry, the status will be 'ADDED'.
*/
ADDED,
/**
* When reported to the system as discovered device. It will show up
* in the inbox.
*/
DISCOVERED,
/**
* When a thing is created, the status will be configured.
*/
CONFIGURED;
}
}

View File

@ -0,0 +1,106 @@
/*
* Copyright (c) 2010-2025 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;
import static org.openhab.binding.ring.RingBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.handler.AccountHandler;
import org.openhab.binding.ring.handler.ChimeHandler;
import org.openhab.binding.ring.handler.DoorbellHandler;
import org.openhab.binding.ring.handler.OtherDeviceHandler;
import org.openhab.binding.ring.handler.StickupcamHandler;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.ComponentContext;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.http.HttpService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link RingHandlerFactory} is responsible for creating things and thing
* handlers.
*
* @author Wim Vissers - Initial contribution
* @author Chris Milbert - Stickupcam contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@Component(service = { ThingHandlerFactory.class,
RingHandlerFactory.class }, immediate = true, configurationPid = "binding.ring")
@NonNullByDefault
public class RingHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(RingHandlerFactory.class);
private final NetworkAddressService networkAddressService;
private final HttpService httpService;
private int httpPort;
private @Nullable ComponentContext componentContext;
public final Gson gson = new Gson();
@Activate
public RingHandlerFactory(@Reference NetworkAddressService networkAddressService,
@Reference HttpService httpService, ComponentContext componentContext) {
super.activate(componentContext);
httpPort = HttpServiceUtil.getHttpServicePort(componentContext.getBundleContext());
if (httpPort == -1) {
httpPort = 8080;
}
this.httpService = httpService;
this.networkAddressService = networkAddressService;
logger.debug("Using OH HTTP port {}", httpPort);
}
@Override
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
}
@Override
protected @Nullable ThingHandler createHandler(final Thing thing) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
logger.info("createHandler thingType: {}", thingTypeUID);
if (thingTypeUID.equals(THING_TYPE_ACCOUNT)) {
if (thing instanceof Bridge bridge) {
return new AccountHandler(bridge, networkAddressService, httpService, httpPort);
} else {
logger.warn("Account Bridge configured as legacy Thing");
return null;
}
} else if (thingTypeUID.equals(THING_TYPE_DOORBELL)) {
return new DoorbellHandler(thing, gson);
} else if (thingTypeUID.equals(THING_TYPE_CHIME)) {
return new ChimeHandler(thing, gson);
} else if (thingTypeUID.equals(THING_TYPE_STICKUPCAM)) {
return new StickupcamHandler(thing, gson);
} else if (thingTypeUID.equals(THING_TYPE_OTHERDEVICE)) {
return new OtherDeviceHandler(thing, gson);
}
return null;
}
}

View File

@ -0,0 +1,114 @@
/*
* Copyright (c) 2010-2025 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;
import static org.openhab.binding.ring.RingBindingConstants.*;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLConnection;
import java.nio.file.FileSystem;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.http.HttpMethod;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.http.HttpService;
import org.osgi.service.http.NamespaceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Main OSGi service and HTTP servlet for Ring Video
*
* @author Peter Mietlowski (zolakk) - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@Component(service = HttpServlet.class)
@NonNullByDefault
public class RingVideoServlet extends HttpServlet {
private static final long serialVersionUID = -5592161948589682812L;
private final Logger logger = LoggerFactory.getLogger(RingVideoServlet.class);
private String videoStoragePath = "";
public RingVideoServlet() {
}
public RingVideoServlet(HttpService httpService, String videoStoragePath) {
Path path = Paths.get(videoStoragePath);
FileSystem fs = path.getFileSystem();
String sep = fs.getSeparator();
this.videoStoragePath = videoStoragePath + (videoStoragePath.endsWith(sep) ? "" : sep);
try {
httpService.registerServlet(SERVLET_VIDEO_PATH, this, null, httpService.createDefaultHttpContext());
} catch (NamespaceException | ServletException e) {
logger.warn("Register servlet fails", e);
}
}
@SuppressWarnings("null")
@Override
protected void service(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
String ipAddress = request.getHeader("HTTP_X_FORWARDED_FOR");
if (ipAddress == null) {
ipAddress = request.getRemoteAddr();
}
String path = request.getRequestURI().substring(0, SERVLET_VIDEO_PATH.length());
logger.trace("RingVideo: Request from {}:{}{} ({}:{}, {})", ipAddress, request.getRemotePort(), path,
request.getRemoteHost(), request.getServerPort(), request.getProtocol());
if (!request.getMethod().equalsIgnoreCase(HttpMethod.GET.toString())) {
logger.warn("RingVideo: Unexpected method='{}'", request.getMethod());
}
if (!path.equalsIgnoreCase(SERVLET_VIDEO_PATH)) {
logger.warn("RingVideo: Invalid request received - path = {}", path);
return;
}
String uri = request.getRequestURI().substring(request.getRequestURI().lastIndexOf("/") + 1);
logger.debug("RingVideo: {} video '{}' requested", request.getMethod(), uri);
String filename = videoStoragePath + uri;
File toBeCopied = new File(filename);
String mimeType = URLConnection.guessContentTypeFromName(toBeCopied.getName());
String contentDisposition = String.format("attachment; filename=%s", toBeCopied.getName());
int fileSize = Long.valueOf(toBeCopied.length()).intValue();
response.setHeader("Content-Disposition", contentDisposition);
response.setContentLength(fileSize);
response.setContentType(mimeType);
response.setHeader("Access-Control-Allow-Origin", "*");
try (OutputStream out = response.getOutputStream()) {
Path videoPath = toBeCopied.toPath();
Files.copy(videoPath, out);
out.flush();
} catch (IOException e) {
// handle exception
logger.error("RingVideo: Unable to process request: {}", e.getMessage());
}
}
}

View File

@ -0,0 +1,90 @@
/*
* Copyright (c) 2010-2025 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.console;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.handler.AccountHandler;
import org.openhab.core.io.console.Console;
import org.openhab.core.io.console.extensions.AbstractConsoleCommandExtension;
import org.openhab.core.io.console.extensions.ConsoleCommandExtension;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingRegistry;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link RingCommandExtension} is responsible for handling console commands
*
* @author Ben Rosenblum - Initial contribution
*/
@NonNullByDefault
@Component(service = ConsoleCommandExtension.class)
public class RingCommandExtension extends AbstractConsoleCommandExtension {
private final Logger logger = LoggerFactory.getLogger(RingCommandExtension.class);
private final ThingRegistry thingRegistry;
@Activate
public RingCommandExtension(final @Reference ThingRegistry thingRegistry) {
super("ring", "Interact with the Ring binding channels directly.");
this.thingRegistry = thingRegistry;
}
@Override
public void execute(String[] args, Console console) {
if (args.length == 5 && "login".equals(args[1])) {
logger.trace("Received Login Command: {} [username: {}, password: {}, 2FA: {}]", args[0], args[2], "***",
"***");
Thing thing = null;
ThingUID thingUID = new ThingUID(args[0]);
thing = thingRegistry.get(thingUID);
ThingHandler thingHandler = null;
AccountHandler handler = null;
if (thing != null) {
thingHandler = thing.getHandler();
if (thingHandler instanceof AccountHandler) {
handler = (AccountHandler) thingHandler;
}
}
if (thing == null) {
console.println("Bad thing uid '" + args[0] + "'");
printUsage(console);
} else if (thingHandler == null) {
console.println("No handler initialized for the thing uid '" + args[0] + "'");
printUsage(console);
} else if (handler == null) {
console.println("'" + args[0] + "' is not an Ring thing uid");
printUsage(console);
} else {
logger.debug("Sending CLI login to handler {}", args[0]);
handler.doLogin(args[2], args[3], args[4]);
}
} else {
printUsage(console);
}
}
@Override
public List<String> getUsages() {
return List.of(buildCommandUsage("<thingUID> login <username> <password> <two factor auth>",
"Send a login request to the RING API"));
}
}

View File

@ -0,0 +1,123 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.handler.RingDeviceHandler;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
/**
* Interface common to all Ring devices.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public abstract class AbstractRingDevice implements RingDevice {
private final Logger logger = LoggerFactory.getLogger(AbstractRingDevice.class);
public final Gson gson = new Gson();
/**
* The JsonObject contains the data retrieved from the Ring API,
* or the data to send to the API.
*/
protected JsonObject jsonObject = new JsonObject();
/**
* The registration status.
*/
private RingDeviceRegistry.Status registrationStatus = RingDeviceRegistry.Status.ADDED;
/**
* The linked Ring account.
*/
private final RingAccount ringAccount;
/**
* The linked RingDeviceHandler.
*/
private @Nullable RingDeviceHandler ringDeviceHandler;
protected AbstractRingDevice(JsonObject jsonObject, RingAccount ringAccount) {
this.jsonObject = jsonObject;
this.ringAccount = ringAccount;
}
/**
* Get the registration status.
*
* @return
*/
@Override
public RingDeviceRegistry.Status getRegistrationStatus() {
return registrationStatus;
}
/**
* Set the registration status.
*
* @param status
*/
@Override
public void setRegistrationStatus(RingDeviceRegistry.Status registrationStatus) {
this.registrationStatus = registrationStatus;
}
/**
* Get the linked Ring Device Handler.
*
* @return the handler.
*/
@Override
@Nullable
public RingDeviceHandler getRingDeviceHandler() {
return ringDeviceHandler;
}
/**
* Set the linked Ring Device Handler.
*
* @param ringDeviceHandler the handler.
*/
@Override
public void setRingDeviceHandler(RingDeviceHandler ringDeviceHandler) {
this.ringDeviceHandler = ringDeviceHandler;
}
/**
* Get the linked Ring account.
*
* @return the account.
*/
@Override
public RingAccount getRingAccount() {
return ringAccount;
}
@Override
public void setJsonObject(JsonObject jsonObject) {
this.jsonObject = jsonObject;
logger.trace("AbstractRingDevice - setJsonObject - Updated JSON: {}", this.jsonObject);
}
@Override
public JsonObject getJsonObject() {
return this.jsonObject;
}
}

View File

@ -0,0 +1,52 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import com.google.gson.JsonObject;
/**
* @author Ben Rosenblum - Initial contribution
*/
@NonNullByDefault
public class Chime extends AbstractRingDevice {
/**
* Create Chime instance from JSON object.
*
* @param jsonChime the JSON Chime retrieved from the Ring API.
* @param ringAccount the Ring Account in use
*/
public Chime(JsonObject jsonChime, RingAccount ringAccount) {
super(jsonChime, ringAccount);
}
/**
* Get the DiscoveryResult object to identify the device as
* discovered thing.
*
* @return the device as DiscoveryResult instance.
*/
@Override
public DiscoveryResult getDiscoveryResult(RingDeviceTO deviceTO) {
DiscoveryResult result = DiscoveryResultBuilder
.create(new ThingUID("ring:chime:" + getRingAccount().getThingId() + ":" + deviceTO.id))
.withLabel("Ring Chime - " + deviceTO.description).build();
return result;
}
}

View File

@ -0,0 +1,51 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.ApiConstants;
/**
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class DataFactory {
public static String getOauthData(String username, String password) {
return "";
}
/**
* Get GET parameters for the session API resource.
*
* @return
*/
public static String getSessionParams(String hardwareId) {
ParamBuilder pb = new ParamBuilder(false);
pb.add("device[os]", "android");
pb.add("device[hardware_id]", hardwareId);
pb.add("device[app_brand]", "ring");
pb.add("device[metadata][device_model]", "VirtualBox");
pb.add("device[metadata][resolution]", "600x800");
pb.add("device[metadata][app_version]", "1.7.29");
pb.add("device[metadata][app_installation_date]", "");
pb.add("device[metadata][os_version]", "4.4.4");
pb.add("device[metadata][manufacturer]", "innotek GmbH");
pb.add("device[metadata][is_tablet]", "true");
pb.add("device[metadata][linphone_initialized]", "true");
pb.add("device[metadata][language]", "en");
pb.add("api_version", "" + ApiConstants.API_VERSION);
return pb.toString();
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import com.google.gson.JsonObject;
/**
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class Doorbell extends AbstractRingDevice {
/**
* Create Doorbell instance from JSON object.
*
* @param jsonDoorbell the JSON doorbell (doorbot) retrieved from the Ring API.
* @param ringAccount the Ring Account in use
*/
public Doorbell(JsonObject jsonDoorbell, RingAccount ringAccount) {
super(jsonDoorbell, ringAccount);
}
/**
* Get the DiscoveryResult object to identify the device as
* discovered thing.
*
* @return the device as DiscoveryResult instance.
*/
@Override
public DiscoveryResult getDiscoveryResult(RingDeviceTO deviceTO) {
DiscoveryResult result = DiscoveryResultBuilder
.create(new ThingUID("ring:doorbell:" + getRingAccount().getThingId() + ":" + deviceTO.id))
.withLabel("Ring Video Doorbell - " + deviceTO.description).build();
return result;
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Wim Vissers - Initial contribution
*/
@NonNullByDefault
public class DoorbotTO {
public String id = "";
public String description = "";
}

View File

@ -0,0 +1,78 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public enum Feature {
REMOTE_LOGGING_FORMAT_STORING,
REMOTE_LOGGING_LEVEL,
SUBSCRIPTIONS_ENABLED,
STICKUPCAM_SETUP_ENABLED,
VOD_ENABLED,
NW_ENABLED,
NW_V2_ENABLED,
NW_USER_ACTIVATED,
RINGPLUS_ENABLED,
LPD_ENABLED,
REACTIVE_SNOOZING_ENABLED,
PROACTIVE_SNOOZING_ENABLED,
OWNER_PROACTIVE_SNOOZING_ENABLED,
LIVE_VIEW_SETTINGS_ENABLED,
DELETE_ALL_SETTINGS_ENABLED,
POWER_CABLE_ENABLED,
DEVICE_HEALTH_ALERTS_ENABLED,
CHIME_PRO_ENABLED,
MULTIPLE_CALLS_ENABLED,
UJET_ENABLED,
MULTIPLE_DELETE_ENABLED,
DELETE_ALL_ENABLED,
LPD_MOTION_ANNOUNCEMENT_ENABLED,
STARRED_EVENTS_ENABLED,
CHIME_DND_ENABLED,
VIDEO_SEARCH_ENABLED,
FLOODLIGHT_CAM_ENABLED,
NW_LARGER_AREA_ENABLED,
RING_CAM_BATTERY_ENABLED,
ELITE_CAM_ENABLED,
DOORBELL_V2_ENABLED,
SPOTLIGHT_BATTERY_DASHBOARD_CONTROLS_ENABLED,
BYPASS_ACCOUNT_VERIFICATION,
LEGACY_CVR_RETENTION_ENABLED,
NEW_DASHBOARD_ENABLED,
RING_CAM_ENABLED,
RING_SEARCH_ENABLED,
RING_CAM_MOUNT_ENABLED,
RING_ALARM_ENABLED,
IN_APP_CALL_NOTIFICATIONS,
RING_CASH_ELIGIBLE_ENABLED,
NEW_RING_PLAYER_ENABLED,
APP_ALERT_TONES_ENABLED,
MOTION_SNOOZING_ENABLED;
/**
* The enum is named according to the json names retrieved from
* the Ring API, but in upper case.
*
* @return the json name.
*/
public String getJsonName() {
return this.toString().toLowerCase();
}
}

View File

@ -0,0 +1,52 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import com.google.gson.JsonObject;
/**
* @author Ben Rosenblum - Initial contribution
*/
@NonNullByDefault
public class OtherDevice extends AbstractRingDevice {
/**
* Create OtherDevice instance from JSON object.
*
* @param jsonOtherDevice the JSON Other retrieved from the Ring API.
* @param ringAccount the Ring Account in use
*/
public OtherDevice(JsonObject jsonOtherDevice, RingAccount ringAccount) {
super(jsonOtherDevice, ringAccount);
}
/**
* Get the DiscoveryResult object to identify the device as
* discovered thing.
*
* @return the device as DiscoveryResult instance.
*/
@Override
public DiscoveryResult getDiscoveryResult(RingDeviceTO deviceTO) {
DiscoveryResult result = DiscoveryResultBuilder
.create(new ThingUID("ring:otherdevice:" + getRingAccount().getThingId() + ":" + deviceTO.id))
.withLabel("Ring Other Device - " + deviceTO.description).build();
return result;
}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright (c) 2010-2025 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.data;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Builder for http request or post parameters.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class ParamBuilder {
/**
* When true, URL encode parameter names and values properly.
*/
private boolean urlEncode;
private static final String URL_ENCODING = "UTF-8";
/**
* The map used to store the parameters.
*/
private final Map<String, String> parameters;
/**
* Create a new ParamBuilder. Specify if it should URL encode it.
*
* @param urlEncoded
*/
public ParamBuilder(boolean urlEncoded) {
this.urlEncode = urlEncoded;
this.parameters = new HashMap<>();
}
/**
* Add a name/value pair.
*
* @param name
* @param value
*/
public void add(String name, String value) {
parameters.put(name, value);
}
/**
* Helper method to handle encoding.
*
* @param input the input String.
* @return the (possibly encode) result.
*/
@Nullable
private String encode(String input) {
try {
return urlEncode ? URLEncoder.encode(input, URL_ENCODING) : input;
} catch (UnsupportedEncodingException e) {
// Should not happen
return null;
}
}
/**
* Get the result string in the format param1=value1&param2=value2, etc.
*/
@Override
public String toString() {
StringBuilder b = new StringBuilder();
for (Map.Entry<String, String> entry : parameters.entrySet()) {
if (b.length() != 0) {
b.append("&");
}
b.append(encode(entry.getKey())).append("=").append(encode(entry.getValue()));
}
return b.toString();
}
}

View File

@ -0,0 +1,117 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.JsonObject;
/**
* {"profile":{
* "id":4445516,
* "email":"",
* "first_name":null,
* "last_name":null,
* "phone_number":null,
* "authentication_token":"CUBSmqFr9YE7cofLZKfy",
* "features":
* {
* "remote_logging_format_storing":false,
* "remote_logging_level":1,
* "subscriptions_enabled":true,
* "stickupcam_setup_enabled":true,
* "vod_enabled":false,
* "nw_enabled":true,
* "nw_v2_enabled":true,
* "nw_user_activated":false,
* "ringplus_enabled":true,
* "lpd_enabled":true,
* "reactive_snoozing_enabled":false,
* "proactive_snoozing_enabled":false,
* "owner_proactive_snoozing_enabled":true,
* "live_view_settings_enabled":true,
* "delete_all_settings_enabled":false,
* "power_cable_enabled":false,
* "device_health_alerts_enabled":true,
* "chime_pro_enabled":true,
* "multiple_calls_enabled":true,
* "ujet_enabled":true,
* "multiple_delete_enabled":true,
* "delete_all_enabled":true,
* "lpd_motion_announcement_enabled":false,
* "starred_events_enabled":true,
* "chime_dnd_enabled":false,
* "video_search_enabled":false,
* "floodlight_cam_enabled":true,
* "nw_larger_area_enabled":false,
* "ring_cam_battery_enabled":true,
* "elite_cam_enabled":true,
* "doorbell_v2_enabled":true,
* "spotlight_battery_dashboard_controls_enabled":false,
* "bypass_account_verification":false,
* "legacy_cvr_retention_enabled":false,
* "new_dashboard_enabled":false,
* "ring_cam_enabled":true,
* "ring_search_enabled":false,
* "ring_cam_mount_enabled":true,
* "ring_alarm_enabled":false,
* "in_app_call_notifications":true,
* "ring_cash_eligible_enabled":true,
* "new_ring_player_enabled":false,
* "app_alert_tones_enabled":true,
* "motion_snoozing_enabled":true
* },
* "hardware_id":"80940d0-7285-3366-8c64-6ea91491982b",
* "explorer_program_terms":null,
* "user_flow":"ring"
* }}
*
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class Profile {
private JsonObject jsonProfile = new JsonObject();
private JsonObject jsonFeatures = new JsonObject();
private String refreshToken = "";
private String accessToken = "";
/**
* Create Profile instance from JSON String.
*
* @param jsonProfile the JSON profile retrieved from the Ring API.
* @param refreshToken needed for the refresh token so we aren't logging in every time.
* Needed as a separate parameter because it's not part of the jsonProfile object.
* @param accessToken needed for the access token so we aren't logging in every time.
* Needed as a separate parameter because it's not part of the jsonProfile object.
*/
public Profile(JsonObject jsonProfile, String refreshToken, String accessToken) {
this.jsonProfile = jsonProfile;
this.jsonFeatures = (JsonObject) jsonProfile.get("features");
this.refreshToken = refreshToken;
this.accessToken = accessToken;
}
public String getRefreshToken() {
return refreshToken;
}
public String getAccessToken() {
return accessToken;
}
public Profile() {
}
}

View File

@ -0,0 +1,80 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.handler.RingDeviceHandler;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.core.config.discovery.DiscoveryResult;
import com.google.gson.JsonObject;
/**
* Interface common to all Ring devices.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public interface RingDevice {
/**
* Get the DiscoveryResult object to identify the device as
* discovered thing.
*
* @return the device as DiscoveryResult instance.
*/
DiscoveryResult getDiscoveryResult(RingDeviceTO deviceTO);
/**
* Get the registration status.
*
* @return
*/
RingDeviceRegistry.Status getRegistrationStatus();
/**
* Set the registration status.
*
* @param registrationStatus
*/
void setRegistrationStatus(RingDeviceRegistry.Status registrationStatus);
/**
* Get the linked Ring account.
*
* @return the account.
*/
RingAccount getRingAccount();
/**
* Get the linked Ring Device Handler.
*
* @return the handler.
*/
@Nullable
RingDeviceHandler getRingDeviceHandler();
/**
* Set the linked Ring Device Handler.
*
* @param ringDeviceHandler the handler.
*/
void setRingDeviceHandler(RingDeviceHandler ringDeviceHandler);
void setJsonObject(JsonObject jsonObject);
JsonObject getJsonObject();
}

View File

@ -0,0 +1,58 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* Interface common to all Ring devices.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class RingDeviceTO {
@SerializedName("id")
public String id = "";
@SerializedName("kind")
public String kind = "";
@SerializedName("description")
public String description = "";
@SerializedName("device_id")
public String deviceId = "";
@SerializedName("time_zone")
public String timeZone = "";
@SerializedName("firmware_version")
public String firmwareVersion = "";
public @NonNullByDefault({}) Health health;
@SerializedName("battery_life")
public String battery = "";
public class Health {
@SerializedName("battery_percentage")
public int batteryPercentage;
}
private RingDeviceTO() {
}
}

View File

@ -0,0 +1,148 @@
/*
* Copyright (c) 2010-2025 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.data;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.ApiConstants;
import org.openhab.binding.ring.internal.RingAccount;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
/**
*
* @author Wim Vissers - Initial contribution
* @author Chris Milbert - stickupcam contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class RingDevices {
private List<Doorbell> doorbells = new ArrayList<>();
private List<Stickupcam> stickupcams = new ArrayList<>();
private List<Chime> chimes = new ArrayList<>();
private List<OtherDevice> otherdevices = new ArrayList<>();
public RingDevices(JsonObject jsonRingDevices, RingAccount ringAccount) {
addDoorbells((JsonArray) jsonRingDevices.get(ApiConstants.DEVICES_DOORBOTS), ringAccount);
addStickupCams((JsonArray) jsonRingDevices.get(ApiConstants.DEVICES_STICKUP_CAMS), ringAccount);
addChimes((JsonArray) jsonRingDevices.get(ApiConstants.DEVICES_CHIMES), ringAccount);
addOtherDevices((JsonArray) jsonRingDevices.get(ApiConstants.DEVICES_OTHERDEVICE), ringAccount);
}
/**
* Helper method to create the doorbell list.
*
* @param jsonDoorbells
* @param ringAccount
*/
private void addDoorbells(JsonArray jsonDoorbells, RingAccount ringAccount) {
for (Object obj : jsonDoorbells) {
Doorbell doorbell = new Doorbell((JsonObject) obj, ringAccount);
doorbells.add(doorbell);
}
}
/**
* Retrieve the Doorbells Collection.
*
* @return
*/
public Collection<Doorbell> getDoorbells() {
return doorbells;
}
/**
* Helper method to create the stickupcam list.
*
* @param jsonStickupcams
* @param ringAccount
*/
private void addStickupCams(JsonArray jsonStickupcams, RingAccount ringAccount) {
for (Object obj : jsonStickupcams) {
Stickupcam stickupcam = new Stickupcam((JsonObject) obj, ringAccount);
stickupcams.add(stickupcam);
}
}
/**
* Retrieve the Stickupcams Collection.
*
* @return
*/
public Collection<Stickupcam> getStickupcams() {
return stickupcams;
}
/**
* Helper method to create the chime list.
*
* @param jsonChimes
* @param ringAccount
*/
private void addChimes(JsonArray jsonChimes, RingAccount ringAccount) {
for (Object obj : jsonChimes) {
Chime chime = new Chime((JsonObject) obj, ringAccount);
chimes.add(chime);
}
}
/**
* Retrieve the Chimes Collection.
*
* @return
*/
public Collection<Chime> getChimes() {
return chimes;
}
/**
* Helper method to create the other list.
*
* @param jsonOther
* @param ringAccount
*/
private void addOtherDevices(JsonArray jsonOtherDevices, RingAccount ringAccount) {
for (Object obj : jsonOtherDevices) {
OtherDevice otherdevice = new OtherDevice((JsonObject) obj, ringAccount);
otherdevices.add(otherdevice);
}
}
/**
* Retrieve the Others Collection.
*
* @return
*/
public Collection<OtherDevice> getOtherDevices() {
return otherdevices;
}
/**
* Retrieve a collection of all devices.
*
* @return
*/
public Collection<RingDevice> getRingDevices() {
List<RingDevice> result = new ArrayList<>();
result.addAll(doorbells);
result.addAll(stickupcams);
result.addAll(chimes);
result.addAll(otherdevices);
return result;
}
}

View File

@ -0,0 +1,57 @@
/*
* Copyright (c) 2010-2025 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.data;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.DateTimeType;
import com.google.gson.annotations.SerializedName;
/**
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class RingEventTO {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter
.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX");
public long id = 0;
@SerializedName("created_at")
public String createdAt = "";
public boolean answered;
public String kind = "";
public boolean favorite;
@SerializedName("snapshot_url")
public @Nullable String snapshotUrl;
public Map<String, String> recording = Map.of();
public List<Object> events = List.of();
public DoorbotTO doorbot = new DoorbotTO();
/**
* Get the date/time created as String.
*
* @return the date/time.
*/
public DateTimeType getCreatedAt() {
return new DateTimeType(
ZonedDateTime.parse(createdAt, DATE_TIME_FORMATTER).withZoneSameInstant(ZoneId.systemDefault()));
}
}

View File

@ -0,0 +1,54 @@
/*
* Copyright (c) 2010-2025 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.data;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.ring.internal.RingAccount;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingUID;
import com.google.gson.JsonObject;
/**
* @author Chris Milbert - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class Stickupcam extends AbstractRingDevice {
/**
* Create Stickup Cam instance from JSON object.
*
* @param jsonStickupcam the JSON Stickup Cam retrieved from the Ring API.
* @param ringAccount the Ring Account in use
*/
public Stickupcam(JsonObject jsonStickupcam, RingAccount ringAccount) {
super(jsonStickupcam, ringAccount);
}
/**
* Get the DiscoveryResult object to identify the device as
* discovered thing.
*
* @return the device as DiscoveryResult instance.
*/
@Override
public DiscoveryResult getDiscoveryResult(RingDeviceTO deviceTO) {
DiscoveryResult result = DiscoveryResultBuilder
.create(new ThingUID("ring:stickupcam:" + getRingAccount().getThingId() + ":" + deviceTO.id))
.withLabel("Ring Video Stickup Cam - " + deviceTO.description).build();
return result;
}
}

View File

@ -0,0 +1,109 @@
/*
* Copyright (c) 2010-2025 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.discovery;
import static org.openhab.binding.ring.RingBindingConstants.*;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.ring.internal.RingDeviceRegistry;
import org.openhab.binding.ring.internal.data.RingDevice;
import org.openhab.binding.ring.internal.data.RingDeviceTO;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryService;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The RingDiscoveryService is responsible for auto detecting a Ring
* device in the local network.
*
* @author Wim Vissers - Initial contribution
* @author Chris Milbert - Stickupcam contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.ring")
@NonNullByDefault
public class RingDiscoveryService extends AbstractDiscoveryService {
private Logger logger = LoggerFactory.getLogger(RingDiscoveryService.class);
private @Nullable ScheduledFuture<?> discoveryJob;
private final Gson gson = new Gson();
public RingDiscoveryService() {
super(SUPPORTED_THING_TYPES_UIDS, 5, true);
}
public void activate() {
logger.debug("Starting Ring discovery...");
startScan();
startBackgroundDiscovery();
}
@Override
public void deactivate() {
logger.debug("Stopping Ring discovery...");
stopBackgroundDiscovery();
stopScan();
}
private void discover() {
RingDeviceRegistry registry = RingDeviceRegistry.getInstance();
for (RingDevice device : registry.getRingDevices(RingDeviceRegistry.Status.ADDED)) {
RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class);
if (deviceTO != null) {
thingDiscovered(device.getDiscoveryResult(deviceTO));
registry.setStatus(deviceTO.id, RingDeviceRegistry.Status.DISCOVERED);
}
}
}
private void refresh() {
discover();
}
@Override
protected void startBackgroundDiscovery() {
discoveryJob = scheduler.scheduleWithFixedDelay(this::refresh, 0, 120, TimeUnit.SECONDS);
}
@Override
protected void stopBackgroundDiscovery() {
logger.info("Stop Ring background discovery");
ScheduledFuture<?> job = discoveryJob;
if (job != null) {
job.cancel(true);
}
discoveryJob = null;
}
@Override
protected void startScan() {
logger.debug("Starting device search...");
discover();
}
@Override
protected synchronized void stopScan() {
removeOlderResults(getTimestampOfLastScan());
super.stopScan();
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2025 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.errors;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* AuthenticationException will be thrown if an invalid username or
* password is used to get access to the Ring account.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class AuthenticationException extends Exception {
private static final long serialVersionUID = -2630294607218363771L;
public AuthenticationException(String message) {
super(message);
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2025 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.errors;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* DeviceNotFoundException will be thrown if an device is requested from
* the device registry with an id that is not registered.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class DeviceNotFoundException extends Exception {
private static final long serialVersionUID = -463646377949508962L;
public DeviceNotFoundException(String message) {
super(message);
}
}

View File

@ -0,0 +1,33 @@
/*
* Copyright (c) 2010-2025 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.errors;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* DuplicateIdException will be thrown if an device is added to
* the device registry with an id that is already registered.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class DuplicateIdException extends Exception {
private static final long serialVersionUID = -4010587859949508962L;
public DuplicateIdException(String message) {
super(message);
}
}

View File

@ -0,0 +1,34 @@
/*
* Copyright (c) 2010-2025 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.errors;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* IllegalDeviceClassException will be thrown if an device is retrieved
* from the RingDeviceRegistry and the class is not as expected.
* E.g. if a Doorbell is expected, but a Chime is returned.
*
* @author Wim Vissers - Initial contribution
* @author Ben Rosenblum - Updated for OH4 / New Maintainer
*/
@NonNullByDefault
public class IllegalDeviceClassException extends Exception {
private static final long serialVersionUID = -4010587859949508962L;
public IllegalDeviceClassException(String message) {
super(message);
}
}

View File

@ -0,0 +1,36 @@
/*
* Copyright (c) 2010-2025 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.utils;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link RingDoorbellHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Ben Rosenblum - Initial contribution
*/
@NonNullByDefault
public class RingUtils {
public static String sanitizeData(@Nullable String sensitive) {
if (sensitive == null) {
return "NULL";
} else if ("".equals(sensitive)) {
return "STRINGEMPTY";
} else {
return "NOTEMPTY";
}
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="ring" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Ring Binding</name>
<description>This is the addon for ring.com devices like the Video Doorbell</description>
<connection>cloud</connection>
</addon:addon>

View File

@ -0,0 +1,87 @@
# add-on
addon.ring.name = Ring Binding
addon.ring.description = This is the addon for ring.com devices like the Video Doorbell
# thing types
thing-type.ring.account.label = Binding Bridge
thing-type.ring.account.description = Account with ring.com to access the devices
thing-type.ring.chime.label = Chime Binding Thing
thing-type.ring.chime.description = Ring Chime connected to the system
thing-type.ring.doorbell.label = Video Doorbell Binding Thing
thing-type.ring.doorbell.description = A Ring Video Doorbell device
thing-type.ring.other.label = Other Binding Thing
thing-type.ring.other.description = A Ring Other device
thing-type.ring.stickupcam.label = Stickup Cam Binding Thing
thing-type.ring.stickupcam.description = A Ring Stickup Cam device
# thing types config
thing-type.config.ring.account.hardwareId.label = A unique hardware id
thing-type.config.ring.account.hardwareId.description = Enter a hardware id that is unique for all your devices connected to Ring (e.g. computer's MAC address)
thing-type.config.ring.account.password.label = Account's Password
thing-type.config.ring.account.password.description = Enter the password you used to subscribe to the Ring services. If using 2 factor authentication, leave this blank and enter the refresh token instead.
thing-type.config.ring.account.refreshInterval.label = Refresh interval
thing-type.config.ring.account.refreshInterval.description = How often to poll the Ring service for events in seconds
thing-type.config.ring.account.twofactorCode.label = 2 factor authentication code
thing-type.config.ring.account.twofactorCode.description = Enter the 2 factor authentication code, if enabled
thing-type.config.ring.account.username.label = User Name
thing-type.config.ring.account.username.description = Enter the user name you used to subscribe to the Ring services. If using 2 factor authentication, leave this blank and enter the refresh token instead.
thing-type.config.ring.account.videoRetentionCount.label = Number of videos to keep
thing-type.config.ring.account.videoRetentionCount.description = The number of video files to keep when automatically downloading the latest event video, or 0 to disable auto downloading
thing-type.config.ring.account.videoStoragePath.label = Video Download Path
thing-type.config.ring.account.videoStoragePath.description = The folder path to save video .mp4 files in when downloaded from ring.com. Note: the openhab user must have rights to save to this location
thing-type.config.ring.chime.offOffset.label = Power-off Time
thing-type.config.ring.chime.offOffset.description = Offset in minutes to switch off
thing-type.config.ring.chime.refreshInterval.label = Refresh Interval
thing-type.config.ring.chime.refreshInterval.description = How often to poll the Ring service for events in seconds
thing-type.config.ring.doorbell.offOffset.label = Power-off Time
thing-type.config.ring.doorbell.offOffset.description = Offset in minutes to switch off
thing-type.config.ring.doorbell.refreshInterval.label = Refresh Interval
thing-type.config.ring.doorbell.refreshInterval.description = How often to poll the Ring service for events in seconds
thing-type.config.ring.other.offOffset.label = Power-off Time
thing-type.config.ring.other.offOffset.description = Offset in minutes to switch off
thing-type.config.ring.other.refreshInterval.label = Refresh Interval
thing-type.config.ring.other.refreshInterval.description = How often to poll the Ring service for events in seconds
thing-type.config.ring.stickupcam.offOffset.label = Power-off Time
thing-type.config.ring.stickupcam.offOffset.description = Offset in minutes to switch off
thing-type.config.ring.stickupcam.refreshInterval.label = Refresh Interval
thing-type.config.ring.stickupcam.refreshInterval.description = How often to poll the Ring service for events in seconds
# channel group types
channel-group-type.ring.controlGroup.label = Control
channel-group-type.ring.controlGroup.description = Operational control and status information
channel-group-type.ring.deviceStatus.label = Device Status
channel-group-type.ring.deviceStatus.description = Device Status Information
channel-group-type.ring.eventGroup.label = Events
channel-group-type.ring.eventGroup.description = Currently selected event information
# channel types
channel-type.ring.battery.label = Battery Level
channel-type.ring.battery.description = Battery level in %
channel-type.ring.createdAt.label = Event DateTime
channel-type.ring.createdAt.description = The date and time the event was created
channel-type.ring.createdAt.state.pattern = %1$tF %1$tR
channel-type.ring.doorbotDescription.label = Event Device Name
channel-type.ring.doorbotDescription.description = The description of the Ring device (doorbell, chime, etc) that generated the currently selected event
channel-type.ring.doorbotId.label = Event Device ID
channel-type.ring.doorbotId.description = The id of the Ring device (doorbell, chime, etc) that generated the currently selected event
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.url.label = URL to recorded video
channel-type.ring.url.description = The URL to a recorded video (only when subscribed)
# binding
binding.ring.name = Ring Binding
binding.ring.description = This is the binding for ring.com devices like the Video Doorbell
# thing types config
thing-type.config.ring.account.refreshToken.label = The Ring account's refresh token
thing-type.config.ring.account.refreshToken.description = Enter the refresh token from Ring. Use this instead of username/password when using 2 factor authentication, or if you don't wish to save the username and password in OpenHAB

View File

@ -0,0 +1,272 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="ring"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Account Bridge Type -->
<bridge-type id="account">
<label>Ring Account Bridge</label>
<description>Account with ring.com to access the devices</description>
<semantic-equipment-tag>WebService</semantic-equipment-tag>
<channel-groups>
<channel-group id="control" typeId="controlGroup"/>
<channel-group id="event" typeId="eventGroup"/>
</channel-groups>
<config-description>
<parameter name="username" type="text" required="false">
<label>User Name</label>
<description>Enter the user name you used to subscribe to the Ring services. If using 2 factor authentication, leave
this blank and enter the refresh token instead.</description>
</parameter>
<parameter name="password" type="text" required="false">
<label>Account's Password</label>
<description>Enter the password you used to subscribe to the Ring services. If using 2 factor authentication, leave
this blank and enter the refresh token instead.</description>
<context>password</context>
</parameter>
<parameter name="twofactorCode" type="text" required="false">
<context>password</context>
<label>2 factor authentication code</label>
<description>Enter the 2 factor authentication code, if enabled</description>
</parameter>
<parameter name="hardwareId" type="text">
<label>A unique hardware id</label>
<description>Enter a hardware id that is unique for all your devices connected to Ring (e.g. computer's MAC address)</description>
<default></default>
</parameter>
<parameter name="refreshInterval" type="integer">
<label>Refresh interval</label>
<description>How often to poll the Ring service for events in seconds</description>
<default>5</default>
</parameter>
<parameter name="videoStoragePath" type="text">
<label>Video Download Path</label>
<description>The folder path to save video .mp4 files in when downloaded from ring.com. Note: the openhab user must
have rights to save to this location</description>
</parameter>
<parameter name="videoRetentionCount" type="integer">
<label>Number of videos to keep</label>
<description>The number of video files to keep when automatically downloading the latest event video, or 0 to
disable auto downloading</description>
<default>10</default>
</parameter>
</config-description>
</bridge-type>
<!-- Other Device Thing Type -->
<thing-type id="otherdevice">
<supported-bridge-type-refs>
<bridge-type-ref id="account"/>
</supported-bridge-type-refs>
<label>Other Device Binding Thing</label>
<description>A Ring Other Device device</description>
<semantic-equipment-tag>Camera</semantic-equipment-tag>
<channel-groups>
<channel-group id="control" typeId="controlGroup"/>
<channel-group id="status" typeId="deviceStatus"/>
</channel-groups>
<properties>
<property name="Description">unknown</property>
<property name="Device ID">unknown</property>
<property name="Kind">unknown</property>
</properties>
<config-description>
<parameter name="offOffset" type="decimal">
<label>Power-off Time</label>
<description>Offset in minutes to switch off</description>
<default>0</default>
</parameter>
<parameter name="refreshInterval" type="integer">
<label>Refresh Interval</label>
<description>How often to poll the Ring service for events in seconds</description>
<default>5</default>
</parameter>
</config-description>
</thing-type>
<!-- Ring Chime Thing Type -->
<thing-type id="chime">
<supported-bridge-type-refs>
<bridge-type-ref id="account"/>
</supported-bridge-type-refs>
<label>Chime Binding Thing</label>
<description>Ring Chime connected to the system</description>
<semantic-equipment-tag>Camera</semantic-equipment-tag>
<channel-groups>
<channel-group id="control" typeId="controlGroup"/>
</channel-groups>
<properties>
<property name="Description">unknown</property>
<property name="Device ID">unknown</property>
<property name="Kind">unknown</property>
</properties>
<config-description>
<parameter name="offOffset" type="decimal">
<label>Power-off Time</label>
<description>Offset in minutes to switch off</description>
<default>0</default>
</parameter>
<parameter name="refreshInterval" type="integer">
<label>Refresh Interval</label>
<description>How often to poll the Ring service for events in seconds</description>
<default>5</default>
</parameter>
</config-description>
</thing-type>
<!-- Doorbell Thing Type -->
<thing-type id="doorbell">
<supported-bridge-type-refs>
<bridge-type-ref id="account"/>
</supported-bridge-type-refs>
<label>Video Doorbell Binding Thing</label>
<description>A Ring Video Doorbell device</description>
<semantic-equipment-tag>Camera</semantic-equipment-tag>
<channel-groups>
<channel-group id="control" typeId="controlGroup"/>
<channel-group id="status" typeId="deviceStatus"/>
</channel-groups>
<properties>
<property name="Description">unknown</property>
<property name="Device ID">unknown</property>
<property name="Kind">unknown</property>
</properties>
<config-description>
<parameter name="offOffset" type="decimal">
<label>Power-off Time</label>
<description>Offset in minutes to switch off</description>
<default>0</default>
</parameter>
<parameter name="refreshInterval" type="integer">
<label>Refresh Interval</label>
<description>How often to poll the Ring service for events in seconds</description>
<default>5</default>
</parameter>
</config-description>
</thing-type>
<!-- StickupCam Thing Type -->
<thing-type id="stickupcam">
<supported-bridge-type-refs>
<bridge-type-ref id="account"/>
</supported-bridge-type-refs>
<label>Stickup Cam Binding Thing</label>
<description>A Ring Stickup Cam device</description>
<semantic-equipment-tag>Camera</semantic-equipment-tag>
<channel-groups>
<channel-group id="control" typeId="controlGroup"/>
<channel-group id="status" typeId="deviceStatus"/>
</channel-groups>
<properties>
<property name="Description">unknown</property>
<property name="Device ID">unknown</property>
<property name="Kind">unknown</property>
</properties>
<config-description>
<parameter name="offOffset" type="decimal">
<label>Power-off Time</label>
<description>Offset in minutes to switch off</description>
<default>0</default>
</parameter>
<parameter name="refreshInterval" type="integer">
<label>Refresh Interval</label>
<description>How often to poll the Ring service for events in seconds</description>
<default>5</default>
</parameter>
</config-description>
</thing-type>
<!-- Channel Groups -->
<channel-group-type id="controlGroup">
<label>Control</label>
<description>Operational control and status information</description>
<channels>
<channel id="enabled" typeId="enabled"/>
</channels>
</channel-group-type>
<channel-group-type id="deviceStatus">
<label>Device Status</label>
<description>Device Status Information</description>
<channels>
<channel id="battery" typeId="battery"/>
</channels>
</channel-group-type>
<channel-group-type id="eventGroup">
<label>Events</label>
<description>Currently selected event information</description>
<channels>
<channel id="url" typeId="url"/>
<channel id="createdAt" typeId="createdAt"/>
<channel id="kind" typeId="kind"/>
<channel typeId="doorbotId" id="doorbotId"></channel>
<channel typeId="doorbotDescription" id="doorbotDescription"></channel>
</channels>
</channel-group-type>
<!-- Channel Types -->
<channel-type id="url">
<item-type>String</item-type>
<label>URL to recorded video</label>
<description>The URL to a recorded video (only when subscribed)</description>
<tags>
<tag>Status</tag>
<tag>Info</tag>
</tags>
</channel-type>
<channel-type id="doorbotId">
<item-type>String</item-type>
<label>Event Device ID</label>
<description>The id of the Ring device (doorbell, chime, etc) that generated the currently selected event</description>
<tags>
<tag>Status</tag>
<tag>Info</tag>
</tags>
</channel-type>
<channel-type id="doorbotDescription">
<item-type>String</item-type>
<label>Event Device Name</label>
<description>The description of the Ring device (doorbell, chime, etc) that generated the currently selected event</description>
<tags>
<tag>Status</tag>
<tag>Info</tag>
</tags>
</channel-type>
<channel-type id="createdAt">
<item-type>DateTime</item-type>
<label>Event DateTime</label>
<description>The date and time the event was created</description>
<tags>
<tag>Status</tag>
<tag>Timestamp</tag>
</tags>
<state pattern="%1$tF %1$tR" readOnly="true"/>
</channel-type>
<channel-type id="kind">
<item-type>String</item-type>
<label>Event Type</label>
<description>The kind of event, usually 'motion' or 'ding'</description>
<tags>
<tag>Status</tag>
<tag>Info</tag>
</tags>
</channel-type>
<channel-type id="enabled">
<item-type>Switch</item-type>
<label>Enable Polling</label>
<description>Account Polling Enabled (on=yes, off=no)</description>
<tags>
<tag>Switch</tag>
<tag>Info</tag>
</tags>
</channel-type>
<channel-type id="battery">
<item-type>Number</item-type>
<label>Battery Level</label>
<description>Battery level in %</description>
<tags>
<tag>Status</tag>
<tag>Level</tag>
</tags>
<state readOnly="true"></state>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,60 @@
/*
* Copyright (c) 2010-2025 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.data;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Scanner;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.Test;
import org.openhab.binding.ring.internal.RestClient;
import com.google.gson.Gson;
/**
* The {@link DeserializeTest} class contains de-serialization tests
*
* @author Jan N. Klug - Initial contribution
*/
@NonNullByDefault
class DeserializeTest {
private static final String RESOURCE_PATH = "src" + File.separator + "test" + File.separator + "resources"
+ File.separator;
private final Gson gson = new Gson();
@Test
void testEventDeserialization() throws FileNotFoundException {
String input = new Scanner(new File(RESOURCE_PATH + "event_response.json")).useDelimiter("\\Z").next();
List<RingEventTO> events = Objects.requireNonNull(gson.fromJson(input, RestClient.RING_EVENT_LIST_TYPE));
assertThat(events.size(), is(1));
RingEventTO event = events.getFirst();
assertThat(event.id, is(7511772057612656721L));
assertThat(event.getCreatedAt().getZonedDateTime(ZoneId.of("GMT")),
equalTo(ZonedDateTime.of(2025, 6, 3, 17, 12, 3, 567000000, ZoneId.of("GMT"))));
assertThat(event.kind, equalTo("ding"));
assertThat(event.doorbot.id, equalTo("6000000000"));
assertThat(event.doorbot.description, equalTo("Haustür"));
}
}

View File

@ -0,0 +1,43 @@
[
{
"id": 7511772057612656721,
"created_at": "2025-06-03T17:12:03.567Z",
"answered": true,
"events": [],
"kind": "ding",
"favorite": false,
"snapshot_url": "",
"recording": {
"status": "ready"
},
"duration": 62.0,
"cv_properties": {
"person_detected": null,
"stream_broken": false,
"detection_type": null,
"detection_types": null,
"security_alerts": null,
"full_description": null,
"short_description": null
},
"properties": {
"is_alexa": false,
"is_sidewalk": false,
"is_autoreply": false,
"stark_reviewed": false
},
"doorbot": {
"id": 6000000000,
"description": "Haustür",
"type": "df_doorbell_clownfish"
},
"device_placement": null,
"geolocation": null,
"last_location": null,
"siren": null,
"is_e2ee": false,
"had_subscription": true,
"owner_id": "100000000",
"riid": "00000000000000000000000000000000"
}
]

View File

@ -362,6 +362,7 @@
<module>org.openhab.binding.renault</module>
<module>org.openhab.binding.resol</module>
<module>org.openhab.binding.rfxcom</module>
<module>org.openhab.binding.ring</module>
<module>org.openhab.binding.rme</module>
<module>org.openhab.binding.robonect</module>
<module>org.openhab.binding.roku</module>