=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 .
=head1 DESCRIPTION
This module provides push notifications through the ntfy.sh service and
other compatible servers.
=head1 AUTHORS
Dominik Meyer
=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_MAX => 5,
PRIO_HIGH => 4,
PRIO_DEFAULT => 3,
PRIO_LOW => 2,
PRIO_MIN => 1
};
# 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->{Match} = "^NTFY:.*";
$hash->{DefFn} = 'NTFY_Define';
$hash->{SetFn} = 'NTFY_Set';
$hash->{ReadFn} = 'NTFY_Read';
$hash->{AttrFn} = 'NTFY_Attr';
$hash->{ParseFn} = 'NTFY_Parse';
$hash->{DeleteFn} = 'NTFY_Delete';
$hash->{AttrList} = "defaultTopic " .
"defaultPriority:max,high,default,low,min "
. $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->{VERSION} = $VERSION;
$hash->{USERNAME} = $h->{user} || "";
$hash->{helper}{PASSWORD} = $h->{password};
$modules{NTFY_CLIENT}{defptr}{$hash->{SERVER}} = $hash;
readingsSingleUpdate($hash, "state", "passive",1);
return;
}
sub NTFY_Get_Subscriptions
{
my $hash = shift;
my @subscriptions;
for my $k (keys %{$modules{NTFY_TOPIC}{defptr}})
{
$k=~/^(.*)_(.*)$/;
push(@subscriptions,$2);
}
return @subscriptions;
}
sub NTFY_Update_Subscriptions_Readings
{
my $hash = shift;
my @topics = NTFY_Get_Subscriptions($hash);
readingsSingleUpdate($hash,"subscriptions", join(",", @topics),1);
}
sub NTFY_newSubscription
{
my $hash = shift;
my $topic = shift;
my $newDeviceName = makeDeviceName($hash->{NAME} . "_" . $topic);
my $token;
if ($hash->{helper}->{PASSWORD})
{
$token= NFTY_Calc_Auth_Token($hash->{helper}->{PASSWORD},$hash->{USERNAME});
fhem("define $newDeviceName NTFY_TOPIC " . $hash->{SERVER} . " " . $token . " " . $topic);
}
else
{
fhem("define $newDeviceName NTFY_TOPIC " . $hash->{SERVER} . " " . $topic);
}
NTFY_Update_Subscriptions_Readings($hash);
}
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} = int($msg->{priority});
}
if ($msg->{keywords})
{
$message->{tags} = $msg->{keywords};
}
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;
}
my $nrPublishedMessages = ReadingsVal($hash->{NAME}, "nrPublishedMessages", 0);
$nrPublishedMessages++;
readingsBeginUpdate($hash);
readingsBulkUpdateIfChanged($hash, "nrPublishedMessages", $nrPublishedMessages);
readingsBulkUpdateIfChanged($hash, "lastUsedTopic", join(",", @{$msg->{topics}}));
readingsBulkUpdateIfChanged($hash, "lastMessageSend", $msg->{text});
readingsBulkUpdateIfChanged($hash, "lastRawMessage", to_json($message));
if ($msg->{priority} == PRIO_MAX)
{
readingsBulkUpdateIfChanged($hash, "lastUsedPriority", "max");
}
elsif ($msg->{priority} == PRIO_HIGH)
{
readingsBulkUpdateIfChanged($hash, "lastUsedPriority", "high");
}
elsif ($msg->{priority} == PRIO_DEFAULT)
{
readingsBulkUpdateIfChanged($hash, "lastUsedPriority", "default");
}
elsif ($msg->{priority} == PRIO_LOW)
{
readingsBulkUpdateIfChanged($hash, "lastUsedPriority", "low");
}
elsif ($msg->{priority} == PRIO_MIN)
{
readingsBulkUpdateIfChanged($hash, "lastUsedPriority", "min");
}
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", "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 "!")
{
$priority=lc(substr($a,1,length($a)));
}
elsif (substr($a,0,1) eq "*")
{
$title=substr($a,1,length($a));
}
else
{
$text .= $a . " ";
}
}
chop $text;
if ($priority eq "default")
{
$priority=PRIO_DEFAULT;
}
elsif($priority eq "max")
{
$priority=PRIO_MAX;
}
elsif($priority eq "high")
{
$priority=PRIO_HIGH;
}
elsif($priority eq "low")
{
$priority=PRIO_LOW;
}
elsif($priority eq "min")
{
$priority=PRIO_MIN;
}
else
{
$priority=PRIO_DEFAULT;
}
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;
}
elsif ($cmd eq "subscribe")
{
NTFY_LOG(LOG_DEBUG, "full command: " . join(' ', @args));
NTFY_newSubscription($hash, $args[0]);
}
else
{
return "Unknown argument $cmd, choose one of publish subscribe"
}
}
sub NTFY_Attr
{
my ( $cmd, $name, $aName, $aValue ) = @_;
return undef;
}
#
# Process the incoming notifications/ messages
#
sub NTFY_Process_Message
{
my $hash = shift;
my $msg = shift;
my $msgData = from_json($msg);
return unless $msgData->{event} eq "message";
my $nrReceivedMessages = ReadingsVal($hash->{NAME}, "nrReceivedMessages", 0);
$nrReceivedMessages++;
readingsBeginUpdate($hash);
readingsBulkUpdateIfChanged($hash,"nrReceivedMessages",$nrReceivedMessages);
readingsBulkUpdateIfChanged($hash,"lastReceivedTopic",$msgData->{topic}) unless !$msgData->{topic};
readingsBulkUpdateIfChanged($hash,"lastReceivedTitle",$msgData->{title}) unless !$msgData->{title};
readingsBulkUpdateIfChanged($hash,"lastReceivedData", $msgData->{message}) unless !$msgData->{message};
readingsBulkUpdateIfChanged($hash,"lastReceivedRawMessage", $msg);
readingsEndUpdate($hash,1);
}
#
# Parse incoming notifications from websocket clients
#
sub NTFY_Parse ($$)
{
my ( $ioHash, $message) = @_;
return unless (substr($message,0,5) eq "NTFY:");
$message=~/^NTFY:(.*)---(.*)$/;
my $server = $1;
my $msg = $2;
if(my $hash = $modules{NTFY_CLIENT}{defptr}{$server})
{
NTFY_Update_Subscriptions_Readings($hash);
NTFY_Process_Message($hash, $msg);
return $hash->{NAME};
}
return;
}
sub NTFY_Delete
{
my ( $hash, $name ) = @_;
#we need to delete all topic devices
for my $k (keys %{$modules{NTFY_TOPIC}{defptr}})
{
$k=~/^(.*)_(.*)$/;
my $h = $modules{NTFY_TOPIC}{defptr}{$k};
fhem("delete " . $h->{NAME});
}
}
1;
#########################################
=pod
=item summary A module for pushing and receiving notifications from an ntfy.sh compatible server
=item summary_DE Ein module zu senden und empfangen von Benachrichtigungen über einen ntfy.sh kompatiblen server
=begin html
NTFY_CLIENT
NTFY_CLIENT is a module for connecting to an ntfy.sh compatible server.
It supports ntfy.sh in the cloud but also self-hosted instances as long as
the fhem server is able to connect to it.
Wiki
The wiki for this module can be found here.
Integration with globalMSG
This module integrates with the fhem messaging system.
To use it you have to set the msgCmdPush
attribute of the
globalMSG
device to someting like
set %DEVICE% publish @%RECIPIENT% !%PRIORITY% *%TITLE% %MSG%
After that you can use the msg
to send out notifications to topics configured within devices.
Please look for the MSG documentation for more information.
Define
define NTFY0 NTFY_CLIENT <url> password=<password> user=<user>
- url
- The url to the ntfy.sh compatible server. This parameter is mandatory.
- password
- If you have an account at one ntfy server you can put the password in here. This parameter is optional.
- user
- If you have an account at one ntfy server you can put the username in here. This parameter is optional.
If you want to use token authentication just set the token as the password and ignore the user parameter.
Set
The module supports the following set commands
- publish
-
This set command publishes a notification through the configured ntfy server.
set publish @fhem-topic #keyword !high *title this is my message
The following key tags are supported right now
- @ - identifies a topic. Can be used multiple times.
- # - a ntfy keyword. Can be used multiple times.
- ! - the priority. The last mentioned priority wins. (max, high, default, low, min)
- * - the title of the notification. The last mentioned title wins.
Everything without a prefix is considered the messages and is concatenated.
- subscribe
-
This set command subscribes to the specified topic on the configured ntfy server.
For this the client adds a NTFY_TOPIC device which is responsible for the websocket
management. Message processing is done in NTFY_CLIENT.
Get
No get commands supported yet.
Attributes
- defaultTopic
-
Sets the default topic used if no topic is provided within the publish string.
- defaultPriority
-
Sets the default priority used if no priority is provided within the publish string.
Values: (max, high, default, low, min)
=end html
=begin html_DE
NTFY_CLIENT
blabla
=end html_DE
=cut