FHEM-NTFY/FHEM/98_NTFY_CLIENT.pm

492 lines
12 KiB
Perl

=head1 NAME
NTFY_CLIENT - client for ntfy.sh based servers
=head1 LICENSE AND COPYRIGHT
Copyright (C) 2024 by Dominik Meyer
This program 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 3 of the
License, or (at your option) any later version.
This module 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 this program.
If not, see <https://www.gnu.org/licenses/>.
=head1 DESCRIPTION
This module provides push notifications through the ntfy.sh service and
other compatible servers.
=head1 AUTHORS
Dominik Meyer <dmeyer@federationhq.de>
=cut
package main;
# enforce strict and warnings mode
use strict;
use warnings;
# required for sending and receiving data from ntfy.sh
use LWP::UserAgent;
use HTTP::Request;
use URI;
use JSON;
use Text::ParseWords;
use HttpUtils;
use FHEM::Text::Unicode qw(:ALL);
use Data::Dumper;
# some module wide constansts
my $MODULE_NAME="NTFY-CLIENT";
my $VERSION = '0.0.1';
use constant {
LOG_CRITICAL => 0,
LOG_ERROR => 1,
LOG_WARNING => 2,
LOG_SEND => 3,
LOG_RECEIVE => 4,
LOG_DEBUG => 5,
PRIO_DEFAULT => 3,
PRIO_HIGH => 5,
PRIO_LOW => 2
};
# NTFY logging method
sub NTFY_LOG
{
my $verbosity = shift;
my $msg = shift;
Log3 $MODULE_NAME, $verbosity, $MODULE_NAME . ":" . $msg;
}
#
# Function to calculate the nfty auth token from a username
# and password/ password token
#
# the username is allowed to be empty ("")
# the password/password token is mandatory
#
sub NFTY_Calc_Auth_Token
{
my $password=shift;
my $username=shift;;
if (!$username)
{
$username="";
}
if (!$password)
{
NTFY_LOG(LOG_ERROR,"password required for NFTY_Calc_Auth_Token");
return;
}
my $auth=encode_base64($username.":".$password);
my $authString = encode_base64("Basic " . $auth);
$authString =~ s/=//g;
return $authString;
}
# initialize the NTFY Module
sub NTFY_CLIENT_Initialize
{
my ($hash) = @_;
$hash->{DefFn} = 'NTFY_Define';
$hash->{SetFn} = 'NTFY_Set';
$hash->{ReadFn} = 'NTFY_Read';
$hash->{AttrFn} = 'NTFY_Attr';
$hash->{AttrList} = "defaultTopic "
. $readingFnAttributes;
}
sub NTFY_Define
{
my ($hash, $define) = @_;
# ensure we have something to parse
if (!$define)
{
warn("$MODULE_NAME: no module definition provided");
NTFY_LOG(LOG_ERROR,"no module definition provided");
return;
}
# parse parameters into array and hash
my($params, $h) = parseParams($define);
my $name = makeDeviceName($params->[0]);
$hash->{NAME} = $name;
$hash->{SERVER} = $params->[2];
$hash->{STATE} = "unknown";
$hash->{USERNAME} = $h->{user} || "";
$hash->{helper}{PASSWORD} = $h->{password};
my @topics;
$hash->{helper}->{topics} = \@topics;
return;
}
sub NTFY_Update_Subscriptions_Readings
{
my $hash = shift;
my $subscriptions="";
for my $thash (@{$hash->{helper}->{subscriptions}})
{
$subscriptions .= $hash->{TOPIC} . ",";
}
chop $subscriptions;
readingsSingleUpdate($hash,"subscriptions", $subscriptions,1);
}
sub NTFY_newSubscription
{
my $hash = shift;
my $topic = shift;
my $thash = {};
$thash->{NAME} = makeDeviceName("NTFY_TOPIC_" . $topic);
$thash->{TYPE} = $hash->{TYPE};
$thash->{NR} = $devcount++;
$thash->{phash} = $hash;
$thash->{PNAME} = $hash->{NAME};
$thash->{TOPIC} = $topic;
$thash->{SERVER} = $hash->{SERVER};
$thash->{USERNAME} = $hash->{USERNAME} || "";
$thash->{helper}->{PASSWORD} = $hash->{helper}->{PASSWORD};
$thash->{TEMPORARY} = 1;
my $useSSL = 0;
if ($thash->{SERVER}=~/https/)
{
$useSSL = 1;
}
my $port = $useSSL == 1 ? 443 : 80;
if ($thash->{SERVER}=~/:(\d+)/)
{
$port = $1;
}
my $dev = $thash->{SERVER} . ":" . $port . "/" . $thash->{TOPIC} . "/ws";
if ($thash->{helper}->{PASSWORD} && length($thash->{helper}->{PASSWORD}) > 0)
{
my $token = NFTY_Calc_Auth_Token($thash->{helper}->{PASSWORD},$thash->{USERNAME});
if (!$token)
{
NTFY_LOG(LOG_ERROR,"Can not suscribe to topic without valid token");
return;
}
$dev .="?auth=" . $token;
}
# swap http(s) to expected ws(s)
$dev =~ s/^.*:\/\//wss:/;
# just for debugging purposes
NTFY_LOG(LOG_DEBUG,"using websocket url: " . $dev);
$thash->{DeviceName}=$dev;
$thash->{WEBSOCKET} = 1;
$attr{$thash->{NAME}}{room} = 'hidden';
$defs{$thash->{NAME}} = $thash;
DevIo_OpenDev( $thash, 0, "NTFY_WS_Handshake", "NTFY_WS_CB" );
# remember topic in main hash helper
push(@{$hash->{helper}->{topics}},$thash);
NTFY_Update_Subscriptions_Readings($hash);
}
sub NTFY_Topic_To_Hash
{
my $hash = shift;
my $topic = shift;
}
sub NTFY_WS_Handshake
{
my $hash = shift;
my $name = $hash->{NAME};
DevIo_SimpleWrite( $hash, '', 2 );
NTFY_LOG(LOG_DEBUG, "websocket connected");
readingsSingleUpdate($hash, "state", "online", 1);
}
sub NTFY_WS_CB
{
my ($hash, $error) = @_;
my $name = $hash->{NAME};
if ($error)
{
readingsBeginUpdate($hash);
readingsBulkUpdate($hash, "state", "error");
readingsBulkUpdate($hash, "error", $error);
readingsEndUpdate($hash,1);
}
NTFY_LOG(LOG_ERROR, "error while connecting to websocket: $error ") if $error;
NTFY_LOG(LOG_DEBUG, "websocket callback called");
return $error;
}
sub NTFY_Read
{
my ( $hash ) = @_;
my $buf = DevIo_SimpleRead($hash);
return unless length($buf) > 0;
my $msg = from_json($buf);
return unless $msg->{event} eq "message";
my $nrReceivedMessages = ReadingsVal($hash->{phash},"nrReceivedMessages",0);
+
$nrReceivedMessages++;
readingsBeginUpdate($hash->{phash});
readingsBulkUpdateIfChanged($hash->{phash},"nrReceivedMessages",$nrReceivedMessages++);
readingsBulkUpdateIfChanged($hash->{phash}, "lastReceivedTopic", $msg->{topic});
readingsEndUpdate($hash->{phash},1);
NTFY_LOG(LOG_DEBUG, $hash->{NAME} . " received: " . $buf);
}
sub NTFY_Publish_Msg
{
my $hash = shift;
my $msg = shift;
NTFY_LOG(LOG_DEBUG, Dumper($msg));
my $auth = "";
if ($hash->{helper}->{PASSWORD} && length($hash->{helper}->{PASSWORD}) > 0)
{
my $token = NFTY_Calc_Auth_Token($hash->{helper}->{PASSWORD},$hash->{USERNAME});
if (!$token)
{
NTFY_LOG(LOG_ERROR,"Can not publish to topic without valid token");
return;
}
$auth .="?auth=" . $token;
}
for my $topic (@{$msg->{topics}})
{
my $url = $hash->{SERVER} ."/". $auth;
my $message = {
topic => $topic,
message => $msg->{text},
};
if ($msg->{title})
{
$message->{title} = $msg->{title};
}
if ($msg->{priority})
{
$message->{priority} = $msg->{priority};
}
NTFY_LOG(LOG_DEBUG, "Publish:" . Dumper($message));
my $param = {
url => $url,
timeout => 5,
hash => $hash,
method => "POST",
data => to_json($message),
callback => sub () {
my ($param, $err, $data) = @_;
my $hash = $param->{hash};
my $name = $hash->{NAME};
if($err ne "")
{
NTFY_LOG(LOG_ERROR, "Error publishing to topic ");
readingsSingleUpdate($hash, "lastError", $err,1);
return;
}
readingsBeginUpdate($hash);
readingsBulkUpdateIfChanged($hash, "lastUsedTopic", $msg->{topic});
readingsBulkUpdateIfChanged($hash, "lastMessageSend", $msg->{text});
readingsBulkUpdateIfChanged($hash, "lastRawMessage", to_json($message));
readingsBulkUpdateIfChanged($hash, "lastEror", "");
readingsEndUpdate($hash,1);
}
};
HttpUtils_NonblockingGet($param);
}
}
sub NTFY_Create_Msg_From_Arguments
{
my $hash = shift;
my $name = shift;
my @args = @_;
my @topics;
my @attachments;
my @keywords;
my $text;
my $title;
my $priority = AttrVal($name, "defaultPriority", PRIO_DEFAULT);
my $string=join(" ",@args);
my $tmpmessage = $string =~ s/\\n/\x0a/rg;
NTFY_LOG(LOG_DEBUG,"create:" . $tmpmessage);
@args=parse_line(' ',0,$tmpmessage);
for my $a (@args)
{
if (substr($a,0,1) eq "@")
{
push(@topics, substr($a,1,length($a)));
}
elsif (substr($a,0,1) eq "#")
{
push(@keywords, substr($a,1,length($a)));
}
elsif (substr($a,0,1) eq "&")
{
push(@attachments, substr($a,1,length($a)));
}
elsif (substr($a,0,1) eq "!")
{
my $tmpPriority=substr($a,1,length($a));
if ($tmpPriority eq "default")
{
$priority=PRIO_DEFAULT;
}
elsif($tmpPriority eq "high")
{
$priority=PRIO_HIGH;
}
elsif($tmpPriority eq "low")
{
$priority=PRIO_LOW;
}
}
elsif (substr($a,0,1) eq "*")
{
$title=substr($a,1,length($a));
}
else
{
$text .= $a . " ";
}
}
chop $text;
if (@topics == 0)
{
my $defaultTopic = AttrVal($name, "defaultTopic",undef);
if (!defined($defaultTopic))
{
NTFY_LOG(LOG_WARNING, "no topic and no default topic given, can not publish");
return;
}
else
{
push(@topics, $defaultTopic);
}
}
my $msg =
{
topics => \@topics,
title => $title,
keywords => \@keywords,
attachments => \@attachments,
priority => $priority,
text => $text
};
return $msg;
}
sub NTFY_Set
{
my ( $hash, $name, $cmd, @args ) = @_;
if ($cmd eq "publish" )
{
NTFY_LOG(LOG_DEBUG, "full command: " . join(' ', @args));
my $msg = NTFY_Create_Msg_From_Arguments($hash, $name,@args);
NTFY_Publish_Msg($hash, $msg) unless !defined($msg);
return undef;
}
else
{
return "Unknown argument $cmd, choose one of publish"
}
}
sub NTFY_Attr
{
my ( $cmd, $name, $aName, $aValue ) = @_;
return undef;
}
1;
#########################################
=pod
=item summary devices to send and receive push notifications through ntfy.sh and self hosted instances
<a name="NTFY_CLIENT"></a>
<h3>NTFY_CLIENT</h3>
<li>
ntfy.sh is a service to publish messages to and receive messages from topics. It is also possible
to host own instances of the service. The service can be used from the commandline using curl or
wget and also from smartphone applications. The later are available for IOS and Android.
</li>
<li>
Module development takes place at <a href="https://rm.byterazor.de/projects/fhem-ntfy">https://rm.byterazor.de/projects/fhem-ntfy</a>.
</li>
=cut