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 events = List.of(); + public DoorbotTO doorbot = new DoorbotTO(); + + /** + * Get the date/time created as String. + * + * @return the date/time. + */ + public DateTimeType getCreatedAt() { + return new DateTimeType( + ZonedDateTime.parse(createdAt, DATE_TIME_FORMATTER).withZoneSameInstant(ZoneId.systemDefault())); + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Stickupcam.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Stickupcam.java new file mode 100644 index 00000000000..7ac5ae5f4d3 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/data/Stickupcam.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 Chris Milbert - Initial contribution + * @author Ben Rosenblum - Updated for OH4 / New Maintainer + */ + +@NonNullByDefault +public class Stickupcam extends AbstractRingDevice { + + /** + * Create Stickup Cam instance from JSON object. + * + * @param jsonStickupcam the JSON Stickup Cam retrieved from the Ring API. + * @param ringAccount the Ring Account in use + */ + public Stickupcam(JsonObject jsonStickupcam, RingAccount ringAccount) { + super(jsonStickupcam, ringAccount); + } + + /** + * Get the DiscoveryResult object to identify the device as + * discovered thing. + * + * @return the device as DiscoveryResult instance. + */ + @Override + public DiscoveryResult getDiscoveryResult(RingDeviceTO deviceTO) { + DiscoveryResult result = DiscoveryResultBuilder + .create(new ThingUID("ring:stickupcam:" + getRingAccount().getThingId() + ":" + deviceTO.id)) + .withLabel("Ring Video Stickup Cam - " + deviceTO.description).build(); + return result; + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/discovery/RingDiscoveryService.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/discovery/RingDiscoveryService.java new file mode 100644 index 00000000000..3c99798304c --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/discovery/RingDiscoveryService.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.discovery; + +import static org.openhab.binding.ring.RingBindingConstants.*; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.ring.internal.RingDeviceRegistry; +import org.openhab.binding.ring.internal.data.RingDevice; +import org.openhab.binding.ring.internal.data.RingDeviceTO; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryService; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + * The RingDiscoveryService is responsible for auto detecting a Ring + * device in the local network. + * + * @author Wim Vissers - Initial contribution + * @author Chris Milbert - Stickupcam contribution + * @author Ben Rosenblum - Updated for OH4 / New Maintainer + */ + +@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.ring") +@NonNullByDefault +public class RingDiscoveryService extends AbstractDiscoveryService { + + private Logger logger = LoggerFactory.getLogger(RingDiscoveryService.class); + private @Nullable ScheduledFuture discoveryJob; + + private final Gson gson = new Gson(); + + public RingDiscoveryService() { + super(SUPPORTED_THING_TYPES_UIDS, 5, true); + } + + public void activate() { + logger.debug("Starting Ring discovery..."); + startScan(); + startBackgroundDiscovery(); + } + + @Override + public void deactivate() { + logger.debug("Stopping Ring discovery..."); + stopBackgroundDiscovery(); + stopScan(); + } + + private void discover() { + RingDeviceRegistry registry = RingDeviceRegistry.getInstance(); + for (RingDevice device : registry.getRingDevices(RingDeviceRegistry.Status.ADDED)) { + RingDeviceTO deviceTO = gson.fromJson(device.getJsonObject(), RingDeviceTO.class); + if (deviceTO != null) { + thingDiscovered(device.getDiscoveryResult(deviceTO)); + registry.setStatus(deviceTO.id, RingDeviceRegistry.Status.DISCOVERED); + } + } + } + + private void refresh() { + discover(); + } + + @Override + protected void startBackgroundDiscovery() { + discoveryJob = scheduler.scheduleWithFixedDelay(this::refresh, 0, 120, TimeUnit.SECONDS); + } + + @Override + protected void stopBackgroundDiscovery() { + logger.info("Stop Ring background discovery"); + ScheduledFuture job = discoveryJob; + if (job != null) { + job.cancel(true); + } + discoveryJob = null; + } + + @Override + protected void startScan() { + logger.debug("Starting device search..."); + discover(); + } + + @Override + protected synchronized void stopScan() { + removeOlderResults(getTimestampOfLastScan()); + super.stopScan(); + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/AuthenticationException.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/AuthenticationException.java new file mode 100644 index 00000000000..34df3047c80 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/AuthenticationException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.errors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * AuthenticationException will be thrown if an invalid username or + * password is used to get access to the Ring account. + * + * @author Wim Vissers - Initial contribution + * @author Ben Rosenblum - Updated for OH4 / New Maintainer + */ + +@NonNullByDefault +public class AuthenticationException extends Exception { + + private static final long serialVersionUID = -2630294607218363771L; + + public AuthenticationException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/DeviceNotFoundException.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/DeviceNotFoundException.java new file mode 100644 index 00000000000..e28f5b8cb4f --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/DeviceNotFoundException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.errors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * DeviceNotFoundException will be thrown if an device is requested from + * the device registry with an id that is not registered. + * + * @author Wim Vissers - Initial contribution + * @author Ben Rosenblum - Updated for OH4 / New Maintainer + */ + +@NonNullByDefault +public class DeviceNotFoundException extends Exception { + + private static final long serialVersionUID = -463646377949508962L; + + public DeviceNotFoundException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/DuplicateIdException.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/DuplicateIdException.java new file mode 100644 index 00000000000..f9c031019b7 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/DuplicateIdException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.errors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * DuplicateIdException will be thrown if an device is added to + * the device registry with an id that is already registered. + * + * @author Wim Vissers - Initial contribution + * @author Ben Rosenblum - Updated for OH4 / New Maintainer + */ + +@NonNullByDefault +public class DuplicateIdException extends Exception { + + private static final long serialVersionUID = -4010587859949508962L; + + public DuplicateIdException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/IllegalDeviceClassException.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/IllegalDeviceClassException.java new file mode 100644 index 00000000000..f2261896f90 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/errors/IllegalDeviceClassException.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.errors; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * IllegalDeviceClassException will be thrown if an device is retrieved + * from the RingDeviceRegistry and the class is not as expected. + * E.g. if a Doorbell is expected, but a Chime is returned. + * + * @author Wim Vissers - Initial contribution + * @author Ben Rosenblum - Updated for OH4 / New Maintainer + */ + +@NonNullByDefault +public class IllegalDeviceClassException extends Exception { + + private static final long serialVersionUID = -4010587859949508962L; + + public IllegalDeviceClassException(String message) { + super(message); + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/utils/RingUtils.java b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/utils/RingUtils.java new file mode 100644 index 00000000000..4bf3f53dbed --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/java/org/openhab/binding/ring/internal/utils/RingUtils.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.utils; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * The {@link RingDoorbellHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Ben Rosenblum - Initial contribution + */ +@NonNullByDefault +public class RingUtils { + + public static String sanitizeData(@Nullable String sensitive) { + if (sensitive == null) { + return "NULL"; + } else if ("".equals(sensitive)) { + return "STRINGEMPTY"; + } else { + return "NOTEMPTY"; + } + } +} diff --git a/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..bc28c46f93c --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,11 @@ + + + + binding + Ring Binding + This is the addon for ring.com devices like the Video Doorbell + cloud + + diff --git a/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/i18n/ring.properties b/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/i18n/ring.properties new file mode 100644 index 00000000000..c88c6ed51b9 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/i18n/ring.properties @@ -0,0 +1,87 @@ +# add-on + +addon.ring.name = Ring Binding +addon.ring.description = This is the addon for ring.com devices like the Video Doorbell + +# thing types + +thing-type.ring.account.label = Binding Bridge +thing-type.ring.account.description = Account with ring.com to access the devices +thing-type.ring.chime.label = Chime Binding Thing +thing-type.ring.chime.description = Ring Chime connected to the system +thing-type.ring.doorbell.label = Video Doorbell Binding Thing +thing-type.ring.doorbell.description = A Ring Video Doorbell device +thing-type.ring.other.label = Other Binding Thing +thing-type.ring.other.description = A Ring Other device +thing-type.ring.stickupcam.label = Stickup Cam Binding Thing +thing-type.ring.stickupcam.description = A Ring Stickup Cam device + +# thing types config + +thing-type.config.ring.account.hardwareId.label = A unique hardware id +thing-type.config.ring.account.hardwareId.description = Enter a hardware id that is unique for all your devices connected to Ring (e.g. computer's MAC address) +thing-type.config.ring.account.password.label = Account's Password +thing-type.config.ring.account.password.description = Enter the password you used to subscribe to the Ring services. If using 2 factor authentication, leave this blank and enter the refresh token instead. +thing-type.config.ring.account.refreshInterval.label = Refresh interval +thing-type.config.ring.account.refreshInterval.description = How often to poll the Ring service for events in seconds +thing-type.config.ring.account.twofactorCode.label = 2 factor authentication code +thing-type.config.ring.account.twofactorCode.description = Enter the 2 factor authentication code, if enabled +thing-type.config.ring.account.username.label = User Name +thing-type.config.ring.account.username.description = Enter the user name you used to subscribe to the Ring services. If using 2 factor authentication, leave this blank and enter the refresh token instead. +thing-type.config.ring.account.videoRetentionCount.label = Number of videos to keep +thing-type.config.ring.account.videoRetentionCount.description = The number of video files to keep when automatically downloading the latest event video, or 0 to disable auto downloading +thing-type.config.ring.account.videoStoragePath.label = Video Download Path +thing-type.config.ring.account.videoStoragePath.description = The folder path to save video .mp4 files in when downloaded from ring.com. Note: the openhab user must have rights to save to this location +thing-type.config.ring.chime.offOffset.label = Power-off Time +thing-type.config.ring.chime.offOffset.description = Offset in minutes to switch off +thing-type.config.ring.chime.refreshInterval.label = Refresh Interval +thing-type.config.ring.chime.refreshInterval.description = How often to poll the Ring service for events in seconds +thing-type.config.ring.doorbell.offOffset.label = Power-off Time +thing-type.config.ring.doorbell.offOffset.description = Offset in minutes to switch off +thing-type.config.ring.doorbell.refreshInterval.label = Refresh Interval +thing-type.config.ring.doorbell.refreshInterval.description = How often to poll the Ring service for events in seconds +thing-type.config.ring.other.offOffset.label = Power-off Time +thing-type.config.ring.other.offOffset.description = Offset in minutes to switch off +thing-type.config.ring.other.refreshInterval.label = Refresh Interval +thing-type.config.ring.other.refreshInterval.description = How often to poll the Ring service for events in seconds +thing-type.config.ring.stickupcam.offOffset.label = Power-off Time +thing-type.config.ring.stickupcam.offOffset.description = Offset in minutes to switch off +thing-type.config.ring.stickupcam.refreshInterval.label = Refresh Interval +thing-type.config.ring.stickupcam.refreshInterval.description = How often to poll the Ring service for events in seconds + +# channel group types + +channel-group-type.ring.controlGroup.label = Control +channel-group-type.ring.controlGroup.description = Operational control and status information +channel-group-type.ring.deviceStatus.label = Device Status +channel-group-type.ring.deviceStatus.description = Device Status Information +channel-group-type.ring.eventGroup.label = Events +channel-group-type.ring.eventGroup.description = Currently selected event information + +# channel types + +channel-type.ring.battery.label = Battery Level +channel-type.ring.battery.description = Battery level in % +channel-type.ring.createdAt.label = Event DateTime +channel-type.ring.createdAt.description = The date and time the event was created +channel-type.ring.createdAt.state.pattern = %1$tF %1$tR +channel-type.ring.doorbotDescription.label = Event Device Name +channel-type.ring.doorbotDescription.description = The description of the Ring device (doorbell, chime, etc) that generated the currently selected event +channel-type.ring.doorbotId.label = Event Device ID +channel-type.ring.doorbotId.description = The id of the Ring device (doorbell, chime, etc) that generated the currently selected event +channel-type.ring.enabled.label = Enable Polling +channel-type.ring.enabled.description = Account Polling Enabled (on=yes, off=no) +channel-type.ring.kind.label = Event Type +channel-type.ring.kind.description = The kind of event, usually 'motion' or 'ding' +channel-type.ring.url.label = URL to recorded video +channel-type.ring.url.description = The URL to a recorded video (only when subscribed) + +# binding + +binding.ring.name = Ring Binding +binding.ring.description = This is the binding for ring.com devices like the Video Doorbell + +# thing types config + +thing-type.config.ring.account.refreshToken.label = The Ring account's refresh token +thing-type.config.ring.account.refreshToken.description = Enter the refresh token from Ring. Use this instead of username/password when using 2 factor authentication, or if you don't wish to save the username and password in OpenHAB diff --git a/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..04f75020960 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,272 @@ + + + + + + + Account with ring.com to access the devices + WebService + + + + + + + + Enter the user name you used to subscribe to the Ring services. If using 2 factor authentication, leave + this blank and enter the refresh token instead. + + + + Enter the password you used to subscribe to the Ring services. If using 2 factor authentication, leave + this blank and enter the refresh token instead. + password + + + password + + Enter the 2 factor authentication code, if enabled + + + + Enter a hardware id that is unique for all your devices connected to Ring (e.g. computer's MAC address) + + + + + How often to poll the Ring service for events in seconds + 5 + + + + The folder path to save video .mp4 files in when downloaded from ring.com. Note: the openhab user must + have rights to save to this location + + + + The number of video files to keep when automatically downloading the latest event video, or 0 to + disable auto downloading + 10 + + + + + + + + + + + A Ring Other Device device + Camera + + + + + + unknown + unknown + unknown + + + + + Offset in minutes to switch off + 0 + + + + How often to poll the Ring service for events in seconds + 5 + + + + + + + + + + Ring Chime connected to the system + Camera + + + + + unknown + unknown + unknown + + + + + Offset in minutes to switch off + 0 + + + + How often to poll the Ring service for events in seconds + 5 + + + + + + + + + + A Ring Video Doorbell device + Camera + + + + + + unknown + unknown + unknown + + + + + Offset in minutes to switch off + 0 + + + + How often to poll the Ring service for events in seconds + 5 + + + + + + + + + + A Ring Stickup Cam device + Camera + + + + + + unknown + unknown + unknown + + + + + Offset in minutes to switch off + 0 + + + + How often to poll the Ring service for events in seconds + 5 + + + + + + + + Operational control and status information + + + + + + + + Device Status Information + + + + + + + + Currently selected event information + + + + + + + + + + + + String + + The URL to a recorded video (only when subscribed) + + Status + Info + + + + String + + The id of the Ring device (doorbell, chime, etc) that generated the currently selected event + + Status + Info + + + + String + + The description of the Ring device (doorbell, chime, etc) that generated the currently selected event + + Status + Info + + + + DateTime + + The date and time the event was created + + Status + Timestamp + + + + + String + + The kind of event, usually 'motion' or 'ding' + + Status + Info + + + + Switch + + Account Polling Enabled (on=yes, off=no) + + Switch + Info + + + + Number + + Battery level in % + + Status + Level + + + + diff --git a/bundles/org.openhab.binding.ring/src/test/java/org/openhab/binding/ring/internal/data/DeserializeTest.java b/bundles/org.openhab.binding.ring/src/test/java/org/openhab/binding/ring/internal/data/DeserializeTest.java new file mode 100644 index 00000000000..e52309655d9 --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/test/java/org/openhab/binding/ring/internal/data/DeserializeTest.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.ring.internal.data; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.io.File; +import java.io.FileNotFoundException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Objects; +import java.util.Scanner; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.ring.internal.RestClient; + +import com.google.gson.Gson; + +/** + * The {@link DeserializeTest} class contains de-serialization tests + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +class DeserializeTest { + private static final String RESOURCE_PATH = "src" + File.separator + "test" + File.separator + "resources" + + File.separator; + + private final Gson gson = new Gson(); + + @Test + void testEventDeserialization() throws FileNotFoundException { + String input = new Scanner(new File(RESOURCE_PATH + "event_response.json")).useDelimiter("\\Z").next(); + + List events = Objects.requireNonNull(gson.fromJson(input, RestClient.RING_EVENT_LIST_TYPE)); + + assertThat(events.size(), is(1)); + RingEventTO event = events.getFirst(); + + assertThat(event.id, is(7511772057612656721L)); + assertThat(event.getCreatedAt().getZonedDateTime(ZoneId.of("GMT")), + equalTo(ZonedDateTime.of(2025, 6, 3, 17, 12, 3, 567000000, ZoneId.of("GMT")))); + assertThat(event.kind, equalTo("ding")); + assertThat(event.doorbot.id, equalTo("6000000000")); + assertThat(event.doorbot.description, equalTo("Haustür")); + } +} diff --git a/bundles/org.openhab.binding.ring/src/test/resources/event_response.json b/bundles/org.openhab.binding.ring/src/test/resources/event_response.json new file mode 100644 index 00000000000..5832c8971ce --- /dev/null +++ b/bundles/org.openhab.binding.ring/src/test/resources/event_response.json @@ -0,0 +1,43 @@ +[ + { + "id": 7511772057612656721, + "created_at": "2025-06-03T17:12:03.567Z", + "answered": true, + "events": [], + "kind": "ding", + "favorite": false, + "snapshot_url": "", + "recording": { + "status": "ready" + }, + "duration": 62.0, + "cv_properties": { + "person_detected": null, + "stream_broken": false, + "detection_type": null, + "detection_types": null, + "security_alerts": null, + "full_description": null, + "short_description": null + }, + "properties": { + "is_alexa": false, + "is_sidewalk": false, + "is_autoreply": false, + "stark_reviewed": false + }, + "doorbot": { + "id": 6000000000, + "description": "Haustür", + "type": "df_doorbell_clownfish" + }, + "device_placement": null, + "geolocation": null, + "last_location": null, + "siren": null, + "is_e2ee": false, + "had_subscription": true, + "owner_id": "100000000", + "riid": "00000000000000000000000000000000" + } +] \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index 0054a7b97a5..504ffaa79af 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -362,6 +362,7 @@ org.openhab.binding.renault org.openhab.binding.resol org.openhab.binding.rfxcom + org.openhab.binding.ring org.openhab.binding.rme org.openhab.binding.robonect org.openhab.binding.roku