[emby] Initial contribution (#18607)

* Initial Commit of proposed EMBY Binding

Signed-off-by: Zachary Christiansen <volfan6415@gmail.com>
pull/18688/head
Zachary Christiansen 2025-05-15 15:49:59 -05:00 committed by GitHub
parent 7192ac3085
commit 9ef01e5f47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 3420 additions and 0 deletions

View File

@ -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

View File

@ -506,6 +506,11 @@
<artifactId>org.openhab.binding.elroconnects</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.emby</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.emotiva</artifactId>

View File

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

View File

@ -0,0 +1,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)

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>5.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.emby</artifactId>
</project>

View File

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

View File

@ -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;
}

View File

@ -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
}

View File

@ -0,0 +1,45 @@
/*
* Copyright (c) 2010-2025 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.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);
}

View File

@ -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 = "";
}
}

View File

@ -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);
}

View File

@ -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<ThingTypeUID> 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<DiscoveryService> discoveryFactory;
private final Map<ThingUID, ComponentInstance<DiscoveryService>> 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<String, Object> cfg = new Hashtable<>();
cfg.put("bridgeUID", bridgeHandler.getThing().getUID().toString());
ComponentFactory<DiscoveryService> factory = Objects.requireNonNull(discoveryFactory,
"discoveryFactory must be injected");
@SuppressWarnings("null")
ComponentInstance<DiscoveryService> 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<DiscoveryService> 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);
}
}

View File

@ -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<ThingTypeUID> 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<String> 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<String, Object> 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;
}
}
}

View File

@ -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<ThingTypeUID> 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<String, Object> 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());
}
}

View File

@ -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");
}
}
}

View File

@ -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 playstate 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 werent already
if (currentStatus != ThingStatus.ONLINE) {
reconnectionCount = 0;
updateStatus(ThingStatus.ONLINE);
}
} else {
// Weve 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();
}
}

View File

@ -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<String> 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<Class<? extends ThingHandlerService>> 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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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 (01) 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);
}
}

View File

@ -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;
}
}

View File

@ -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<Session> future = requireNonNull(client.connect(socket, uri, request),
"WebSocketClient.connect returned null Future<Session>");
Session wsSession = requireNonNull(future.get(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS),
"Future<Session>.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 connectionopened 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<List<EmbyPlayStateModel>>() {
}.getType();
@SuppressWarnings("unchecked")
List<EmbyPlayStateModel> states = (List<EmbyPlayStateModel>) 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();
}
}
}
}

View File

@ -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();
}

View File

@ -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 openHABs 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();
}
}

View File

@ -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");
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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<String, Long> 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;
}
}

View File

@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="emby" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>Emby Binding</name>
<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.
</description>
<!-- binding only works on the local network -->
<connection>local</connection>
<discovery-methods>
<!-- 1) Standard mDNS discovery for _emby-server._tcp.local. -->
<discovery-method>
<service-type>mdns</service-type>
<discovery-parameters>
<discovery-parameter>
<name>mdnsServiceType</name>
<value>_emby-server._tcp.local.</value>
</discovery-parameter>
</discovery-parameters>
</discovery-method>
<!-- 2) UDP broadcast “who is EmbyServer?” on port 7359 -->
<discovery-method>
<service-type>ip</service-type>
<discovery-parameters>
<discovery-parameter>
<name>type</name>
<value>ipBroadcast</value>
</discovery-parameter>
<discovery-parameter>
<name>destPort</name>
<value>7359</value>
</discovery-parameter>
<discovery-parameter>
<name>request</name>
<value>who is EmbyServer?</value>
</discovery-parameter>
<discovery-parameter>
<name>timeoutMs</name>
<value>5000</value>
</discovery-parameter>
</discovery-parameters>
<match-properties>
<!-- match any JSON reply containing the “Id” field -->
<match-property>
<name>response</name>
<regex>.*"Id".*</regex>
</match-property>
</match-properties>
</discovery-method>
</discovery-methods>
</addon:addon>

View File

@ -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").

View File

@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="emby"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<!-- Sample Thing Type -->
<bridge-type id="controller">
<label>EMBY Server</label>
<description>This is the Bridge to an instance of an EMBY server you want to connect to.</description>
<semantic-equipment-tag>NetworkAppliance</semantic-equipment-tag>
<config-description>
<parameter name="api" type="text" required="true">
<label>API Key</label>
<description>This is the API key generated from EMBY used for Authorization.</description>
</parameter>
<parameter name="ipAddress" type="text" required="true">
<label>Server Host</label>
<description>IP address or hostname of the EMBY server.</description>
<context>network-address</context>
</parameter>
<parameter name="port" type="integer" min="1" max="65535" required="true">
<label>Server Port</label>
<description>Port number for the EMBY server.</description>
<default>8096</default>
</parameter>
<parameter name="refreshInterval" type="integer" min="1000">
<label>Refresh Interval</label>
<description>Polling interval for play-state updates.</description>
<default>10000</default>
</parameter>
<parameter name="discovery" type="boolean">
<label>Auto Discover</label>
<description>Enable or disable automatic device discovery.</description>
<default>true</default>
<advanced>true</advanced>
</parameter>
</config-description>
</bridge-type>
<thing-type id="device">
<supported-bridge-type-refs>
<bridge-type-ref id="controller"/>
</supported-bridge-type-refs>
<label>EMBY Device</label>
<description>This is a player device which connects to an EMBY server.</description>
<semantic-equipment-tag>MediaPlayer</semantic-equipment-tag>
<channels>
<channel id="control" typeId="control"/>
<channel id="stop" typeId="stop"/>
<channel id="title" typeId="title"/>
<channel id="mute" typeId="mute"/>
<channel id="show-title" typeId="show-title"/>
<channel id="image-url" typeId="image-url"/>
<channel id="current-time" typeId="current-time"/>
<channel id="duration" typeId="duration"/>
<channel id="media-type" typeId="media-type"/>
</channels>
<config-description>
<parameter name="deviceID" type="text" required="true">
<label>DeviceID</label>
<description>This is the deviceId you want to connect to.</description>
</parameter>
</config-description>
</thing-type>
<channel-type id="title">
<item-type>String</item-type>
<label>Title</label>
<description>Title of the current song</description>
<state readOnly="true" pattern="%s"/>
</channel-type>
<channel-type id="show-title">
<item-type>String</item-type>
<label>Show Title</label>
<description>Title of the current show</description>
<state readOnly="true" pattern="%s"/>
</channel-type>
<channel-type id="control">
<item-type>Player</item-type>
<label>Control</label>
<description>Control the Emby Player, e.g. start/stop/next/previous/ffward/rewind</description>
<category>Player</category>
</channel-type>
<channel-type id="stop">
<item-type>Switch</item-type>
<label>Stop</label>
<description>Stops the player. ON if the player is stopped.</description>
</channel-type>
<channel-type id="mute">
<item-type>Switch</item-type>
<label>Mute</label>
<description>Mute/unmute your device</description>
</channel-type>
<channel-type id="current-time">
<item-type>Number:Time</item-type>
<label>Current Time</label>
<description>Current time of currently playing media</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="duration">
<item-type>Number:Time</item-type>
<label>Duration</label>
<description>Length of currently playing media</description>
<state readOnly="true" pattern="%d %unit%"/>
</channel-type>
<channel-type id="media-type">
<item-type>String</item-type>
<label>Media Type</label>
<description>Media type of the current file</description>
<state readOnly="true" pattern="%s"/>
</channel-type>
<channel-type id="image-url">
<item-type>String</item-type>
<label>image url</label>
<description>The url of the playing media</description>
<state readOnly="true" pattern="%s"/>
<config-description>
<parameter name="imageUrlType" type="text">
<default>Primary</default>
<options>
<option value="Primary">Primary</option>
<option value="Art">Art</option>
<option value="Backdrop">Backdrop</option>
<option value="Banner">Banner</option>
<option value="Logo">Logo</option>
<option value="Thumb">Thumb</option>
<option value="Disc">Disc</option>
<option value="Box">Box</option>
<option value="Screenshot">Screenshot</option>
<option value="Menu">Menu</option>
<option value="Chapter">Chapter</option>
</options>
</parameter>
<parameter name="imageUrlMaxHeight" type="text">
<label>Image Max Height</label>
<description>The maximum height of the image that will be retrieved.</description>
</parameter>
<parameter name="imageUrlMaxWidth" type="text">
<label>Image Max Width</label>
<description>The maximum width of the image that will be retrieved.</description>
</parameter>
<parameter name="imageUrlPercentPlayed" type="boolean">
<label>Show Percent Played Overlay</label>
<description>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.</description>
</parameter>
</config-description>
</channel-type>
</thing:thing-descriptions>

View File

@ -137,6 +137,7 @@
<module>org.openhab.binding.electroluxappliance</module>
<module>org.openhab.binding.elerotransmitterstick</module>
<module>org.openhab.binding.elroconnects</module>
<module>org.openhab.binding.emby</module>
<module>org.openhab.binding.emotiva</module>
<module>org.openhab.binding.energenie</module>
<module>org.openhab.binding.energidataservice</module>