########################################################################################
#
# ControlPoint.pm
#
# $Id$
#
# Now (in this version) part of Fhem.
#
# Fhem is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 2 of the License, or
# (at your option) any later version.
#
# Fhem is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with fhem. If not, see .
#
########################################################################################
package UPnP::ControlPoint;
use 5.006;
use strict;
use warnings;
use utf8;
use Carp;
use IO::Socket::INET;
use Socket;
use IO::Select;
use HTTP::Daemon;
use HTTP::Headers;
use LWP::UserAgent;
use UPnP::Common;
use vars qw($VERSION @ISA);
require Exporter;
our @ISA = qw(Exporter UPnP::Common::DeviceLoader);
our $VERSION = $UPnP::Common::VERSION;
use constant DEFAULT_SSDP_SEARCH_PORT => 8008;
use constant DEFAULT_SUBSCRIPTION_PORT => 8058;
use constant DEFAULT_SUBSCRIPTION_URL => '/eventSub';
our @IGNOREIP;
our @USEDONLYIP;
our $LogLevel;
our $EnvPrefix;
our $EnvNamespace;
sub isIgnoreIP($) {
my($ip) = @_;
foreach my $elem (@IGNOREIP) {
if ($elem =~ m/^\/(.*)\/$/) {
if ($ip =~ m/^$1$/) {
return 1;
}
} else {
if ($ip eq $elem) {
return 1;
}
}
}
return 0;
}
sub isUsedOnlyIP($) {
my($ip) = @_;
return 1 if (!scalar(@USEDONLYIP));
foreach my $elem (@USEDONLYIP) {
if ($elem =~ m/^\/(.*)\/$/) {
if ($ip =~ m/^$1$/) {
return 1;
}
} else {
if ($ip eq $elem) {
return 1;
}
}
}
return 0;
}
sub new {
my($self, %args) = @_;
my $class = ref($self) || $self;
$self = $class->SUPER::new(%args);
my $searchPort = defined($args{SearchPort}) ? $args{SearchPort} : DEFAULT_SSDP_SEARCH_PORT;
my $subscriptionPort = defined($args{SubscriptionPort}) ? $args{SubscriptionPort} : DEFAULT_SUBSCRIPTION_PORT;
my $maxWait = $args{MaxWait} || 3;
@IGNOREIP = @{$args{IgnoreIP}};
@USEDONLYIP = @{$args{UsedOnlyIP}};
$LogLevel = $args{LogLevel} || 0;
$EnvPrefix = $args{EnvPrefix} || $SOAP::Constants::PREFIX_ENV;
$EnvNamespace = $args{EnvNamespace} || 'u';
my $reuseport = $args{ReusePort};
$reuseport = 0 if (!defined($reuseport));
# Create the socket on which search requests go out
$self->{_searchSocket} = IO::Socket::INET->new(Proto => 'udp', LocalPort => $searchPort) || carp("Error creating search socket: $!\n");
setsockopt($self->{_searchSocket},
IP_LEVEL,
IP_MULTICAST_TTL,
pack 'I', 4);
$self->{_maxWait} = $maxWait;
# Create the socket on which we'll listen for events to which we are
# subscribed.
$self->{_subscriptionSocket} = HTTP::Daemon->new(LocalPort => $subscriptionPort, Reuse=>1, Listen=>20) || carp("Error creating subscription socket: $!\n");
$self->{_subscriptionURL} = $args{SubscriptionURL} || DEFAULT_SUBSCRIPTION_URL;
$self->{_subscriptionPort} = $self->{_subscriptionSocket}->sockport();;
# Create the socket on which we'll listen for SSDP Notifications.
# First try with ReusePort (if given as parameter)...
eval {
$self->{_ssdpMulticastSocket} = IO::Socket::INET->new(
Proto => 'udp',
Reuse => 1,
ReusePort => $reuseport,
LocalPort => SSDP_PORT) ||
croak("Error creating SSDP multicast listen socket: $!\n");
};
if ($@ =~ /Your vendor has not defined Socket macro SO_REUSEPORT/i) {
$self->{_ssdpMulticastSocket} = IO::Socket::INET->new(
Proto => 'udp',
Reuse => 1,
LocalPort => SSDP_PORT) ||
croak("Error creating SSDP multicast listen socket: $!\n");
} elsif($@) {
# Weiterwerfen...
croak($@);
}
my $ip_mreq = inet_aton(SSDP_IP) . INADDR_ANY;
setsockopt($self->{_ssdpMulticastSocket},
IP_LEVEL,
IP_ADD_MEMBERSHIP,
$ip_mreq);
setsockopt($self->{_ssdpMulticastSocket},
IP_LEVEL,
IP_MULTICAST_TTL,
pack 'I', 4);
return $self;
}
sub DESTROY {
my $self = shift;
for my $subscription (values %{$self->{_subscriptions}}) {
if ($subscription) {
$subscription->unsubscribe;
}
}
}
sub searchByType {
my $self = shift;
my $type = shift;
my $callback = shift;
my $search = UPnP::ControlPoint::Search->new(Callback => $callback,
Type => $type);
$self->{_activeSearches}->{$search} = $search;
$self->_startSearch($type);
return $search;
}
sub searchByUDN {
my $self = shift;
my $udn = shift;
my $callback = shift;
my $search = UPnP::ControlPoint::Search->new(Callback => $callback,
UDN => $udn);
$self->{_activeSearches}->{$search} = $search;
$self->_startSearch("upnp:rootdevice");
$search;
}
sub searchByFriendlyName {
my $self = shift;
my $name = shift;
my $callback = shift;
my $search = UPnP::ControlPoint::Search->new(Callback => $callback,
FriendlyName => $name);
$self->{_activeSearches}->{$search} = $search;
$self->_startSearch("upnp:rootdevice");
$search;
}
sub stopSearch {
my $self = shift;
my $search = shift;
delete $self->{_activeSearches}->{$search};
}
sub sockets {
my $self = shift;
return ($self->{_subscriptionSocket},
$self->{_ssdpMulticastSocket},
$self->{_searchSocket},);
}
sub handleOnce {
my $self = shift;
my $socket = shift;
if ($socket == $self->{_searchSocket}) {
$self->_receiveSearchResponse($socket);
}
elsif ($socket == $self->{_ssdpMulticastSocket}) {
$self->_receiveSSDPEvent($socket);
}
elsif ($socket == $self->{_subscriptionSocket}) {
if (my $connect = $socket->accept()) {
return if (!isUsedOnlyIP($connect->peerhost()));
return if (isIgnoreIP($connect->peerhost()));
$self->_receiveSubscriptionNotification($connect);
}
}
}
sub handle {
my $self = shift;
my @mysockets = $self->sockets();
my $select = IO::Select->new(@mysockets);
$self->{_handling} = 1;
while ($self->{_handling}) {
my @sockets = $select->can_read(1);
for my $sock (@sockets) {
$self->handleOnce($sock);
}
}
}
sub stopHandling {
my $self = shift;
$self->{_handling} = 0;
}
sub subscriptionURL {
my $self = shift;
return URI->new_abs($self->{_subscriptionURL},
'http://' . UPnP::Common::getLocalIP() . ':' .
$self->{_subscriptionPort});
}
sub addSubscription {
my $self = shift;
my $subscription = shift;
$self->{_subscriptions}->{$subscription->SID} = $subscription;
}
sub removeSubscription {
my $self = shift;
my $subscription = shift;
delete $self->{_subscriptions}->{$subscription->SID};
}
sub _startSearch {
my $self = shift;
my $target = shift;
my $header = 'M-SEARCH * HTTP/1.1' . CRLF .
'HOST: ' . SSDP_IP . ':' . SSDP_PORT . CRLF .
'MAN: "ssdp:discover"' . CRLF .
'ST: ' . $target . CRLF .
'MX: ' . $self->{_maxWait} . CRLF .
CRLF;
my $destaddr = sockaddr_in(SSDP_PORT, inet_aton(SSDP_IP));
send($self->{_searchSocket}, $header, 0, $destaddr);
}
sub _parseUSNHeader {
my $usn = shift;
my ($udn, $deviceType, $serviceType);
if ($usn =~ /^uuid:schemas(.*?):device(.*?):(.*?):(.+)$/) {
$udn = 'uuid:' . $4;
$deviceType = 'urn:schemas' . $1 . ':device' . $2 . ':' . $3;
}
elsif ($usn =~ /^uuid:(.+?)::/) {
$udn = 'uuid:' . $1;
if ($usn =~ /urn:(.+)$/) {
my $urn = $1;
if ($usn =~ /:service:/) {
$serviceType = 'urn:' . $urn;
}
elsif ($usn =~ /:device:/) {
$deviceType = 'urn:' . $urn;
}
}
}
else {
$udn = $usn;
}
return ($udn, $deviceType, $serviceType);
}
sub _firstLocation {
my $headers = shift;
my $location = $headers->header('Location');
return $location if $location;
my $al = $headers->header('AL');
if ($al && $al =~ /^<(\S+?)>/) {
return $1;
}
return undef;
}
sub newService {
my $self = shift;
return UPnP::ControlPoint::Service->new(@_);
}
sub newDevice {
my $self = shift;
return UPnP::ControlPoint::Device->new(@_);
}
sub _createDevice {
my $self = shift;
my $location = shift;
my $device;
# We've found examples of where devices claim to do transfer
# encoding, but wind up sending chunks without chunk size headers.
# This code temporarily disables the TE header in the request.
#push(@LWP::Protocol::http::EXTRA_SOCK_OPTS, SendTE => 0);
my @SOCK_OPTS_Backup = @LWP::Protocol::http::EXTRA_SOCK_OPTS;
_addSendTE();
my $ua = LWP::UserAgent->new(timeout => 20);
my $response = $ua->get($location);
my $base;
if ($response->is_success && $response->content ne '') {
($device, $base) = $self->parseDeviceDescription($response->content,
{Location => $location},
{ControlPoint => $self});
} else {
carp('400-URL-Absolute-Error! Location: "'.$location.'", Content: "'.$response->content.'"') if ($response->code == 400);
carp("Loading device description failed with error: " . $response->code . " " . $response->message . ' (Location: ' . $location . ')') if ($response->code != 200);
}
#pop(@LWP::Protocol::http::EXTRA_SOCK_OPTS);
@LWP::Protocol::http::EXTRA_SOCK_OPTS = @SOCK_OPTS_Backup;
if ($device) {
$device->base($base ? $base : $location);
if ($response->is_success && $response->content ne '') {
$device->descriptionDocument($response->content);
}
}
return $device;
}
sub _addSendTE {
my %arg = @LWP::Protocol::http::EXTRA_SOCK_OPTS;
$arg{SendTE} = 0;
@LWP::Protocol::http::EXTRA_SOCK_OPTS = %arg;
}
sub _getDeviceFromHeaders {
my $self = shift;
my $headers = shift;
my $create = shift;
my $location = _firstLocation($headers);
my ($udn, $deviceType, $serviceType) =
_parseUSNHeader($headers->header('USN'));
my $device = $self->{_devices}->{$udn};
if (!defined($device) && $create) {
$device = $self->_createDevice($location);
if ($device) {
$self->{_devices}->{$udn} = $device;
}
}
return $device;
}
sub _deviceAdded {
my $self = shift;
my $device = shift;
for my $search (values %{$self->{_activeSearches}}) {
$search->deviceAdded($device);
}
}
sub _deviceRemoved {
my $self = shift;
my $device = shift;
for my $search (values %{$self->{_activeSearches}}) {
$search->deviceRemoved($device);
}
}
use Data::Dumper;
sub _receiveSearchResponse {
my $self = shift;
my $socket = shift;
my $buf = '';
my $peer = recv($socket, $buf, 2048, 0);
my @peerdata = unpack_sockaddr_in($peer);
return if (!isUsedOnlyIP(inet_ntoa($peerdata[1])));
return if (isIgnoreIP(inet_ntoa($peerdata[1])));
if ($buf !~ /\015?\012\015?\012/) {
return;
}
$buf =~ s/^(?:\015?\012)+//; # ignore leading blank lines
unless ($buf =~ s/^(\S+)[ \t]+(\S+)[ \t]+(\S+)[^\012]*\012//) {
# Bad header
return;
}
# Basic check to see if the response is actually for a search
my $found = 0;
foreach my $searchkey (keys %{$self->{_activeSearches}}) {
my $search = $self->{_activeSearches}->{$searchkey};
if ($search->{_type} && $buf =~ $search->{_type}) {
print 'xxxx.xx.xx xx:xx:xx 5: ControlPoint: Accepted Search-Response: "'.$buf.'"'."\n" if ($LogLevel >= 5);
$found = 1;
last;
}
if ($search->{_udn} && $buf =~ $search->{_udn}) {
$found = 1;
last;
}
if ($search->{_friendlyName} && $buf =~ $search->{_friendlyName}) {
$found = 1;
last;
}
}
if (! $found) {
print 'xxxx.xx.xx xx:xx:xx 5: ControlPoint: Unknown Search-Response: "'.$buf.'"'."\n" if ($LogLevel >= 5);
return;
}
my $code = $2;
if ($code ne '200') {
# We expect a success response code
return;
}
my $headers = UPnP::Common::parseHTTPHeaders($buf);
my $device = $self->_getDeviceFromHeaders($headers, 1);
if ($device) {
$self->_deviceAdded($device);
}
}
sub _receiveSSDPEvent {
my $self = shift;
my $socket = shift;
my $buf = '';
my $peer = recv($socket, $buf, 2048, 0);
return if (!defined($peer));
my @peerdata = unpack_sockaddr_in($peer);
return if (!@peerdata);
return if (!isUsedOnlyIP(inet_ntoa($peerdata[1])));
return if (isIgnoreIP(inet_ntoa($peerdata[1])));
if ($buf !~ /\015?\012\015?\012/) {
return;
}
$buf =~ s/^(?:\015?\012)+//; # ignore leading blank lines
unless ($buf =~ s/^(\S+)[ \t]+(\S+)(?:[ \t]+(HTTP\/\d+\.\d+))?[^\012]*\012//) {
# Bad header
return;
}
#print Dumper($buf); #ALW uncomment
my $method = $1;
if ($method ne 'NOTIFY') {
# We only care about notifications
return;
}
my $headers = UPnP::Common::parseHTTPHeaders($buf);
my $eventType = $headers->header('NTS');
my $device = $self->_getDeviceFromHeaders($headers,
$eventType =~ /alive/ ?
1 : 0);
if ($device) {
if ($eventType =~ /alive/) {
$self->_deviceAdded($device);
}
elsif ($eventType =~ /byebye/) {
$self->_deviceRemoved($device);
$self->{_devices}->{$device->UDN()} = undef;
}
}
}
sub _parseProperty {
my $self = shift;
my $element = shift;
my ($name, $attrs, $children) = @$element;
my ($key, $value);
if ($name =~ /property/) {
my $childElement = $children->[0];
$key = $childElement->[0];
$value = $childElement->[2];
}
($key, $value);
}
sub _parsePropertySet {
my $self = shift;
my $content = shift;
my %properties = ();
my $parser = $self->parser;
my $element = $parser->parse($content);
if (defined($element) && (ref $element eq 'ARRAY') &&
$element->[0] =~ /propertyset/) {
my($name, $attrs, $children) = @$element;
for my $child (@$children) {
my ($key, $value) = $self->_parseProperty($child);
if ($key) {
$properties{$key} = $value;
}
}
}
return %properties;
}
sub _receiveSubscriptionNotification {
my $self = shift;
my $connect = shift;
my $request = $connect->get_request();
if ($request && ($request->method eq 'NOTIFY') &&
($request->header('NT') eq 'upnp:event') &&
($request->header('NTS') eq 'upnp:propchange')) {
my $sid = $request->header('SID');
my $subscription = $self->{_subscriptions}->{$sid};
if ($subscription) {
my %propSet = $self->_parsePropertySet($request->content);
$subscription->propChange(%propSet);
}
}
$connect->send_response(HTTP::Response->new(HTTP::Status::RC_OK));
$connect->close;
}
# ----------------------------------------------------------------------
package UPnP::ControlPoint::Device;
use strict;
use vars qw(@ISA);
use UPnP::Common;
our @ISA = qw(UPnP::Common::Device);
sub base {
my $self = shift;
my $base = shift;
if (defined($base)) {
$self->{_base} = $base;
for my $service ($self->services) {
$service->base($base);
}
for my $device ($self->children) {
$device->base($base);
}
}
return $self->{_base};
}
sub descriptionDocument {
my $self = shift;
my $descriptionDocument = shift;
if (defined($descriptionDocument)) {
$self->{_descriptionDocument} = $descriptionDocument;
}
return $self->{_descriptionDocument};
}
# ----------------------------------------------------------------------
package UPnP::ControlPoint::Service;
use strict;
use Socket;
use Scalar::Util qw(weaken);
use SOAP::Lite;
use Carp;
use vars qw($AUTOLOAD @ISA %urlProperties);
use UPnP::Common;
our @ISA = qw(UPnP::Common::Service);
for my $prop (qw(SCPDURL controlURL eventSubURL)) {
$urlProperties{$prop}++;
}
sub new {
my ($self, %args) = @_;
my $class = ref($self) || $self;
$self = $class->SUPER::new(%args);
if ($args{ControlPoint}) {
$self->{_controlPoint} = $args{ControlPoint};
weaken($self->{_controlPoint});
}
return $self;
}
sub AUTOLOAD {
my $self = shift;
my $attr = $AUTOLOAD;
$attr =~ s/.*:://;
return if $attr eq 'DESTROY';
my $superior = "SUPER::$attr";
my $val = $self->$superior(@_);
if ($urlProperties{$attr}) {
my $base = $self->base;
if ($base) {
return URI->new_abs($val, $base);
}
return URI->new($val);
}
return $val;
}
sub controlProxy {
my $self = shift;
$self->_loadDescription;
return UPnP::ControlPoint::ControlProxy->new($self);
}
sub queryStateVariable {
my $self = shift;
my $name = shift;
$self->_loadDescription;
my $var = $self->getStateVariable($name);
if (!$var) { croak("No such state variable $name"); }
if (!$var->evented) { croak("Variable $name is not evented"); }
my $result;
if ($SOAP::Lite::VERSION >= 0.67) {
if ($EnvNamespace eq '') {
$result = SOAP::Lite
->envprefix($EnvPrefix)
->uri('urn:schemas-upnp-org:control-1-0')
->proxy($self->controlURL)
->call('QueryStateVariable' =>
SOAP::Data->name('varName')
->uri('urn:schemas-upnp-org:control-1-0')
->value($name));
} else {
$result = SOAP::Lite
->envprefix($EnvPrefix)
->ns($EnvNamespace)
->uri('urn:schemas-upnp-org:control-1-0')
->proxy($self->controlURL)
->call('QueryStateVariable' =>
SOAP::Data->name('varName')
->uri('urn:schemas-upnp-org:control-1-0')
->value($name));
}
} else {
$result = SOAP::Lite
->envprefix($EnvPrefix)
->uri('urn:schemas-upnp-org:control-1-0')
->proxy($self->controlURL)
->call('QueryStateVariable' =>
SOAP::Data->name('varName')
->uri('urn:schemas-upnp-org:control-1-0')
->value($name));
}
if ($result->fault()) {
carp("Query failed with fault " . $result->faultstring());
return undef;
}
return $result->result;
}
sub subscribe {
my $self = shift;
my $callback = shift;
my $timeout = shift;
my $cp = $self->{_controlPoint};
if (!defined $UPnP::Common::LocalIP) {
# Find our local IP
my $u = URI->new($self->eventSubURL);
my $proto = getprotobyname('tcp');
socket(Socket_Handle, PF_INET, SOCK_STREAM, $proto);
my $sin = sockaddr_in($u->port(),inet_aton($u->host()));
connect(Socket_Handle,$sin);
my ($port, $addr) = sockaddr_in(getsockname(Socket_Handle));
close(Socket_Handle);
UPnP::Common::setLocalIP($addr);
}
if (defined($cp)) {
my $url = $self->eventSubURL;
my $request = HTTP::Request->new('SUBSCRIBE',
"$url");
$request->header('NT', 'upnp:event');
$request->header('Callback', '<' . $cp->subscriptionURL . '>');
$request->header('Timeout',
'Second-' . defined($timeout) ? $timeout : 'infinite');
my $ua = LWP::UserAgent->new(timeout => 20);
my $response = $ua->request($request);
if ($response->is_success) {
if ($response->code == 200) {
my $sid = $response->header('SID');
$timeout = $response->header('Timeout');
if ($timeout =~ /^Second-(\d+)$/) {
$timeout = $1;
}
my $subscription = UPnP::ControlPoint::Subscription->new(
Service => $self,
Callback => $callback,
SID => $sid,
Timeout => $timeout,
EventSubURL => "$url");
$cp->addSubscription($subscription);
return $subscription;
} else {
carp("Subscription request successful but answered with error: " . $response->code . " " . $response->message);
}
} else {
carp("Subscription request failed with error: " . $response->code . " " . $response->message);
}
}
return undef;
}
sub unsubscribe {
my $self = shift;
my $subscription = shift;
my $url = $self->eventSubURL;
my $request = HTTP::Request->new('UNSUBSCRIBE',
"$url");
$request->header('SID', $subscription->SID);
my $ua = LWP::UserAgent->new(timeout => 20);
my $response = $ua->request($request);
if ($response->is_success) {
my $cp = $self->{_controlPoint};
if (defined($cp)) {
$cp->removeSubscription($subscription);
}
}
else {
if ($response->code != 412) {
carp("Unsubscription request failed with error: " .
$response->code . " " . $response->message);
}
}
}
sub _loadDescription {
my $self = shift;
if ($self->{_loadedDescription}) {
return;
}
my $location = $self->SCPDURL;
my $cp = $self->{_controlPoint};
unless (defined($location)) {
carp("Service doesn't have a SCPD location");
return;
}
unless (defined($cp)) {
carp("ControlPoint instance no longer exists");
return;
}
my $parser = $cp->parser;
#push(@LWP::Protocol::http::EXTRA_SOCK_OPTS, SendTE => 0);
my @SOCK_OPTS_Backup = @LWP::Protocol::http::EXTRA_SOCK_OPTS;
_addSendTE();
my $ua = LWP::UserAgent->new(timeout => 20);
my $response = $ua->get($location);
if ($response->is_success) {
$self->parseServiceDescription($parser, $response->content);
}
else {
carp("Error loading SCPD document: $!");
}
#pop(@LWP::Protocol::http::EXTRA_SOCK_OPTS);
@LWP::Protocol::http::EXTRA_SOCK_OPTS = @SOCK_OPTS_Backup;
$self->{_loadedDescription} = 1;
}
sub _addSendTE {
my %arg = @LWP::Protocol::http::EXTRA_SOCK_OPTS;
$arg{SendTE} = 0;
@LWP::Protocol::http::EXTRA_SOCK_OPTS = %arg;
}
# ----------------------------------------------------------------------
package UPnP::ControlPoint::ControlProxy;
use strict;
use SOAP::Lite;
use Carp;
use vars qw($AUTOLOAD);
sub new {
my($class, $service) = @_;
if ($SOAP::Lite::VERSION >= 0.67) {
if ($EnvNamespace eq '') {
return bless {
_service => $service,
_proxy => SOAP::Lite->envprefix($EnvPrefix)
->uri($service->serviceType)->proxy($service->controlURL),
}, $class;
} else {
return bless {
_service => $service,
_proxy => SOAP::Lite->envprefix($EnvPrefix)
->ns($EnvNamespace)
->uri($service->serviceType)->proxy($service->controlURL),
}, $class;
}
} else {
return bless {
_service => $service,
_proxy => SOAP::Lite->envprefix($EnvPrefix)->uri($service->serviceType)->proxy($service->controlURL),
}, $class;
}
}
sub AUTOLOAD {
my $self = shift;
my $service = $self->{_service};
my $proxy = $self->{_proxy};
my $method = $AUTOLOAD;
$method =~ s/.*:://;
return if $method eq 'DESTROY';
my $action = $service->getAction($method);
croak "invalid method: ->$method()" unless $action;
my @inArgs;
for my $arg ($action->inArguments) {
my $val = shift;
my $type = $service->getArgumentType($arg);
push @inArgs, SOAP::Data->type($type => $val)->name($arg->name);
}
return UPnP::ControlPoint::ActionResult->new(
Action => $action,
Service => $service,
SOM => $proxy->call($method => @inArgs));
}
# ----------------------------------------------------------------------
package UPnP::ControlPoint::ActionResult;
use strict;
use SOAP::Lite;
use HTML::Entities ();
use Carp;
use vars qw($AUTOLOAD);
sub new {
my($class, %args) = @_;
my $som = $args{SOM};
my $self = bless {
_som => $som,
}, $class;
unless (defined($som->fault())) {
for my $out ($args{Action}->outArguments) {
my $name = $out->name;
my $data = $som->match('/Envelope/Body//' . $name)->dataof();
if ($data) {
my $type = $args{Service}->getArgumentType($out);
$data->type($type);
if ($type eq 'string') {
$self->{_results}->{$name} = HTML::Entities::decode(
$data->value);
}
else {
$self->{_results}->{$name} = $data->value;
}
}
}
}
return $self;
}
sub isSuccessful {
my $self = shift;
return !defined($self->{_som}->fault());
}
sub getValue {
my $self = shift;
my $name = shift;
if (defined($self->{_results})) {
return $self->{_results}->{$name};
}
return undef;
}
sub AUTOLOAD {
my $self = shift;
my $method = $AUTOLOAD;
$method =~ s/.*:://;
return if $method eq 'DESTROY';
return $self->{_som}->$method(@_);
}
# ----------------------------------------------------------------------
package UPnP::ControlPoint::Search;
use strict;
sub new {
my($class, %args) = @_;
return bless {
_callback => $args{Callback},
_type => $args{Type},
_udn => $args{UDN},
_friendlyName => $args{FriendlyName},
}, $class;
}
sub _passesFilter {
my $self = shift;
my $device = shift;
my $type = $self->{_type};
my $name = $self->{_friendlyName};
my $udn = $self->{_udn};
if ((!defined($type) || ($type eq $device->deviceType()) ||
($type eq 'ssdp:all')) &&
(!defined($name) || ($name eq $device->friendlyName())) &&
(!defined($udn) || ($udn eq $device->udn()))) {
return 1;
}
return 0;
}
sub deviceAdded {
my $self = shift;
my $device = shift;
if ($self->_passesFilter($device) &&
!$self->{_devices}->{$device}) {
&{$self->{_callback}}($self, $device, 'deviceAdded');
$self->{_devices}->{$device}++;
}
}
sub deviceRemoved {
my $self = shift;
my $device = shift;
if ($self->_passesFilter($device) &&
$self->{_devices}->{$device}) {
&{$self->{_callback}}($self, $device, 'deviceRemoved');
delete $self->{_devices}->{$device};
}
}
# ----------------------------------------------------------------------
package UPnP::ControlPoint::Subscription;
use strict;
use Time::HiRes;
use Scalar::Util qw(weaken);
use Carp;
sub new {
my($class, %args) = @_;
my $self = bless {
_callback => $args{Callback},
_sid => $args{SID},
_timeout => $args{Timeout},
_startTime => Time::HiRes::time(),
_eventSubURL => $args{EventSubURL},
}, $class;
weaken($self->{_service} = $args{Service});
return $self;
}
sub SID {
my $self = shift;
return $self->{_sid};
}
sub timeout {
my $self = shift;
return $self->{_timeout};
}
sub expired {
my $self = shift;
if ($self->{_timeout} eq 'INFINITE') {
return 0;
}
my $now = Time::HiRes::time();
if ($now - $self->{_startTime} > $self->{_timeout}) {
return 1;
}
return 0;
}
sub renew {
my $self = shift;
my $timeout = shift;
my $url = $self->{_eventSubURL};
my $request = HTTP::Request->new('SUBSCRIBE',
"$url");
$request->header('SID', $self->{_sid});
$request->header('Timeout',
'Second-' . defined($timeout) ? $timeout : 'infinite');
my $ua = LWP::UserAgent->new(timeout => 20);
my $response = $ua->request($request);
if ($response->is_success) {
$timeout = $response->header('Timeout');
if ($timeout =~ /^Second-(\d+)$/) {
$timeout = $1;
}
$self->{_timeout} = $timeout;
$self->{_startTime} = Time::HiRes::time();
}
else {
carp("Renewal of subscription failed with error: " .
$response->code . " " . $response->message);
}
return $self;
}
sub unsubscribe {
my $self = shift;
if ($self->{_service}) {
$self->{_service}->unsubscribe($self);
}
}
sub propChange {
my $self = shift;
my %properties = @_;
if ($self->{_service}) {
&{$self->{_callback}}($self->{_service}, %properties);
}
}
1;
__END__
=head1 NAME
UPnP::ControlPoint - A UPnP ControlPoint implementation.
=head1 SYNOPSIS
use UPnP::ControlPoint;
my $cp = UPnP::ControlPoint->new;
my $search = $cp->searchByType("urn:schemas-upnp-org:device:TestDevice:1",
\&callback);
$cp->handle;
sub callback {
my ($search, $device, $action) = @_;
if ($action eq 'deviceAdded') {
print("Device: " . $device->friendlyName . " added. Device contains:\n");
for my $service ($device->services) {
print("\tService: " . $service->serviceType . "\n");
}
}
elsif ($action eq 'deviceRemoved') {
print("Device: " . $device->friendlyName . " removed\n");
}
}
=head1 DESCRIPTION
Implements a UPnP ControlPoint. This module implements the various
aspects of the UPnP architecture from the standpoint of a ControlPoint:
=over 4
=item 1. Discovery
A ControlPoint can be used to actively search for devices and services
on a local network or listen for announcements as devices enter and
leave the network. The protocol used for discovery is the Simple
Service Discovery Protocol (SSDP).
=item 2. Description
A ControlPoint can get information describing devices and
services. Devices can be queried for services and vendor-specific
information. Services can be queried for actions and state variables.
=item 3. Control
A ControlPoint can invoke actions on services and poll for state
variable values. Control-related calls are generally made using the
Simple Object Access Protocol (SOAP).
=item 4. Eventing
ControlPoints can listen for events describing state changes in
devices and services. Subscription requests and state change events
are generally sent using the General Event Notification Architecture
(GENA).
=back
Since the UPnP architecture leverages several existing protocols such
as TCP, UDP, HTTP and SOAP, this module requires several Perl modules
that implement these protocols. These include
L,
L,
L and
C (L).
=head1 METHODS
=head2 UPnP::ControlPoint
A ControlPoint implementor will generally create a single instance of
the C class (though more than one can exist within
a process assuming that they have been set up to avoid port
conflicts).
=over 4
=item new ( [ARGS] )
Creates a C object. Accepts the following
key-value pairs as optional arguments (default values are listed
below):
SearchPort Port on which search requests are received 8008
SubscriptionPort Port on which event notification are received 8058
SubscriptionURL URL on which event notification are received /eventSub
MaxWait Max wait before search responses should be sent 3
While this call creates the sockets necessary for the ControlPoint to
function, the ControlPoint is not active until its sockets are
actually serviced, either by invoking the C
method or by externally selecting using the ControlPoint's
C and invoking the
C method as each becomes ready for
reading.
=item sockets
Returns a list of sockets that need to be serviced for the
ControlPoint to correctly function. This method is generally used in
conjunction with the C method by users who want to run
their own C