From 0c907922e88ddabde566b5068d388bde776ab5d9 Mon Sep 17 00:00:00 2001 From: Hans Karlinius Date: Fri, 20 Mar 2026 22:19:43 +0100 Subject: [PATCH] Change all, use hass websocket. --- API.pm | 223 +++++++++++ Changelog.md | 9 + HASS.pm | 173 -------- .../{cover_opened.png => cover_open.png} | Bin .../Assistant/html/images/group_off.png | Bin 1343 -> 0 bytes .../Assistant/html/images/group_on.png | Bin 1105 -> 0 bytes .../Assistant/html/images/group_unknown.png | Bin 590 -> 0 bytes .../EN/plugins/Assistant/html/images/icon.png | Bin 42257 -> 5400 bytes HTML/EN/plugins/Assistant/settings.html | 8 +- Handlers.pm | 50 +++ LICENSE | 2 +- Plugin.pm | 376 ++++++++---------- README.md | 5 +- Settings.pm | 33 +- SimpleAsyncWS.pm | 289 ++++++++++++++ install.xml | 5 +- strings.txt | 12 +- 17 files changed, 772 insertions(+), 413 deletions(-) create mode 100644 API.pm delete mode 100644 HASS.pm rename HTML/EN/plugins/Assistant/html/images/{cover_opened.png => cover_open.png} (100%) delete mode 100644 HTML/EN/plugins/Assistant/html/images/group_off.png delete mode 100644 HTML/EN/plugins/Assistant/html/images/group_on.png delete mode 100644 HTML/EN/plugins/Assistant/html/images/group_unknown.png create mode 100644 Handlers.pm create mode 100644 SimpleAsyncWS.pm 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 8ac1bfa9abeb1074bedb22540fc0dbaba3898510..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1343 zcmV-F1;F}=P)a`WCo?jQT3HtV@Ulb~i7X^WUwA-? zEVvCHN2bZ<;-Zgi60;5C!^F7_BpWE%h?NC|&9D{+i-W=~^xk`p54ON{$X0Gp@g3m<$8}-$|i*T_ajyo7(tXa$O8MEpOfTQ7{Q7%MxbHl zM5&acV2{UL8)I=hDF%R2s^@)b;;?rH5CTdW7#KJL5VmlNAS9+>covTo#TGWoaRhU*N$=4CQGML^z*iG@uWqm6BMy*8h{g8HrbnBpOKEVkDYO z7io+z05oZ;8;Qs}lFlO0Wvwt{go<8_b$t)l^-Gu>yamsL`v3rA=0>c`+X>5_*N|2A zDmcACgDa6JjK`E^YyZz~7iGv=>Lb^+3vitN1cGyTN#Uv1EhwmIg5|mWiH$8P3ro5d zlVvzgHK4cg;Ibe9KyVJDxA7nxry9UyIeA>mai$T&tu2~`{@L0B2B6^Z(S)VnkacA2 z`o$m+gx4dd@?BV8coXK_?EnDx$nS7=oX5S3Ux7*DJnwL83)XKhLDrtiga9FSUyLjc zJ1U8|`u5WhoFnt^QqnCbZvGbQY&%qiO%B~gPyHL1n{X|PnOu~A^D8)BANLrUx5#3v zxH{VvAUM4NyPMl!wiSk~IcD1q>~3yDckNzK(d&zu5x6?rVcB~?!TZDWW=$K_8BNG2 z*@q`<#LsxYvJmdFO zc{m1wz%_ZNPr+oVsDc`w+OkwsDP8VPUl32EB)b2>z!&u@dI26P(LVQZOz~{W*%o$Z zY0B9aEGvfX!+6h+3-bKK84I)f)zTre=%y5HI(!o*15uBWlnu&4KYjO)u$2Vz2Fi+- z(u*rX#mK^m`&U|ik%GzDaSm#HYRlPiPMHAmi-LF}uHNSL$gllODUY#*?l*8{me~~o zM%&L~YN&r%5K}|_7;QhR_%^f59yJi1(S#?;58#7b^ON+N!F zrwE>ag80<&Hkd;cMEB5b^nLWYq97WrxhVbmw}cc#Rf`Z_kGz^=%Iq`Y!j0N;44kaP zBgaiho*77<89Z{_#K6fq+^8*AfB=BJnqwfmK5Vy)W|0?FEaIoZ(;wqr1Wh9VG+s|VLEVCc&R zc$S$-jMgo%y>}Fu2_LK^fmDP6#6RjzbhTr=^CH}Xw=g?C3IKR4V-w7IJCX5hCDP06 znnvB20g)wvRQwbkkfgrXk|kkvT+opaZTw24RRd)8-2JY()*6sCAZtKY91yY^fbf(u zkEZ{-S%RxhSbO6{j>^(y&4LrT!Z})5>ft=oQ%zKoTnRtv7)^&%mYuW0bR7YJ;`4V4 z45_AOPN&<&5n@~cT?=6h9$A+9=VshTdf&?J{TI6}YSywxrYrye002ovPDHLkV1i_d BZubBH 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 cc305f3a2bdcb6eccc7b4e50efa379d7e450c86a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1105 zcmV-X1g`suP)Oy-M-O5L>U+*`+(mWm47i({fF+k?25Jw(DL zRoNabY!7o2w#T`LVJ&;9s7Pxkbt`jk($-P56_YtI@g~jv{qFI>gc^I(^!EN~TKa?E zLrCuV{XV~Qe&>764FSN3;bPP@IcW&_0TUts81+2}3_TbK^o3OA^!e?DmFk~`c6fq)t@g)`)jBbc#sq?q$l#qLvu&A8zs$-@M z(WaQ-*F0a1*|U>k0H$dM)zUH^?41E3m?ne4PzWHNK1C3@3Zt?YDT=2zswjk4FCUk^ zXIVc4jXGhpIS_ zD`jinjqIe!SnDUm+uCJ(68{p>@Yt-so6zI&X<0&M!9I6Qc z0MYOml3Nbpa6APCvVKDAxN5bL)6+<{m(7{E_sXJ^0ce#};_2M6Z?Q62|l-DuT% zs@hll5X1l(IFd*?wsRzrLV%3wI${N*t$2WAiBs6o)Q7N|v$f!!8`+A_ZCWqZHQaX$ zvaaDic4@unPw$@@T2>D>-fY9WYksp0VV$L}xR~*-RP)G8H9ek4qA~ctgW9LE zc-+)D>>9|X#^D+E`9&E&Gp<^iuDV*yH-#R#2GSIIG;P1!oUupMuh+?gU#iW@5ny>o z{(REz>cUToYdyDRsUzQye1oUPDn}>w^1PRAaSh~Nwx!ZOb9F&XPp!viLxZ^act3Oz zaSU=R-QgO@t#n7F{Ze?*``2@x7mWcjaB)`)q7JoWBEJzwZ~q0!LF=N?>nS*h3nS+o zAIOD~a}XS~E>Y}g!)&wG7|3+u()h`FLtGj^iGfUK)%Ms@5Zw?#tMTLL888SR#m?KZ z{mc00xH#Hny#|%!k;3{KJ;_5ojvq$PqQ7QiC3I?E0FZL4`;OL& z-K%@>M&xg-F15Pp!Ul}wwxK`WiGfT<)mFCxL<*hSuPqFSt4c40zBFFYk&UcX?_iHjdz{{ Xfe!N|vL)ty00000NkvXXu0mjfFVPRy 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 942d06130da6cace71f3335eb04041c33fc4be75..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 590 zcmV-U0cId20ozGL zK~!ko?O8ca#4r?n&*K@1MKp92NVHTmP;dh{0B4|~q@Y7Iv=rQeBX9tU8Hs`mP(ek* zl38N=Q4m0kgqUHHNn#~ZszkP*-}1c>0FG`)Ya!?)b$(C8G61344v-EE3Bmh^LGpS`0NB2|-|Q?dpPN=|%a98-ShY|>-+OmAe0w?V c9c=X80ld-W9LN5*XaE2J07*qoM6N<$f^~iVFaQ7m diff --git a/HTML/EN/plugins/Assistant/html/images/icon.png b/HTML/EN/plugins/Assistant/html/images/icon.png index 04e1dade7004e4a0e25c781d9236e5c45417bb47..09929fab91e4dec378d2b175d0feac61b0f634a1 100644 GIT binary patch literal 5400 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 literal 42257 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

U`n2-0eOsgN6PK3N_5IJvdy~gBx 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 NXsYR|)+yV^{0}W`I;{Wz 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