[amplipi] Add discovery and PA support (#11586)

Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
Kai Kreuzer 2021-11-21 23:12:43 +01:00 committed by GitHub
parent b80f41f3b8
commit 59444937bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 3160 additions and 416 deletions

View File

@ -1,6 +1,6 @@
# AmpliPi Binding
This binding supports the multi room audio system [AmpliPi](http://www.amplipi.com/) from [MicroNova](http://www.micro-nova.com/).
This binding supports the multi-room audio system [AmpliPi](http://www.amplipi.com/) from [MicroNova](http://www.micro-nova.com/).
## Supported Things
@ -10,7 +10,7 @@ Every available zone as well as group is managed as an individual Thing of type
## Discovery
Once the AmpliPi announces itself through mDNS (still a pending feature), it will be automatically discovered on the network.
The AmpliPi announces itself through mDNS, so that the bindings is able to find it automatically.
As soon as the AmpliPi is online, its zones and groups are automatically retrieved and added as Things to the Inbox.
@ -45,6 +45,12 @@ The `zone` and `group` Things have the following channels:
| mute | Switch | Mutes the zone/group |
| source | Number | The source (1-4) that this zone/group is playing |
## Audio Sink
For every AmpliPi controller, an audio sink is registered with the id of the thing.
This audio sink accepts urls and audio files to be played.
It uses the AmpliPi's PA feature for announcements on all available zones.
If no volume value is passed, the current volume of each zone is used, otherwise the provided volume is temporarily set on all zones for the announcement.
## Full Example

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,182 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* A PA-like Announcement IF no zones or groups are specified, all available zones are used
**/
public class Announcement {
/**
* URL to media to play as the announcement
**/
private String media;
/**
* Output volume in dB
**/
private Integer vol = -40;
/**
* Source to announce with
**/
private Integer sourceId = 3;
/**
* Set of zone ids belonging to a group
**/
private List<Integer> zones = null;
/**
* List of group ids
**/
private List<Integer> groups = null;
/**
* URL to media to play as the announcement
*
* @return media
**/
@JsonProperty("media")
public String getMedia() {
return media;
}
public void setMedia(String media) {
this.media = media;
}
public Announcement media(String media) {
this.media = media;
return this;
}
/**
* Output volume in dB
* minimum: -79
* maximum: 0
*
* @return vol
**/
@JsonProperty("vol")
public Integer getVol() {
return vol;
}
public void setVol(Integer vol) {
this.vol = vol;
}
public Announcement vol(Integer vol) {
this.vol = vol;
return this;
}
/**
* Source to announce with
* minimum: 0
* maximum: 3
*
* @return sourceId
**/
@JsonProperty("source_id")
public Integer getSourceId() {
return sourceId;
}
public void setSourceId(Integer sourceId) {
this.sourceId = sourceId;
}
public Announcement sourceId(Integer sourceId) {
this.sourceId = sourceId;
return this;
}
/**
* Set of zone ids belonging to a group
*
* @return zones
**/
@JsonProperty("zones")
public List<Integer> getZones() {
return zones;
}
public void setZones(List<Integer> zones) {
this.zones = zones;
}
public Announcement zones(List<Integer> zones) {
this.zones = zones;
return this;
}
public Announcement addZonesItem(Integer zonesItem) {
this.zones.add(zonesItem);
return this;
}
/**
* List of group ids
*
* @return groups
**/
@JsonProperty("groups")
public List<Integer> getGroups() {
return groups;
}
public void setGroups(List<Integer> groups) {
this.groups = groups;
}
public Announcement groups(List<Integer> groups) {
this.groups = groups;
return this;
}
public Announcement addGroupsItem(Integer groupsItem) {
this.groups.add(groupsItem);
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class Announcement {\n");
sb.append(" media: ").append(toIndentedString(media)).append("\n");
sb.append(" vol: ").append(toIndentedString(vol)).append("\n");
sb.append(" sourceId: ").append(toIndentedString(sourceId)).append("\n");
sb.append(" zones: ").append(toIndentedString(zones)).append("\n");
sb.append(" groups: ").append(toIndentedString(groups)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private static String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,199 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reconfiguration of a specific Group
**/
public class GroupUpdateWithId {
/**
* Friendly name
**/
private String name;
/**
* id of the connected source
**/
private Integer sourceId;
/**
* Set of zone ids belonging to a group
**/
private List<Integer> zones = null;
/**
* Set to true if output is all zones muted
**/
private Boolean mute;
/**
* Average input volume in dB
**/
private Integer volDelta;
private Integer id;
/**
* Friendly name
*
* @return name
**/
@JsonProperty("name")
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public GroupUpdateWithId name(String name) {
this.name = name;
return this;
}
/**
* id of the connected source
* minimum: 0
* maximum: 3
*
* @return sourceId
**/
@JsonProperty("source_id")
public Integer getSourceId() {
return sourceId;
}
public void setSourceId(Integer sourceId) {
this.sourceId = sourceId;
}
public GroupUpdateWithId sourceId(Integer sourceId) {
this.sourceId = sourceId;
return this;
}
/**
* Set of zone ids belonging to a group
*
* @return zones
**/
@JsonProperty("zones")
public List<Integer> getZones() {
return zones;
}
public void setZones(List<Integer> zones) {
this.zones = zones;
}
public GroupUpdateWithId zones(List<Integer> zones) {
this.zones = zones;
return this;
}
public GroupUpdateWithId addZonesItem(Integer zonesItem) {
this.zones.add(zonesItem);
return this;
}
/**
* Set to true if output is all zones muted
*
* @return mute
**/
@JsonProperty("mute")
public Boolean getMute() {
return mute;
}
public void setMute(Boolean mute) {
this.mute = mute;
}
public GroupUpdateWithId mute(Boolean mute) {
this.mute = mute;
return this;
}
/**
* Average input volume in dB
* minimum: -79
* maximum: 0
*
* @return volDelta
**/
@JsonProperty("vol_delta")
public Integer getVolDelta() {
return volDelta;
}
public void setVolDelta(Integer volDelta) {
this.volDelta = volDelta;
}
public GroupUpdateWithId volDelta(Integer volDelta) {
this.volDelta = volDelta;
return this;
}
/**
* Get id
*
* @return id
**/
@JsonProperty("id")
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public GroupUpdateWithId id(Integer id) {
this.id = id;
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class GroupUpdateWithId {\n");
sb.append(" name: ").append(toIndentedString(name)).append("\n");
sb.append(" sourceId: ").append(toIndentedString(sourceId)).append("\n");
sb.append(" zones: ").append(toIndentedString(zones)).append("\n");
sb.append(" mute: ").append(toIndentedString(mute)).append("\n");
sb.append(" volDelta: ").append(toIndentedString(volDelta)).append("\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private static String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,125 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reconfiguration of multiple zones specified by zone_ids and group_ids
**/
public class MultiZoneUpdate {
/**
* Set of zone ids belonging to a group
**/
private List<Integer> zones = null;
/**
* List of group ids
**/
private List<Integer> groups = null;
private ZoneUpdate update;
/**
* Set of zone ids belonging to a group
*
* @return zones
**/
@JsonProperty("zones")
public List<Integer> getZones() {
return zones;
}
public void setZones(List<Integer> zones) {
this.zones = zones;
}
public MultiZoneUpdate zones(List<Integer> zones) {
this.zones = zones;
return this;
}
public MultiZoneUpdate addZonesItem(Integer zonesItem) {
this.zones.add(zonesItem);
return this;
}
/**
* List of group ids
*
* @return groups
**/
@JsonProperty("groups")
public List<Integer> getGroups() {
return groups;
}
public void setGroups(List<Integer> groups) {
this.groups = groups;
}
public MultiZoneUpdate groups(List<Integer> groups) {
this.groups = groups;
return this;
}
public MultiZoneUpdate addGroupsItem(Integer groupsItem) {
this.groups.add(groupsItem);
return this;
}
/**
* Get update
*
* @return update
**/
@JsonProperty("update")
public ZoneUpdate getUpdate() {
return update;
}
public void setUpdate(ZoneUpdate update) {
this.update = update;
}
public MultiZoneUpdate update(ZoneUpdate update) {
this.update = update;
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class MultiZoneUpdate {\n");
sb.append(" zones: ").append(toIndentedString(zones)).append("\n");
sb.append(" groups: ").append(toIndentedString(groups)).append("\n");
sb.append(" update: ").append(toIndentedString(update)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private static String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,192 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public class SourceInfo {
private String name;
private String state;
private String artist;
private String track;
private String album;
private String station;
private String imgUrl;
/**
* Get name
*
* @return name
**/
@JsonProperty("name")
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public SourceInfo name(String name) {
this.name = name;
return this;
}
/**
* Get state
*
* @return state
**/
@JsonProperty("state")
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
public SourceInfo state(String state) {
this.state = state;
return this;
}
/**
* Get artist
*
* @return artist
**/
@JsonProperty("artist")
public String getArtist() {
return artist;
}
public void setArtist(String artist) {
this.artist = artist;
}
public SourceInfo artist(String artist) {
this.artist = artist;
return this;
}
/**
* Get track
*
* @return track
**/
@JsonProperty("track")
public String getTrack() {
return track;
}
public void setTrack(String track) {
this.track = track;
}
public SourceInfo track(String track) {
this.track = track;
return this;
}
/**
* Get album
*
* @return album
**/
@JsonProperty("album")
public String getAlbum() {
return album;
}
public void setAlbum(String album) {
this.album = album;
}
public SourceInfo album(String album) {
this.album = album;
return this;
}
/**
* Get station
*
* @return station
**/
@JsonProperty("station")
public String getStation() {
return station;
}
public void setStation(String station) {
this.station = station;
}
public SourceInfo station(String station) {
this.station = station;
return this;
}
/**
* Get imgUrl
*
* @return imgUrl
**/
@JsonProperty("img_url")
public String getImgUrl() {
return imgUrl;
}
public void setImgUrl(String imgUrl) {
this.imgUrl = imgUrl;
}
public SourceInfo imgUrl(String imgUrl) {
this.imgUrl = imgUrl;
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class SourceInfo {\n");
sb.append(" name: ").append(toIndentedString(name)).append("\n");
sb.append(" state: ").append(toIndentedString(state)).append("\n");
sb.append(" artist: ").append(toIndentedString(artist)).append("\n");
sb.append(" track: ").append(toIndentedString(track)).append("\n");
sb.append(" album: ").append(toIndentedString(album)).append("\n");
sb.append(" station: ").append(toIndentedString(station)).append("\n");
sb.append(" imgUrl: ").append(toIndentedString(imgUrl)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private static String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,112 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.model;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Partial reconfiguration of a specific audio Source
**/
public class SourceUpdateWithId {
/**
* Friendly name
**/
private String name;
private String input;
private Integer id;
/**
* Friendly name
*
* @return name
**/
@JsonProperty("name")
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public SourceUpdateWithId name(String name) {
this.name = name;
return this;
}
/**
* Get input
*
* @return input
**/
@JsonProperty("input")
public String getInput() {
return input;
}
public void setInput(String input) {
this.input = input;
}
public SourceUpdateWithId input(String input) {
this.input = input;
return this;
}
/**
* Get id
* minimum: 0
* maximum: 4
*
* @return id
**/
@JsonProperty("id")
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public SourceUpdateWithId id(Integer id) {
this.id = id;
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class SourceUpdateWithId {\n");
sb.append(" name: ").append(toIndentedString(name)).append("\n");
sb.append(" input: ").append(toIndentedString(input)).append("\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private static String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -0,0 +1,194 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.model;
import com.fasterxml.jackson.annotation.JsonProperty;
/**
* Reconfiguration of a specific Zone
**/
public class ZoneUpdateWithId {
/**
* Friendly name
**/
private String name;
/**
* id of the connected source
**/
private Integer sourceId;
/**
* Set to true if output is muted
**/
private Boolean mute;
/**
* Output volume in dB
**/
private Integer vol;
/**
* Set to true if not connected to a speaker
**/
private Boolean disabled;
private Integer id;
/**
* Friendly name
*
* @return name
**/
@JsonProperty("name")
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public ZoneUpdateWithId name(String name) {
this.name = name;
return this;
}
/**
* id of the connected source
* minimum: 0
* maximum: 3
*
* @return sourceId
**/
@JsonProperty("source_id")
public Integer getSourceId() {
return sourceId;
}
public void setSourceId(Integer sourceId) {
this.sourceId = sourceId;
}
public ZoneUpdateWithId sourceId(Integer sourceId) {
this.sourceId = sourceId;
return this;
}
/**
* Set to true if output is muted
*
* @return mute
**/
@JsonProperty("mute")
public Boolean getMute() {
return mute;
}
public void setMute(Boolean mute) {
this.mute = mute;
}
public ZoneUpdateWithId mute(Boolean mute) {
this.mute = mute;
return this;
}
/**
* Output volume in dB
* minimum: -79
* maximum: 0
*
* @return vol
**/
@JsonProperty("vol")
public Integer getVol() {
return vol;
}
public void setVol(Integer vol) {
this.vol = vol;
}
public ZoneUpdateWithId vol(Integer vol) {
this.vol = vol;
return this;
}
/**
* Set to true if not connected to a speaker
*
* @return disabled
**/
@JsonProperty("disabled")
public Boolean getDisabled() {
return disabled;
}
public void setDisabled(Boolean disabled) {
this.disabled = disabled;
}
public ZoneUpdateWithId disabled(Boolean disabled) {
this.disabled = disabled;
return this;
}
/**
* Get id
* minimum: 0
* maximum: 35
*
* @return id
**/
@JsonProperty("id")
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public ZoneUpdateWithId id(Integer id) {
this.id = id;
return this;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("class ZoneUpdateWithId {\n");
sb.append(" name: ").append(toIndentedString(name)).append("\n");
sb.append(" sourceId: ").append(toIndentedString(sourceId)).append("\n");
sb.append(" mute: ").append(toIndentedString(mute)).append("\n");
sb.append(" vol: ").append(toIndentedString(vol)).append("\n");
sb.append(" disabled: ").append(toIndentedString(disabled)).append("\n");
sb.append(" id: ").append(toIndentedString(id)).append("\n");
sb.append("}");
return sb.toString();
}
/**
* Convert the given object to string with each line indented by 4 spaces
* (except the first line).
*/
private static String toIndentedString(Object o) {
if (o == null) {
return "null";
}
return o.toString().replace("\n", "\n ");
}
}

View File

@ -12,16 +12,20 @@
*/
package org.openhab.binding.amplipi.internal;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link AmpliPiConfiguration} class contains fields mapping thing configuration parameters.
*
* @author Kai Kreuzer - Initial contribution
*/
@NonNullByDefault
public class AmpliPiConfiguration {
/**
* Sample configuration parameters. Replace with your own.
*/
public String hostname;
public @Nullable String hostname;
public int refreshInterval;
}

View File

@ -16,7 +16,6 @@ import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
@ -90,7 +89,7 @@ public class AmpliPiGroupHandler extends BaseThingHandler implements AmpliPiStat
}
@Override
public void handleCommand(@NonNull ChannelUID channelUID, @NonNull Command command) {
public void handleCommand(ChannelUID channelUID, Command command) {
if (command == RefreshType.REFRESH) {
// do nothing - we just wait for the next automatic refresh
return;
@ -134,7 +133,7 @@ public class AmpliPiGroupHandler extends BaseThingHandler implements AmpliPiStat
}
@Override
public void receive(@NonNull Status status) {
public void receive(Status status) {
int id = getId(thing);
Optional<Group> group = status.getGroups().stream().filter(z -> z.getId().equals(id)).findFirst();
if (group.isPresent()) {

View File

@ -31,12 +31,16 @@ import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.amplipi.internal.audio.PAAudioSink;
import org.openhab.binding.amplipi.internal.discovery.AmpliPiZoneAndGroupDiscoveryService;
import org.openhab.binding.amplipi.internal.model.Announcement;
import org.openhab.binding.amplipi.internal.model.Preset;
import org.openhab.binding.amplipi.internal.model.SourceUpdate;
import org.openhab.binding.amplipi.internal.model.Status;
import org.openhab.binding.amplipi.internal.model.Stream;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
@ -67,7 +71,9 @@ public class AmpliPiHandler extends BaseBridgeHandler {
private final Logger logger = LoggerFactory.getLogger(AmpliPiHandler.class);
private final HttpClient httpClient;
private AudioHTTPServer audioHTTPServer;
private final Gson gson;
private @Nullable String callbackUrl;
private String url = "http://amplipi";
private List<Preset> presets = List.of();
@ -76,9 +82,12 @@ public class AmpliPiHandler extends BaseBridgeHandler {
private @Nullable ScheduledFuture<?> refreshJob;
public AmpliPiHandler(Thing thing, HttpClient httpClient) {
public AmpliPiHandler(Thing thing, HttpClient httpClient, AudioHTTPServer audioHTTPServer,
@Nullable String callbackUrl) {
super((Bridge) thing);
this.httpClient = httpClient;
this.audioHTTPServer = audioHTTPServer;
this.callbackUrl = callbackUrl;
this.gson = new Gson();
}
@ -190,7 +199,7 @@ public class AmpliPiHandler extends BaseBridgeHandler {
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Set.of(PresetCommandOptionProvider.class, InputStateOptionProvider.class,
AmpliPiZoneAndGroupDiscoveryService.class);
AmpliPiZoneAndGroupDiscoveryService.class, PAAudioSink.class);
}
public List<Preset> getPresets() {
@ -205,6 +214,10 @@ public class AmpliPiHandler extends BaseBridgeHandler {
return url;
}
public AudioHTTPServer getAudioHTTPServer() {
return audioHTTPServer;
}
public void addStatusChangeListener(AmpliPiStatusChangeListener listener) {
changeListeners.add(listener);
}
@ -212,4 +225,31 @@ public class AmpliPiHandler extends BaseBridgeHandler {
public void removeStatusChangeListener(AmpliPiStatusChangeListener listener) {
changeListeners.remove(listener);
}
public void playPA(String audioUrl, @Nullable PercentType volume) {
Announcement announcement = new Announcement();
announcement.setMedia(audioUrl);
if (volume != null) {
announcement.setVol(AmpliPiUtils.percentTypeToVolume(volume));
}
String url = getUrl() + "/api/announce";
StringContentProvider contentProvider = new StringContentProvider(gson.toJson(announcement));
try {
ContentResponse response = httpClient.newRequest(url).method(HttpMethod.POST)
.content(contentProvider, "application/json").send();
if (response.getStatus() != HttpStatus.OK_200) {
logger.error("AmpliPi API returned HTTP status {}.", response.getStatus());
logger.debug("Content: {}", response.getContentAsString());
} else {
logger.debug("PA request sent successfully.");
}
} catch (InterruptedException | TimeoutException | ExecutionException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"AmpliPi request failed: " + e.getMessage());
}
}
public @Nullable String getCallbackUrl() {
return callbackUrl;
}
}

View File

@ -19,7 +19,10 @@ import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.core.audio.AudioHTTPServer;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.net.HttpServiceUtil;
import org.openhab.core.net.NetworkAddressService;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@ -28,6 +31,8 @@ import org.openhab.core.thing.binding.ThingHandlerFactory;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AmpliPiHandlerFactory} is responsible for creating things and thing
@ -39,11 +44,18 @@ import org.osgi.service.component.annotations.Reference;
@Component(configurationPid = "binding.amplipi", service = ThingHandlerFactory.class)
public class AmpliPiHandlerFactory extends BaseThingHandlerFactory {
private final Logger logger = LoggerFactory.getLogger(AmpliPiHandlerFactory.class);
private HttpClient httpClient;
private AudioHTTPServer audioHttpServer;
private final NetworkAddressService networkAddressService;
@Activate
public AmpliPiHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
public AmpliPiHandlerFactory(@Reference HttpClientFactory httpClientFactory,
@Reference AudioHTTPServer audioHttpServer, @Reference NetworkAddressService networkAddressService) {
this.httpClient = httpClientFactory.getCommonHttpClient();
this.audioHttpServer = audioHttpServer;
this.networkAddressService = networkAddressService;
}
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_CONTROLLER, THING_TYPE_ZONE,
@ -59,7 +71,8 @@ public class AmpliPiHandlerFactory extends BaseThingHandlerFactory {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_CONTROLLER.equals(thingTypeUID)) {
return new AmpliPiHandler(thing, httpClient);
String callbackUrl = createCallbackUrl();
return new AmpliPiHandler(thing, httpClient, audioHttpServer, callbackUrl);
}
if (THING_TYPE_ZONE.equals(thingTypeUID)) {
return new AmpliPiZoneHandler(thing, httpClient);
@ -70,4 +83,21 @@ public class AmpliPiHandlerFactory extends BaseThingHandlerFactory {
return null;
}
private @Nullable String createCallbackUrl() {
final String ipAddress = networkAddressService.getPrimaryIpv4HostAddress();
if (ipAddress == null) {
logger.warn("No network interface could be found.");
return null;
}
// we do not use SSL as it can cause certificate validation issues.
final int port = HttpServiceUtil.getHttpServicePort(bundleContext);
if (port == -1) {
logger.warn("Cannot find port of the http service.");
return null;
}
return "http://" + ipAddress + ":" + port;
}
}

View File

@ -16,7 +16,6 @@ import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import org.eclipse.jdt.annotation.NonNull;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
@ -90,7 +89,7 @@ public class AmpliPiZoneHandler extends BaseThingHandler implements AmpliPiStatu
}
@Override
public void handleCommand(@NonNull ChannelUID channelUID, @NonNull Command command) {
public void handleCommand(ChannelUID channelUID, Command command) {
if (command == RefreshType.REFRESH) {
// do nothing - we just wait for the next automatic refresh
return;
@ -133,7 +132,7 @@ public class AmpliPiZoneHandler extends BaseThingHandler implements AmpliPiStatu
}
@Override
public void receive(@NonNull Status status) {
public void receive(Status status) {
int id = getId(thing);
Optional<Zone> zone = status.getZones().stream().filter(z -> z.getId().equals(id)).findFirst();
if (zone.isPresent()) {

View File

@ -0,0 +1,151 @@
/**
* Copyright (c) 2010-2021 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.amplipi.internal.audio;
import java.io.IOException;
import java.util.Locale;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.amplipi.internal.AmpliPiHandler;
import org.openhab.core.audio.AudioFormat;
import org.openhab.core.audio.AudioSink;
import org.openhab.core.audio.AudioStream;
import org.openhab.core.audio.FixedLengthAudioStream;
import org.openhab.core.audio.URLAudioStream;
import org.openhab.core.audio.UnsupportedAudioFormatException;
import org.openhab.core.audio.UnsupportedAudioStreamException;
import org.openhab.core.library.types.PercentType;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This is an audio sink that allows to do public announcements on the AmpliPi.
*
* @author Kai Kreuzer - Initial contribution
*
*/
@NonNullByDefault
public class PAAudioSink implements AudioSink, ThingHandlerService {
private final Logger logger = LoggerFactory.getLogger(PAAudioSink.class);
private static final Set<AudioFormat> SUPPORTED_AUDIO_FORMATS = Set.of(AudioFormat.MP3, AudioFormat.WAV);
private static final Set<Class<? extends AudioStream>> SUPPORTED_AUDIO_STREAMS = Set
.of(FixedLengthAudioStream.class, URLAudioStream.class);
private @Nullable AmpliPiHandler handler;
private @Nullable PercentType volume;
@Override
public void process(@Nullable AudioStream audioStream)
throws UnsupportedAudioFormatException, UnsupportedAudioStreamException {
if (audioStream == null) {
// in case the audioStream is null, this should be interpreted as a request to end any currently playing
// stream.
logger.debug("Web Audio sink does not support stopping the currently playing stream.");
return;
}
AmpliPiHandler localHandler = this.handler;
if (localHandler != null) {
try (AudioStream stream = audioStream) {
logger.debug("Received audio stream of format {}", audioStream.getFormat());
String audioUrl;
if (audioStream instanceof URLAudioStream) {
// it is an external URL, so we can directly pass this on.
URLAudioStream urlAudioStream = (URLAudioStream) audioStream;
audioUrl = urlAudioStream.getURL();
} else if (audioStream instanceof FixedLengthAudioStream) {
String callbackUrl = localHandler.getCallbackUrl();
if (callbackUrl == null) {
throw new UnsupportedAudioStreamException(
"Cannot play audio since no callback url is available.", audioStream.getClass());
} else {
// we need to serve it for a while, hence only
// FixedLengthAudioStreams are supported.
String relativeUrl = localHandler.getAudioHTTPServer()
.serve((FixedLengthAudioStream) audioStream, 10).toString();
audioUrl = callbackUrl + relativeUrl;
}
} else {
throw new UnsupportedAudioStreamException(
"Web audio sink can only handle FixedLengthAudioStreams and URLAudioStreams.",
audioStream.getClass());
}
localHandler.playPA(audioUrl, volume);
// we reset the volume value again, so that a next invocation without a volume will again use the zones
// defaults.
volume = null;
} catch (IOException e) {
logger.debug("Error while closing the audio stream: {}", e.getMessage(), e);
}
}
}
@Override
public Set<AudioFormat> getSupportedFormats() {
return SUPPORTED_AUDIO_FORMATS;
}
@Override
public Set<Class<? extends AudioStream>> getSupportedStreams() {
return SUPPORTED_AUDIO_STREAMS;
}
@Override
public String getId() {
if (handler != null) {
return handler.getThing().getUID().toString();
} else {
throw new IllegalStateException();
}
}
@Override
public @Nullable String getLabel(@Nullable Locale locale) {
if (handler != null) {
return handler.getThing().getLabel();
} else {
return null;
}
}
@Override
public PercentType getVolume() throws IOException {
PercentType vol = volume;
if (vol != null) {
return vol;
} else {
throw new IOException("Audio sink does not support reporting the volume.");
}
}
@Override
public void setVolume(final PercentType volume) throws IOException {
this.volume = volume;
}
@Override
public void setThingHandler(ThingHandler handler) {
this.handler = (AmpliPiHandler) handler;
}
@Override
public @Nullable ThingHandler getThingHandler() {
return handler;
}
}

View File

@ -12,6 +12,7 @@
*/
package org.openhab.binding.amplipi.internal.discovery;
import java.net.InetAddress;
import java.util.Set;
import javax.jmdns.ServiceInfo;
@ -24,6 +25,7 @@ import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
/**
* This is a discovery participant which finds AmpliPis on the local network
@ -33,8 +35,11 @@ import org.openhab.core.thing.ThingUID;
*
*/
@NonNullByDefault
@Component
public class AmpliPiMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant {
private static final String AMPLIPI_API = "amplipi-api";
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return Set.of(AmpliPiBindingConstants.THING_TYPE_CONTROLLER);
@ -42,16 +47,15 @@ public class AmpliPiMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant
@Override
public String getServiceType() {
return "_http._tcp";
return "_http._tcp.local.";
}
@Override
public @Nullable DiscoveryResult createResult(ServiceInfo service) {
ThingUID uid = getThingUID(service);
if (uid != null) {
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(service.getName())
.withProperty(AmpliPiBindingConstants.CFG_PARAM_HOSTNAME,
service.getInet4Addresses()[0].getHostAddress())
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel("AmpliPi Controller")
.withProperty(AmpliPiBindingConstants.CFG_PARAM_HOSTNAME, getIpAddress(service).getHostAddress())
.withRepresentationProperty(AmpliPiBindingConstants.CFG_PARAM_HOSTNAME).build();
return result;
} else {
@ -61,7 +65,21 @@ public class AmpliPiMDNSDiscoveryParticipant implements MDNSDiscoveryParticipant
@Override
public @Nullable ThingUID getThingUID(ServiceInfo service) {
// TODO: Currently, the AmpliPi does not seem to announce any services.
if (service.getName().equals(AMPLIPI_API)) {
InetAddress ip = getIpAddress(service);
if (ip != null) {
String id = ip.toString().substring(1).replaceAll("\\.", "");
return new ThingUID(AmpliPiBindingConstants.THING_TYPE_CONTROLLER, id);
}
}
return null;
}
private @Nullable InetAddress getIpAddress(ServiceInfo service) {
if (service.getInet4Addresses().length > 0) {
return service.getInet4Addresses()[0];
} else {
return null;
}
}
}

View File

@ -78,7 +78,7 @@ public class AmpliPiZoneAndGroupDiscoveryService extends AbstractDiscoveryServic
if (handler != null) {
ThingUID bridgeUID = handler.getThing().getUID();
ThingUID uid = new ThingUID(AmpliPiBindingConstants.THING_TYPE_ZONE, bridgeUID, z.getId().toString());
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(z.getName())
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel("AmpliPi Zone '" + z.getName() + "'")
.withProperty(AmpliPiBindingConstants.CFG_PARAM_ID, z.getId()).withBridge(bridgeUID)
.withRepresentationProperty(AmpliPiBindingConstants.CFG_PARAM_ID).build();
thingDiscovered(result);
@ -89,7 +89,7 @@ public class AmpliPiZoneAndGroupDiscoveryService extends AbstractDiscoveryServic
if (handler != null) {
ThingUID bridgeUID = handler.getThing().getUID();
ThingUID uid = new ThingUID(AmpliPiBindingConstants.THING_TYPE_GROUP, bridgeUID, g.getId().toString());
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel(g.getName())
DiscoveryResult result = DiscoveryResultBuilder.create(uid).withLabel("AmpliPi Group '" + g.getName() + "'")
.withProperty(AmpliPiBindingConstants.CFG_PARAM_ID, g.getId()).withBridge(bridgeUID)
.withRepresentationProperty(AmpliPiBindingConstants.CFG_PARAM_ID).build();
thingDiscovered(result);