diff --git a/API.pm b/API.pm new file mode 100644 index 0000000..e50253b --- /dev/null +++ b/API.pm @@ -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):///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; diff --git a/Changelog.md b/Changelog.md index 549e7d5..c8d0d54 100644 --- a/Changelog.md +++ b/Changelog.md @@ -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 diff --git a/HASS.pm b/HASS.pm deleted file mode 100644 index 72bd580..0000000 --- a/HASS.pm +++ /dev/null @@ -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; diff --git a/HTML/EN/plugins/Assistant/html/images/cover_opened.png b/HTML/EN/plugins/Assistant/html/images/cover_open.png similarity index 100% rename from HTML/EN/plugins/Assistant/html/images/cover_opened.png rename to HTML/EN/plugins/Assistant/html/images/cover_open.png diff --git a/HTML/EN/plugins/Assistant/html/images/group_off.png b/HTML/EN/plugins/Assistant/html/images/group_off.png deleted file mode 100644 index 8ac1bfa..0000000 Binary files a/HTML/EN/plugins/Assistant/html/images/group_off.png and /dev/null differ diff --git a/HTML/EN/plugins/Assistant/html/images/group_on.png b/HTML/EN/plugins/Assistant/html/images/group_on.png deleted file mode 100644 index cc305f3..0000000 Binary files a/HTML/EN/plugins/Assistant/html/images/group_on.png and /dev/null differ diff --git a/HTML/EN/plugins/Assistant/html/images/group_unknown.png b/HTML/EN/plugins/Assistant/html/images/group_unknown.png deleted file mode 100644 index 942d061..0000000 Binary files a/HTML/EN/plugins/Assistant/html/images/group_unknown.png and /dev/null differ diff --git a/HTML/EN/plugins/Assistant/html/images/icon.png b/HTML/EN/plugins/Assistant/html/images/icon.png index 04e1dad..09929fa 100644 Binary files a/HTML/EN/plugins/Assistant/html/images/icon.png and b/HTML/EN/plugins/Assistant/html/images/icon.png differ diff --git a/HTML/EN/plugins/Assistant/settings.html b/HTML/EN/plugins/Assistant/settings.html index e4ce472..2119127 100644 --- a/HTML/EN/plugins/Assistant/settings.html +++ b/HTML/EN/plugins/Assistant/settings.html @@ -8,11 +8,9 @@ [% END %] - [% WRAPPER setting title="PLUGIN_ASSISTANT_SHOW_HOME" desc="PLUGIN_ASSISTANT_SHOW_HOME_DESC" %] - + [% WRAPPER setting title="PLUGIN_ASSISTANT_ENABLED" %] + + [% END %] [% PROCESS settings/footer.html %] diff --git a/Handlers.pm b/Handlers.pm new file mode 100644 index 0000000..0029125 --- /dev/null +++ b/Handlers.pm @@ -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; diff --git a/LICENSE b/LICENSE index 19db223..26150da 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/Plugin.pm b/Plugin.pm index f8aa405..1c77214 100644 --- a/Plugin.pm +++ b/Plugin.pm @@ -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; \ No newline at end of file +1; diff --git a/README.md b/README.md index 2ba3f6f..29e51e3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Settings.pm b/Settings.pm index 19afb91..350553d 100644 --- a/Settings.pm +++ b/Settings.pm @@ -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; \ No newline at end of file +1; diff --git a/SimpleAsyncWS.pm b/SimpleAsyncWS.pm new file mode 100644 index 0000000..3d3fb9b --- /dev/null +++ b/SimpleAsyncWS.pm @@ -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(' ', $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; diff --git a/install.xml b/install.xml index 69d7048..a55558a 100644 --- a/install.xml +++ b/install.xml @@ -3,7 +3,6 @@ PLUGIN_ASSISTANT Hans Karlinius enabled - false PLUGIN_ASSISTANT_DESCRIPTION hans.karlinius@live.com plugins/Assistant/html/images/icon.png @@ -11,9 +10,9 @@ Plugins::Assistant::Plugin plugins/Assistant/settings.html - Logitech Media Server + Lyrion Media Server * - 7.6 + 9.0 2 0.8 diff --git a/strings.txt b/strings.txt index 811dde6..e779ddc 100644 --- a/strings.txt +++ b/strings.txt @@ -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