diff --git a/CODEOWNERS b/CODEOWNERS index 2acfcd6ac72..06fc47d3909 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -104,6 +104,7 @@ /bundles/org.openhab.binding.electroluxappliance/ @jannegpriv /bundles/org.openhab.binding.elerotransmitterstick/ @vbier /bundles/org.openhab.binding.elroconnects/ @mherwege +/bundles/org.openhab.binding.emby/ @volfan6415 /bundles/org.openhab.binding.emotiva/ @espenaf /bundles/org.openhab.binding.energenie/ @hmerk /bundles/org.openhab.binding.energidataservice/ @jlaur diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 19439b3b7d2..8ee1624743b 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -506,6 +506,11 @@ org.openhab.binding.elroconnects ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.emby + ${project.version} + org.openhab.addons.bundles org.openhab.binding.emotiva diff --git a/bundles/org.openhab.binding.emby/NOTICE b/bundles/org.openhab.binding.emby/NOTICE new file mode 100644 index 00000000000..d339c4f8b77 --- /dev/null +++ b/bundles/org.openhab.binding.emby/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 \ No newline at end of file diff --git a/bundles/org.openhab.binding.emby/README.md b/bundles/org.openhab.binding.emby/README.md new file mode 100644 index 00000000000..03ad31caa21 --- /dev/null +++ b/bundles/org.openhab.binding.emby/README.md @@ -0,0 +1,164 @@ +# Emby Binding + +The **Emby Binding** integrates [Emby](https://emby.media/), a personal media server, with openHAB. + +It allows controlling Emby players and retrieving player status data. +For example, you can monitor the currently playing movie title or automatically dim your lights when playback starts. +This binding supports multiple Emby clients connected to a single Emby Media Server. +It provides functionality similar to the Plex Binding. + +## Supported Things + +This binding defines the following Thing Type IDs: + +- `controller` + Represents a connection to an Emby server (a Bridge Thing). + +- `device` + Represents a client/player device connected to the Emby server. + +## Automatic Discovery + +The binding supports automatic discovery for both servers (`controller`) and clients (`device`). + +## Binding Configuration + +There is no global binding-level configuration required or supported. + +## Thing Configuration + +### `controller` Bridge Configuration + +The following Configuration Parameter Keys are available: + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|---------------------------------------------------------|---------|----------|----------| +| ipAddress | Text | IP address or hostname of the Emby server. | N/A | Yes | No | +| api | Text | API Key generated from Emby for authorization. | N/A | Yes | No | +| bufferSize | Integer | WebSocket buffer size in bytes. | 10,000 | No | No | +| refreshInterval | Integer | Polling interval for play-state updates (milliseconds). | 10,000 | No | No | +| port | Integer | Port in which EMBY is listening for communication. | 8096 | No | No | +| discovery | Boolean | Enable or disable automatic device discovery. | true | No | Yes | + +### `device` Thing Configuration + +The following Configuration Parameter Key is available: + +- `deviceID` + The unique identifier for the client device connected to the Emby server. + +## Channels + +The following Channel IDs are available for a `device` Thing: + +| Channel ID | Item Type | Config Parameters | Description | +|--------------|-------------|-----------------------------------------------------------------------|-----------------------------------------------------------------------| +| control | Player | None | Playback control (play, pause, next, previous, fast-forward, rewind). | +| stop | Switch | None | Indicates playback state; OFF stops playback. | +| title | String | None | Title of the currently playing song. | +| show-title | String | None | Title of the currently playing movie or TV show. | +| mute | Switch | None | Mute status control. | +| image-url | String | imageUrlMaxHeight, imageMaxWidth, imageUrlType, imageUrlPercentPlayed | URL for current media artwork. | +| current-time | Number:Time | None | Current playback position. | +| duration | Number:Time | None | Total media duration. | +| media-type | String | None | Type of media (e.g., Movie, Episode). | + +## `image-url` Config Parameters + +| Parameter Name | Type | Default | Description | +|-------------------------|---------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `imageUrlType` | Text | Primary | Specifies the image type to retrieve. Options include `Primary`, `Art`, `Backdrop`, `Banner`, `Logo`, `Thumb`, `Disc`, `Box`, `Screenshot`, `Menu`, and `Chapter`. | +| `imageUrlMaxHeight` | Text | None | The maximum height (in pixels) of the retrieved image. | +| `imageUrlMaxWidth` | Text | None | The maximum width (in pixels) of the retrieved image. | +| `imageUrlPercentPlayed` | Boolean | false | If true, adds an overlay indicating the percent played (e.g., 47%). | + + +## Full Example + +### `emby.things` Example + +```java +Bridge emby:controller:myEmbyServer [ + ipAddress="192.168.1.100", + api="YOUR_EMBY_API_KEY", + bufferSize=16384, + refreshInterval=2000, + discovery=true +] { + Thing emby:device:myClientDevice [ + deviceID="YOUR_CLIENT_DEVICE_ID" + ] +} +``` + +### `emby.items` Example + +```java +Switch Emby_PlayPause "Play/Pause" { channel="emby:device:myEmbyServer:myClientDevice:control" } +Switch Emby_Stop "Stop" { channel="emby:device:myEmbyServer:myClientDevice:stop" } +Switch Emby_Mute "Mute" { channel="emby:device:myEmbyServer:myClientDevice:mute" } +String Emby_Title "Title [%s]" { channel="emby:device:myEmbyServer:myClientDevice:title" } +String Emby_ShowTitle "Show Title [%s]" { channel="emby:device:myEmbyServer:myClientDevice:show-title" } +Number:Time Emby_CurrentTime "Current Time [%d %unit%]" { channel="emby:device:myEmbyServer:myClientDevice:current-time" } +Number:Time Emby_Duration "Duration [%d %unit%]" { channel="emby:device:myEmbyServer:myClientDevice:duration" } +String Emby_MediaType "Media Type [%s]" { channel="emby:device:myEmbyServer:myClientDevice:media-type" } +String Emby_ImageURL "Artwork URL [%s]" { channel="emby:device:myEmbyServer:myClientDevice:image-url" } +``` + +### `emby.sitemap` Configuration Example + +```perl +sitemap emby label="Emby Control" +{ + Frame label="Controls" { + Switch item=Emby_PlayPause + Switch item=Emby_Stop + Switch item=Emby_Mute + } + Frame label="Now Playing" { + Text item=Emby_Title + Text item=Emby_ShowTitle + Text item=Emby_MediaType + Text item=Emby_CurrentTime + Text item=Emby_Duration + Text item=Emby_ImageURL + } +} +``` + +## Rule Actions + +All playback and control commands are now implemented as Rule Actions rather than channels. Use the standard `getActions` API in your rules to invoke these. + +### Available Actions + +| Action ID | Method Signature | Description | +|----------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------| +| sendPlay | `sendPlay(ItemIds: String, PlayCommand: String, StartPositionTicks: Integer?, MediaSourceId: String?, AudioStreamIndex: Integer?, SubtitleStreamIndex: Integer?, StartIndex: Integer?)` | Send a play command with optional parameters to an Emby player. | +| sendGeneralCommand | `sendGeneralCommand(CommandName: String)` | Send a generic Emby control command (e.g., MoveUp, ToggleMute, GoHome). | +| sendGeneralCommandWithArgs | `sendGeneralCommandWithArgs(CommandName: String, Arguments: String)` | Send a generic Emby control command with a JSON arguments blob (e.g., SetVolume, DisplayMessage, etc.). | + +### Example Rule (XTend) + +```xtend +rule "Play Movie on Emby" +when + Item MySwitch changed to ON +then + val embyActions = getActions("emby", "emby:device:myServer:myDevice") + // Play item IDs "abc,def" immediately + embyActions.sendPlay("abc,def", "PlayNow", null, null, null, null, null) +end +``` + +### Example Rule (JavaScript) + +```javascript +// inside a JS Scripting rule +let emby = actions.getActions("emby", "emby:device:myServer:myDevice"); +emby.sendGeneralCommand("ToggleMute"); +``` + +## References + +- [Emby Remote Control API Documentation](https://github.com/MediaBrowser/Emby/wiki/Remote-control) diff --git a/bundles/org.openhab.binding.emby/pom.xml b/bundles/org.openhab.binding.emby/pom.xml new file mode 100644 index 00000000000..3b00d47cc8d --- /dev/null +++ b/bundles/org.openhab.binding.emby/pom.xml @@ -0,0 +1,15 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.0.0-SNAPSHOT + + + org.openhab.binding.emby + + diff --git a/bundles/org.openhab.binding.emby/src/main/feature/feature.xml b/bundles/org.openhab.binding.emby/src/main/feature/feature.xml new file mode 100644 index 00000000000..54b42199c94 --- /dev/null +++ b/bundles/org.openhab.binding.emby/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.emby/${project.version} + + diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBindingConstants.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBindingConstants.java new file mode 100644 index 00000000000..59e1e7faf0f --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBindingConstants.java @@ -0,0 +1,70 @@ +/* + * 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.emby.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link EmbyBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyBindingConstants { + + private static final String BINDING_ID = "emby"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_EMBY_CONTROLLER = new ThingTypeUID(BINDING_ID, "controller"); + public static final ThingTypeUID THING_TYPE_EMBY_DEVICE = new ThingTypeUID(BINDING_ID, "device"); + + // List of all Channel ids + public static final String CHANNEL_MUTE = "mute"; + public static final String CHANNEL_STOP = "stop"; + public static final String CHANNEL_CONTROL = "control"; + + public static final String CHANNEL_TITLE = "title"; + public static final String CHANNEL_SHOWTITLE = "show-title"; + + public static final String CHANNEL_MEDIATYPE = "media-type"; + + public static final String CHANNEL_CURRENTTIME = "current-time"; + + public static final String CHANNEL_DURATION = "duration"; + public static final String CHANNEL_IMAGEURL = "image-url"; + public static final String CHANNEL_IMAGEURL_CONFIG_TYPE = "imageUrlType"; + public static final String CHANNEL_IMAGEURL_CONFIG_MAXWIDTH = "imageUrlMaxWidth"; + public static final String CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT = "imageUrlMaxHeight"; + public static final String CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED = "imageUrlPercentPlayed"; + + // Module Properties + + public static final String CONFIG_HOST_PARAMETER = "ipAddress"; + public static final String CONFIG_WS_PORT_PARAMETER = "port"; + public static final String CONFIG_REFRESH_PARAMETER = "refreshInterval"; + public static final String CONFIG_API_KEY = "api"; + public static final String CONFIG_DEVICE_ID = "deviceID"; + public static final String CONFIG_DISCOVERY_ENABLE = "discovery"; + // control constant commands + public static final String CONTROL_SESSION = "/Sessions/"; + public static final String CONTROL_GENERALCOMMAND = "/Command/"; + public static final String CONTROL_SENDPLAY = "/Playing"; + public static final String CONTROL_PLAY = "/Playing/Unpause"; + public static final String CONTROL_PAUSE = "/Playing/Pause"; + public static final String CONTROL_MUTE = "/Command/Mute"; + public static final String CONTROL_UNMUTE = "/Command/Unmute"; + public static final String CONTROL_STOP = "/Playing/Stop"; + public static final int CONNECTION_CHECK_INTERVAL_MS = 360000; +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeConfiguration.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeConfiguration.java new file mode 100644 index 00000000000..a7a086a15be --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeConfiguration.java @@ -0,0 +1,37 @@ +/* + * 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.emby.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.config.core.Configuration; + +/** + * The {@link EmbyBridgeConfiguration} class contains fields mapping thing configuration parameters. + * + * @param api - This is the API key generated from EMBY used for Authorization. + * @param ipAddress - IP address of the EMBY Server. + * @param port - Port of the EMBY Server. Default is 8096. + * @param refreshInterval - Refresh interval in milliseconds. Default is 10,000. + * @param discovery - Enable/disable device auto-discovery. Default is true (enabled). + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyBridgeConfiguration extends Configuration { + + public String api = ""; + public String ipAddress = ""; + public int port = 8096; // Default server port + public int refreshInterval = 10000; // Default refresh interval + public boolean discovery = true; // Discovery enabled by default +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeListener.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeListener.java new file mode 100644 index 00000000000..f435595b994 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyBridgeListener.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.emby.internal; + +import java.util.EventListener; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; + +/** + * Interface which has to be implemented by a class in order to get status + * updates from a {@link EmbyConnection} + * + * @author Zachary Christiansen - Initial Contribution + */ +@NonNullByDefault +public interface EmbyBridgeListener extends EventListener { + + /** + * Callback invoked when the connection state to the Emby server changes. + * + * @param connected {@code true} if the binding is currently connected to the Emby server, + * {@code false} otherwise. + */ + void updateConnectionState(boolean connected); + + /** + * Callback invoked when a playback event is received from the Emby server. + * + * @param playstate the {@link EmbyPlayStateModel} containing details about the current playback state + * @param hostname the hostname or IP address of the Emby server that sent the event + * @param embyport the port number on which the Emby server is running + */ + void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport); +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyDeviceConfiguration.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyDeviceConfiguration.java new file mode 100644 index 00000000000..795ddc34bd4 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyDeviceConfiguration.java @@ -0,0 +1,38 @@ +/* + * 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.emby.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link EmbyDeviceConfiguration} class contains fields mapping thing configuration parameters. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyDeviceConfiguration { + + public String deviceID; + public String imageMaxWidth; + public String imageMaxHeight; + public boolean imagePercentPlayed; + public String imageImageType; + + public EmbyDeviceConfiguration(String setDeviceID) { + deviceID = setDeviceID; + imageMaxWidth = ""; + imageMaxHeight = ""; + imagePercentPlayed = false; + imageImageType = ""; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyEventListener.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyEventListener.java new file mode 100644 index 00000000000..8927b51f6a4 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyEventListener.java @@ -0,0 +1,137 @@ +/* + * 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.emby.internal; + +import java.util.EventListener; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; + +/** + * Listener interface to receive status updates from an {@link EmbyConnection}. + * Implementations of this interface will be notified of connection changes, + * playback events, and metadata updates from the Emby server. + * + * @author Zachary Christiansen - Initial Contribution + */ +@NonNullByDefault +public interface EmbyEventListener extends EventListener { + + /** + * Enumeration of possible player states reported by the Emby server. + */ + public enum EmbyState { + /** Playback has started or resumed. */ + PLAY, + /** Playback is paused. */ + PAUSE, + /** Playback has reached the end of media. */ + END, + /** Playback has been stopped. */ + STOP, + /** Playback is rewinding. */ + REWIND, + /** Playback is fast-forwarding. */ + FASTFORWARD + } + + /** + * Enumeration of playlist modifications events reported by the Emby server. + */ + public enum EmbyPlaylistState { + /** Item(s) are about to be added to the playlist. */ + ADD, + /** Item(s) have been added to the playlist. */ + ADDED, + /** Item(s) are about to be inserted into the playlist. */ + INSERT, + /** Item(s) are about to be removed from the playlist. */ + REMOVE, + /** Item(s) have been removed from the playlist. */ + REMOVED, + /** The playlist has been cleared. */ + CLEAR + } + + /** + * Called when the connection state to the Emby server changes. + * + * @param connected true if connected to the server, false otherwise + */ + void updateConnectionState(boolean connected); + + /** + * Called when the screen saver state changes. + * + * @param screenSaveActive true if the screen saver is active, false otherwise + */ + void updateScreenSaverState(boolean screenSaveActive); + + /** + * Called when the player state changes (play, pause, stop, etc.). + * + * @param state new playback state + */ + void updatePlayerState(EmbyState state); + + /** + * Called when the media title changes. + * + * @param title the current title of the media + */ + void updateTitle(String title); + + /** + * Called when the show or series title changes. + * + * @param title the current title of the show or series + */ + void updateShowTitle(String title); + + /** + * Called when the media type changes (e.g., Movie, Episode, Song). + * + * @param mediaType the type of media currently playing + */ + void updateMediaType(String mediaType); + + /** + * Called to report the current playback position. + * + * @param currentTime playback position in milliseconds + */ + void updateCurrentTime(long currentTime); + + /** + * Called to report the total duration of the media. + * + * @param duration total duration in milliseconds + */ + void updateDuration(long duration); + + /** + * Called when the primary image URL (e.g., cover art) changes. + * + * @param imageURL URL of the new primary image + */ + void updatePrimaryImageURL(String imageURL); + + /** + * Generic handler for play state change events, providing detailed model data. + * + * @param playstate model with detailed playback state information + * @param hostname host name of the Emby server + * @param embyport port number used for the Emby connection + */ + void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport); +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyHandlerFactory.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyHandlerFactory.java new file mode 100644 index 00000000000..0b132f58664 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/EmbyHandlerFactory.java @@ -0,0 +1,134 @@ +/* + * 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.emby.internal; + +import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_CONTROLLER; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_DEVICE; + +import java.util.Collections; +import java.util.Dictionary; +import java.util.HashMap; +import java.util.Hashtable; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.emby.internal.discovery.EmbyClientDiscoveryService; +import org.openhab.binding.emby.internal.handler.EmbyBridgeHandler; +import org.openhab.binding.emby.internal.handler.EmbyDeviceHandler; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.io.net.http.WebSocketFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +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.ComponentFactory; +import org.osgi.service.component.ComponentInstance; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ConfigurationPolicy; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link EmbyHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +@Component(service = ThingHandlerFactory.class, configurationPid = "binding.emby", configurationPolicy = ConfigurationPolicy.OPTIONAL, immediate = true) +public class EmbyHandlerFactory extends BaseThingHandlerFactory { + + private Logger logger = LoggerFactory.getLogger(EmbyHandlerFactory.class); + + private WebSocketFactory webSocketClientFactory; + private TranslationProvider i18nProvider; + + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections + .unmodifiableSet(Stream.of(THING_TYPE_EMBY_CONTROLLER, THING_TYPE_EMBY_DEVICE).collect(Collectors.toSet())); + + @Reference(target = "(component.factory=emby:client)") + private @Nullable ComponentFactory discoveryFactory; + private final Map> discoveryInstances = new HashMap<>(); + + @Activate + public EmbyHandlerFactory(@Reference WebSocketFactory webSocketClientFactory, ComponentContext componentContext, + @Reference TranslationProvider i18nProvider) { + super.activate(componentContext); + this.webSocketClientFactory = webSocketClientFactory; + this.i18nProvider = i18nProvider; + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + if (THING_TYPE_EMBY_DEVICE.equals(thing.getThingTypeUID())) { + logger.debug("Creating EMBY Device Handler for {}.", thing.getLabel()); + return new EmbyDeviceHandler(thing, this.i18nProvider); + } + + if (THING_TYPE_EMBY_CONTROLLER.equals(thing.getThingTypeUID())) { + EmbyBridgeHandler bridgeHandler = new EmbyBridgeHandler((Bridge) thing, + webSocketClientFactory.getCommonWebSocketClient(), this.i18nProvider); + Dictionary cfg = new Hashtable<>(); + cfg.put("bridgeUID", bridgeHandler.getThing().getUID().toString()); + + ComponentFactory factory = Objects.requireNonNull(discoveryFactory, + "discoveryFactory must be injected"); + @SuppressWarnings("null") + ComponentInstance ci = factory.newInstance(cfg); + EmbyClientDiscoveryService discovery = (EmbyClientDiscoveryService) ci.getInstance(); + discovery.setBridge(bridgeHandler); + bridgeHandler.setClientDiscoveryService(discovery); + discoveryInstances.put(bridgeHandler.getThing().getUID(), ci); + + return bridgeHandler; + } + + return null; // unknown thing-type + } + + @Override + public void removeHandler(ThingHandler handler) { + ThingUID uid = handler.getThing().getUID(); + + ComponentInstance ci = discoveryInstances.remove(uid); + if (ci != null) { + EmbyClientDiscoveryService discovery = (EmbyClientDiscoveryService) ci.getInstance(); + + // undo the manual wiring done in createHandler(…) + if (handler instanceof EmbyBridgeHandler bridge) { + discovery.clearBridge(bridge); + } + + ci.dispose(); // shuts the DS component down + } + + super.removeHandler(handler); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyBridgeDiscoveryService.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyBridgeDiscoveryService.java new file mode 100644 index 00000000000..b37b31caab8 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyBridgeDiscoveryService.java @@ -0,0 +1,174 @@ +/* + * 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.emby.internal.discovery; + +import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_DEVICE_ID; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_HOST_PARAMETER; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_WS_PORT_PARAMETER; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_CONTROLLER; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.net.NetUtil; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +/** + * The {@EmbyBridgeDiscoveryService} handles the bridge discovery which finds emby servers on the network + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +@Component(service = DiscoveryService.class, immediate = true, configurationPid = "discovery.embybridge") +public class EmbyBridgeDiscoveryService extends AbstractDiscoveryService { + + private static final String REQUEST_MSG = "who is EmbyServer?"; + private static final int REQUEST_PORT = 7359; + + private final Logger logger = LoggerFactory.getLogger(EmbyBridgeDiscoveryService.class); + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections + .singleton(THING_TYPE_EMBY_CONTROLLER); + + public EmbyBridgeDiscoveryService() { + super(SUPPORTED_THING_TYPES_UIDS, 30, false); + } + + @Override + public void startScan() { + // Find the server using UDP broadcast + try (DatagramSocket socket = new DatagramSocket()) { + socket.setBroadcast(true); + socket.setSoTimeout(5000); + byte[] sendData = REQUEST_MSG.getBytes(); + + // Send to 255.255.255.255 broadcast address + try { + DatagramPacket sendPacket = new DatagramPacket(sendData, sendData.length, + InetAddress.getByName("255.255.255.255"), REQUEST_PORT); + socket.send(sendPacket); + logger.trace(">>> Request packet sent to: {} ({})", REQUEST_MSG, REQUEST_PORT); + } catch (InterruptedIOException ie) { + // Discovery loop was interrupted—stop scanning + Thread.currentThread().interrupt(); + logger.debug("Discovery interrupted, exiting"); + } + + List broadcastStrings = NetUtil.getAllBroadcastAddresses(); + for (String broadcastStr : broadcastStrings) { + try { + InetAddress broadcast = InetAddress.getByName(broadcastStr); + socket.send(new DatagramPacket(sendData, sendData.length, broadcast, REQUEST_PORT)); + logger.trace(">>> Request packet sent to: {}", broadcast.getHostAddress()); + } catch (IOException e) { + logger.warn("Failed to send broadcast to {}: {}", broadcastStr, e.getMessage()); + } + } + logger.trace(">>> Done sending broadcasts. Now waiting for a reply!"); + + // Wait for a response + byte[] recvBuf = new byte[15000]; + DatagramPacket receivePacket = new DatagramPacket(recvBuf, recvBuf.length); + + try { + socket.receive(receivePacket); + + // We have a response + logger.debug(">>> Broadcast response from server: {}", receivePacket.getAddress().getHostAddress()); + String message = new String(receivePacket.getData(), StandardCharsets.UTF_8).trim(); + logger.debug("The message is {}", message); + + final Gson gson = new Gson(); + + @Nullable + JsonObject body = gson.fromJson(message, JsonObject.class); + body = Objects.requireNonNull(body, "EmbyBridgeDiscoveryService: response body was null"); + + String serverId = body.get("Id").getAsString(); + String serverName = body.get("Name").getAsString(); + String serverAddress = body.get("Address").getAsString(); + + EmbyDeviceEncoder encoder = new EmbyDeviceEncoder(); + serverId = encoder.encodeDeviceID(serverId); + + try { + URI serverAddressURI = new URI(serverAddress); + addEMBYServer(serverAddressURI.getHost(), serverAddressURI.getPort(), serverId, serverName); + } catch (URISyntaxException use) { + logger.error("Unexpected URI syntax: {}", use.getMessage(), use); + throw new IllegalStateException(use); + } + } catch (SocketTimeoutException timeout) { + logger.debug("Socket receive timed out, no Emby server discovered"); + } + } catch (IOException e) { + logger.warn("Exception occurred during Emby server discovery: {}", e.getMessage(), e); + } + } + + public void addEMBYServer(String hostAddress, int embyPort, @Nullable String DeviceID, String Name) { + logger.debug("creating discovery result with address: {}:{}, for server {}", hostAddress, + Integer.toString(embyPort), Name); + ThingUID thingUID = getThingUID(DeviceID); + ThingTypeUID thingTypeUID = THING_TYPE_EMBY_CONTROLLER; + + if (thingUID != null && DeviceID != null) { + Map properties = new HashMap<>(); + properties.put(CONFIG_DEVICE_ID, DeviceID); + properties.put(CONFIG_HOST_PARAMETER, hostAddress); + properties.put(CONFIG_WS_PORT_PARAMETER, Integer.toString(embyPort)); + + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID) + .withProperties(properties).withRepresentationProperty(CONFIG_DEVICE_ID).withLabel(Name).build(); + + thingDiscovered(discoveryResult); + } else { + logger.debug("Unable to add {} found at {}:{} with id of {}", Name, hostAddress, embyPort, DeviceID); + } + } + + private @Nullable ThingUID getThingUID(@Nullable String DeviceID) { + ThingTypeUID thingTypeUID = THING_TYPE_EMBY_CONTROLLER; + if (DeviceID != null) { + return new ThingUID(thingTypeUID, DeviceID); + } else { + return null; + } + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyClientDiscoveryService.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyClientDiscoveryService.java new file mode 100644 index 00000000000..a83df3fcfca --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/discovery/EmbyClientDiscoveryService.java @@ -0,0 +1,115 @@ +/* + * 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.emby.internal.discovery; + +import static java.util.Objects.requireNonNull; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONFIG_DEVICE_ID; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.THING_TYPE_EMBY_DEVICE; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.emby.internal.handler.EmbyBridgeHandler; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; +import org.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResult; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@EmbyClientDiscoveryService} handles the discovery of devices which are playing media on an emby server which + * has been setup as a bridge. This discovery service receives events from the corresponding {@EmbyBridgeHandler} that + * it is attached to + * + * @author Zachary Christiansen - Initial contribution + */ +@Component(service = DiscoveryService.class, factory = "emby:client", configurationPid = "discovery.embydevice", property = { + "discovery.interval:Integer=0", "thingTypeUIDs=emby:device" }) +@NonNullByDefault +public class EmbyClientDiscoveryService extends AbstractDiscoveryService { + private final Logger logger = LoggerFactory.getLogger(EmbyClientDiscoveryService.class); + private static final Set SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_EMBY_DEVICE); + // DS will inject the one-and-only bridge handler whose thingUID matches + private @Nullable EmbyBridgeHandler embyBridgeHandler; + + public EmbyClientDiscoveryService() { + super(SUPPORTED_THING_TYPES_UIDS, 0, true); + } + + /* + * ------------------------------------------------------------------ + * Called once by EmbyHandlerFactory immediately after it creates the + * discovery service instance. + * ------------------------------------------------------------------ + */ + public void setBridge(EmbyBridgeHandler handler) { + this.embyBridgeHandler = handler; + } + + /* + * ------------------------------------------------------------------ + * Called by the factory just before it disposes the ComponentInstance. + * ------------------------------------------------------------------ + */ + public void clearBridge(EmbyBridgeHandler handler) { + if (Objects.equals(this.embyBridgeHandler, handler)) { + this.embyBridgeHandler = null; + } + } + + @Override + public void startScan() { + // this discovery service does not do any scanning all of the scanning is handled by the bridge handler and + // passed in to this service + } + + public void addDeviceIDDiscover(EmbyPlayStateModel playstate) { + logger.debug("adding new emby device"); + ThingUID thingUID = getThingUID(playstate); + ThingTypeUID thingTypeUID = THING_TYPE_EMBY_DEVICE; + EmbyDeviceEncoder encode = new EmbyDeviceEncoder(); + String modelId = encode.encodeDeviceID(playstate.getDeviceId()); + if (thingUID != null) { + ThingUID bridgeUID = requireNonNull(embyBridgeHandler, + "EmbyClientDiscoveryService: Bridge Handler Cannot be null").getThing().getUID(); + Map properties = new HashMap<>(1); + properties.put(CONFIG_DEVICE_ID, modelId); + logger.debug("Disovered device {} with id {}", playstate.getDeviceName(), modelId); + DiscoveryResult discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID) + .withProperties(properties).withBridge(bridgeUID).withRepresentationProperty(CONFIG_DEVICE_ID) + .withLabel(playstate.getDeviceName()).build(); + thingDiscovered(discoveryResult); + } else { + logger.debug("discovered unsupported device of type '{}' and model '{}' with id {}", + playstate.getDeviceName(), modelId, playstate.getDeviceId()); + } + } + + private @Nullable ThingUID getThingUID(EmbyPlayStateModel playstate) { + ThingUID bridgeUID = requireNonNull(embyBridgeHandler, + "EmbyClientDiscoveryService: Bridge Handler Cannot be null").getThing().getUID(); + ThingTypeUID thingTypeUID = THING_TYPE_EMBY_DEVICE; + return new ThingUID(thingTypeUID, bridgeUID, playstate.getDeviceId()); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyActions.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyActions.java new file mode 100644 index 00000000000..e9c568871a8 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyActions.java @@ -0,0 +1,145 @@ +/* + * 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.emby.internal.handler; + +import static java.util.Objects.requireNonNull; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.automation.annotation.ActionInput; +import org.openhab.core.automation.annotation.RuleAction; +import org.openhab.core.thing.binding.ThingActions; +import org.openhab.core.thing.binding.ThingActionsScope; +import org.openhab.core.thing.binding.ThingHandler; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.ServiceScope; + +/** + * The {@link EmbyActions} is responsible for handling the actions which can be sent to {@link EmbyDeviceHandler}. + * + * @author Zachary Christiansen - Initial contribution + */ +@Component(scope = ServiceScope.PROTOTYPE, service = EmbyActions.class) +@ThingActionsScope(name = "emby") +@NonNullByDefault +public class EmbyActions implements ThingActions { + + public enum EmbyPlayCommand { + PlayNow, + PlayNext, + PlayLast + } + + public enum EmbyGeneralCommand { + MoveUp, + MoveDown, + MoveLeft, + MoveRight, + PageUp, + PageDown, + PreviousLetter, + NextLetter, + ToggleOsdMenu, + ToggleContextMenu, + ToggleMute, + Select, + Back, + TakeScreenshot, + GoHome, + GoToSettings, + VolumeUp, + VolumeDown, + ToggleFullscreen, + GoToSearch + } + + public enum EmbyCommandWithArgs { + SetVolume, + SetAudioStreamIndex, + SetSubtitleStreamIndex, + DisplayContent, + PlayTrailers, + SendString, + DisplayMessage, + SetPlaybackRate, + SetSubtitleOffset, + IncrementSubtitleOffset + } + + private @Nullable EmbyDeviceHandler handler; + + @Override + public void setThingHandler(@Nullable ThingHandler handler) { + this.handler = (EmbyDeviceHandler) handler; + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return handler; + } + + @RuleAction(label = "@text/action.emby.SendPlay.label", description = "@text/action.emby.SendPlay.desc") + public void sendPlay( + @ActionInput(name = "itemIds", label = "@text/action.emby.SendPlay.input.itemIds.label", description = "@text/action.emby.SendPlay.input.itemIds.desc") String itemIds, + @ActionInput(name = "playCommand", label = "@text/action.emby.SendPlay.input.playCommand.label", description = "@text/action.emby.SendPlay.input.playCommand.desc") EmbyPlayCommand playCommand, + @ActionInput(name = "startPositionTicks", label = "@text/action.emby.SendPlay.input.startPositionTicks.label", description = "@text/action.emby.SendPlay.input.startPositionTicks.desc") @Nullable Integer startPositionTicks, + @ActionInput(name = "mediaSourceId", label = "@text/action.emby.SendPlay.input.mediaSourceId.label", description = "@text/action.emby.SendPlay.input.mediaSourceId.desc") @Nullable String mediaSourceId, + @ActionInput(name = "audioStreamIndex", label = "@text/action.emby.SendPlay.input.audioStreamIndex.label", description = "@text/action.emby.SendPlay.input.audioStreamIndex.desc") @Nullable Integer audioStreamIndex, + @ActionInput(name = "subtitleStreamIndex", label = "@text/action.emby.SendPlay.input.subtitleStreamIndex.label", description = "@text/action.emby.SendPlay.input.subtitleStreamIndex.desc") @Nullable Integer subtitleStreamIndex, + @ActionInput(name = "startIndex", label = "@text/action.emby.SendPlay.input.startIndex.label", description = "@text/action.emby.SendPlay.input.startIndex.desc") @Nullable Integer startIndex) { + requireNonNull(handler, "EmbyDeviceHandler not set").sendPlayWithParams(itemIds, playCommand.name(), + startPositionTicks, mediaSourceId, audioStreamIndex, subtitleStreamIndex, startIndex); + } + + public static void sendPlay(ThingActions actions, String itemIds, EmbyPlayCommand playCommand, + @Nullable Integer startPositionTicks, @Nullable String mediaSourceId, @Nullable Integer audioStreamIndex, + @Nullable Integer subtitleStreamIndex, @Nullable Integer startIndex) { + if (actions instanceof EmbyActions) { + ((EmbyActions) actions).sendPlay(requireNonNull(itemIds), requireNonNull(playCommand), startPositionTicks, + mediaSourceId, audioStreamIndex, subtitleStreamIndex, startIndex); + } else { + throw new IllegalArgumentException("Not an EmbyActions instance"); + } + } + + @RuleAction(label = "@text/action.emby.SendGeneralCommand.label", description = "@text/action.emby.SendGeneralCommand.desc") + public void sendGeneralCommand( + @ActionInput(name = "commandName", label = "@text/action.emby.SendGeneralCommand.input.commandName.label", description = "@text/action.emby.SendGeneralCommand.input.commandName.desc") EmbyGeneralCommand commandName) { + requireNonNull(handler, "EmbyDeviceHandler not set").sendGeneralCommand(commandName.name()); + } + + public static void sendGeneralCommand(ThingActions actions, EmbyGeneralCommand commandName) { + if (actions instanceof EmbyActions embyActions) { + embyActions.sendGeneralCommand(commandName); + } else { + throw new IllegalArgumentException("Not an EmbyActions instance"); + } + } + + @RuleAction(label = "@text/action.emby.SendGeneralCommandWithArgs.label", description = "@text/action.emby.SendGeneralCommandWithArgs.desc") + public void sendGeneralCommandWithArgs( + @ActionInput(name = "commandName", label = "@text/action.emby.SendGeneralCommandWithArgs.input.commandName.label", description = "@text/action.emby.SendGeneralCommandWithArgs.input.commandName.desc") EmbyCommandWithArgs commandName, + @ActionInput(name = "jsonArguments", label = "@text/action.emby.SendGeneralCommandWithArgs.input.jsonArguments.label", description = "@text/action.emby.SendGeneralCommandWithArgs.input.jsonArguments.desc") String jsonArguments) { + requireNonNull(handler, "EmbyDeviceHandler not set").sendGeneralCommandWithArgs(commandName.name(), + jsonArguments); + } + + public static void sendGeneralCommandWithArgs(ThingActions actions, EmbyCommandWithArgs commandName, + String jsonArguments) { + if (actions instanceof EmbyActions embyActions) { + embyActions.sendGeneralCommandWithArgs(requireNonNull(commandName), requireNonNull(jsonArguments)); + } else { + throw new IllegalArgumentException("Not an EmbyActions instance"); + } + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyBridgeHandler.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyBridgeHandler.java new file mode 100644 index 00000000000..7a21df71973 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyBridgeHandler.java @@ -0,0 +1,275 @@ +/* + * 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.emby.internal.handler; + +import static java.util.Objects.requireNonNull; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONNECTION_CHECK_INTERVAL_MS; + +import java.util.Collections; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.emby.internal.EmbyBridgeConfiguration; +import org.openhab.binding.emby.internal.EmbyBridgeListener; +import org.openhab.binding.emby.internal.discovery.EmbyClientDiscoveryService; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; +import org.openhab.binding.emby.internal.protocol.EmbyConnection; +import org.openhab.binding.emby.internal.protocol.EmbyHTTPUtils; +import org.openhab.binding.emby.internal.protocol.EmbyHttpRetryExceeded; +import org.openhab.core.config.core.validation.ConfigValidationException; +import org.openhab.core.config.core.validation.ConfigValidationMessage; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.StringType; +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.ThingStatusInfo; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link EmbyBridgeHandler} is responsible for handling commands, + * managing the connection to Emby and dispatching play‐state events. + * + * @author Zachary Christiansen - Initial Contribution + */ +@NonNullByDefault +public class EmbyBridgeHandler extends BaseBridgeHandler implements EmbyBridgeListener { + + private final Logger logger = LoggerFactory.getLogger(EmbyBridgeHandler.class); + + private volatile @Nullable EmbyConnection connection; + private final WebSocketClient webSocketClient; + private @Nullable ScheduledFuture connectionCheckerFuture; + private @Nullable EmbyClientDiscoveryService clientDiscoveryService; + private @Nullable EmbyHTTPUtils httputils; + private @Nullable EmbyBridgeConfiguration config; + private int reconnectionCount; + private @Nullable String lastDiscoveryStatus; + private TranslationProvider i18nProvider; + + public EmbyBridgeHandler(Bridge bridge, WebSocketClient webSocketClient, TranslationProvider i18nProvider) { + super(bridge); + this.webSocketClient = requireNonNull(webSocketClient, "webSocketClient must not be null"); + this.i18nProvider = requireNonNull(i18nProvider, "translation provider must not be null"); + } + + public void sendCommand(String commandURL) { + logger.trace("Sending command without payload: {}", commandURL); + final EmbyHTTPUtils localHttpUtils = requireNonNull(this.httputils, "HTTP utils not initialized"); + try { + localHttpUtils.doPost(commandURL, "", 2); + } catch (EmbyHttpRetryExceeded e) { + logger.debug("Retry limit exceeded for {}", commandURL, e.getCause()); + } + } + + public void sendCommand(String commandURL, String payload) { + logger.trace("Sending command: {} with payload: {}", commandURL, payload); + final EmbyHTTPUtils localHttpUtils = requireNonNull(this.httputils, "HTTP utils not initialized"); + try { + localHttpUtils.doPost(commandURL, payload, 2); + } catch (EmbyHttpRetryExceeded e) { + logger.debug("Retry limit exceeded for {}", commandURL, e.getCause()); + } + } + + private void establishConnection() { + final ScheduledExecutorService exec = requireNonNull(scheduler, "scheduler must not be null"); + + exec.execute(() -> { + try { + final EmbyConnection conn = requireNonNull(this.connection, "connection must not be null"); + final EmbyBridgeConfiguration cfg = requireNonNull(this.config, "config must not be null"); + final String ipAddress = requireNonNull(cfg.ipAddress, "config.ipAddress must not be null"); + final String apiKey = requireNonNull(cfg.api, "config.api must not be null"); + final Integer refreshInterval = requireNonNull(cfg.refreshInterval, + "config.refreshInterval must not be null"); + final Integer port = requireNonNull(cfg.port, "config.port must not be null"); + + conn.connect(ipAddress, port, apiKey, exec, refreshInterval); + + this.connectionCheckerFuture = exec.scheduleWithFixedDelay(() -> { + if (!conn.checkConnection()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/thing.status.bridge.connectionLost"); + } + }, CONNECTION_CHECK_INTERVAL_MS, CONNECTION_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS); + } catch (Exception e) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/thing.status.bridge.connectionFailed" + e.getMessage()); + } + }); + } + + @Override + public void initialize() { + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NOT_YET_READY); + + final ScheduledExecutorService exec = requireNonNull(scheduler, "scheduler must not be null"); + + exec.execute(() -> { + this.reconnectionCount = 0; + try { + this.config = checkConfiguration(); + this.connection = new EmbyConnection(this, this.webSocketClient); + establishConnection(); + updateStatus(ThingStatus.ONLINE); + } catch (ConfigValidationException cve) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/thing.status.bridge.configurationFailed" + cve.getMessage()); + } + }); + } + + @Override + public void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport) { + final EmbyClientDiscoveryService service = this.clientDiscoveryService; + final EmbyBridgeConfiguration cfg = requireNonNull(this.config, "config must not be null"); + + if (service != null && cfg.discovery) { + service.addDeviceIDDiscover(playstate); + } + + getThing().getThings().forEach(thing -> { + EmbyDeviceHandler handler = (EmbyDeviceHandler) thing.getHandler(); + if (handler != null) { + handler.handleEvent(playstate, hostname, embyport); + logger.trace("Dispatched event to {}", thing.getLabel()); + } else { + logger.trace("No handler for {}", thing.getLabel()); + } + }); + } + + @Override + public void updateConnectionState(boolean connected) { + // Grab current status and the last detail message + ThingStatusInfo info = getThing().getStatusInfo(); + ThingStatus currentStatus = info.getStatus(); + + if (connected) { + // Only transition to ONLINE if we weren’t already + if (currentStatus != ThingStatus.ONLINE) { + reconnectionCount = 0; + updateStatus(ThingStatus.ONLINE); + } + } else { + // We’ve gone offline: increment retry count and build new detail text + reconnectionCount++; + logger.debug("@text/thing.status.bridge.connectionRetry{}", reconnectionCount); + + // Only emit a new OFFLINE event if status changed, or the message changed + boolean statusChanged = currentStatus != ThingStatus.OFFLINE; + + if (statusChanged) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + "@text/thing.status.bridge.connectionRetry"); + } + + ScheduledFuture localConnectionCheckerFuture = this.connectionCheckerFuture; + if (localConnectionCheckerFuture != null) { + localConnectionCheckerFuture.cancel(false); + this.connectionCheckerFuture = null; + } + } + } + + public void setClientDiscoveryService(@Nullable EmbyClientDiscoveryService discovery) { + this.clientDiscoveryService = discovery; + } + + @Nullable + public EmbyClientDiscoveryService getClientDiscoveryService() { + return clientDiscoveryService; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, pollCurrentValueBridge(channelUID)); + } else { + logger.trace("Ignored command {} on {}", command, channelUID.getId()); + } + } + + public void updateDiscoveryStatus(String status) { + this.lastDiscoveryStatus = status; + updateState(new ChannelUID(getThing().getUID(), "discoveryStatus"), new StringType(status)); + } + + private EmbyBridgeConfiguration checkConfiguration() throws ConfigValidationException { + EmbyBridgeConfiguration embyConfig = getConfigAs(EmbyBridgeConfiguration.class); + if (embyConfig.api.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, + "@text/thing.status.bridge.missingAPI"); + throwValidationError("api", "@text/thing.status.bridge.missingAPI"); + } + if (embyConfig.ipAddress.isEmpty()) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Missing server address"); + throwValidationError("ipAddress", "@text/thing.status.bridge.missingIP"); + } + this.httputils = new EmbyHTTPUtils(30, embyConfig.api, embyConfig.ipAddress + ":" + embyConfig.port); + return embyConfig; + } + + private void throwValidationError(String parameterName, String errorMessage) throws ConfigValidationException { + final TranslationProvider provider = requireNonNull(this.i18nProvider, "i18nProvider must not be null"); + Bundle bundle = FrameworkUtil.getBundle(getClass()); + ConfigValidationMessage message = new ConfigValidationMessage(parameterName, "error", errorMessage); + throw new ConfigValidationException(bundle, provider, Collections.singletonList(message)); + } + + private State pollCurrentValueBridge(ChannelUID channelUID) { + switch (channelUID.getId()) { + case "serverReachable": + return (getThing().getStatus() == ThingStatus.ONLINE) ? OnOffType.ON : OnOffType.OFF; + case "discoveryStatus": + return (this.lastDiscoveryStatus != null) ? new StringType(this.lastDiscoveryStatus) : UnDefType.UNDEF; + default: + return UnDefType.UNDEF; + } + } + + @Override + public void dispose() { + // cancel checker + final ScheduledFuture future = this.connectionCheckerFuture; + if (future != null && !future.isCancelled()) { + future.cancel(true); + } + // close connection + final EmbyConnection conn = requireNonNull(this.connection, "connection not initialized"); + conn.dispose(); + // detach discovery + final EmbyClientDiscoveryService service = this.clientDiscoveryService; + if (service != null) { + service.clearBridge(this); + this.clientDiscoveryService = null; + } + super.dispose(); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyDeviceHandler.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyDeviceHandler.java new file mode 100644 index 00000000000..c84a704025a --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/handler/EmbyDeviceHandler.java @@ -0,0 +1,542 @@ +/* + * 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.emby.internal.handler; + +import static java.util.Objects.requireNonNull; +import static org.openhab.binding.emby.internal.EmbyBindingConstants.*; +import static org.openhab.core.thing.ThingStatus.OFFLINE; +import static org.openhab.core.thing.ThingStatusDetail.*; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.emby.internal.EmbyDeviceConfiguration; +import org.openhab.binding.emby.internal.EmbyEventListener; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; +import org.openhab.binding.emby.internal.util.EmbyThrottle; +import org.openhab.core.config.core.Configuration; +import org.openhab.core.config.core.validation.ConfigValidationException; +import org.openhab.core.config.core.validation.ConfigValidationMessage; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PlayPauseType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RewindFastforwardType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * The {@link EmbyDeviceHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Zachary Christiansen - Initial contribution + */ + +@NonNullByDefault +public class EmbyDeviceHandler extends BaseThingHandler implements EmbyEventListener { + + private final Logger logger = LoggerFactory.getLogger(EmbyDeviceHandler.class); + + private @Nullable EmbyDeviceConfiguration config; + private @Nullable EmbyPlayStateModel currentPlayState; + private @Nullable EmbyBridgeHandler bridgeHandler; + private @Nullable String lastImageUrl; + private @Nullable String lastShowTitle; + private @Nullable String lastMediaType; + private boolean lastMuted = false; + private long lastCurrentTime = -1; + private long lastDuration = -1; + + private final EmbyThrottle throttle = new EmbyThrottle(1000); + + private TranslationProvider i18nProvider; + + private static final List ALLOWED_IMAGE_TYPES = Collections.unmodifiableList(Arrays.asList("Primary", "Art", + "Backdrop", "Banner", "Logo", "Thumb", "Disc", "Box", "Screenshot", "Menu", "Chapter")); + + public EmbyDeviceHandler(Thing thing, TranslationProvider i18nProvider) { + super(thing); + this.i18nProvider = i18nProvider; + } + + @Override + public Collection> getServices() { + return List.of(EmbyActions.class); + } + + public void sendGeneralCommand(String commandName) { + final EmbyBridgeHandler handler = bridgeHandler; + final EmbyPlayStateModel play = currentPlayState; + if (handler == null || play == null) { + throw new IllegalStateException("Cannot send command: no bridge or no active session"); + } + String url = CONTROL_SESSION + play.getId() + CONTROL_GENERALCOMMAND + commandName; + handler.sendCommand(url); + } + + public void sendGeneralCommandWithArgs(String commandName, String jsonArguments) { + final EmbyBridgeHandler handler = bridgeHandler; + final EmbyPlayStateModel play = currentPlayState; + if (handler == null || play == null) { + throw new IllegalStateException("Cannot send command: no bridge or no active session"); + } + JsonObject args = JsonParser.parseString(jsonArguments).getAsJsonObject(); + JsonObject envelope = new JsonObject(); + envelope.add("Arguments", args); + String url = CONTROL_SESSION + play.getId() + CONTROL_GENERALCOMMAND + commandName; + handler.sendCommand(url, envelope.toString()); + } + + public void sendPlayWithParams(String itemIds, String playCommand, @Nullable Integer startPositionTicks, + @Nullable String mediaSourceId, @Nullable Integer audioStreamIndex, @Nullable Integer subtitleStreamIndex, + @Nullable Integer startIndex) { + final EmbyBridgeHandler handler = bridgeHandler; + final EmbyPlayStateModel play = currentPlayState; + if (handler == null || play == null) { + throw new IllegalStateException("No bridge or active session available"); + } + + JsonObject payload = new JsonObject(); + payload.addProperty("ItemIds", itemIds); + payload.addProperty("PlayCommand", playCommand); + if (startPositionTicks != null) { + payload.addProperty("StartPositionTicks", startPositionTicks); + } + if (mediaSourceId != null) { + payload.addProperty("MediaSourceId", mediaSourceId); + } + if (audioStreamIndex != null) { + payload.addProperty("AudioStreamIndex", audioStreamIndex); + } + if (subtitleStreamIndex != null) { + payload.addProperty("SubtitleStreamIndex", subtitleStreamIndex); + } + if (startIndex != null) { + payload.addProperty("StartIndex", startIndex); + } + + String url = CONTROL_SESSION + play.getId() + CONTROL_SENDPLAY; + handler.sendCommand(url, payload.toString()); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + if (command instanceof RefreshType) { + updateState(channelUID, pollCurrentValue(channelUID)); + return; + } + + final Channel channel = thing.getChannel(channelUID.getId()); + if (channel == null) { + logger.warn("Unsupported channel: {}", channelUID); + return; + } + + final EmbyBridgeHandler handler = bridgeHandler; + final EmbyDeviceConfiguration cfg = config; + if (handler == null || cfg == null) { + return; + } + + final EmbyPlayStateModel play = currentPlayState; + switch (channelUID.getId()) { + case CHANNEL_CONTROL: + if (play == null) { + updateState(channelUID, UnDefType.UNDEF); + return; + } + if (command instanceof PlayPauseType) { + String url = CONTROL_SESSION + play.getId() + + (PlayPauseType.PLAY.equals(command) ? CONTROL_PLAY : CONTROL_PAUSE); + handler.sendCommand(url); + } + break; + + case CHANNEL_MUTE: + if (play == null) { + updateState(channelUID, UnDefType.UNDEF); + return; + } + String muteUrl = CONTROL_SESSION + play.getId() + + (OnOffType.ON.equals(command) ? CONTROL_MUTE : CONTROL_UNMUTE); + handler.sendCommand(muteUrl); + break; + + case CHANNEL_STOP: + if (play == null) { + updateState(channelUID, UnDefType.UNDEF); + return; + } + if (OnOffType.ON.equals(command)) { + handler.sendCommand(CONTROL_SESSION + play.getId() + CONTROL_STOP); + } + break; + + default: + logger.warn("Unsupported channel: {}", channelUID.getAsString()); + break; + } + } + + private State pollCurrentValue(ChannelUID channelUID) { + final EmbyPlayStateModel play = currentPlayState; + + return switch (channelUID.getId()) { + case CHANNEL_CONTROL -> { + if (play == null) { + yield UnDefType.UNDEF; + } + Boolean paused = play.getEmbyPlayStatePausedState(); + yield Boolean.TRUE.equals(paused) ? PlayPauseType.PAUSE : PlayPauseType.PLAY; + } + case CHANNEL_MUTE -> { + if (play == null) { + yield UnDefType.UNDEF; + } + Boolean muted = play.getEmbyMuteSate(); + yield Boolean.TRUE.equals(muted) ? OnOffType.ON : OnOffType.OFF; + } + case CHANNEL_STOP -> { + if (play == null) { + yield UnDefType.UNDEF; + } + Boolean stopped = play.getEmbyPlayStatePausedState(); + yield Boolean.TRUE.equals(stopped) ? OnOffType.ON : OnOffType.OFF; + } + case CHANNEL_TITLE -> createStringState(play != null ? play.getNowPlayingName() : null); + case CHANNEL_SHOWTITLE -> createStringState(lastShowTitle); + case CHANNEL_MEDIATYPE -> createStringState(lastMediaType); + case CHANNEL_CURRENTTIME -> (lastCurrentTime < 0) ? UnDefType.UNDEF + : createQuantityState(convertTicksToSeconds(lastCurrentTime), Units.SECOND); + case CHANNEL_DURATION -> (lastDuration < 0) ? UnDefType.UNDEF + : createQuantityState(convertTicksToSeconds(lastDuration), Units.SECOND); + case CHANNEL_IMAGEURL -> createStringState(lastImageUrl); + default -> UnDefType.UNDEF; + }; + } + + private void updateState(EmbyState state) { + updatePlayerState(state); + + if (state == EmbyState.STOP || state == EmbyState.END) { + // reset all last-* fields + lastImageUrl = null; + lastShowTitle = null; + lastMediaType = null; + lastMuted = false; + lastCurrentTime = -1; + lastDuration = -1; + + // restore original post-stop behavior + updatePlayerState(state); // sets CONTROL and STOP channels + updateState(CHANNEL_MUTE, OnOffType.from(false)); + + updateTitle(""); + updateShowTitle(""); + updatePrimaryImageURL(""); + updateMediaType(""); + updateCurrentTime(-1); + updateDuration(-1); + } + } + + @Override + public void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport) { + final EmbyDeviceConfiguration cfg = config; + if (cfg == null || !playstate.compareDeviceId(cfg.deviceID)) { + return; + } + this.currentPlayState = playstate; + + try { + URI imageURI = playstate.getPrimaryImageURL(hostname, embyport, cfg.imageImageType, cfg.imageMaxWidth, + cfg.imageMaxHeight); + + if (playstate.getNowPlayingItem() == null) { + updateState(EmbyState.END); + updateState(EmbyState.STOP); + return; + } + + if (playstate.getEmbyPlayStatePausedState()) { + logger.debug("Setting state to PAUSE for {}", playstate.getDeviceName()); + updateState(EmbyState.PAUSE); + } else { + logger.debug("Setting state to PLAY for {}", playstate.getDeviceName()); + updateState(EmbyState.PLAY); + } + + // Image URL + String newImage = imageURI.toString(); + if (!newImage.equals(lastImageUrl)) { + updatePrimaryImageURL(newImage); + logger.trace("Throttled updatePrimaryImageURL: {}", newImage); + lastImageUrl = newImage; + } + + // Mute (instant) + boolean newMute = playstate.getEmbyMuteSate(); + if (newMute != lastMuted) { + updateState(CHANNEL_MUTE, OnOffType.from(newMute)); + logger.trace("updateMuted: {}", newMute); + lastMuted = newMute; + } + + // Show Title + String newTitle = playstate.getNowPlayingName(); + if (!newTitle.equals(lastShowTitle)) { + updateShowTitle(newTitle); + logger.trace("Throttled updateShowTitle: {}", newTitle); + + lastShowTitle = newTitle; + } + + // CurrentTime + long newTime = playstate.getNowPlayingTime().longValue(); + if (newTime != lastCurrentTime) { + updateCurrentTime(newTime); + logger.trace("Throttled updateCurrentTime: {}", newTime); + + lastCurrentTime = newTime; + } + + // Duration + long newDur = playstate.getNowPlayingTotalTime().longValue(); + if (newDur != lastDuration) { + updateDuration(newDur); + logger.trace("Throttled updateDuration: {}", newDur); + + lastDuration = newDur; + } + + // MediaType + String newType = playstate.getNowPlayingMediaType(); + if (!newType.equals(lastMediaType)) { + updateMediaType(newType); + logger.trace("Throttled updateMediaType: {}", newType); + lastMediaType = newType; + } + + } catch (URISyntaxException e) { + logger.debug("Unable to create image URL for {}: {}", playstate.getDeviceName(), e.getMessage()); + } + } + + private EmbyDeviceConfiguration validateConfiguration() throws ConfigValidationException { + Object deviceId = requireNonNull(thing.getConfiguration().get(CONFIG_DEVICE_ID)); + if (deviceId.toString().isEmpty()) { + throwValidationError(CONFIG_DEVICE_ID, "@text/thing.status.device.config.noDeviceID"); + } + EmbyDeviceConfiguration cfg = new EmbyDeviceConfiguration(deviceId.toString()); + + final Channel imgChannel = thing.getChannel(CHANNEL_IMAGEURL); + if (imgChannel == null) { + throwValidationError(CHANNEL_IMAGEURL, + "@text/thing.status.device.config.noChannelDefined: " + CHANNEL_IMAGEURL); + } else { + Configuration imgCfg = imgChannel.getConfiguration(); + String maxWidth = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_MAXWIDTH, ""); + if (!maxWidth.matches("\\d*")) { + throwValidationError(CHANNEL_IMAGEURL_CONFIG_MAXWIDTH, + "@text/thing.status.device.config.notNumber" + CHANNEL_IMAGEURL_CONFIG_MAXWIDTH); + } else { + cfg.imageMaxWidth = maxWidth; + } + String maxHeight = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT, ""); + if (!maxHeight.matches("\\d*")) { + throwValidationError(CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT, + "@text/thing.status.device.config.notNumber" + CHANNEL_IMAGEURL_CONFIG_MAXHEIGHT); + } + cfg.imageMaxHeight = maxHeight; + String pct = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED, "false"); + if (!("true".equalsIgnoreCase(pct) || "false".equalsIgnoreCase(pct))) { + throwValidationError(CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED, + "thing.status.device.config.booleanRequried" + CHANNEL_IMAGEURL_CONFIG_PERCENTPLAYED); + } + cfg.imagePercentPlayed = Boolean.parseBoolean(pct); + + String imgType = getOrDefault(imgCfg, CHANNEL_IMAGEURL_CONFIG_TYPE, "Primary"); + if (!ALLOWED_IMAGE_TYPES.contains(imgType)) { + throwValidationError(CHANNEL_IMAGEURL_CONFIG_TYPE, + "@text/thing.status.device.config.invalidImageType" + imgType); + } + cfg.imageImageType = imgType; + } + return cfg; + } + + private void throwValidationError(String parameterName, String errorMessage) throws ConfigValidationException { + TranslationProvider provider = Objects.requireNonNull(i18nProvider, + "TranslationProvider must not be null for validation"); + Bundle bundle = FrameworkUtil.getBundle(getClass()); + ConfigValidationMessage msg = new ConfigValidationMessage(parameterName, "error", errorMessage); + throw new ConfigValidationException(bundle, provider, Collections.singletonList(msg)); + } + + private String getOrDefault(Configuration config, String key, String defaultValue) { + String val = (String) config.get(key); + return val != null ? val : defaultValue; + } + + @Override + public void initialize() { + updateStatus(ThingStatus.UNKNOWN); + scheduler.execute(() -> { + try { + this.config = validateConfiguration(); + + if (!(getBridge() instanceof Bridge bridge) + || !(bridge.getHandler() instanceof EmbyBridgeHandler bridgeHandler)) { + updateStatus(ThingStatus.OFFLINE, CONFIGURATION_ERROR, "@text/thing.status.device.noBridge"); + return; + } + this.bridgeHandler = bridgeHandler; + + if (bridge.getStatus() == OFFLINE) { + updateStatus(OFFLINE, BRIDGE_OFFLINE, "@text/thing.status.device.bridgeOffline"); + return; + } + + updateStatus(ThingStatus.ONLINE); + } catch (ConfigValidationException e) { + updateStatus(ThingStatus.OFFLINE, CONFIGURATION_ERROR, + "@text/thing.status.device.configInValid " + e.getMessage()); + } catch (Exception e) { + updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR, "@text/thing.status.device.initalizationFalied"); + logger.error("Initialization failed: {}", e.getMessage()); + } + }); + } + + @Override + public void updateConnectionState(boolean connected) { + if (connected) { + updateStatus(ThingStatus.ONLINE); + } else { + updateStatus(ThingStatus.OFFLINE, COMMUNICATION_ERROR); + } + } + + @Override + public void updateScreenSaverState(boolean screenSaveActive) { + /* no-op */ } + + @Override + public void updatePlayerState(EmbyState state) { + switch (state) { + case PLAY: + updateState(CHANNEL_CONTROL, PlayPauseType.PLAY); + updateState(CHANNEL_STOP, OnOffType.OFF); + break; + case PAUSE: + updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE); + updateState(CHANNEL_STOP, OnOffType.OFF); + break; + case STOP: + case END: + updateState(CHANNEL_CONTROL, PlayPauseType.PAUSE); + updateState(CHANNEL_STOP, OnOffType.ON); + break; + case FASTFORWARD: + updateState(CHANNEL_CONTROL, RewindFastforwardType.FASTFORWARD); + updateState(CHANNEL_STOP, OnOffType.OFF); + break; + case REWIND: + updateState(CHANNEL_CONTROL, RewindFastforwardType.REWIND); + updateState(CHANNEL_STOP, OnOffType.OFF); + break; + } + } + + @Override + public void updateTitle(String title) { + if (throttle.shouldProceed("updateTitle")) { + updateState(CHANNEL_TITLE, createStringState(title)); + } + } + + @Override + public void updatePrimaryImageURL(String imageUrl) { + if (throttle.shouldProceed("updatePrimaryImageURL")) { + updateState(CHANNEL_IMAGEURL, createStringState(imageUrl)); + } + } + + @Override + public void updateShowTitle(String title) { + if (throttle.shouldProceed("updateShowTitle")) { + updateState(CHANNEL_SHOWTITLE, createStringState(title)); + } + } + + @Override + public void updateMediaType(String mediaType) { + if (throttle.shouldProceed("updateMediaType")) { + updateState(CHANNEL_MEDIATYPE, createStringState(mediaType)); + } + } + + @Override + public void updateCurrentTime(long currentTime) { + if (throttle.shouldProceed("updateCurrentTime")) { + updateState(CHANNEL_CURRENTTIME, createQuantityState(convertTicksToSeconds(currentTime), Units.SECOND)); + } + } + + @Override + public void updateDuration(long duration) { + if (throttle.shouldProceed("updateDuration")) { + updateState(CHANNEL_DURATION, createQuantityState(convertTicksToSeconds(duration), Units.SECOND)); + } + } + + private State createStringState(@Nullable String string) { + return (string == null || string.isBlank()) ? UnDefType.UNDEF : new StringType(string); + } + + private State createQuantityState(@Nullable Number value, Unit unit) { + return (value == null) ? UnDefType.UNDEF : new QuantityType<>(value, unit); + } + + public static double convertTicksToSeconds(long ticks) { + double raw = ticks / 10_000_000.0; + return Math.round(raw * 10.0) / 10.0; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingCurrentProgram.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingCurrentProgram.java new file mode 100644 index 00000000000..7b1ba362204 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingCurrentProgram.java @@ -0,0 +1,39 @@ +/* + * 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.emby.internal.model; + +import java.math.BigDecimal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * embyPlayState - part of the model for the json object received from the server + * + * @author Zachary Christiansen - Initial Contribution + * + */ +@NonNullByDefault +public class EmbyNowPlayingCurrentProgram { + + @SerializedName("RunTimeTicks") + private BigDecimal runTimeTicks = BigDecimal.ZERO; + + /** + * @return the run time of the item + */ + BigDecimal getRunTimeTicks() { + return runTimeTicks; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingItem.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingItem.java new file mode 100644 index 00000000000..ba7ba9d9128 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyNowPlayingItem.java @@ -0,0 +1,104 @@ +/* + * 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.emby.internal.model; + +import static java.util.Objects.requireNonNull; + +import java.math.BigDecimal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.annotations.SerializedName; + +/** + * embyPlayState - part of the model for the json object received from the server + * + * @author Zachary Christiansen - Initial Contribution + * + */ +@NonNullByDefault +public class EmbyNowPlayingItem { + + @SerializedName("Name") + private String name = ""; + @SerializedName("OriginalTitle") + private String originalTitle = ""; + @SerializedName("Id") + private String id = ""; + @SerializedName("RunTimeTicks") + private BigDecimal runTimeTicks = BigDecimal.ZERO; + @SerializedName("Overview") + private String overview = ""; + @SerializedName("SeasonId") + private String seasonId = ""; + @SerializedName("Type") + private String nowPlayingType = ""; + @SerializedName("CurrentProgram") + private @Nullable EmbyNowPlayingCurrentProgram currentProgram; + private final Logger logger = LoggerFactory.getLogger(EmbyNowPlayingItem.class); + + String getName() { + return name; + } + + String getSeasonId() { + return this.seasonId; + } + + String getNowPlayingType() { + return nowPlayingType; + } + + String getOriginalTitle() { + if (originalTitle.isEmpty()) { + return name; + } else { + return originalTitle; + } + } + + /** + * @return the media source id of the now playing item + */ + String getId() { + return this.id; + } + + /** + * @return the total runtime ticks of the currently playing item, returns a BigDecimal 0 if this value is not set or + * Unknown + */ + BigDecimal getRunTimeTicks() { + logger.debug("the media type is {}", nowPlayingType); + BigDecimal returnValue = new BigDecimal(0); + switch (nowPlayingType) { + case "TvChannel": + returnValue = requireNonNull(currentProgram, "currentProgram must not be null").getRunTimeTicks(); + break; + case "Recording": + break; + case "Episode": + case "Movie": + returnValue = runTimeTicks; + break; + } + return returnValue; + } + + String getOverview() { + return overview; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayState.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayState.java new file mode 100644 index 00000000000..eb1485a433c --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayState.java @@ -0,0 +1,77 @@ +/* + * 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.emby.internal.model; + +import java.math.BigDecimal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * embyPlayState - part of the model for the json object received from the server + * + * @author Zachary Christiansen - Initial Contribution + * + */ +@NonNullByDefault +public class EmbyPlayState { + + @SerializedName("PositionTicks") + private BigDecimal positionTicks = BigDecimal.ZERO; + + @SerializedName("CanSeek") + private boolean canSeek = false; + + @SerializedName("IsPaused") + private boolean isPaused = false; + + @SerializedName("IsMuted") + private boolean isMuted = false; + + @SerializedName("VolumeLevel") + private Integer volumeLevel = 0; + + @SerializedName("MediaSourceId") + private String mediaSoureId = ""; + + @SerializedName("PlayMethod") + private String playMethod = ""; + + @SerializedName("repeatMode") + private String repeatMode = ""; + + /** + * @return the current position in the playback of the now playing item, can be compared to the total + * runtimeticks + * to get percentage played + */ + BigDecimal getPositionTicks() { + return positionTicks; + } + + boolean getPaused() { + return isPaused; + } + + boolean getIsMuted() { + return isMuted; + } + + /** + * @return the item id of the now playing item + */ + String getMediaSourceID() { + return mediaSoureId; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayStateModel.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayStateModel.java new file mode 100644 index 00000000000..ebb3831d804 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayStateModel.java @@ -0,0 +1,191 @@ +/* + * 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.emby.internal.model; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URI; +import java.net.URISyntaxException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link EmbyPlayStateModel} holds data about the current play state + * and provides safe getters that guard against missing playState or nowPlayingItem. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyPlayStateModel { + + @Nullable + @com.google.gson.annotations.SerializedName("PlayState") + private EmbyPlayState playState; + + @com.google.gson.annotations.SerializedName("RemoteEndPoint") + private String remoteEndPoint = ""; + + @com.google.gson.annotations.SerializedName("Id") + private String id = ""; + + @com.google.gson.annotations.SerializedName("UserId") + private String userId = ""; + + @com.google.gson.annotations.SerializedName("UserName") + private String userName = ""; + + @com.google.gson.annotations.SerializedName("Client") + private String client = ""; + + @com.google.gson.annotations.SerializedName("DeviceName") + private String deviceName = ""; + + @com.google.gson.annotations.SerializedName("DeviceId") + private String deviceId = ""; + + @com.google.gson.annotations.SerializedName("SupportsRemoteControl") + private Boolean supportsRemoteControl = false; + + @Nullable + @com.google.gson.annotations.SerializedName("NowPlayingItem") + private EmbyNowPlayingItem nowPlayingItem; + + private final Logger logger = LoggerFactory.getLogger(EmbyPlayStateModel.class); + + /** May be null if nothing is playing. */ + public @Nullable EmbyPlayState getPlayStates() { + return playState; + } + + public Boolean getEmbyPlayStatePausedState() { + final EmbyPlayState state = this.playState; + return (state != null) ? state.getPaused() : Boolean.FALSE; + } + + public Boolean getEmbyMuteSate() { + final EmbyPlayState state = this.playState; + return (state != null) ? state.getIsMuted() : Boolean.FALSE; + } + + public String getNowPlayingName() { + final EmbyNowPlayingItem item = this.nowPlayingItem; + return (item != null) ? item.getName() : ""; + } + + public BigDecimal getNowPlayingTime() { + final EmbyPlayState state = this.playState; + return (state != null) ? state.getPositionTicks() : BigDecimal.ZERO; + } + + public BigDecimal getNowPlayingTotalTime() { + final EmbyNowPlayingItem item = this.nowPlayingItem; + return (item != null) ? item.getRunTimeTicks() : BigDecimal.ZERO; + } + + public String getNowPlayingMediaType() { + final EmbyNowPlayingItem item = this.nowPlayingItem; + return (item != null) ? item.getNowPlayingType() : ""; + } + + public Boolean compareDeviceId(String compareId) { + // deviceId is never null; equals() handles null compareId safely + return getDeviceId().equals(compareId); + } + + /** + * Returns the fraction (0–1) of playback completed, rounded to 2 decimal places. + */ + public BigDecimal getPercentPlayed() { + final EmbyPlayState state = this.playState; + final EmbyNowPlayingItem item = this.nowPlayingItem; + + BigDecimal positionTicks = (state != null) ? state.getPositionTicks() : BigDecimal.ZERO; + BigDecimal runTimeTicks = (item != null) ? item.getRunTimeTicks() : BigDecimal.ZERO; + + logger.debug("The play state position is {}", positionTicks); + + if (BigDecimal.ZERO.equals(runTimeTicks)) { + return BigDecimal.ZERO; + } + return positionTicks.divide(runTimeTicks, 2, RoundingMode.HALF_UP); + } + + public @Nullable EmbyNowPlayingItem getNowPlayingItem() { + return nowPlayingItem; + } + + /** @return the IP address of the user playing the media */ + public String getRemoteEndPoint() { + return remoteEndPoint; + } + + public String getId() { + return id; + } + + /** @return the Emby ID of the user */ + public String getuserId() { + return userId; + } + + public String userName() { + return userName; + } + + public String getClient() { + return client; + } + + public String getDeviceName() { + return deviceName; + } + + public String getDeviceId() { + EmbyDeviceEncoder encoder = new EmbyDeviceEncoder(); + return encoder.encodeDeviceID(deviceId); + } + + public Boolean getSupportsRemoteControl() { + return supportsRemoteControl; + } + + public URI getPrimaryImageURL(String embyHost, int embyPort, String embyType, String maxWidth, String maxHeight) + throws URISyntaxException { + logger.debug("Received an image URL request for: {} , port: {}, type: {}, max: {}/{} , percentPlayed: {}", + embyHost, embyPort, embyType, maxWidth, maxHeight, getPercentPlayed()); + + final EmbyNowPlayingItem item = this.nowPlayingItem; + if (item == null) { + // no media playing → special URI + return new URI("http", null, "NotPlaying", 8096, null, null, null); + } + + // build path based on media type + String imagePath = item.getNowPlayingType().equalsIgnoreCase("Episode") + ? "/emby/items/" + item.getSeasonId() + "/Images/" + embyType + : "/emby/items/" + item.getId() + "/Images/" + embyType; + + // build query string + StringBuilder query = new StringBuilder(); + + query.append("MaxWidth=").append(maxWidth).append("&"); + + query.append("MaxHeight=").append(maxHeight).append("&"); + + return new URI("http", null, embyHost, embyPort, imagePath, query.toString(), null); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayingPostJsonModel.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayingPostJsonModel.java new file mode 100644 index 00000000000..b3c4d177996 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/model/EmbyPlayingPostJsonModel.java @@ -0,0 +1,96 @@ +/* + * 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.emby.internal.model; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * embyPlayState - part of the model for the json object received from the server + * + * @author Zachary Christiansen - Initial Contribution + * + */ +@NonNullByDefault +public class EmbyPlayingPostJsonModel { + + @SerializedName("ItemsIds") + private String itemsIds = ""; + + @SerializedName("PlayCommand") + private String playCommand = ""; + + @SerializedName("StartPositionTicks") + private String startPositionTicks = ""; + + @SerializedName("MediaSourceId") + private String mediaSourceId = ""; + + @SerializedName("AudioStreamIndex") + private String audioStreamIndex = ""; + + @SerializedName("SubtitleStreamIndex") + private String subtitleStreamIndex = ""; + + @SerializedName("StartIndex") + private String startIndex = ""; + + public String getItemsIds() { + return this.itemsIds; + } + + public void setItemsIds(String settingItemsIds) { + this.itemsIds = settingItemsIds; + } + + public String getPlayCommand() { + return this.playCommand; + } + + public void setPlayCommand(String settingPlayCommand) { + this.playCommand = settingPlayCommand; + } + + public String getStartPositionTicks() { + return this.startPositionTicks; + } + + public void setStartPositionTicks(String settingStartPositionTicks) { + this.startPositionTicks = settingStartPositionTicks; + } + + public String getMediaSourceIds() { + return this.mediaSourceId; + } + + public void setMediaSourceId0(String settingMediaSourceId) { + this.mediaSourceId = settingMediaSourceId; + } + + public String getSubtitleStreamIndex() { + return this.subtitleStreamIndex; + } + + public void setSubtitleStreamIndex(String settingSubtitleStreamIndex) { + this.subtitleStreamIndex = settingSubtitleStreamIndex; + } + + public String getStartIndex() { + return this.startIndex; + } + + public void setStartIndex(String settingStartIndex) { + this.startIndex = settingStartIndex; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocket.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocket.java new file mode 100644 index 00000000000..4b8d5a5c96c --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocket.java @@ -0,0 +1,205 @@ +/* + * 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.emby.internal.protocol; + +import static java.util.Objects.requireNonNull; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.util.List; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.client.ClientUpgradeRequest; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.reflect.TypeToken; + +/** + * EmbyClientSocket implements the low level communication to Emby through + * websocket. Usually this communication is done through port 9090 + * + * @author Zachary Christiansen - Initial contribution + * + */ +@NonNullByDefault +public class EmbyClientSocket { + + private static final int REQUEST_TIMEOUT_MS = 10000; + private final Logger logger = LoggerFactory.getLogger(EmbyClientSocket.class); + private final ScheduledExecutorService scheduler; + private final Gson mapper = new Gson(); + private final EmbyClientSocketEventListener eventHandler; + private final WebSocketClient client; + private final ClientUpgradeRequest request = new ClientUpgradeRequest(); + private final EmbyWebSocketListener socket = new EmbyWebSocketListener(); + + private @Nullable URI uri; + private @Nullable Session session; + + private volatile boolean shouldReconnect = true; + private int reconnectAttempts = 0; + + public EmbyClientSocket(EmbyClientSocketEventListener handler, @Nullable URI setUri, + ScheduledExecutorService setScheduler, WebSocketClient sharedWebSocketClient) { + this.eventHandler = handler; + this.uri = setUri; + this.scheduler = setScheduler; + this.client = sharedWebSocketClient; + } + + public synchronized void open() throws Exception { + if (isConnected()) { + logger.debug("already open"); + return; + } + + Future future = requireNonNull(client.connect(socket, uri, request), + "WebSocketClient.connect returned null Future"); + Session wsSession = requireNonNull(future.get(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS), + "Future.get returned null Session"); + requireNonNull(wsSession, "WebSocketClient.connect returned null Session"); + this.session = wsSession; + logger.debug("Connected to Emby"); + // Reset retry counters + reconnectAttempts = 0; + // Fire the connection‐opened event + scheduler.submit(() -> eventHandler.onConnectionOpened()); + } + + public void close() { + shouldReconnect = false; + reconnectAttempts = 0; + Session s = session; + if (s != null) { + try { + s.close(); + } catch (Exception e) { + logger.debug("Exception during closing the websocket: {}", e.getMessage(), e); + } + session = null; + } + } + + public boolean isConnected() { + Session s = session; + return s != null && s.isOpen(); + } + + public synchronized void attemptReconnect() { + if (!shouldReconnect) { + return; + } + reconnectAttempts++; + long delay = Math.min(60_000, (1 << Math.min(reconnectAttempts, 6)) * 1000L); + logger.debug("Scheduling reconnect #{} in {}ms", reconnectAttempts, delay); + scheduler.schedule(() -> { + try { + open(); + } catch (Exception e) { + logger.debug("Reconnect attempt #{} failed: {}", reconnectAttempts, e.getMessage()); + attemptReconnect(); + } + }, delay, TimeUnit.MILLISECONDS); + } + + public void sendCommand(String methodName, String dataParams) { + JsonObject payload = new JsonObject(); + payload.addProperty("MessageType", methodName); + payload.addProperty("Data", dataParams); + scheduler.submit(() -> { + try { + Session s = this.session; + if (s == null || !s.isOpen()) { + logger.debug("Cannot send {}, session not open", methodName); + return; + } + s.getRemote().sendString(mapper.toJson(payload)); + logger.debug("Sent command: {}", payload); + } catch (IOException e) { + logger.error("Failed sending {}: {}", methodName, e.getMessage(), e); + } + }); + } + + @WebSocket + public class EmbyWebSocketListener { + @OnWebSocketConnect + public void onConnect(Session wssession) { + } + + @OnWebSocketMessage + public void onMessage(String message) { + logger.trace("Message received from server: {}", message); + JsonObject json = JsonParser.parseString(message).getAsJsonObject(); + if (json.has("Data")) { + JsonArray dataArr = json.get("Data").getAsJsonArray(); + Type listType = new TypeToken>() { + }.getType(); + @SuppressWarnings("unchecked") + List states = (List) mapper.fromJson(dataArr, listType); + + if (states != null) { + states.forEach(eventHandler::handleEvent); + } else { + logger.trace("Parsed EmbyPlayStateModel list was null; skipping event handling"); + } + } + } + + @OnWebSocketClose + public void onClose(int statusCode, @Nullable String reason) { + logger.debug("WebSocket closed ({}): {}", statusCode, reason); + session = null; + eventHandler.onConnectionClosed(); + if (shouldReconnect) { + attemptReconnect(); + } + } + + @OnWebSocketError + public void onError(@Nullable Throwable error) { + logger.error("WebSocket error, scheduling reconnect", error); + Session current = session; + if (current != null) { + try { + current.disconnect(); + } catch (Exception e) { + logger.error("Failed to cleanly disconnect WebSocket session: {}", e.getMessage(), e); + } + current = null; + } + eventHandler.onConnectionClosed(); + if (shouldReconnect) { + attemptReconnect(); + } + } + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocketEventListener.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocketEventListener.java new file mode 100644 index 00000000000..d388250af11 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyClientSocketEventListener.java @@ -0,0 +1,47 @@ +/* + * 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.emby.internal.protocol; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; + +/** + * Listener interface for receiving events from the {@code EmbyClientSocket}. + * Implement this interface to handle connection lifecycle events and playback state updates + * received from an Emby server. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public interface EmbyClientSocketEventListener { + + /** + * Called when a playback state update is received from the Emby server. + * Implementers should handle the new {@link EmbyPlayStateModel} accordingly. + * + * @param playstate the updated playback state information + */ + void handleEvent(EmbyPlayStateModel playstate); + + /** + * Called when the connection to the Emby server has been successfully opened. + * Implementers can perform initialization or resource allocation here. + */ + void onConnectionOpened(); + + /** + * Called when the connection to the Emby server has been closed. + * Implementers should handle cleanup or reconnection logic here. + */ + void onConnectionClosed(); +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyConnection.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyConnection.java new file mode 100644 index 00000000000..6e87ac27a69 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyConnection.java @@ -0,0 +1,162 @@ +/* + * 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.emby.internal.protocol; + +import static java.util.Objects.requireNonNull; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.websocket.client.WebSocketClient; +import org.openhab.binding.emby.internal.EmbyBridgeListener; +import org.openhab.binding.emby.internal.model.EmbyPlayStateModel; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * EmbyConnection provides an API for accessing an Emby device. + * + * All nullable fields are checked via a local copy + null-guard before use, + * per openHAB’s Null Annotations guidelines. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyConnection implements EmbyClientSocketEventListener, AutoCloseable { + + private final Logger logger = LoggerFactory.getLogger(EmbyConnection.class); + + private String hostname = ""; + private int embyport = 0; + + private @Nullable URI wsUri; + private @Nullable EmbyClientSocket socket; + private @Nullable ScheduledExecutorService schedulerInstance; + + private int refreshRate = 0; + + private final EmbyBridgeListener listener; + private final WebSocketClient sharedWebSocketClient; + + public EmbyConnection(EmbyBridgeListener listener, WebSocketClient embyWebSocketClient) { + this.listener = listener; + this.sharedWebSocketClient = embyWebSocketClient; + } + + @Override + public synchronized void onConnectionClosed() { + listener.updateConnectionState(false); + } + + @Override + public synchronized void onConnectionOpened() { + listener.updateConnectionState(true); + + // local copy + guard for socket: + final EmbyClientSocket sock = this.socket; + // once the connection is open, start sessions (if connected) + if (sock != null && sock.isConnected()) { + sock.sendCommand("SessionsStart", "0," + refreshRate); + } else { + logger.warn("onConnectionOpened() called but socket is not yet connected"); + } + } + + public synchronized void connect(String setHostName, int port, String apiKey, ScheduledExecutorService scheduler, + int refreshRate) { + this.schedulerInstance = scheduler; + this.hostname = setHostName; + this.embyport = port; + this.refreshRate = refreshRate; + + try { + close(); // tear down any previous socket + + // build and store the WS URI + wsUri = new URI("ws", null, hostname, embyport, null, "api_key=" + apiKey, null); + + // create and start the socket + EmbyClientSocket localSocket = requireNonNull( + new EmbyClientSocket(this, wsUri, scheduler, sharedWebSocketClient), + "EmbyClientSocket constructor returned null"); + localSocket.attemptReconnect(); + this.socket = localSocket; + } catch (URISyntaxException e) { + logger.error("Exception constructing URI host={}, port={}", hostname, embyport, e); + } + } + + @Override + public synchronized void close() { + final EmbyClientSocket sock = this.socket; + if (sock != null) { + sock.close(); // stops reconnection internally + this.socket = null; + } + } + + /** + * Cleans up all resources held by this connection: + * - closes the WebSocket socket (stopping any reconnection loops) + * - shuts down the scheduler if present + */ + public synchronized void dispose() { + // close the socket + close(); + ScheduledExecutorService localScheduler = schedulerInstance; + // shut down the scheduler + if (localScheduler != null) { + try { + localScheduler.shutdownNow(); + if (!localScheduler.awaitTermination(5, TimeUnit.SECONDS)) { + logger.warn("Scheduler did not terminate cleanly"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while shutting down scheduler", e); + } + schedulerInstance = null; + } + + listener.updateConnectionState(false); + logger.debug("Disposed EmbyConnection for {}:{}", hostname, embyport); + } + + @Override + public void handleEvent(EmbyPlayStateModel playstate) { + logger.debug("Received event from EMBY server, passing to bridge: host={}, port={}", hostname, embyport); + listener.handleEvent(playstate, hostname, embyport); + } + + public boolean checkConnection() { + final EmbyClientSocket sock = this.socket; + if (!(sock instanceof EmbyClientSocket) || !sock.isConnected()) { + logger.debug("Connection down, scheduling reconnect to {}", wsUri); + if (sock instanceof EmbyClientSocket) { + sock.attemptReconnect(); + } + return false; + } + return true; + } + + public String getConnectionName() { + // blow up early if no URI + final URI uri = requireNonNull(wsUri, "EmbyConnection not initialized (call connect() first)"); + return uri.toString(); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyDeviceEncoder.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyDeviceEncoder.java new file mode 100644 index 00000000000..27ed393b8ec --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyDeviceEncoder.java @@ -0,0 +1,41 @@ +/* + * 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.emby.internal.protocol; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link EmbyDeviceEncoder} is responsible for encoding device identifiers + * for communication with the Emby server. It transforms a given device ID into + * a format acceptable by the Emby protocol by replacing non-alphanumeric + * characters with a fixed token. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyDeviceEncoder { + + /** + * Encodes the specified device identifier by replacing any character + * that is not an ASCII letter (A-Z, a-z) or digit (0-9) with the token + * {@code UYHJKU}. This ensures the device ID conforms to the Emby protocol + * requirements. + * + * @param deviceID the original device identifier to encode + * @return the encoded device identifier, where all non-alphanumeric characters + * have been replaced with {@code UYHJKU} + */ + public String encodeDeviceID(String deviceID) { + return deviceID.replaceAll("[^A-Za-z0-9]", "UYHJKU"); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHTTPUtils.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHTTPUtils.java new file mode 100644 index 00000000000..dddf2fb6021 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHTTPUtils.java @@ -0,0 +1,113 @@ +/* + * 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.emby.internal.protocol; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.net.http.HttpUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * HTTP Get and Put request class. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyHTTPUtils { + private final Logger logger = LoggerFactory.getLogger(EmbyHTTPUtils.class); + private int requestTimeout; + private String apiKey; + private String hostIpPort; + private String logTest = ""; + + public EmbyHTTPUtils(int requestTimeout, String apiKey, String hostIpPort) { + this.requestTimeout = (int) TimeUnit.SECONDS.toMillis(requestTimeout); + this.apiKey = apiKey; + this.hostIpPort = hostIpPort; + } + + public void setRequestTimeout(int requestTimeout) { + this.requestTimeout = (int) TimeUnit.SECONDS.toMillis(requestTimeout); + } + + /** + * Sends an HTTP POST request to the Emby server. + * + * @param urlAddress the endpoint path (appended to the configured hostIpPort) to send the request to + * @param payload the JSON payload to include in the request body + * @return the response body as a string + * @throws IOException if an I/O error occurs during the request + */ + private String doPost(String urlAddress, String payload) throws IOException { + urlAddress = "http://" + hostIpPort + urlAddress; + logger.debug("The String url we want to post is : {}", urlAddress); + logger.debug("The payload we want to post is: {}", payload); + ByteArrayInputStream input = new ByteArrayInputStream(payload.getBytes(StandardCharsets.UTF_8)); + logTest = HttpUtil.executeUrl("POST", urlAddress, getHttpHeaders(), input, "application/json", requestTimeout); + logger.debug("{}", logTest); + return logTest; + } + + protected Properties getHttpHeaders() { + Properties httpHeaders = new Properties(); + httpHeaders.put("Content-Type", "application/json"); + httpHeaders.put("X-Emby-Token", this.apiKey); + return httpHeaders; + } + + /** + * Sends an HTTP POST request to the Emby server with retry logic. + * + * Implements a simple linear backoff strategy between retries. + * + * @param urlAddress the endpoint path (appended to the configured hostIpPort) to send the request to + * @param payload the JSON payload to include in the request body + * @param retryCount the maximum number of retry attempts before failing + * @return the response body as a string, or null if no response was received + * @throws EmbyHttpRetryExceeded if the number of retries is exceeded or the thread is interrupted + */ + public synchronized @Nullable String doPost(String urlAddress, String payload, int retryCount) + throws EmbyHttpRetryExceeded { + String response = null; + int x = 0; + + while (true) { + try { + response = doPost(urlAddress, payload); + break; + } catch (IOException e) { + x++; + try { + Thread.sleep(1000 * x); // Simple linear backoff + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); // preserve interrupt status + logger.warn("Sleep interrupted during retry delay", ie); + throw new EmbyHttpRetryExceeded("Interrupted while retrying POST request", ie); + } + + if (x > retryCount) { + logger.warn("Attempt {} failed to POST to {}: {}", x, urlAddress, e.getMessage()); + throw new EmbyHttpRetryExceeded("The number of retry attempts was exceeded", e.getCause()); + } + } + } + return response; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHttpRetryExceeded.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHttpRetryExceeded.java new file mode 100644 index 00000000000..f966014e634 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/protocol/EmbyHttpRetryExceeded.java @@ -0,0 +1,32 @@ +/* + * 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.emby.internal.protocol; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; + +/** + * Custom exception class to be thrown when number of retries is exceeded. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyHttpRetryExceeded extends Exception { + + // Properly handle serialization warning instead of suppressing it + private static final long serialVersionUID = 1L; + + public EmbyHttpRetryExceeded(String message, @Nullable Throwable cause) { + super(message, cause); + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/util/EmbyThrottle.java b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/util/EmbyThrottle.java new file mode 100644 index 00000000000..daa224ef885 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/java/org/openhab/binding/emby/internal/util/EmbyThrottle.java @@ -0,0 +1,53 @@ +/* + * 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.emby.internal.util; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link EmbyThrottle} is a Utility to throttle high-frequency events on a per-key basis. + * Ensures updates for each key are only allowed after a configured interval. + * + * @author Zachary Christiansen - Initial contribution + */ +@NonNullByDefault +public class EmbyThrottle { + + private final long intervalMillis; + private final Map lastExecutionTimes = new ConcurrentHashMap<>(); + + public EmbyThrottle(long intervalMillis) { + this.intervalMillis = intervalMillis; + } + + /** + * Returns true if the operation associated with the given key should proceed, + * based on the configured throttle interval. + * + * @param key A unique identifier for the event type (e.g. "updateTitle"). + * @return true if enough time has passed since the last execution for this key. + */ + public boolean shouldProceed(String key) { + long now = System.currentTimeMillis(); + return lastExecutionTimes.compute(key, (k, lastTime) -> { + if (lastTime == null || now - lastTime >= intervalMillis) { + return now; // Allow update and set new timestamp + } else { + return lastTime; // Block update + } + }) == now; + } +} diff --git a/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 00000000000..a688774ffce --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,60 @@ + + + + binding + Emby Binding + + The Emby Binding integrates Emby, a personal media server (https://emby.media/), with openHAB. It supports + both controlling the player and retrieving player status data like the currently playing movie title. + + + + local + + + + + + mdns + + + mdnsServiceType + _emby-server._tcp.local. + + + + + + + ip + + + type + ipBroadcast + + + destPort + 7359 + + + request + who is EmbyServer? + + + timeoutMs + 5000 + + + + + + response + .*"Id".* + + + + + + diff --git a/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/i18n/emby.properties b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/i18n/emby.properties new file mode 100644 index 00000000000..99cde2f1937 --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/i18n/emby.properties @@ -0,0 +1,125 @@ +# add-on + +addon.emby.name = Emby Binding +addon.emby.description = The Emby Binding integrates Emby, a personal media server (https://emby.media/), with openHAB. It supports both controlling the player and retrieving player status data like the currently playing movie title. + +@text/thing.status.device.config.invalidImageType +# thing status messages +thing.status.bridge.connectionLost = "Connection Lost" +thing.status.bridge.connectionFailed = "Connection Failed: " +thing.status.bridge.configurationFailed = "Configuration error: " +thing.status.bridge.connectionRetry = "Retrying Connection: " +thing.status.bridge.missingAPI = "Invalid API key" +thing.status.bridge.missingIP = "Invalid Server IP" +thing.status.device.noBridge = "No Bridge Selected" +thing.status.device.bridgeOffline = "Linked Bridge Offline" +thing.status.device.configInValid = "Invalid Config: " +thing.status.device.initalizationFalied = "Initialization failed" +thing.status.device.config.noDeviceID = "No DeviceID" +thing.status.device.config.noChannelDefined = "Missing Channel: " +thing.status.device.config.invalidImageType = "Invalid Image Type: " +thing.status.device.config.notNumber = "Number Required for: " +thing.status.device.config.booleanRequried = "Boolean Required: " + +# thing types + +thing-type.emby.controller.label = EMBY Server +thing-type.emby.controller.description = This is the Bridge to an instance of an EMBY server you want to connect to. +thing-type.emby.device.label = EMBY Binding Device +thing-type.emby.device.description = This is a player device which connects to an EMBY server to play files that you want to montior. + +# thing types config + +thing-type.config.emby.controller.api.label = API Key +thing-type.config.emby.controller.api.description = This is the API key generated from EMBY used for Authorization. +thing-type.config.emby.controller.discovery.label = Discovery +thing-type.config.emby.controller.discovery.description = If set to false the controller will not add new things from devices to the inbox. +thing-type.config.emby.controller.ipAddress.label=IP Address +thing-type.config.emby.controller.ipAddress.description = This is the ip address of the EMBY Server. +thing-type.config.emby.controller.port.label = Port +thing-type.config.emby.controller.port.description = This is the port of the EMBY server. +thing-type.config.emby.controller.refreshInterval.label = Refresh Parameter +thing-type.config.emby.controller.refreshInterval.description = This is the refresh interval in milliseconds that will be sent to the websocket. Default is 10,000 +thing-type.config.emby.device.deviceID.label = DeviceID +thing-type.config.emby.device.deviceID.description = This is the deviceId you want to connect to to monitor. + +# channel types + +channel-type.emby.control.label = Control +channel-type.emby.control.description = Control the Emby Player, e.g. start/stop/next/previous/ffward/rewind +channel-type.emby.current-time.label = Current Time +channel-type.emby.current-time.description = Current time of currently playing media +channel-type.emby.duration.label = Duration +channel-type.emby.duration.description = Length of currently playing media +channel-type.emby.image-url.label=Image URL +channel-type.emby.image-url.description = The url of the playing media +channel-type.emby.media-type.label = Media Type +channel-type.emby.media-type.description = Media type of the current file +channel-type.emby.mute.label = Mute +channel-type.emby.mute.description = Mute/unmute your device +channel-type.emby.show-title.label = Show Title +channel-type.emby.show-title.description = Title of the current show +channel-type.emby.stop.label = Stop +channel-type.emby.stop.description = Stops the player. ON if the player is stopped. +channel-type.emby.title.label = Title +channel-type.emby.title.description = Title of the current song + +# channel types config + +channel-type.config.emby.image-url.image-url_maxHeight.label = Image Max Height +channel-type.config.emby.image-url.image-url_maxHeight.description = The maximum height of the image that will be retrieved. +channel-type.config.emby.image-url.image-url_maxWidth.label = Image Max Width +channel-type.config.emby.image-url.image-url_maxWidth.description = The maximum width of the image that will be retrieved. +channel-type.config.emby.image-url.image-url_type.option.Primary = Primary +channel-type.config.emby.image-url.image-url_type.option.Art = Art +channel-type.config.emby.image-url.image-url_type.option.Backdrop = Backdrop +channel-type.config.emby.image-url.image-url_type.option.Banner = Banner +channel-type.config.emby.image-url.image-url_type.option.Logo = Logo +channel-type.config.emby.image-url.image-url_type.option.Thumb = Thumb +channel-type.config.emby.image-url.image-url_type.option.Disc = Disc +channel-type.config.emby.image-url.image-url_type.option.Box = Box +channel-type.config.emby.image-url.image-url_type.option.Screenshot = Screenshot +channel-type.config.emby.image-url.image-url_type.option.Menu = Menu +channel-type.config.emby.image-url.image-url_type.option.Chapter = Chapter + + # SendPlay action +action.emby.SendPlay.label = Send Play Command +action.emby.SendPlay.desc = Send a play command to an Emby player device. + +# SendPlay inputs +action.emby.SendPlay.input.itemIds.label = Item IDs +action.emby.SendPlay.input.itemIds.desc = Comma-delimited list of item IDs to play (e.g. "id1,id2,id3"). +action.emby.SendPlay.input.playCommand.label = Play Command +action.emby.SendPlay.input.playCommand.desc = One of PlayNow, PlayNext or PlayLast. +action.emby.SendPlay.input.startPositionTicks.label = Start Position (ticks) +action.emby.SendPlay.input.startPositionTicks.desc = Starting offset (in ticks) for the first title. +action.emby.SendPlay.input.mediaSourceId.label = Media Source ID +action.emby.SendPlay.input.mediaSourceId.desc = Media source to use for the first item. +action.emby.SendPlay.input.audioStreamIndex.label = Audio Stream Index +action.emby.SendPlay.input.audioStreamIndex.desc = Audio stream index for the first item. +action.emby.SendPlay.input.subtitleStreamIndex.label = Subtitle Stream Index +action.emby.SendPlay.input.subtitleStreamIndex.desc = Subtitle stream index for the first item. +action.emby.SendPlay.input.startIndex.label = Start Index +action.emby.SendPlay.input.startIndex.desc = Zero-based index of which item in the list to start playback at. + +# SendGeneralCommand action +action.emby.SendGeneralCommand.label = Send General Command +action.emby.SendGeneralCommand.desc = Send one of the generic Emby control commands (no arguments). + +# SendGeneralCommand inputs +action.emby.SendGeneralCommand.input.commandName.label = Command Name +action.emby.SendGeneralCommand.input.commandName.desc = The general command to send (e.g. MoveUp, ToggleMute, GoHome). + +# SendGeneralCommandWithArgs action +action.emby.SendGeneralCommandWithArgs.label = Send General Command With Arguments +action.emby.SendGeneralCommandWithArgs.desc = Send a generic Emby control command plus any required arguments. + +# SendGeneralCommandWithArgs inputs +action.emby.SendGeneralCommandWithArgs.input.commandName.label = Command Name +action.emby.SendGeneralCommandWithArgs.input.commandName.desc = The general command to send (e.g. SetVolume, DisplayMessage). +action.emby.SendGeneralCommandWithArgs.input.jsonArguments.label = Arguments +action.emby.SendGeneralCommandWithArgs.input.jsonArguments.desc = Comma-delimited values, depending on the command (e.g. "Volume", "Header,Text,TimeoutMs"). + + + + diff --git a/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 00000000000..f5a5bd7c64b --- /dev/null +++ b/bundles/org.openhab.binding.emby/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,155 @@ + + + + + + This is the Bridge to an instance of an EMBY server you want to connect to. + NetworkAppliance + + + + This is the API key generated from EMBY used for Authorization. + + + + IP address or hostname of the EMBY server. + network-address + + + + Port number for the EMBY server. + 8096 + + + + Polling interval for play-state updates. + 10000 + + + + Enable or disable automatic device discovery. + true + true + + + + + + + + + + This is a player device which connects to an EMBY server. + MediaPlayer + + + + + + + + + + + + + + + This is the deviceId you want to connect to. + + + + + String + + Title of the current song + + + + + String + + Title of the current show + + + + + Player + + Control the Emby Player, e.g. start/stop/next/previous/ffward/rewind + Player + + + + Switch + + Stops the player. ON if the player is stopped. + + + + Switch + + Mute/unmute your device + + + + Number:Time + + Current time of currently playing media + + + + + Number:Time + + Length of currently playing media + + + + + String + + Media type of the current file + + + + String + + The url of the playing media + + + + Primary + + + + + + + + + + + + + + + + + The maximum height of the image that will be retrieved. + + + + The maximum width of the image that will be retrieved. + + + + If set to true, a percent played overlay will be added to the image. For example, using + PercentPlayed=47 will overlay a 47% progress indicator. Default is false. + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 6378d8c8be0..0f898fb54ee 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -137,6 +137,7 @@ org.openhab.binding.electroluxappliance org.openhab.binding.elerotransmitterstick org.openhab.binding.elroconnects + org.openhab.binding.emby org.openhab.binding.emotiva org.openhab.binding.energenie org.openhab.binding.energidataservice