=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