[Perl] DAQ system for the FAPG.
Add tests for EZO::USB and MQTT modules.
Changed files
roles/daq-node/t/01-ezo-usb.t
@@ -0,0 +1,106 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
use v5.40.1;
4
Added:
use strict;
5
Added:
use warnings;
6
Added:
7
Added:
use Test2::V0;
8
Added:
9
Added:
use FindBin;
10
Added:
use lib "${FindBin::Bin}/../lib/perl5/";
11
Added:
12
Added:
use FAPG::DAQ::EZO::USB qw(
13
Added:
ezo_command
14
Added:
read_probe_info
15
Added:
detect_probe_type
16
Added:
read_once
17
Added:
parse_reading
18
Added:
normalize_probe_type
19
Added:
unit_for_probe_type
20
Added:
);
21
Added:
22
Added:
{
23
Added:
24
Added:
package Local::FakeSerial;
25
Added:
26
Added:
use v5.40.1;
27
Added:
use warnings;
28
Added:
29
Added:
sub new ( $class, @responses ) {
30
Added:
return bless {
31
Added:
responses => [ map { $_ =~ /\r\z/ ? $_ : "$_\r" } @responses ],
32
Added:
rx => q{},
33
Added:
writes => [],
34
Added:
}, $class;
35
Added:
}
36
Added:
37
Added:
sub write ( $self, $bytes ) {
38
Added:
push $self->{writes}->@*, $bytes;
39
Added:
$self->{rx} .= shift( $self->{responses}->@* ) // q{};
40
Added:
return length $bytes;
41
Added:
}
42
Added:
43
Added:
sub read ( $self, $wanted ) {
44
Added:
return ( 0, q{} ) if $self->{rx} eq q{};
45
Added:
46
Added:
my $chunk = substr $self->{rx}, 0, $wanted, q{};
47
Added:
return ( length($chunk), $chunk );
48
Added:
}
49
Added:
50
Added:
sub writes ($self) {
51
Added:
return $self->{writes};
52
Added:
}
53
Added:
}
54
Added:
55
Added:
subtest 'basic command framing' => sub {
56
Added:
my $serial = Local::FakeSerial->new('7.12');
57
Added:
58
Added:
is ezo_command( $serial, 'R' ), '7.12', 'returns response without CR';
59
Added:
is $serial->writes, ["R\r"], 'writes command with CR terminator';
60
Added:
};
61
Added:
62
Added:
subtest 'probe detection from info command' => sub {
63
Added:
my $serial = Local::FakeSerial->new('?I,pH,2.15');
64
Added:
65
Added:
my $info = read_probe_info($serial);
66
Added:
67
Added:
is $info->{raw}, '?I,pH,2.15';
68
Added:
is $info->{device}, 'pH';
69
Added:
is $info->{firmware}, '2.15';
70
Added:
is $info->{probe}, 'ph';
71
Added:
is $info->{unit}, 'pH';
72
Added:
73
Added:
is detect_probe_type( Local::FakeSerial->new('?I,ORP,2.13') ), 'redox';
74
Added:
};
75
Added:
76
Added:
subtest 'single-value reading' => sub {
77
Added:
my $serial = Local::FakeSerial->new('8.34');
78
Added:
79
Added:
my $reading = read_once( $serial, probe => 'DO' );
80
Added:
81
Added:
is $reading->{probe}, 'do';
82
Added:
is $reading->{unit}, 'mg/L';
83
Added:
is $reading->{raw}, '8.34';
84
Added:
is $reading->{value}, 8.34;
85
Added:
is $reading->{values}, [8.34];
86
Added:
};
87
Added:
88
Added:
subtest 'EC multi-value reading keeps all values but uses first as primary' => sub {
89
Added:
my $parsed = parse_reading( '1413,704,0.70,1.001', probe => 'ec' );
90
Added:
91
Added:
is $parsed->{value}, 1413;
92
Added:
is $parsed->{values}, [ 1413, 704, 0.70, 1.001 ];
93
Added:
};
94
Added:
95
Added:
subtest 'normalization and units' => sub {
96
Added:
is normalize_probe_type('pH'), 'ph';
97
Added:
is normalize_probe_type('Dissolved Oxygen'), 'do';
98
Added:
is normalize_probe_type('ORP'), 'redox';
99
Added:
is normalize_probe_type('EC'), 'ec';
100
Added:
101
Added:
is unit_for_probe_type('ph'), 'pH';
102
Added:
is unit_for_probe_type('redox'), 'mV';
103
Added:
is unit_for_probe_type('ec'), 'uS/cm';
104
Added:
};
105
Added:
106
Added:
done_testing;
roles/daq-node/t/01-probe.t
@@ -1,89 +0,0 @@
1
Removed:
#!/usr/bin/env perl
2
Removed:
# -*- mode: cperl; -*-
3
Removed:
4
Removed:
# Basic Atlas Scientific EZO probe smoke test.
5
Removed:
6
Removed:
use 5.40.1;
7
Removed:
use strict;
8
Removed:
use warnings;
9
Removed:
10
Removed:
use Test2::V0;
11
Removed:
plan tests => 3;
12
Removed:
use Device::SerialPort;
13
Removed:
use Time::HiRes qw(usleep);
14
Removed:
15
Removed:
my $port = '/dev/ttyUSB0';
16
Removed:
my $baud = 9600;
17
Removed:
18
Removed:
my $serial = Device::SerialPort->new($port)
19
Removed:
or BAIL_OUT("Cannot open serial port: $port");
20
Removed:
21
Removed:
$serial->baudrate($baud);
22
Removed:
$serial->databits(8);
23
Removed:
$serial->parity('none');
24
Removed:
$serial->stopbits(1);
25
Removed:
26
Removed:
$serial->read_char_time(0);
27
Removed:
$serial->read_const_time(100);
28
Removed:
29
Removed:
note("Testing EZO probe on $port at $baud baud");
30
Removed:
31
Removed:
my $info = ezo_command($serial, 'I', 500_000);
32
Removed:
note("I => " . printable($info));
33
Removed:
34
Removed:
ok($info ne '', 'probe is reachable');
35
Removed:
like($info, qr/(?:\?I,|EZO|PH|EC|DO|ORP)/i, 'probe is identifiable');
36
Removed:
37
Removed:
my $reading = ezo_command($serial, 'R', 1_500_000);
38
Removed:
note("R => " . printable($reading));
39
Removed:
40
Removed:
like(
41
Removed:
$reading,
42
Removed:
qr/OK/,
43
Removed:
'probe returns a single reading'
44
Removed:
);
45
Removed:
46
Removed:
sub ezo_command {
47
Removed:
my ($serial, $command, $wait_us) = @_;
48
Removed:
49
Removed:
drain_serial($serial);
50
Removed:
51
Removed:
my $written = $serial->write("$command\r");
52
Removed:
return '' unless defined $written && $written > 0;
53
Removed:
54
Removed:
usleep($wait_us);
55
Removed:
56
Removed:
my $reply = '';
57
Removed:
while (1) {
58
Removed:
my ($count, $buffer) = $serial->read(255);
59
Removed:
last unless $count;
60
Removed:
$reply .= $buffer;
61
Removed:
}
62
Removed:
63
Removed:
return clean_reply($reply);
64
Removed:
}
65
Removed:
66
Removed:
sub drain_serial {
67
Removed:
my ($serial) = @_;
68
Removed:
69
Removed:
while (1) {
70
Removed:
my ($count, undef) = $serial->read(255);
71
Removed:
last unless $count;
72
Removed:
}
73
Removed:
}
74
Removed:
75
Removed:
sub clean_reply {
76
Removed:
my ($reply) = @_;
77
Removed:
78
Removed:
$reply
79
Removed:
=~ s/\r/\n/gr
80
Removed:
=~ s/\n+/\n/gr
81
Removed:
=~ s/^\n|\n$//g;
82
Removed:
83
Removed:
return $reply;
84
Removed:
}
85
Removed:
86
Removed:
sub printable {
87
Removed:
my ($value) = @_;
88
Removed:
return $value eq '' ? '<no response>' : $value;
89
Removed:
}
roles/daq-node/t/02-probe.t
@@ -0,0 +1,84 @@
1
Added:
#!/usr/bin/env perl
2
Added:
# -*- mode: cperl; -*-
3
Added:
4
Added:
# Basic Atlas Scientific EZO probe smoke test.
5
Added:
6
Added:
use 5.40.1;
7
Added:
use strict;
8
Added:
use warnings;
9
Added:
10
Added:
use Test2::V0;
11
Added:
plan tests => 3;
12
Added:
use Device::SerialPort;
13
Added:
use Time::HiRes qw(usleep);
14
Added:
15
Added:
my $port = '/dev/ttyUSB0';
16
Added:
my $baud = 9600;
17
Added:
18
Added:
my $serial = Device::SerialPort->new($port)
19
Added:
or die "Cannot open serial port: $port";
20
Added:
21
Added:
$serial->baudrate($baud);
22
Added:
$serial->databits(8);
23
Added:
$serial->parity('none');
24
Added:
$serial->stopbits(1);
25
Added:
26
Added:
$serial->read_char_time(0);
27
Added:
$serial->read_const_time(100);
28
Added:
29
Added:
note("Testing EZO probe on $port at $baud baud");
30
Added:
31
Added:
my $info = ezo_command( $serial, 'I', 500_000 );
32
Added:
note( "I => " . printable($info) );
33
Added:
34
Added:
ok( $info ne '', 'probe is reachable' );
35
Added:
like( $info, qr/(?:\?I,|EZO|PH|EC|DO|ORP)/i, 'probe is identifiable' );
36
Added:
37
Added:
my $reading = ezo_command( $serial, 'R', 1_500_000 );
38
Added:
note( "R => " . printable($reading) );
39
Added:
40
Added:
like( $reading, qr/OK/, 'probe returns a single reading' );
41
Added:
42
Added:
sub ezo_command {
43
Added:
my ( $serial, $command, $wait_us ) = @_;
44
Added:
45
Added:
drain_serial($serial);
46
Added:
47
Added:
my $written = $serial->write("$command\r");
48
Added:
return '' unless defined $written && $written > 0;
49
Added:
50
Added:
usleep($wait_us);
51
Added:
52
Added:
my $reply = '';
53
Added:
while (1) {
54
Added:
my ( $count, $buffer ) = $serial->read(255);
55
Added:
last unless $count;
56
Added:
$reply .= $buffer;
57
Added:
}
58
Added:
59
Added:
return clean_reply($reply);
60
Added:
}
61
Added:
62
Added:
sub drain_serial {
63
Added:
my ($serial) = @_;
64
Added:
65
Added:
while (1) {
66
Added:
my ( $count, undef ) = $serial->read(255);
67
Added:
last unless $count;
68
Added:
}
69
Added:
}
70
Added:
71
Added:
sub clean_reply {
72
Added:
my ($reply) = @_;
73
Added:
74
Added:
$reply =~ s/\r/\n/g;
75
Added:
$reply =~ s/\n+/\n/g;
76
Added:
$reply =~ s/^\n|\n$//g;
77
Added:
78
Added:
return $reply;
79
Added:
}
80
Added:
81
Added:
sub printable {
82
Added:
my ($value) = @_;
83
Added:
return $value eq '' ? '<no response>' : $value;
84
Added:
}
roles/daq-node/t/03-mqtt.t
@@ -0,0 +1,105 @@
1
Added:
# -*- mode: cperl; -*-
2
Added:
3
Added:
use v5.40.1;
4
Added:
use strict;
5
Added:
use warnings;
6
Added:
7
Added:
use Test2::V0;
8
Added:
use JSON::PP qw(decode_json);
9
Added:
10
Added:
use FindBin;
11
Added:
use lib "${FindBin::Bin}/../lib/perl5/";
12
Added:
13
Added:
use FAPG::DAQ::MQTT qw(
14
Added:
publish_mqtt
15
Added:
mqtt_payload_for_reading
16
Added:
mqtt_topic_for_reading
17
Added:
mqtt_broker_endpoint
18
Added:
);
19
Added:
20
Added:
{
21
Added:
package Local::FakeMQTT;
22
Added:
23
Added:
use v5.40.1;
24
Added:
use warnings;
25
Added:
26
Added:
sub new ($class) {
27
Added:
return bless { published => [] }, $class;
28
Added:
}
29
Added:
30
Added:
sub publish ($self, $topic, $payload) {
31
Added:
push $self->{published}->@*, [$topic, $payload];
32
Added:
return 1;
33
Added:
}
34
Added:
35
Added:
sub published ($self) {
36
Added:
return $self->{published};
37
Added:
}
38
Added:
}
39
Added:
40
Added:
subtest 'topic shape' => sub {
41
Added:
my $topic = mqtt_topic_for_reading(
42
Added:
{ probe => 'pH', value => 7.12, unit => 'pH' },
43
Added:
node => 'fapg-daq-zero-01',
44
Added:
);
45
Added:
46
Added:
is $topic, 'fapg/daq/ph/fapg-daq-zero-01/reading';
47
Added:
};
48
Added:
49
Added:
subtest 'payload shape' => sub {
50
Added:
my $payload = mqtt_payload_for_reading(
51
Added:
{
52
Added:
probe => 'pH',
53
Added:
value => 7.12,
54
Added:
unit => 'pH',
55
Added:
raw => '7.12',
56
Added:
timestamp => '2026-07-06T10:00:00Z',
57
Added:
},
58
Added:
node => 'fapg-daq-zero-01',
59
Added:
);
60
Added:
61
Added:
my $decoded = decode_json($payload);
62
Added:
63
Added:
is $decoded->{schema}, 'fapg.daq.reading.v1';
64
Added:
is $decoded->{timestamp}, '2026-07-06T10:00:00Z';
65
Added:
is $decoded->{node}, 'fapg-daq-zero-01';
66
Added:
is $decoded->{probe}, 'ph';
67
Added:
is $decoded->{value}, 7.12;
68
Added:
is $decoded->{unit}, 'pH';
69
Added:
is $decoded->{raw}, '7.12';
70
Added:
is $decoded->{source}, 'fapg-daq-node';
71
Added:
};
72
Added:
73
Added:
subtest 'publish uses injected client, so unit tests need no broker' => sub {
74
Added:
my $fake = Local::FakeMQTT->new;
75
Added:
76
Added:
my $result = publish_mqtt(
77
Added:
{
78
Added:
probe => 'DO',
79
Added:
value => 8.34,
80
Added:
unit => 'mg/L',
81
Added:
timestamp => '2026-07-06T10:00:00Z',
82
Added:
},
83
Added:
node => 'fapg-daq-zero-02',
84
Added:
client => $fake,
85
Added:
);
86
Added:
87
Added:
is $result->{topic}, 'fapg/daq/do/fapg-daq-zero-02/reading';
88
Added:
is scalar $fake->published->@*, 1;
89
Added:
is $fake->published->[0][0], 'fapg/daq/do/fapg-daq-zero-02/reading';
90
Added:
91
Added:
my $decoded = decode_json($fake->published->[0][1]);
92
Added:
is $decoded->{probe}, 'do';
93
Added:
is $decoded->{value}, 8.34;
94
Added:
};
95
Added:
96
Added:
subtest 'broker endpoint defaults to the Pi 5 broker' => sub {
97
Added:
local $ENV{FAPG_MQTT_HOST};
98
Added:
local $ENV{MQTT_HOST};
99
Added:
local $ENV{FAPG_MQTT_PORT};
100
Added:
local $ENV{MQTT_PORT};
101
Added:
102
Added:
is mqtt_broker_endpoint(), 'fapg-daq-five-01:1883';
103
Added:
};
104
Added:
105
Added:
done_testing;