diff --git a/CODEOWNERS b/CODEOWNERS
index 9cac4b3e092..cc02be04d54 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -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
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index 045ca269502..5103d6c98a1 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1621,6 +1621,11 @@
org.openhab.binding.rfxcom
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.ring
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.rme
diff --git a/bundles/org.openhab.binding.ring/NOTICE b/bundles/org.openhab.binding.ring/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/NOTICE
@@ -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
diff --git a/bundles/org.openhab.binding.ring/README.md b/bundles/org.openhab.binding.ring/README.md
new file mode 100644
index 00000000000..6d2c082525c
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/README.md
@@ -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 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 Doorbell" [ refreshInterval=5, offOffset=0 ]
+ring:chime: "Ring Chime" [ refreshInterval=5, offOffset=0 ]
+ring:stickupcam: "Ring Stickup Camera" [ refreshInterval=5, offOffset=0 ]
+ring:other: "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::control#enabled" }
+Number RingDoorbellBattery "Ring Doorbell Battery [%s]%" { channel="ring:doorbell::status#battery"}
+
+Switch RingChimeEnabled "Ring Chime Polling Enabled" { channel="ring:chime::control#enabled" }
+
+Switch RingStickupEnabled "Ring Stickup Polling Enabled" { channel="ring:stickupcam::control#enabled" }
+Number RingStickupBattery "Ring Stickup Battery [%s]%" { channel="ring:stickupcam::status#battery"}
+
+Switch RingOtherEnabled "Ring Other Polling Enabled" { channel="ring:other::control#enabled" }
+Number RingOtherBattery "Ring Other Battery [%s]%" { channel="ring:other::status#battery"}
+```
diff --git a/bundles/org.openhab.binding.ring/pom.xml b/bundles/org.openhab.binding.ring/pom.xml
new file mode 100644
index 00000000000..11c61996f9d
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/pom.xml
@@ -0,0 +1,16 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 5.0.0-SNAPSHOT
+
+
+ org.openhab.binding.ring
+
+ openHAB Add-ons :: Bundles :: Ring Binding
+
diff --git a/bundles/org.openhab.binding.ring/src/main/feature/feature.xml b/bundles/org.openhab.binding.ring/src/main/feature/feature.xml
new file mode 100644
index 00000000000..3adaeccd249
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.ring/${project.version}
+
+
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/RingBindingConstants.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/RingBindingConstants.java
new file mode 100644
index 00000000000..1a8e69824f5
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/RingBindingConstants.java
@@ -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 SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_ACCOUNT, THING_TYPE_DOORBELL,
+ THING_TYPE_CHIME, THING_TYPE_STICKUPCAM, THING_TYPE_OTHERDEVICE);
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AbstractRingHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AbstractRingHandler.java
new file mode 100644
index 00000000000..ccadcd7507c
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AbstractRingHandler.java
@@ -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);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AccountConfiguration.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AccountConfiguration.java
new file mode 100644
index 00000000000..2a0492ae8d2
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AccountConfiguration.java
@@ -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;
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AccountHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AccountHandler.java
new file mode 100644
index 00000000000..82a1017bc16
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/AccountHandler.java
@@ -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 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();
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/ChimeHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/ChimeHandler.java
new file mode 100644
index 00000000000..8f14a274acd
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/ChimeHandler.java
@@ -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();
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/DoorbellHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/DoorbellHandler.java
new file mode 100644
index 00000000000..37c5c9b0fc4
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/DoorbellHandler.java
@@ -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);
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/OtherDeviceHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/OtherDeviceHandler.java
new file mode 100644
index 00000000000..600fc1f76ab
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/OtherDeviceHandler.java
@@ -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);
+
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/RingDeviceHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/RingDeviceHandler.java
new file mode 100644
index 00000000000..03558d2d580
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/RingDeviceHandler.java
@@ -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());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/StickupcamHandler.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/StickupcamHandler.java
new file mode 100644
index 00000000000..3f942613c71
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/handler/StickupcamHandler.java
@@ -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);
+
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/ApiConstants.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/ApiConstants.java
new file mode 100644
index 00000000000..2e8aab4caa0
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/ApiConstants.java
@@ -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";
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RestClient.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RestClient.java
new file mode 100644
index 00000000000..029646c2105
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RestClient.java
@@ -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>() {
+ }.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 map = new HashMap();
+
+ 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 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 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 "";
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingAccount.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingAccount.java
new file mode 100644
index 00000000000..159168d19a9
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingAccount.java
@@ -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();
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingDeviceRegistry.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingDeviceRegistry.java
new file mode 100644
index 00000000000..c6e0d6734fa
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingDeviceRegistry.java
@@ -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 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 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 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;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingHandlerFactory.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingHandlerFactory.java
new file mode 100644
index 00000000000..655f70e0e46
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingHandlerFactory.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingVideoServlet.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingVideoServlet.java
new file mode 100644
index 00000000000..8f4387da55b
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/RingVideoServlet.java
@@ -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());
+ }
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/console/RingCommandExtension.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/console/RingCommandExtension.java
new file mode 100644
index 00000000000..0aa4375c2b8
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/console/RingCommandExtension.java
@@ -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 getUsages() {
+ return List.of(buildCommandUsage(" login ",
+ "Send a login request to the RING API"));
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/AbstractRingDevice.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/AbstractRingDevice.java
new file mode 100644
index 00000000000..b28b4b6e547
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/AbstractRingDevice.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Chime.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Chime.java
new file mode 100644
index 00000000000..6b26e9e5864
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Chime.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/DataFactory.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/DataFactory.java
new file mode 100644
index 00000000000..36cce939412
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/DataFactory.java
@@ -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();
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Doorbell.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Doorbell.java
new file mode 100644
index 00000000000..6b3adc58f98
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Doorbell.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/DoorbotTO.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/DoorbotTO.java
new file mode 100644
index 00000000000..c9c8b52bb87
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/DoorbotTO.java
@@ -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 = "";
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Feature.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Feature.java
new file mode 100644
index 00000000000..358f5a4eeb2
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Feature.java
@@ -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();
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/OtherDevice.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/OtherDevice.java
new file mode 100644
index 00000000000..d498716c609
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/OtherDevice.java
@@ -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;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/ParamBuilder.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/ParamBuilder.java
new file mode 100644
index 00000000000..cb8136f3010
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/ParamBuilder.java
@@ -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 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¶m2=value2, etc.
+ */
+ @Override
+ public String toString() {
+ StringBuilder b = new StringBuilder();
+ for (Map.Entry entry : parameters.entrySet()) {
+ if (b.length() != 0) {
+ b.append("&");
+ }
+ b.append(encode(entry.getKey())).append("=").append(encode(entry.getValue()));
+ }
+ return b.toString();
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Profile.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Profile.java
new file mode 100644
index 00000000000..5bf511abe36
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Profile.java
@@ -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() {
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDevice.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDevice.java
new file mode 100644
index 00000000000..622876f8540
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDevice.java
@@ -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();
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDeviceTO.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDeviceTO.java
new file mode 100644
index 00000000000..3ade29c2b2b
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDeviceTO.java
@@ -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() {
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDevices.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDevices.java
new file mode 100644
index 00000000000..0a86208b5ec
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingDevices.java
@@ -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 doorbells = new ArrayList<>();
+ private List stickupcams = new ArrayList<>();
+ private List chimes = new ArrayList<>();
+ private List 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 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 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 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 getOtherDevices() {
+ return otherdevices;
+ }
+
+ /**
+ * Retrieve a collection of all devices.
+ *
+ * @return
+ */
+ public Collection getRingDevices() {
+ List result = new ArrayList<>();
+ result.addAll(doorbells);
+ result.addAll(stickupcams);
+ result.addAll(chimes);
+ result.addAll(otherdevices);
+ return result;
+ }
+}
diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingEventTO.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingEventTO.java
new file mode 100644
index 00000000000..98685b7d948
--- /dev/null
+++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/RingEventTO.java
@@ -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 recording = Map.of();
+ public List