Change all, use hass websocket.

This commit is contained in:
Hans Karlinius
2026-03-20 22:19:43 +01:00
parent aa46e7d4bf
commit 0c907922e8
17 changed files with 772 additions and 413 deletions

223
API.pm Normal file
View File

@@ -0,0 +1,223 @@
package Plugins::Assistant::API;
use strict;
use base qw(Slim::Utils::Accessor);
use JSON::XS::VersionOneAndTwo;
use Plugins::Assistant::SimpleAsyncWS;
use Slim::Utils::Log;
use Slim::Utils::Prefs;
my %pendingCb;
our $ws = 0;
my $authenticated = 0;
my $log = logger('plugin.assistant');
my $prefs = preferences('plugin.assistant');
my $messageId = 1;
sub init {
my ($class, $args) = @_;
Slim::Utils::Timers::setTimer(
undef,
time(),
\&_connect,
);
}
sub getAreas {
my ($self, $cb, $args) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug('getAreas');
$self->_write({
type => 'config/area_registry/list'
}, $cb);
}
sub getEntities {
my ($self, $cb, $areaId) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug('getEnteties for ', $areaId);
$self->_write({
type => 'extract_from_target',
target => {
area_id => [$areaId]
},
}, $cb);
}
sub subscribeEntities {
my ($self, $cb, @entities) = @_;
my $subscribeEntities = join ',', @entities;
main::DEBUGLOG && $log->is_debug && $log->debug('subscribeEntities ', $subscribeEntities);
$self->_write({
type => 'subscribe_entities',
entity_ids => $subscribeEntities
}, $cb);
}
sub serviceAction {
my ($self, $cb, $actionRequest) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug('serviceAction ', JSON::XS->new->pretty->encode($actionRequest));
$self->_write({
type => 'call_service',
domain => $actionRequest->{domain},
service => $actionRequest->{service},
target => {
entity_id => $actionRequest->{entity}
}
}, $cb);
}
sub getImage {
my ($self, $image) = @_;
unless ($image) { return; }
my $base = $prefs->get('connect') || '';
unless ($base) {
$log->error('Connect URL not set');
return;
}
return $base.$image;
}
sub _connect {
my ($class, $args) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug('Connecting');
# Build ws(s)://<host>/api/websocket from connect pref
my $base = $prefs->get('connect') || '';
unless ($base) {
main::DEBUGLOG && $log->is_debug && $log->error('Connect URL not set');
return;
}
my $hostpart = $base =~ m{^(https?://[^/]+)}i ? $1 : $base;
my $ws_url = $hostpart;
$ws_url =~ s{^https}{wss}i; $ws_url =~ s{^http}{ws}i;
$ws_url .= '/api/websocket' unless $ws_url =~ m{/api/websocket$}i;
main::DEBUGLOG && $log->is_debug && $log->debug('Connect URL: '.$ws_url);
eval {
$ws = Plugins::Assistant::SimpleAsyncWS->new(
$ws_url,
\&_connected,
\&_connectError,
\&_message,
\&_readError
);
1;
} or do {
my $e = $@;
$log->error('Failed connecting: ', $e);
Slim::Utils::Timers::setTimer(
undef,
time() + 10,
\&_connect,
);
}
}
sub _connectAuth {
main::DEBUGLOG && $log->is_debug && $log->debug('Authenticating');
my $token = $prefs->get('pass') || '';
if ($ws && $token) {
my $auth = encode_json({ type => 'auth', access_token => $token });
$ws->send($auth);
} else {
$log->error('Token not set or no connection');
}
}
sub _write {
my ($self, $msg, $cb) = @_;
die "Not authenticated" unless $authenticated;
my $sentMessageId = $messageId++;
my $sentMessage = {%$msg, id => $sentMessageId};
$pendingCb{$sentMessageId} = $cb;
my $req = encode_json($sentMessage);
main::DEBUGLOG && $log->is_debug && $log->debug('Write message: ', $req);
$ws->send($req) if $ws;
}
sub _message {
my ($buf) = @_;
my $payload;
eval { $payload = decode_json($buf) };
if ($@) {
$log->error('Failed to decode websocket message');
return;
}
if (!$ws) {
main::DEBUGLOG && $log->is_debug && $log->debug('Client not done connecting');
}
my $type = $payload->{type} || '';
$log->debug('Got message: ', $type);
if ($type eq 'auth_required') {
Plugins::Assistant::API->_connectAuth();
return;
}
elsif ($type eq 'auth_ok') {
$authenticated = 1;
return;
}
elsif ($type eq 'result' && defined $payload->{id}) {
main::DEBUGLOG && $log->is_debug && $log->debug('Got message type result and id='.$payload->{id}.' success='.$payload->{success});
my $result = encode_json($payload->{result} || []);
$pendingCb{$payload->{id}}->($result);
return;
}
elsif ($type eq 'event' && defined $payload->{id}) {
main::DEBUGLOG && $log->is_debug && $log->debug('Got message type result and id='.$payload->{id});
my $event = encode_json($payload->{event} || []);
$pendingCb{$payload->{id}}->($event);
return;
}
# Other message types can be handled/logged here
main::INFOLOG && $log->is_info && $log->info('Unhandled message type: ' . $type);
}
sub _connected {
main::DEBUGLOG && $log->is_debug && $log->debug('Connected');
}
sub _connectError {
$log->error('WebSocket connection error');
}
sub _readError {
$log->error('WebSocket read error');
}
sub getStatus {
my ($self) = @_;
return {
connected => $ws->{socket_open} // 0,
listening => $ws->{continue_listening} // 0,
authenticated => $ws->{client}->{hs}->is_done // 0,
url => $ws->{client}->{url}
} if $ws;
}
sub shutdown {
Slim::Utils::Timers::killTimers(undef, \&_connect);
$ws->close() unless !$ws;
}
1;

View File

@@ -1,3 +1,12 @@
#### Version 0.10
##### Changed
- Now using websocket to Home Assistant
- Only handles Entities assigned to Areas, first menu is select Area
#### Version 0.9
##### Update
- Failed fix to handle auth
#### Version 0.8
##### Added
- Add entity to handle group with common domain

173
HASS.pm
View File

@@ -1,173 +0,0 @@
package Plugins::Assistant::HASS;
use strict;
use JSON::XS::VersionOneAndTwo;
use threads::shared;
use Slim::Networking::SimpleAsyncHTTP;
use Slim::Networking::SqueezeNetwork;
use Slim::Utils::Log;
use Slim::Utils::Prefs;
my $log = logger('plugin.assistant');
my $cache;
my $prefs = preferences('plugin.assistant');
sub init {
($cache) = @_;
}
sub testHassConnection {
my ( $client, $cb, $params, $args ) = @_;
if (defined $prefs->get('connect')) {
my $http = Slim::Networking::SimpleAsyncHTTP->new(
sub {
$log->info("Connected to Home Assistant at (".$prefs->get('connect').")");
},
sub {
$log->error("Error (".$prefs->get('connect')."): $_[1]");
},
{
timeout => 5,
},
);
$http->get(
$prefs->get('connect'),
'x-ha-access' => $prefs->get('pass'),
'Content-Type' => 'application/json',
'charset' => 'UTF-8',
);
}
}
sub getEntities {
my ( $client, $cb, $params, $args ) = @_;
our $result :shared = [];
our $counter :shared = 0;
if (defined $args->{'entity_ids'}) {
foreach my $entity_id(@{$args->{'entity_ids'}}) {
$counter++;
Plugins::Assistant::HASS::getEntity(
$client,
sub {
my $entity = shift;
if (defined $entity) {
push @$result, $entity;
}
$counter--;
if ($counter <= 0) {
$cb->($result);
}
},
$params,
{
entity_id => $entity_id,
},
);
}
} else {
Plugins::Assistant::HASS::getEntity(
$client,
sub {
my $entities = shift;
foreach my $entity(@$entities) {
push @$result, $entity;
}
$cb->($result);
},
$params,
{},
);
}
}
sub getEntity {
my ($client, $cb, $params, $args) = @_;
my $url = $prefs->get('connect').'states';
if (defined $args->{'entity_id'}) {
$url = $url.'/'.$args->{'entity_id'};
}
$log->debug('Get Entity: ', $url);
my $http = Slim::Networking::SimpleAsyncHTTP->new(
sub {
my $response = shift;
my $params = $response->params('params');
my $result;
if ( $response->headers->content_type =~ /json/ ) {
$result = decode_json($response->content);
}
$cb->($result);
},
sub {
$log->error("Error (".$url."): $_[1]");
$cb->();
},
{
params => $params,
timeout => 5,
},
);
$http->get(
$url,
'x-ha-access' => $prefs->get('pass'),
'Content-Type' => 'application/json',
'charset' => 'UTF-8',
);
}
sub services {
my ($client, $cb, $params, $args) = @_;
my $url = $prefs->get('connect').'services/'.$args->{'domain'}.'/'.$args->{'service'};
my $req->{'entity_id'} = $args->{'entity_id'};
$log->debug($url.' { '.$req->{'entity_id'}.' }');
my $http = Slim::Networking::SimpleAsyncHTTP->new(
sub {
my $response = shift;
my $params = $response->params('params');
my $result;
if ( $response->headers->content_type =~ /json/ ) {
$log->debug($response->content);
$result = decode_json($response->content);
}
$cb->($client, $result, $params, $args);
},
sub {
$log->error("Error (".$url."): $_[1]");
$cb->();
},
{
timeout => 5,
},
);
$http->post(
$url,
'x-ha-access' => $prefs->get('pass'),
'Content-Type' => 'application/json',
'charset' => 'UTF-8',
encode_json($req),
);
}
1;

View File

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 590 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -8,11 +8,9 @@
<input type="text" name="pref_pass" value="[% prefs.pass == '_pass_' ? '' : prefs.pass %]">
[% END %]
[% WRAPPER setting title="PLUGIN_ASSISTANT_SHOW_HOME" desc="PLUGIN_ASSISTANT_SHOW_HOME_DESC" %]
<select class="stdedit" name="pref_show_home">
<option [% IF NOT prefs.show_home %]selected [% END %]value="0">[% 'NO' | getstring %]</option>
<option [% IF prefs.show_home %]selected [% END %]value="1">[% 'YES' | getstring %]</option>
</select>
[% WRAPPER setting title="PLUGIN_ASSISTANT_ENABLED" %]
<input type="checkbox" [% IF prefs.pref_enabled %]checked="checked"[% END %] class="stdedit" name="pref_enabled" id="pref_enabled" />
<label for="pref_enabled">[% "PLUGIN_ASSISTANT_ENABLE_DESC" | string %]</label>
[% END %]
[% PROCESS settings/footer.html %]

50
Handlers.pm Normal file
View File

@@ -0,0 +1,50 @@
package Plugins::Assistant::Handlers;
use strict;
our %entities = (
a => sub {
my ($data) = @_;
my @results;
foreach my $entity (keys %{$data->{a}}) {
push @results, {
entity => $entity,
name => $data->{a}->{$entity}->{a}->{friendly_name} // $entity,
icon => $data->{a}->{$entity}->{a}->{icon},
state => $data->{a}->{$entity}->{s}
}
}
return \@results;
},
c => sub {
my ($data) = @_;
my @results;
foreach my $entity (keys %{$data->{c}}) {
push @results, {
entity => $entity,
state => $data->{c}->{$entity}->{'+'}->{s}
}
}
return \@results;
}
);
our %service = (
light => sub {
my ($state) = @_;
my $results = $state eq 'off' ? 'turn_on' : 'turn_off';
return $results;
},
switch => sub {
my ($state) = @_;
my $results = $state eq 'off' ? 'turn_on' : 'turn_off';
return $results;
},
cover => sub {
my ($state) = @_;
my $results = $state eq 'open' ? 'close_cover' : 'open_cover';
return $results;
}
);
1;

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2017 Hans Karlinius
Copyright (c) 2026 Hans Karlinius
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

376
Plugin.pm
View File

@@ -2,255 +2,221 @@ package Plugins::Assistant::Plugin;
use strict;
use base qw(Slim::Plugin::OPMLBased);
use JSON::XS::VersionOneAndTwo;
use threads::shared;
use Data::Dumper;
use Slim::Utils::Log;
use Slim::Utils::OSDetect;
use Slim::Utils::Prefs;
use Slim::Utils::Strings qw(string cstring);
use Plugins::Assistant::HASS;
use Plugins::Assistant::API;
use Plugins::Assistant::Handlers;
use constant IMAGE_PATH => 'plugins/Assistant/html/images/';
use constant IMAGE_UNKNOWN => 'group_unknown';
my $log = Slim::Utils::Log->addLogCategory(
{
'category' => 'plugin.assistant',
'defaultLevel' => 'ERROR',
'defaultLevel' => 'INFO',
'description' => 'PLUGIN_ASSISTANT',
}
);
my $prefs = preferences('plugin.assistant');
my $cache = Slim::Utils::Cache->new('assistant', 3);
my %entities;
my @images = ('cover_closed', 'cover_open', 'group_on', 'group_off', 'group_unknown', 'light_off', 'light_on', 'switch_off', 'switch_on');
my $menuItems;
my @domains = ('light', 'switch', 'cover');
sub initPlugin {
my $class = shift;
Plugins::Assistant::HASS->init($cache);
$class->SUPER::initPlugin(
feed => \&handleFeed,
tag => 'assistant',
menu => 'radios',
is_app => 1,
weight => 1,
);
if (main::WEBUI) {
require Plugins::Assistant::Settings;
Plugins::Assistant::Settings->new();
}
$class->SUPER::initPlugin(
feed => \&handleFeed,
tag => 'assistant',
menu => 'apps',
);
if ($prefs->get('enabled')) {
require Plugins::Assistant::API;
Plugins::Assistant::API->init();
Slim::Utils::Timers::setTimer(
undef,
time() + 5,
\&Assistant,
);
}
}
sub shutdownPlugin {
main::DEBUGLOG && $log->is_debug && $log->debug('shutdown');
main::DEBUGLOG && $log->is_debug && $log->debug(Dumper(Plugins::Assistant::API->getStatus()));
Slim::Utils::Timers::killTimers(undef, \&Assistant);
Plugins::Assistant::API->shutdown();
}
sub getDisplayName { 'PLUGIN_ASSISTANT' }
# don't add this plugin to the Extras menu
sub playerMenu {}
sub Assistant {
main::INFOLOG && $log->is_info && $log->info('Assistant');
eval {
Plugins::Assistant::API->getAreas(sub {
my $areas = shift;
my $jsAreas = decode_json($areas);
foreach my $area (@$jsAreas) {
main::DEBUGLOG && $log->is_debug && $log->debug("Area ", JSON::XS->new->pretty->encode($area));
push @$menuItems, {
name => $area->{name},
image => _imagePath($area->{picture}),
areaId => $area->{area_id},
};
};
foreach my $menuItem (@$menuItems) {
Plugins::Assistant::API->getEntities(sub {
my $entities = shift;
my @entitySubscriptions;
my $jsEntities = decode_json($entities)->{referenced_entities};
my $domain;
my $items = [];
foreach my $entity (@$jsEntities) {
$domain = substr($entity, 0, index($entity, '.')),
main::DEBUGLOG && $log->is_debug && $log->debug('Domain: ', $domain, ' - Entity: ', $entity);
next unless grep {$_ eq $domain} @domains;
push @entitySubscriptions, $entity;
push @$items, {
entity => $entity,
domain => $domain
}
}
if (@$items) {
$menuItem->{items} = $items;
main::DEBUGLOG && $log->is_debug && $log->debug('Entity subscriptions for ',$menuItem->{name} , ' - ', join ',', @entitySubscriptions);
Plugins::Assistant::API->subscribeEntities(\&AssistanEntity, @entitySubscriptions);
}
}, $menuItem->{areaId});
}
});
1;
} or do {
my $e = $@;
$log->error('Failed to getAreas: ', $e);
Slim::Utils::Timers::setTimer(
undef,
time() + 5,
\&Assistant,
);
}
}
sub AssistantAction {
my ($client, $cb, $params, $args) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug('AssistantAction start ', JSON::XS->new->pretty->encode($args));
my $service = $Plugins::Assistant::Handlers::service{$args->{domain}}($args->{state});
my $actionRequest = {
domain => $args->{domain},
entity => $args->{entity},
service => $service
};
Plugins::Assistant::API->serviceAction (sub {
my $result = shift;
main::DEBUGLOG && $log->is_debug && $log->debug('AssistantAction sent ', $result);
}, $actionRequest);
$cb->();
return;
}
sub AssistanEntity {
my $entities = shift;
main::DEBUGLOG && $log->is_debug && $log->debug('AssistanEntity entities ', $entities);
my $decoded = JSON::XS->new->decode($entities);
return unless ref $decoded eq 'HASH';
my $key = (keys %{$decoded})[0];
main::DEBUGLOG && $log->is_debug && $log->debug('Handler key ', $key);
unless (ref($Plugins::Assistant::Handlers::entities{$key}) eq 'CODE') {
die "No handler for key: $key\n";
}
my $entities = $Plugins::Assistant::Handlers::entities{$key}($decoded);
foreach my $entity (@$entities) {
main::DEBUGLOG && $log->is_debug && $log->debug('Entity update ', JSON::XS->new->pretty->encode($entity));
my $found = 0;
foreach my $menuItem (@$menuItems) {
my $menuItemItems = $menuItem->{items};
foreach my $menuItemEntity (@$menuItemItems) {
if ($menuItemEntity->{entity} eq $entity->{entity}) {
$found = 1;
$menuItemEntity->{name} //= $entity->{name};
$menuItemEntity->{entityIcon} //= $entity->{icon};
$menuItemEntity->{state} = $entity->{state};
$menuItemEntity->{image} = _imageWithStatePath($menuItemEntity->{domain}, $entity->{state});
$menuItemEntity->{nextWindow} = 'parent';
$menuItemEntity->{type} = 'link';
$menuItemEntity->{url} = \&AssistantAction;
$menuItemEntity->{passthrough} = [{
entity => $entity->{entity},
domain => $menuItemEntity->{domain},
state => $entity->{state}
}];
$menuItemEntity->{fetched} = 0;
$log->debug('Updated menu item:');
$log->debug(Dumper($menuItemEntity));
last;
}
}
last if $found;
}
}
}
sub handleFeed {
my ($client, $cb, $args) = @_;
Plugins::Assistant::HASS::getEntities(
$client,
sub {
my $tentities = shift;
my $items = [];
my $order = 1000;
foreach my $tentity(@$tentities) {
$entities{$tentity->{'entity_id'}} = $tentity;
}
foreach my $id(keys %entities) {
my ($namespace, $name) = split('\.', $id, 2);
if (($namespace eq 'group' && (!$entities{$id}->{'attributes'}->{'hidden'} || $entities{$id}->{'attributes'}->{'view'}))
|| $prefs->get('show_home') == 1) {
my $item = getItem($id);
$item->{'order'} = $order++ if (!defined $item->{'order'});
$log->debug('getEntities: '.$id.' - '.$item->{'name'}.' - '.$item->{'order'});
push @$items, $item;
}
}
$items = [ sort { uc($a->{order}) cmp uc($b->{order}) } @$items ];
$cb->(
{
items => $items,
}
);
},
$args,
);
}
sub getItem {
my ($id) = @_;
my ($namespace, $name) = split('\.', $id, 2);
$log->debug($id);
if ($namespace eq 'group') {
my $gorder = 2000;
my $gitems = [];
# Add unique entity for group of same type excluded group
# As I do beleive is similar to what HASS does :)
my %seen;
my @uniqueGroup = grep {not $seen{$_}++ } map { /^(?!group)(\S*)\./ } @{$entities{$id}->{'attributes'}->{'entity_id'}};
if (scalar(@uniqueGroup) == 1) {
$namespace = @uniqueGroup[0];
my $tid = $namespace.'.'.$name;
$entities{$tid} = $entities{$id};
if (!grep(/$tid/, @{$entities{$id}->{'attributes'}->{'entity_id'}})) {
push @{$entities{$id}->{'attributes'}->{'entity_id'}}, $tid;
}
}
foreach my $gid(@{$entities{$id}->{'attributes'}->{'entity_id'}}) {
my $gitem = getItem($gid, %entities);
$gitem->{'order'} = $gorder++ if (!defined $gitem->{'order'});
$log->debug($id.' - '.$gitem->{'name'}.' - '.$gitem->{'order'});
push @$gitems, $gitem;
}
$gitems = [ sort { uc($a->{order}) cmp uc($b->{order}) } @$gitems ];
return {
name => $entities{$id}->{'attributes'}->{'friendly_name'},
image => getImage($namespace.'_'.$entities{$id}->{'state'}),
order => $entities{$id}->{'attributes'}->{'order'},
type => 'link',
items => $gitems,
};
} elsif ($namespace eq 'light' || $namespace eq 'switch') {
return {
name => $entities{$id}->{'attributes'}->{'friendly_name'},
image => getImage($namespace.'_'.$entities{$id}->{'state'}),
order => $entities{$id}->{'attributes'}->{'order'},
nextWindow => 'refresh',
type => 'link',
url => \&servicesCall,
passthrough => [
{
entity_id => $entities{$id}->{'entity_id'},
domain => $namespace,
service => $entities{$id}->{'state'} eq 'on' ? 'turn_off' : 'turn_on',
}
],
};
} elsif ($namespace eq 'cover') {
my $service = 'stop_cover';
if ($entities{$id}->{'state'} eq 'closed') {
$service = 'open_cover';
} elsif ($entities{$id}->{'state'} eq 'open') {
$service = 'close_cover';
}
return {
name => $entities{$id}->{'attributes'}->{'friendly_name'},
image => getImage($namespace.'_'.$entities{$id}->{'state'}),
order => $entities{$id}->{'attributes'}->{'order'},
nextWindow => 'refresh',
type => 'link',
url => \&servicesCall,
passthrough => [
{
entity_id => $entities{$id}->{'entity_id'},
domain => $namespace,
service => $service,
}
],
};
} elsif ($namespace eq 'sensor') {
my $name = $entities{$id}->{'attributes'}->{'friendly_name'}.' '.$entities{$id}->{'state'}.$entities{$id}->{'attributes'}->{'unit_of_measurement'};
$name =~ s/\R//g;
return {
name => $name,
order => $entities{$id}->{'attributes'}->{'order'},
type => 'text',
};
} else {
return {
name => $entities{$id}->{'attributes'}->{'friendly_name'}.' '.$entities{$id}->{'state'},
order => $entities{$id}->{'attributes'}->{'order'},
type => 'text',
};
if ($prefs->get('enabled') && $menuItems) {
$cb->({
items => $menuItems,
});
return;
}
die "Assistant is not enabled or something is wrong...";
}
sub _imagePath {
my ($image) = @_;
sub getImage {
my ($img) = @_;
my $base = $prefs->get('connect') || '';
if (grep(/^$img$/, @images)) {
return IMAGE_PATH.$img.'.png';
} else {
return IMAGE_PATH.IMAGE_UNKNOWN.'.png';
unless ($base && $image) {
return Plugins::Assistant::Plugin->_pluginDataFor('icon');
}
main::DEBUGLOG && $log->is_debug && $log->debug(' ImagePath: ', $base.$image);
my $resize_url = Slim::Web::ImageProxy::proxiedImage($base.$image);
main::DEBUGLOG && $log->is_debug && $log->debug(' ImagePathResize: ', $resize_url);
return Slim::Web::ImageProxy::proxiedImage($base.$image);
}
sub servicesCall {
my ($client, $cb, $params, $args) = @_;
Plugins::Assistant::HASS::services(
$client,
sub {
my ($client, $result, $params, $args) = @_;
my $newstate = '';
foreach my $entity (@$result) {
if ($entity->{'entity_id'} eq $args->{'entity_id'}) {
$newstate = $entity->{'state'};
}
}
my $items = [];
push @$items,
{
name => $entities{$args->{'entity_id'}}->{'attributes'}->{'friendly_name'}.' '.$newstate,
type => 'text',
showBriefly => 1,
};
$cb->(
{
items => $items,
}
);
},
$params,
$args,
);
sub _imageWithStatePath {
my ($domain, $state) = @_;
my $path;
unless ($domain && $state) { return; }
main::DEBUGLOG && $log->is_debug && $log->debug(' ImageWithStatePath: ', IMAGE_PATH.$domain.'_'.$state.'.png');
return IMAGE_PATH.$domain.'_'.$state.'.png';
}
1;
1;

View File

@@ -5,9 +5,8 @@ This is a Plugin for Squeezebox server where you can control entities in Home As
## Supports
- Lights on/off
- Cover open/close
- Switch on/off
- Generic entity to handle group
- Cover open/close
## Tested on
@@ -17,7 +16,7 @@ This is a Plugin for Squeezebox server where you can control entities in Home As
## Known limitations
- Menues are not updated without going back and forward
- Only handles Areas and the Entities assigned to Areas.
## Installation

View File

@@ -7,37 +7,36 @@ use Slim::Utils::Prefs;
my $prefs = preferences('plugin.assistant');
sub name {
return 'PLUGIN_ASSISTANT';
return Slim::Web::HTTP::CSRF->protectName('PLUGIN_ASSISTANT');
}
sub prefs {
return ($prefs, qw(connect pass show_home));
}
sub page {
return 'plugins/Assistant/settings.html';
return Slim::Web::HTTP::CSRF->protectURI('plugins/Assistant/settings.html');
}
sub prefs {
return ($prefs, qw(connect pass enabled));
}
sub handler {
my ($class, $client, $params, $callback, @args) = @_;
$params->{'pref_enabled'} = defined $params->{'pref_enabled'} ? 1 : 0;
if ( $params->{saveSettings} ) {
$prefs->set('connect', $params->{pref_connect});
$prefs->set('pass', $params->{pref_pass});
$prefs->set('show_home', $params->{pref_show_home});
$prefs->set('enabled', $params->{pref_enabled});
$prefs->savenow();
if ( $params->{'pref_enabled'} ) {
Plugins::Assistant::Plugin->initPlugin();
} else {
Plugins::Assistant::Plugin->shutdownPlugin();
}
}
if ( $prefs->get('connect') ) {
Plugins::Assistant::HASS->testHassConnection();
}
return $class->SUPER::handler($client, $params);
return $class->SUPER::handler($client, $params, $callback, @args);
}
1;
1;

289
SimpleAsyncWS.pm Normal file
View File

@@ -0,0 +1,289 @@
package Plugins::Assistant::SimpleAsyncWS;
# Lyrion Music Server Copyright 2024 Lyrion Community.
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License,
# version 2.
# This class provides a non-blocking WebSockets client connection from Lyrion Music Server.
# This class is intended for plugins and other code needing simply to
# handle a persistent websockets connection. If you have more complex
# needs consider writing a fuller implementation.
# This is a copy with some changes of the original SimpleWS file.
use strict;
use IO::Socket;
use IO::Socket::SSL;
use IO::Select;
use Protocol::WebSocket::Client;
use URI;
use Slim::Utils::Log;
use Slim::Utils::Prefs;
my $log = logger('plugin.assistant');
sub new {
my ($class, $url, $cbConnected, $cbConnectFailed, $cbRead, $cbReadFailed) = @_;
my $self = {
client => 0,
tcp_socket => 0,
socket_open => 0,
continue_listening => 0,
cb_Read => 0,
cb_Read_Failed => 0,
};
bless $self, $class;
$self->_connect( $url, $cbConnected, $cbConnectFailed, $cbRead, $cbReadFailed);
return $self;
}
sub close {
my ($self) = @_;
Slim::Utils::Timers::killTimers($self, \&_receive);
main::INFOLOG && $log->is_info && $log->info("Close web socket connect with status: " . $self->{tcp_socket}->connected() );
$self->{continue_listening} = 0;
$self->{client}->disconnect;
$self->{tcp_socket}->close if $self->{socket_open};
$self->{socket_open} = 0;
return;
}
sub _connect {
my ($self, $url, $cbConnected, $cbConnectFailed, $cbRead, $cbReadFailed) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug("Connecting to webSocket $url");
my $uri = URI->new($url);
my $proto = $uri->scheme;
my $host = $uri->host;
my $path = $uri->path;
my $port = $uri->port;
if (! (($proto =~ /ws|wss/) && $host) ) {
$log->warn("Failed to parse $url");
$cbConnectFailed->("Failed to parse Host/Port for ws URL from $url");
return;
} elsif ($port == 433 ) {
$proto = 'wss';
}
main::INFOLOG && $log->is_info && $log->info("Attempting to open socket to $proto://$host:$port...");
if ($proto eq 'wss') {
IO::Socket::SSL::set_defaults(SSL_verify_mode => Net::SSLeay::VERIFY_NONE())
if preferences('server')->get('insecureHTTPS');
$self->{tcp_socket} = IO::Socket::SSL->new(
PeerAddr => $host,
PeerPort => "$proto($port)",
Proto => 'tcp',
Blocking => 1,
SSL_startHandshake => 1,
) or $cbConnectFailed->("Failed to connect to socket: $!,$SSL_ERROR");
} else {
$self->{tcp_socket} = IO::Socket::INET->new(
PeerAddr => $host,
PeerPort => "$proto($port)",
Proto => 'tcp',
Blocking => 1,
) or $cbConnectFailed->("Failed to connect to socket: $!");
}
main::INFOLOG && $log->is_info && $log->info("Starting To Listen Async");
$self->{cb_Read} = $cbRead;
$self->{cb_Read_Failed} = $cbReadFailed;
$self->{continue_listening} = 1;
$self->_receive();
main::INFOLOG && $log->is_info && $log->info("Trying to create Protocol::WebSocket::Client handler for $url...");
$self->{client} = Protocol::WebSocket::Client->new(url => $url);
$self->{socket_open} = 1;
# Set up the various methods for the WS Protocol handler
# On Write: take the buffer (WebSocket packet) and send it on the socket.
$self->{client}->on(
write => sub {
my $client = shift;
my ($buf) = @_;
#main::DEBUGLOG && $log->is_debug && $log->debug("Sending $buf ...");
syswrite $self->{tcp_socket}, $buf if $self->{socket_open};
}
);
# On Connect: this is what happens after the handshake succeeds, and we
# are "connected" to the service.
$self->{client}->on(
connect => sub {
my $client = shift;
main::INFOLOG && $log->is_info && $log->info("Successfully Connected to $url...", $client);
$cbConnected->();
}
);
$self->{client}->on(
error => sub {
my $client = shift;
my ($buf) = @_;
$log->error("ERROR ON WEBSOCKET: $buf");
$self->{tcp_socket}->close;
die "Websocket error, socket closed";
}
);
$self->{client}->on(
read => sub {
my $client = shift;
my ($buf) = @_;
main::INFOLOG && $log->is_info && $log->info("Message Recieved : $buf");
$self->_read($buf);
}
);
$self->{client}->on(
ping => sub {
my $client = shift;
my ($buf) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug("Ping sent, sending pong : " . sprintf("%v02X", $buf));
$client->pong($buf);
}
);
main::INFOLOG && $log->is_info && $log->info("connecting to client");
$self->{client}->connect;
# read until handshake is complete. This is blocking but should be over quickly.
while (!$self->{client}->{hs}->is_done){
my $recv_data;
#my $bytes_read = sysread $self->{tcp_socket}, $recv_data, 16384;
my $bytes_read = sysread $self->{tcp_socket}, $recv_data, 16;
#$log->debug('<DATA> ', $recv_data);
if (!defined $bytes_read) {
$log->error("sysread on tcp_socket failed: $!");
$cbConnectFailed->("WS Handshake failed");
return;
}elsif ($bytes_read == 0) {
$log->error("Connection terminated.");
$cbConnectFailed->("WS Handshake failed");
return;
}
$self->{client}->read($recv_data);
}
return;
}
sub _read {
my ($self, $buf) = @_;
$self->{cb_Read}->($buf);
return;
}
sub _receive {
my ($self) = @_;
main::DEBUGLOG && $log->is_debug && $log->debug("Starting Listening");
eval {
if ($self->{continue_listening}) {
my $s = IO::Select->new();
$s->add($self->{tcp_socket});
$! = 0;
main::DEBUGLOG && $log->is_debug && $log->debug("Checking the socket for something to read");
my @ready = $s->can_read(0);
if (@ready) {
my $recv_data;
my $bytes_read = sysread $ready[0], $recv_data, 16384;
if (!defined $bytes_read) {
$log->error("Error reading from socket : $!");
$self->{cb_Read_Failed}->();
# poll again in 1 second
$self->_continueListen(1);
} elsif ($bytes_read == 0) {
# Remote socket closed
$log->error("Connection terminated by remote. $!");
$self->{cb_Read_Failed}->();
close();
} else {
main::DEBUGLOG && $log->is_debug && $log->debug("Received data : $recv_data ");
$self->{client}->read($recv_data);
# if Async, poll immediately so that we pull everything off the socket if something is there.
$self->_continueListen(1);
}
} else {
main::DEBUGLOG && $log->is_debug && $log->debug("No Data Present, continue listening");
# poll again in 1 second
$self->_continueListen(1);
}
}
} or do {
my $e = $@;
$log->error('Failed receive: ', $e);
}
}
sub _continueListen {
my ($self, $pollTimeSeconds) = @_;
eval {
Slim::Utils::Timers::setTimer($self, time() + $pollTimeSeconds, \&_receive);
} or do {
my $e = $@;
$log->error('Failed timer: ', $e);
}
}
sub send {
my ($self, $buf) = @_;
main::INFOLOG && $log->is_info && $log->info("Sending on web socket : $buf ");
$self->{client}->write($buf);
return;
}
1;

View File

@@ -3,7 +3,6 @@
<name>PLUGIN_ASSISTANT</name>
<creator>Hans Karlinius</creator>
<defaultState>enabled</defaultState>
<needsMySB>false</needsMySB>
<description>PLUGIN_ASSISTANT_DESCRIPTION</description>
<email>hans.karlinius@live.com</email>
<icon>plugins/Assistant/html/images/icon.png</icon>
@@ -11,9 +10,9 @@
<module>Plugins::Assistant::Plugin</module>
<optionsURL>plugins/Assistant/settings.html</optionsURL>
<targetApplication>
<id>Logitech Media Server</id>
<id>Lyrion Media Server</id>
<maxVersion>*</maxVersion>
<minVersion>7.6</minVersion>
<minVersion>9.0</minVersion>
</targetApplication>
<type>2</type>
<version>0.8</version>

View File

@@ -5,16 +5,16 @@ PLUGIN_ASSISTANT_DESCRIPTION
EN Remote controlling entities in Home Assistant.
PLUGIN_ASSISTANT_CONNECT
EN Home Assistant connect url
EN Home Assistant connect URL
PLUGIN_ASSISTANT_CONNECT_DESC
EN Should be the same as used for web access with addition of /api/ at the end like http://localhost:8123/api/
EN Should be the same as used for web access like http://homeassistant.local:8123/
PLUGIN_ASSISTANT_SHOW_HOME
EN Show "home" in menu
PLUGIN_ASSISTANT_ENABLED
EN Enable Assistant plugin
PLUGIN_ASSISTANT_SHOW_HOME_DESC
EN By not showing "home" only groups will be shown for a cleaner looking menu.
PLUGIN_ASSISTANT_ENABLE_DESC
EN Enable and start the plugin requires connection URL and API password.
PLUGIN_ASSISTANT_PASS
EN Home Assistant API password