[emby] Initial contribution (#18607)
* Initial Commit of proposed EMBY Binding Signed-off-by: Zachary Christiansen <volfan6415@gmail.com>pull/18688/head
parent
7192ac3085
commit
9ef01e5f47
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
|
@ -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)
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,275 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.emby.internal.handler;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.openhab.binding.emby.internal.EmbyBindingConstants.CONNECTION_CHECK_INTERVAL_MS;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.websocket.client.WebSocketClient;
|
||||
import org.openhab.binding.emby.internal.EmbyBridgeConfiguration;
|
||||
import org.openhab.binding.emby.internal.EmbyBridgeListener;
|
||||
import org.openhab.binding.emby.internal.discovery.EmbyClientDiscoveryService;
|
||||
import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
|
||||
import org.openhab.binding.emby.internal.protocol.EmbyConnection;
|
||||
import org.openhab.binding.emby.internal.protocol.EmbyHTTPUtils;
|
||||
import org.openhab.binding.emby.internal.protocol.EmbyHttpRetryExceeded;
|
||||
import org.openhab.core.config.core.validation.ConfigValidationException;
|
||||
import org.openhab.core.config.core.validation.ConfigValidationMessage;
|
||||
import org.openhab.core.i18n.TranslationProvider;
|
||||
import org.openhab.core.library.types.OnOffType;
|
||||
import org.openhab.core.library.types.StringType;
|
||||
import org.openhab.core.thing.Bridge;
|
||||
import org.openhab.core.thing.ChannelUID;
|
||||
import org.openhab.core.thing.ThingStatus;
|
||||
import org.openhab.core.thing.ThingStatusDetail;
|
||||
import org.openhab.core.thing.ThingStatusInfo;
|
||||
import org.openhab.core.thing.binding.BaseBridgeHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.openhab.core.types.UnDefType;
|
||||
import org.osgi.framework.Bundle;
|
||||
import org.osgi.framework.FrameworkUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link EmbyBridgeHandler} is responsible for handling commands,
|
||||
* managing the connection to Emby and dispatching play‐state events.
|
||||
*
|
||||
* @author Zachary Christiansen - Initial Contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EmbyBridgeHandler extends BaseBridgeHandler implements EmbyBridgeListener {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(EmbyBridgeHandler.class);
|
||||
|
||||
private volatile @Nullable EmbyConnection connection;
|
||||
private final WebSocketClient webSocketClient;
|
||||
private @Nullable ScheduledFuture<?> connectionCheckerFuture;
|
||||
private @Nullable EmbyClientDiscoveryService clientDiscoveryService;
|
||||
private @Nullable EmbyHTTPUtils httputils;
|
||||
private @Nullable EmbyBridgeConfiguration config;
|
||||
private int reconnectionCount;
|
||||
private @Nullable String lastDiscoveryStatus;
|
||||
private TranslationProvider i18nProvider;
|
||||
|
||||
public EmbyBridgeHandler(Bridge bridge, WebSocketClient webSocketClient, TranslationProvider i18nProvider) {
|
||||
super(bridge);
|
||||
this.webSocketClient = requireNonNull(webSocketClient, "webSocketClient must not be null");
|
||||
this.i18nProvider = requireNonNull(i18nProvider, "translation provider must not be null");
|
||||
}
|
||||
|
||||
public void sendCommand(String commandURL) {
|
||||
logger.trace("Sending command without payload: {}", commandURL);
|
||||
final EmbyHTTPUtils localHttpUtils = requireNonNull(this.httputils, "HTTP utils not initialized");
|
||||
try {
|
||||
localHttpUtils.doPost(commandURL, "", 2);
|
||||
} catch (EmbyHttpRetryExceeded e) {
|
||||
logger.debug("Retry limit exceeded for {}", commandURL, e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
public void sendCommand(String commandURL, String payload) {
|
||||
logger.trace("Sending command: {} with payload: {}", commandURL, payload);
|
||||
final EmbyHTTPUtils localHttpUtils = requireNonNull(this.httputils, "HTTP utils not initialized");
|
||||
try {
|
||||
localHttpUtils.doPost(commandURL, payload, 2);
|
||||
} catch (EmbyHttpRetryExceeded e) {
|
||||
logger.debug("Retry limit exceeded for {}", commandURL, e.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private void establishConnection() {
|
||||
final ScheduledExecutorService exec = requireNonNull(scheduler, "scheduler must not be null");
|
||||
|
||||
exec.execute(() -> {
|
||||
try {
|
||||
final EmbyConnection conn = requireNonNull(this.connection, "connection must not be null");
|
||||
final EmbyBridgeConfiguration cfg = requireNonNull(this.config, "config must not be null");
|
||||
final String ipAddress = requireNonNull(cfg.ipAddress, "config.ipAddress must not be null");
|
||||
final String apiKey = requireNonNull(cfg.api, "config.api must not be null");
|
||||
final Integer refreshInterval = requireNonNull(cfg.refreshInterval,
|
||||
"config.refreshInterval must not be null");
|
||||
final Integer port = requireNonNull(cfg.port, "config.port must not be null");
|
||||
|
||||
conn.connect(ipAddress, port, apiKey, exec, refreshInterval);
|
||||
|
||||
this.connectionCheckerFuture = exec.scheduleWithFixedDelay(() -> {
|
||||
if (!conn.checkConnection()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/thing.status.bridge.connectionLost");
|
||||
}
|
||||
}, CONNECTION_CHECK_INTERVAL_MS, CONNECTION_CHECK_INTERVAL_MS, TimeUnit.MILLISECONDS);
|
||||
} catch (Exception e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/thing.status.bridge.connectionFailed" + e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.NOT_YET_READY);
|
||||
|
||||
final ScheduledExecutorService exec = requireNonNull(scheduler, "scheduler must not be null");
|
||||
|
||||
exec.execute(() -> {
|
||||
this.reconnectionCount = 0;
|
||||
try {
|
||||
this.config = checkConfiguration();
|
||||
this.connection = new EmbyConnection(this, this.webSocketClient);
|
||||
establishConnection();
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
} catch (ConfigValidationException cve) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/thing.status.bridge.configurationFailed" + cve.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleEvent(EmbyPlayStateModel playstate, String hostname, int embyport) {
|
||||
final EmbyClientDiscoveryService service = this.clientDiscoveryService;
|
||||
final EmbyBridgeConfiguration cfg = requireNonNull(this.config, "config must not be null");
|
||||
|
||||
if (service != null && cfg.discovery) {
|
||||
service.addDeviceIDDiscover(playstate);
|
||||
}
|
||||
|
||||
getThing().getThings().forEach(thing -> {
|
||||
EmbyDeviceHandler handler = (EmbyDeviceHandler) thing.getHandler();
|
||||
if (handler != null) {
|
||||
handler.handleEvent(playstate, hostname, embyport);
|
||||
logger.trace("Dispatched event to {}", thing.getLabel());
|
||||
} else {
|
||||
logger.trace("No handler for {}", thing.getLabel());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateConnectionState(boolean connected) {
|
||||
// Grab current status and the last detail message
|
||||
ThingStatusInfo info = getThing().getStatusInfo();
|
||||
ThingStatus currentStatus = info.getStatus();
|
||||
|
||||
if (connected) {
|
||||
// Only transition to ONLINE if we weren’t already
|
||||
if (currentStatus != ThingStatus.ONLINE) {
|
||||
reconnectionCount = 0;
|
||||
updateStatus(ThingStatus.ONLINE);
|
||||
}
|
||||
} else {
|
||||
// We’ve gone offline: increment retry count and build new detail text
|
||||
reconnectionCount++;
|
||||
logger.debug("@text/thing.status.bridge.connectionRetry{}", reconnectionCount);
|
||||
|
||||
// Only emit a new OFFLINE event if status changed, or the message changed
|
||||
boolean statusChanged = currentStatus != ThingStatus.OFFLINE;
|
||||
|
||||
if (statusChanged) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"@text/thing.status.bridge.connectionRetry");
|
||||
}
|
||||
|
||||
ScheduledFuture<?> localConnectionCheckerFuture = this.connectionCheckerFuture;
|
||||
if (localConnectionCheckerFuture != null) {
|
||||
localConnectionCheckerFuture.cancel(false);
|
||||
this.connectionCheckerFuture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void setClientDiscoveryService(@Nullable EmbyClientDiscoveryService discovery) {
|
||||
this.clientDiscoveryService = discovery;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public EmbyClientDiscoveryService getClientDiscoveryService() {
|
||||
return clientDiscoveryService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
if (command instanceof RefreshType) {
|
||||
updateState(channelUID, pollCurrentValueBridge(channelUID));
|
||||
} else {
|
||||
logger.trace("Ignored command {} on {}", command, channelUID.getId());
|
||||
}
|
||||
}
|
||||
|
||||
public void updateDiscoveryStatus(String status) {
|
||||
this.lastDiscoveryStatus = status;
|
||||
updateState(new ChannelUID(getThing().getUID(), "discoveryStatus"), new StringType(status));
|
||||
}
|
||||
|
||||
private EmbyBridgeConfiguration checkConfiguration() throws ConfigValidationException {
|
||||
EmbyBridgeConfiguration embyConfig = getConfigAs(EmbyBridgeConfiguration.class);
|
||||
if (embyConfig.api.isEmpty()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/thing.status.bridge.missingAPI");
|
||||
throwValidationError("api", "@text/thing.status.bridge.missingAPI");
|
||||
}
|
||||
if (embyConfig.ipAddress.isEmpty()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Missing server address");
|
||||
throwValidationError("ipAddress", "@text/thing.status.bridge.missingIP");
|
||||
}
|
||||
this.httputils = new EmbyHTTPUtils(30, embyConfig.api, embyConfig.ipAddress + ":" + embyConfig.port);
|
||||
return embyConfig;
|
||||
}
|
||||
|
||||
private void throwValidationError(String parameterName, String errorMessage) throws ConfigValidationException {
|
||||
final TranslationProvider provider = requireNonNull(this.i18nProvider, "i18nProvider must not be null");
|
||||
Bundle bundle = FrameworkUtil.getBundle(getClass());
|
||||
ConfigValidationMessage message = new ConfigValidationMessage(parameterName, "error", errorMessage);
|
||||
throw new ConfigValidationException(bundle, provider, Collections.singletonList(message));
|
||||
}
|
||||
|
||||
private State pollCurrentValueBridge(ChannelUID channelUID) {
|
||||
switch (channelUID.getId()) {
|
||||
case "serverReachable":
|
||||
return (getThing().getStatus() == ThingStatus.ONLINE) ? OnOffType.ON : OnOffType.OFF;
|
||||
case "discoveryStatus":
|
||||
return (this.lastDiscoveryStatus != null) ? new StringType(this.lastDiscoveryStatus) : UnDefType.UNDEF;
|
||||
default:
|
||||
return UnDefType.UNDEF;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
// cancel checker
|
||||
final ScheduledFuture<?> future = this.connectionCheckerFuture;
|
||||
if (future != null && !future.isCancelled()) {
|
||||
future.cancel(true);
|
||||
}
|
||||
// close connection
|
||||
final EmbyConnection conn = requireNonNull(this.connection, "connection not initialized");
|
||||
conn.dispose();
|
||||
// detach discovery
|
||||
final EmbyClientDiscoveryService service = this.clientDiscoveryService;
|
||||
if (service != null) {
|
||||
service.clearBridge(this);
|
||||
this.clientDiscoveryService = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,191 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.emby.internal.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.emby.internal.protocol.EmbyDeviceEncoder;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* The {@link EmbyPlayStateModel} holds data about the current play state
|
||||
* and provides safe getters that guard against missing playState or nowPlayingItem.
|
||||
*
|
||||
* @author Zachary Christiansen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EmbyPlayStateModel {
|
||||
|
||||
@Nullable
|
||||
@com.google.gson.annotations.SerializedName("PlayState")
|
||||
private EmbyPlayState playState;
|
||||
|
||||
@com.google.gson.annotations.SerializedName("RemoteEndPoint")
|
||||
private String remoteEndPoint = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("Id")
|
||||
private String id = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("UserId")
|
||||
private String userId = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("UserName")
|
||||
private String userName = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("Client")
|
||||
private String client = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("DeviceName")
|
||||
private String deviceName = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("DeviceId")
|
||||
private String deviceId = "";
|
||||
|
||||
@com.google.gson.annotations.SerializedName("SupportsRemoteControl")
|
||||
private Boolean supportsRemoteControl = false;
|
||||
|
||||
@Nullable
|
||||
@com.google.gson.annotations.SerializedName("NowPlayingItem")
|
||||
private EmbyNowPlayingItem nowPlayingItem;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(EmbyPlayStateModel.class);
|
||||
|
||||
/** May be null if nothing is playing. */
|
||||
public @Nullable EmbyPlayState getPlayStates() {
|
||||
return playState;
|
||||
}
|
||||
|
||||
public Boolean getEmbyPlayStatePausedState() {
|
||||
final EmbyPlayState state = this.playState;
|
||||
return (state != null) ? state.getPaused() : Boolean.FALSE;
|
||||
}
|
||||
|
||||
public Boolean getEmbyMuteSate() {
|
||||
final EmbyPlayState state = this.playState;
|
||||
return (state != null) ? state.getIsMuted() : Boolean.FALSE;
|
||||
}
|
||||
|
||||
public String getNowPlayingName() {
|
||||
final EmbyNowPlayingItem item = this.nowPlayingItem;
|
||||
return (item != null) ? item.getName() : "";
|
||||
}
|
||||
|
||||
public BigDecimal getNowPlayingTime() {
|
||||
final EmbyPlayState state = this.playState;
|
||||
return (state != null) ? state.getPositionTicks() : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
public BigDecimal getNowPlayingTotalTime() {
|
||||
final EmbyNowPlayingItem item = this.nowPlayingItem;
|
||||
return (item != null) ? item.getRunTimeTicks() : BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
public String getNowPlayingMediaType() {
|
||||
final EmbyNowPlayingItem item = this.nowPlayingItem;
|
||||
return (item != null) ? item.getNowPlayingType() : "";
|
||||
}
|
||||
|
||||
public Boolean compareDeviceId(String compareId) {
|
||||
// deviceId is never null; equals() handles null compareId safely
|
||||
return getDeviceId().equals(compareId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fraction (0–1) of playback completed, rounded to 2 decimal places.
|
||||
*/
|
||||
public BigDecimal getPercentPlayed() {
|
||||
final EmbyPlayState state = this.playState;
|
||||
final EmbyNowPlayingItem item = this.nowPlayingItem;
|
||||
|
||||
BigDecimal positionTicks = (state != null) ? state.getPositionTicks() : BigDecimal.ZERO;
|
||||
BigDecimal runTimeTicks = (item != null) ? item.getRunTimeTicks() : BigDecimal.ZERO;
|
||||
|
||||
logger.debug("The play state position is {}", positionTicks);
|
||||
|
||||
if (BigDecimal.ZERO.equals(runTimeTicks)) {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
return positionTicks.divide(runTimeTicks, 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
public @Nullable EmbyNowPlayingItem getNowPlayingItem() {
|
||||
return nowPlayingItem;
|
||||
}
|
||||
|
||||
/** @return the IP address of the user playing the media */
|
||||
public String getRemoteEndPoint() {
|
||||
return remoteEndPoint;
|
||||
}
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** @return the Emby ID of the user */
|
||||
public String getuserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String userName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public String getClient() {
|
||||
return client;
|
||||
}
|
||||
|
||||
public String getDeviceName() {
|
||||
return deviceName;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
EmbyDeviceEncoder encoder = new EmbyDeviceEncoder();
|
||||
return encoder.encodeDeviceID(deviceId);
|
||||
}
|
||||
|
||||
public Boolean getSupportsRemoteControl() {
|
||||
return supportsRemoteControl;
|
||||
}
|
||||
|
||||
public URI getPrimaryImageURL(String embyHost, int embyPort, String embyType, String maxWidth, String maxHeight)
|
||||
throws URISyntaxException {
|
||||
logger.debug("Received an image URL request for: {} , port: {}, type: {}, max: {}/{} , percentPlayed: {}",
|
||||
embyHost, embyPort, embyType, maxWidth, maxHeight, getPercentPlayed());
|
||||
|
||||
final EmbyNowPlayingItem item = this.nowPlayingItem;
|
||||
if (item == null) {
|
||||
// no media playing → special URI
|
||||
return new URI("http", null, "NotPlaying", 8096, null, null, null);
|
||||
}
|
||||
|
||||
// build path based on media type
|
||||
String imagePath = item.getNowPlayingType().equalsIgnoreCase("Episode")
|
||||
? "/emby/items/" + item.getSeasonId() + "/Images/" + embyType
|
||||
: "/emby/items/" + item.getId() + "/Images/" + embyType;
|
||||
|
||||
// build query string
|
||||
StringBuilder query = new StringBuilder();
|
||||
|
||||
query.append("MaxWidth=").append(maxWidth).append("&");
|
||||
|
||||
query.append("MaxHeight=").append(maxHeight).append("&");
|
||||
|
||||
return new URI("http", null, embyHost, embyPort, imagePath, query.toString(), null);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 connection‐opened event
|
||||
scheduler.submit(() -> eventHandler.onConnectionOpened());
|
||||
}
|
||||
|
||||
public void close() {
|
||||
shouldReconnect = false;
|
||||
reconnectAttempts = 0;
|
||||
Session s = session;
|
||||
if (s != null) {
|
||||
try {
|
||||
s.close();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Exception during closing the websocket: {}", e.getMessage(), e);
|
||||
}
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConnected() {
|
||||
Session s = session;
|
||||
return s != null && s.isOpen();
|
||||
}
|
||||
|
||||
public synchronized void attemptReconnect() {
|
||||
if (!shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
reconnectAttempts++;
|
||||
long delay = Math.min(60_000, (1 << Math.min(reconnectAttempts, 6)) * 1000L);
|
||||
logger.debug("Scheduling reconnect #{} in {}ms", reconnectAttempts, delay);
|
||||
scheduler.schedule(() -> {
|
||||
try {
|
||||
open();
|
||||
} catch (Exception e) {
|
||||
logger.debug("Reconnect attempt #{} failed: {}", reconnectAttempts, e.getMessage());
|
||||
attemptReconnect();
|
||||
}
|
||||
}, delay, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
public void sendCommand(String methodName, String dataParams) {
|
||||
JsonObject payload = new JsonObject();
|
||||
payload.addProperty("MessageType", methodName);
|
||||
payload.addProperty("Data", dataParams);
|
||||
scheduler.submit(() -> {
|
||||
try {
|
||||
Session s = this.session;
|
||||
if (s == null || !s.isOpen()) {
|
||||
logger.debug("Cannot send {}, session not open", methodName);
|
||||
return;
|
||||
}
|
||||
s.getRemote().sendString(mapper.toJson(payload));
|
||||
logger.debug("Sent command: {}", payload);
|
||||
} catch (IOException e) {
|
||||
logger.error("Failed sending {}: {}", methodName, e.getMessage(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@WebSocket
|
||||
public class EmbyWebSocketListener {
|
||||
@OnWebSocketConnect
|
||||
public void onConnect(Session wssession) {
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
public void onMessage(String message) {
|
||||
logger.trace("Message received from server: {}", message);
|
||||
JsonObject json = JsonParser.parseString(message).getAsJsonObject();
|
||||
if (json.has("Data")) {
|
||||
JsonArray dataArr = json.get("Data").getAsJsonArray();
|
||||
Type listType = new TypeToken<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright (c) 2010-2025 Contributors to the openHAB project
|
||||
*
|
||||
* See the NOTICE file(s) distributed with this work for additional
|
||||
* information.
|
||||
*
|
||||
* This program and the accompanying materials are made available under the
|
||||
* terms of the Eclipse Public License 2.0 which is available at
|
||||
* http://www.eclipse.org/legal/epl-2.0
|
||||
*
|
||||
* SPDX-License-Identifier: EPL-2.0
|
||||
*/
|
||||
package org.openhab.binding.emby.internal.protocol;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.websocket.client.WebSocketClient;
|
||||
import org.openhab.binding.emby.internal.EmbyBridgeListener;
|
||||
import org.openhab.binding.emby.internal.model.EmbyPlayStateModel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* EmbyConnection provides an API for accessing an Emby device.
|
||||
*
|
||||
* All nullable fields are checked via a local copy + null-guard before use,
|
||||
* per openHAB’s Null Annotations guidelines.
|
||||
*
|
||||
* @author Zachary Christiansen - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class EmbyConnection implements EmbyClientSocketEventListener, AutoCloseable {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(EmbyConnection.class);
|
||||
|
||||
private String hostname = "";
|
||||
private int embyport = 0;
|
||||
|
||||
private @Nullable URI wsUri;
|
||||
private @Nullable EmbyClientSocket socket;
|
||||
private @Nullable ScheduledExecutorService schedulerInstance;
|
||||
|
||||
private int refreshRate = 0;
|
||||
|
||||
private final EmbyBridgeListener listener;
|
||||
private final WebSocketClient sharedWebSocketClient;
|
||||
|
||||
public EmbyConnection(EmbyBridgeListener listener, WebSocketClient embyWebSocketClient) {
|
||||
this.listener = listener;
|
||||
this.sharedWebSocketClient = embyWebSocketClient;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onConnectionClosed() {
|
||||
listener.updateConnectionState(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void onConnectionOpened() {
|
||||
listener.updateConnectionState(true);
|
||||
|
||||
// local copy + guard for socket:
|
||||
final EmbyClientSocket sock = this.socket;
|
||||
// once the connection is open, start sessions (if connected)
|
||||
if (sock != null && sock.isConnected()) {
|
||||
sock.sendCommand("SessionsStart", "0," + refreshRate);
|
||||
} else {
|
||||
logger.warn("onConnectionOpened() called but socket is not yet connected");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void connect(String setHostName, int port, String apiKey, ScheduledExecutorService scheduler,
|
||||
int refreshRate) {
|
||||
this.schedulerInstance = scheduler;
|
||||
this.hostname = setHostName;
|
||||
this.embyport = port;
|
||||
this.refreshRate = refreshRate;
|
||||
|
||||
try {
|
||||
close(); // tear down any previous socket
|
||||
|
||||
// build and store the WS URI
|
||||
wsUri = new URI("ws", null, hostname, embyport, null, "api_key=" + apiKey, null);
|
||||
|
||||
// create and start the socket
|
||||
EmbyClientSocket localSocket = requireNonNull(
|
||||
new EmbyClientSocket(this, wsUri, scheduler, sharedWebSocketClient),
|
||||
"EmbyClientSocket constructor returned null");
|
||||
localSocket.attemptReconnect();
|
||||
this.socket = localSocket;
|
||||
} catch (URISyntaxException e) {
|
||||
logger.error("Exception constructing URI host={}, port={}", hostname, embyport, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized void close() {
|
||||
final EmbyClientSocket sock = this.socket;
|
||||
if (sock != null) {
|
||||
sock.close(); // stops reconnection internally
|
||||
this.socket = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up all resources held by this connection:
|
||||
* - closes the WebSocket socket (stopping any reconnection loops)
|
||||
* - shuts down the scheduler if present
|
||||
*/
|
||||
public synchronized void dispose() {
|
||||
// close the socket
|
||||
close();
|
||||
ScheduledExecutorService localScheduler = schedulerInstance;
|
||||
// shut down the scheduler
|
||||
if (localScheduler != null) {
|
||||
try {
|
||||
localScheduler.shutdownNow();
|
||||
if (!localScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
logger.warn("Scheduler did not terminate cleanly");
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
logger.warn("Interrupted while shutting down scheduler", e);
|
||||
}
|
||||
schedulerInstance = null;
|
||||
}
|
||||
|
||||
listener.updateConnectionState(false);
|
||||
logger.debug("Disposed EmbyConnection for {}:{}", hostname, embyport);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleEvent(EmbyPlayStateModel playstate) {
|
||||
logger.debug("Received event from EMBY server, passing to bridge: host={}, port={}", hostname, embyport);
|
||||
listener.handleEvent(playstate, hostname, embyport);
|
||||
}
|
||||
|
||||
public boolean checkConnection() {
|
||||
final EmbyClientSocket sock = this.socket;
|
||||
if (!(sock instanceof EmbyClientSocket) || !sock.isConnected()) {
|
||||
logger.debug("Connection down, scheduling reconnect to {}", wsUri);
|
||||
if (sock instanceof EmbyClientSocket) {
|
||||
sock.attemptReconnect();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public String getConnectionName() {
|
||||
// blow up early if no URI
|
||||
final URI uri = requireNonNull(wsUri, "EmbyConnection not initialized (call connect() first)");
|
||||
return uri.toString();
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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").
|
||||
|
||||
|
||||
|
||||
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue