diff --git a/FHEM/00_SIGNALduino.pm b/FHEM/00_SIGNALduino.pm index 52ab2103b..762b78c62 100644 --- a/FHEM/00_SIGNALduino.pm +++ b/FHEM/00_SIGNALduino.pm @@ -1,4 +1,4 @@ -# $Id: 00_SIGNALduino.pm 0 2026-01-16 10:03:31Z sidey79 $ +# $Id: 00_SIGNALduino.pm 0 2026-01-18 22:55:34Z sidey79 $ # https://github.com/RFD-FHEM/RFFHEM/tree/master # The module is inspired by the FHEMduino project and modified in serval ways for processing the incoming messages # see http://www.fhemwiki.de/wiki/SIGNALDuino @@ -37,6 +37,8 @@ use FHEM::Devices::SIGNALduino::SD_Clients; use FHEM::Devices::SIGNALduino::SD_Message; use FHEM::Devices::SIGNALduino::SD_Matchlist; use List::Util qw(first); +use FHEM::Utility::MQTT2_Dispatcher qw(:DEFAULT); +use FHEM::Devices::SIGNALduino::SD_MQTT; #$| = 1; #Puffern abschalten, Hilfreich fuer PEARL WARNINGS Search @@ -44,7 +46,7 @@ use List::Util qw(first); use constant { - SDUINO_VERSION => '4.0.0', # Datum wird automatisch bei jedem pull request aktualisiert + SDUINO_VERSION => '4.0.0+20260118', # Datum wird automatisch bei jedem pull request aktualisiert SDUINO_INIT_WAIT_XQ => 1.5, # wait disable device SDUINO_INIT_WAIT => 2, SDUINO_INIT_MAXRETRY => 3, @@ -306,28 +308,40 @@ sub SIGNALduino_Define { my ($hash, $def) = @_; my @a =split m{\s+}xms, $def; - if(@a != 3) { - my $msg = 'Define, wrong parameter count: define SIGNALduino {none | devicename[\@baudrate] | devicename\@directio | hostname:port}'; + my $name = $a[0]; + my $dev = $a[2]; + if ($dev eq 'mqtt') { + if(@a < 3 || @a > 4) { + my $msg = 'Define, wrong mqtt syntax: define SIGNALduino mqtt []'; + Log3 undef, 2, $msg; + return $msg; + } + my $basetopic = $a[3] // 'signalduino/v1'; + $hash->{mqttSubscribe} = "$basetopic/state/messages"; + } elsif(@a != 3) { + my $msg = 'Define, wrong parameter count: define SIGNALduino {none | devicename[\@baudrate] | devicename\@directio | hostname:port | mqtt []}'; Log3 undef, 2, $msg; return $msg; } DevIo_CloseDev($hash); - my $name = $a[0]; - my $dev = $a[2]; + #$hash->{debugMethod}->(qq[dev: $dev]); + #my $hardware=AttrVal($name,'hardware','nano'); + #$hash->{debugMethod}->(qq[hardware: $hardware]); if($dev eq 'none') { Log3 $name, 1, "$name: Define, device is none, commands will be echoed only"; $attr{$name}{dummy} = 1; - } elsif ($dev !~ m/\@/) { + } elsif ($dev !~ m/\@/ && $dev ne 'mqtt') { + Log3 $name, 5, "$name: Define, device is $dev"; if ( ($dev =~ m~^(?:/[^/ ]*)+?$~xms || $dev =~ m~^COM\d$~xms) ) # bei einer IP oder hostname wird kein \@57600 angehaengt { $dev .= '@57600' } elsif ($dev !~ /@\d+$/ && ($dev !~ /^ (?: (?:[a-z0-9-]+(?:\.[a-z]{2,6})?)*|(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])\.){3} (?:25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])) - : (?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|[1-9]\d{0,3})$/xmsi) ) { # bei einer IP oder hostname wird kein \@57600 angehaengt{ - my $msg = "Define, wrong hostname/port syntax ($dev): define SIGNALduino {none | devicename[\@baudrate] | devicename\@directio | hostname:port}"; + : (?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|[1-9]\d{0,3})$/xmsi) ) { + my $msg = qq[Define, wrong hostname/port/mqtt syntax ($dev): define SIGNALduino {none | devicename[\@] | devicename\@directio | hostname:port | mqtt []}]; Log3 undef, 2, $msg; $hash->{STATE} = 'error'; return $msg; @@ -347,11 +361,16 @@ sub SIGNALduino_Define { FHEM::Core::Timer::Helper::addTimer($name, time(), \&SIGNALduino_IdList,"sduino_IdList:$name",0 ); - if($dev ne 'none') { - $ret = DevIo_OpenDev($hash, 0, \&SIGNALduino_DoInit, \&SIGNALduino_Connect); - } else { + if ($dev eq 'none') { + $hash->{DevState} = 'initialized'; + readingsSingleUpdate($hash, 'state', 'opened', 1); + } elsif ($dev eq 'mqtt') { $hash->{DevState} = 'initialized'; readingsSingleUpdate($hash, 'state', 'opened', 1); + + FHEM::Devices::SIGNALduino::SD_MQTT::Init($name); + } else { + $ret = DevIo_OpenDev($hash, 0, \&SIGNALduino_DoInit, \&SIGNALduino_Connect); } $hash->{DMSG} = 'nothing'; @@ -384,6 +403,11 @@ sub SIGNALduino_Undef { my ($hash, $arg) = @_; my $name = $hash->{NAME}; + if (defined($hash->{Listener})) { + del_mqtt($hash->{Listener}); + delete $hash->{Listener}; + } + foreach my $d (sort keys %defs) { if(defined($defs{$d}) && defined($defs{$d}{IODev}) && @@ -3106,7 +3130,7 @@ sub SIGNALduino_FW_Detail { my $hash = $defs{$name}; my @dspec=devspec2array("DEF=.*fakelog"); - my $lfn = $dspec[0]; + my $lfn = $dspec[0] // ""; my $fn=$defs{$name}->{TYPE}."-Flash.log"; my $ret = "
Information menu @@ -4277,6 +4301,13 @@ USB-connected devices (SIGNALduino):
If the baudrate is "directio" (e.g.: /dev/ttyACM0@directio), then the perl module Device::SerialPort is not needed, and fhem opens the device with simple file io. This might work if the operating system uses sane defaults for the serial parameters, e.g. some Linux distributions and OSX.

+MQTT-connected devices:
+
    +
  • + mqtt uses MQTT as transport instead of serial or telnet. +
    is optional (default: signalduino/v1). +
  • +
@@ -4870,6 +4901,9 @@ USB-connected devices (SIGNALduino):
WiFi-connected devices (SIGNALduino):

Bei einem ESP8266 oder ESP32 wird anstelle des seriellen Ports die IP des SIGNALduino in der Form xxx.xxx.xxx.xxx:23 angegeben. Dabei stellt die 23 den verwendeten Port dar.

+ MQTT-connected devices:

+ mqtt nutzt MQTT als Transportweg anstelle von seriell oder Telnet. +
ist optional (Standard: signalduino/v1). diff --git a/controls_signalduino.txt b/controls_signalduino.txt index 179bf293a..fa4aa3fd0 100644 --- a/controls_signalduino.txt +++ b/controls_signalduino.txt @@ -1,4 +1,4 @@ -UPD 2026-01-18_12:38:53 237102 FHEM/00_SIGNALduino.pm +UPD 2026-01-18_23:53:00 238429 FHEM/00_SIGNALduino.pm UPD 2023-01-06_12:08:43 20082 FHEM/10_FS10.pm UPD 2024-01-03_23:05:39 27250 FHEM/10_SD_GT.pm UPD 2023-01-01_18:10:40 25403 FHEM/10_SD_Rojaflex.pm @@ -17,5 +17,6 @@ UPD 2025-12-04_20:25:15 257338 FHEM/lib/SD_ProtocolData.pm UPD 2024-01-06_20:21:35 81862 FHEM/lib/SD_Protocols.pm UPD 2026-01-16_10:54:58 1821 lib/FHEM/Devices/SIGNALduino/SD_Clients.pm UPD 2026-01-16_10:54:58 1364 lib/FHEM/Devices/SIGNALduino/SD_Logger.pm +UPD 2026-01-18_23:53:00 2280 lib/FHEM/Devices/SIGNALduino/SD_MQTT.pm UPD 2026-01-16_10:54:58 4868 lib/FHEM/Devices/SIGNALduino/SD_Matchlist.pm UPD 2026-01-16_10:54:58 6330 lib/FHEM/Devices/SIGNALduino/SD_Message.pm diff --git a/lib/FHEM/Devices/SIGNALduino/SD_MQTT.pm b/lib/FHEM/Devices/SIGNALduino/SD_MQTT.pm new file mode 100644 index 000000000..37bbd978d --- /dev/null +++ b/lib/FHEM/Devices/SIGNALduino/SD_MQTT.pm @@ -0,0 +1,82 @@ +# $Id: SD_MQTT.pm 0 2026-01-10 15:36:13Z sidey79 $ +# The file is part of the SIGNALduino project. +# MQTT helper for SIGNALduino device messages. + + +package FHEM::Devices::SIGNALduino::SD_MQTT; + +use strict; +use warnings; +use FHEM::Devices::SIGNALduino::SD_Logger; +use FHEM::Devices::SIGNALduino::SD_Message; +eval { + require FHEM::Utility::MQTT2_Dispatcher; + FHEM::Utility::MQTT2_Dispatcher->import(qw(:DEFAULT)); +} ; +eval { require FHEM::Core::Timer::Helper; } ; + +sub Init { + my $name = shift; + my $hash = $main::defs{$name}; + + if (!$main::init_done) { + FHEM::Devices::SIGNALduino::SD_Logger::Log($hash, 3, "$name: InitMQTT, init not done, delayed..."); + FHEM::Core::Timer::Helper::addTimer($name, time() + 0, \&Init, $name, 0); + return; + } + + my $mqtt_topic = $hash->{mqttSubscribe}; + if (defined($mqtt_topic)) { + FHEM::Devices::SIGNALduino::SD_Logger::Log($hash, 3, "$name: InitMQTT, registering listener for $mqtt_topic"); + + if (defined($hash->{Listener})) { + del_mqtt($hash->{Listener}); + delete $hash->{Listener}; + } + + $hash->{Listener} = on_mqtt($mqtt_topic, sub { + on_message($hash, @_); + }) or do { + FHEM::Devices::SIGNALduino::SD_Logger::Log($hash, 2, "$name: Error creating MQTT listener: $@"); + }; + } else { + FHEM::Devices::SIGNALduino::SD_Logger::Log($hash, 2, "$name: InitMQTT, no mqttSubscribe topic found"); + } +} + +sub on_message { + my ($hash, $topic, $value, $io_name) = @_; + + # Process message topic + if ($topic =~ m{state/messages}) { + FHEM::Devices::SIGNALduino::SD_Message::json2Dispatch($value, $hash->{NAME}); + return; + } + FHEM::Devices::SIGNALduino::SD_Logger::Log($hash, 5, "SIGNALduino MQTT message ignored: topic=$topic, value=$value"); + return; +} + +1; + +=pod + +=head1 NAME + +FHEM::Devices::SIGNALduino::SD_MQTT - MQTT handler for SIGNALduino + +=head1 SYNOPSIS + + use FHEM::Devices::SIGNALduino::SD_MQTT; + FHEM::Devices::SIGNALduino::SD_MQTT::on_message($hash, $topic, $value, $io_name); + +=head1 DESCRIPTION + +Handles incoming MQTT messages for SIGNALduino. + +=head1 FUNCTIONS + +=head2 on_message($hash, $topic, $value, $io_name) + +Callback function for MQTT2_Dispatcher. + +=cut diff --git a/t/FHEM/00_SIGNALduino/01_SIGNALduino_Define.t b/t/FHEM/00_SIGNALduino/01_SIGNALduino_Define.t index cf8a84ad5..69d514168 100644 --- a/t/FHEM/00_SIGNALduino/01_SIGNALduino_Define.t +++ b/t/FHEM/00_SIGNALduino/01_SIGNALduino_Define.t @@ -112,7 +112,7 @@ InternalTimer(time()+0.8, sub { }, { DEF => q{Hostname:65536}, - testname => q[Hostname|Linux|Invalid: port out of range], + testname => q[Hostname|Linux|Invalid:], check => hash { field STATE => q{error}; field DeviceName => U(); @@ -122,19 +122,20 @@ InternalTimer(time()+0.8, sub { rValue => D(), }, { - DEF => q{192:168:122:57:45476}, - testname => q[IPv4|Linux|Invalid: colon instead of dot], + DEF => q{192:168:122:56:45476}, + testname => q[Hostname|Linux|Invalid:], check => hash { field STATE => q{error}; field DeviceName => U(); - field DEF => q{192:168:122:57:45476}; + field DEF => q{192:168:122:56:45476}; etc(); }, rValue => D(), }, + { DEF => q{192.168.122.56:44323}, - testname => q[IPv4|Linux|Valid:], + testname => q[Hostname|Linux|Valid:IPv4], check => hash { field STATE => q{disconnected}; field DeviceName => q{192.168.122.56:44323}; @@ -142,10 +143,10 @@ InternalTimer(time()+0.8, sub { etc(); }, rValue => U(), - }, + }, { DEF => q{192.168.122:44323}, - testname => q[IPv4|Linux|Invalid: ip incomplete], + testname => q[Hostname|Linux|Invalid:IPv4], check => hash { field STATE => q{error}; field DeviceName => U(); @@ -156,7 +157,7 @@ InternalTimer(time()+0.8, sub { }, { DEF => q{sernetgw:44323}, - testname => q[Hostname|Linux|Valid:], + testname => q[Hostname|Linux|Valid:hostname], check => hash { field STATE => q{disconnected}; field DeviceName => q{sernetgw:44323}; @@ -167,7 +168,7 @@ InternalTimer(time()+0.8, sub { }, { DEF => q{sernetgw.local.host:44323}, - testname => q[Hostname|Linux|Valid:], + testname => q[Hostname|Linux|Valid:hostname with domain], check => hash { field STATE => q{disconnected}; field DeviceName => q{sernetgw.local.host:44323}; @@ -178,24 +179,64 @@ InternalTimer(time()+0.8, sub { }, { DEF => q{sernetgw.local.host}, - testname => q[Hostname|Linux|inValid: missing port], + testname => q[Hostname|Linux|inValid:hostname without port], check => hash { field STATE => q{error}; field DeviceName => U(); field DEF => q{sernetgw.local.host}; etc(); }, - rValue => D(), + rValue => D(), }, { DEF => q{ESP-DB7D13-Testboard:23}, - testname => q[Hostname|Linux|Valid:], + testname => q[Hostname|Linux|Valid:hostname with dash], check => hash { field STATE => q{disconnected}; field DeviceName => q{ESP-DB7D13-Testboard:23}; field DEF => q{ESP-DB7D13-Testboard:23}; etc(); }, + rValue => U(), + }, + { + plan => 3, + DEF => q{mqtt}, + testname => q[MQTT|Valid: default basetopic], + check => hash { + field STATE => q{opened}; + field DEF => q{mqtt}; + field mqttSubscribe => q{signalduino/v1/state/messages}; + field protocolObject => object { + prop isa => 'lib::SD_Protocols'; + prop reftype => 'HASH'; + call [qw(_logging testmessage 1)] => validator(sub { + return is(FhemTestUtils_gotLog(qr/\sdefTest:.*testmessage/),1,q[verify logging message]); + }); + }; + etc(); + }, + rValue => U(), + }, + + { + plan => 3, + DEF => q{mqtt mytopic}, + testname => q[MQTT|Valid: with basetopic], + check => hash { + field STATE => q{opened}; + field DeviceName => q{mqtt}; + field DEF => q{mqtt mytopic}; + field mqttSubscribe => q{mytopic/state/messages}; + field protocolObject => object { + prop isa => 'lib::SD_Protocols'; + prop reftype => 'HASH'; + call [qw(_logging testmessage 1)] => validator(sub { + return is(FhemTestUtils_gotLog(qr/\sdefTest:.*testmessage/),1,q[verify logging message]); + }); + }; + etc(); + }, rValue => U(), }, { @@ -270,7 +311,7 @@ InternalTimer(time()+0.8, sub { rValue => U(), }, ); - + while (@testDataset) { my $element = pop(@testDataset); @@ -286,7 +327,9 @@ InternalTimer(time()+0.8, sub { $hash{NAME} = $element->{NAME} // $name; FhemTestUtils_resetLogs(); $hash{DEF} = $element->{DEF}; - plan( $element->{plan} // 2 ); + plan( $element->{plan} // 2 ); + + note "Testing with Name=$hash{NAME} TYPE=$hash{TYPE} DEF=$hash{DEF}"; my $ret = SIGNALduino_Define(\%hash,qq{$hash{NAME} $hash{TYPE} $hash{DEF} }); is ($ret, $element->{rValue}, 'check returnvalue SIGNALduino_Define'); diff --git a/t/FHEM/Devices/SIGNALduino/SD_MQTT.t b/t/FHEM/Devices/SIGNALduino/SD_MQTT.t new file mode 100644 index 000000000..9b4a0c0a7 --- /dev/null +++ b/t/FHEM/Devices/SIGNALduino/SD_MQTT.t @@ -0,0 +1,229 @@ +use Test2::V0; +use lib 'lib'; + +my $mock_Timer = mock 'FHEM::Core::Timer::Helper' => ( + track => 1, # Enable call tracking if needed, though we use overrides below + add => { + addTimer => sub { + my ($name, $time, $func, $param, $repeat) = @_; + return 1; # Mocked behavior + }, + }, +); + +my $mock_MQTT = mock 'FHEM::Devices::SIGNALduino::SD_MQTT' => ( + track => 1, # Enable call tracking if needed, though we use overrides below + add => { + on_mqtt => sub { + my ($self, $subscription, $fn) = @_; + return 1; # Mocked behavior + }, + del_mqtt => sub { + my ($self, $listener) = @_; + return 1; # Mocked behavior + }, + }, +); + + +# Load the module to be tested +require FHEM::Devices::SIGNALduino::SD_MQTT; + +# Create mocks +my $mock_logger = mock 'FHEM::Devices::SIGNALduino::SD_Logger' => ( + track => 1, # Enable call tracking if needed, though we use overrides below +); + +my $mock_message = mock 'FHEM::Devices::SIGNALduino::SD_Message' => ( + track => 1, +); + +my $hash = { NAME => 'MySIGNALduino' }; +my $io_name = 'MyIO'; + +subtest 'Topic matches /state/messages/' => sub { + plan(4); + my $topic = 'some/state/messages/test'; + my $value = '{"foo":"bar"}'; + + my $log_called = 0; + my $dispatch_called = 0; + + # Override Log to verify arguments + $mock_logger->override( + Log => sub { + $log_called++; + return; + } + ); + + # Override json2Dispatch to verify arguments + $mock_message->override( + json2Dispatch => sub { + my ($val, $name) = @_; + $dispatch_called++; + is($val, $value, 'json2Dispatch: Value matches'); + is($name, $hash->{NAME}, 'json2Dispatch: Name matches'); + return; + } + ); + + FHEM::Devices::SIGNALduino::SD_MQTT::on_message($hash, $topic, $value, $io_name); + + is($log_called,0, 'SD_Logger::Log was called'); + ok($dispatch_called, 'SD_Message::json2Dispatch was called'); +}; + +subtest 'Init (delayed)' => sub { + plan(5); + + my $addTimer_called = 0; + my $log_called = 0; + + # Set up delayed init context + local $main::init_done = 0; + local $main::defs{$hash->{NAME}} = $hash; + + $mock_Timer->override( + addTimer => sub { + my ($name, $time, $func, $param, $repeat) = @_; + $addTimer_called++; + is($func, \&FHEM::Devices::SIGNALduino::SD_MQTT::Init, 'addTimer: function is Init'); + return 1; + } + ); + + $mock_logger->override( + Log => sub { + my ($h, $lvl, $msg) = @_; + $log_called++; + is($lvl, 3, 'Log: Level is 3'); + like($msg, qr/: InitMQTT, init not done, delayed.../, 'Log: Message indicates delayed init'); + return; + } + ); + + FHEM::Devices::SIGNALduino::SD_MQTT::Init($hash->{NAME}); + + is($addTimer_called, 1, 'FHEM::Core::Timer::Helper::addTimer was called once'); + is($log_called, 1, 'SD_Logger::Log was called once'); + + $mock_Timer->restore('addTimer'); + $mock_logger->restore('Log'); +}; + +subtest 'Init (successful registration)' => sub { + plan(8); + + my $on_mqtt_called = 0; + my $del_mqtt_called = 0; + my $log_called = 0; + + local $main::init_done = 1; + local $main::defs{$hash->{NAME}} = $hash; + + $hash->{mqttSubscribe} = 'fhem/signalduino/state/messages/+'; + $hash->{Listener} = 'old_listener_handle'; # Existing listener for del_mqtt test + + $mock_MQTT->override( + on_mqtt => sub { + my ($subscription, $fn) = @_; + $on_mqtt_called++; + is($subscription, $hash->{mqttSubscribe}, 'on_mqtt: Subscription topic matches'); + return 'new_listener_handle'; # Return new listener handle + }, + del_mqtt => sub { + my ( $listener) = @_; + $del_mqtt_called++; + is($listener, 'old_listener_handle', 'del_mqtt: Called with old listener handle'); + return 1; + } + ); + + $mock_logger->override( + Log => sub { + my ($h, $lvl, $msg) = @_; + $log_called++; + is($lvl, 3, 'Log: Level is 3'); + like($msg, qr/: InitMQTT, registering listener for /, 'Log: Message indicates registration'); + return; + } + ); + + FHEM::Devices::SIGNALduino::SD_MQTT::Init($hash->{NAME}); + + is($del_mqtt_called, 1, 'del_mqtt was called once to remove old listener'); + is($on_mqtt_called, 1, 'on_mqtt was called once for new registration'); + is($hash->{Listener}, 'new_listener_handle', 'New listener handle stored in $hash->{Listener}'); + is($log_called, 1, 'SD_Logger::Log was called once (Level 3)'); + + $mock_Timer->restore('addTimer'); + $mock_logger->restore('Log'); + $mock_MQTT->restore('on_mqtt'); + $mock_MQTT->restore('del_mqtt'); +}; + +subtest 'Init (no subscribe topic)' => sub { + plan(4); + + my $on_mqtt_called = 0; + my $log_called = 0; + + local $main::init_done = 1; + local $main::defs{$hash->{NAME}} = $hash; + + delete $hash->{mqttSubscribe}; # Ensure no subscribe topic + delete $hash->{Listener}; # Ensure no old listener + + $mock_MQTT->override( + on_mqtt => sub { $on_mqtt_called++; return 1; }, + ); + + $mock_logger->override( + Log => sub { + my ($h, $lvl, $msg) = @_; + $log_called++; + is($lvl, 2, 'Log: Level is 2 (Error)'); + like($msg, qr/: InitMQTT, no mqttSubscribe topic found/, 'Log: Message indicates missing topic'); + return; + } + ); + + FHEM::Devices::SIGNALduino::SD_MQTT::Init($hash->{NAME}); + + is($on_mqtt_called, 0, 'on_mqtt was NOT called'); + is($log_called, 1, 'SD_Logger::Log was called once (Level 2)'); + $mock_MQTT->restore('on_mqtt'); + $mock_logger->restore('Log'); +}; + +subtest 'Topic does NOT match /state/messages/' => sub { + plan(3); + my $topic = 'some/other/topic'; + my $value = '{"foo":"bar"}'; + + my $log_called = 0; + my $dispatch_called = 0; + + # Override with simple counters + $mock_logger->override( + Log => sub { + my ($h, $lvl, $msg) = @_; + like($msg, qr/ignored: topic=$topic/, 'Log: Message indicates missing topic'); + $log_called++; + } + ); + + $mock_message->override( + json2Dispatch => sub { $dispatch_called++; } + ); + + FHEM::Devices::SIGNALduino::SD_MQTT::on_message($hash, $topic, $value, $io_name); + + is($log_called, 1, 'SD_Logger::Log was called'); + is($dispatch_called, 0, 'SD_Message::json2Dispatch was NOT called'); + $mock_logger->restore('Log'); + $mock_message->restore('json2Dispatch'); +}; + +done_testing;