File Coverage

lib/App/mqtt2job.pm
Criterion Covered Total %
statement 37 37 100.0
branch 1 2 50.0
condition 1 2 50.0
subroutine 10 10 100.0
pod 0 2 0.0
total 49 53 92.4


line stmt bran cond sub pod time code
1             package App::mqtt2job;
2              
3 2     2   273169 use strict;
  2         4  
  2         97  
4 2     2   12 use warnings;
  2         4  
  2         128  
5              
6 2     2   1234 use Template;
  2         59972  
  2         96  
7 2     2   2120 use File::Temp;
  2         82087  
  2         268  
8 2     2   21 use Exporter qw/import/;
  2         4  
  2         1089  
9              
10             # ABSTRACT: Helper module for mqtt2job
11              
12             our @EXPORT_OK = qw/ helper_v1 ha_helper_cfg /;
13              
14             sub helper_v1 {
15 1     1 0 224652 my ($obj) = @_;
16              
17 1 50       7 my $unlink = $obj->{rm} ? 1 : 0;
18              
19             # helper scripts need to be saved, so set up & prepare a filehandle
20 1   50     36 my $fh = File::Temp->new( SUFFIX => "." . ($obj->{suffix} || "pl"), UNLINK => $unlink );
21 1         1940 $obj->{wrapper_location} = $fh->filename;
22            
23 1         16 my $tt = Template->new();
24 1         23271 my $ttout = undef;
25            
26 1         6 $tt->process( _helper_v1(), $obj, \$ttout );
27              
28 1         35180 return _save($fh, $ttout, $obj->{rm});
29             }
30              
31             sub ha_helper_cfg {
32 1     1 0 239898 my ($obj) = @_;
33              
34 1         18 my $tt = Template->new();
35 1         22902 my $ttout = undef;
36            
37 1         5 $tt->process( _ha_helper_cfg(), $obj, \$ttout );
38              
39 1         47190 return $ttout;
40             }
41              
42             sub _save {
43 1     1   3 my ($fh, $output) = @_;
44 1         5 print $fh $output;
45 1         4 return $fh;
46             }
47              
48             sub _helper_v1 {
49 1     1   2 my $tpl = undef;
50 1         3 $tpl = <<'_RUNNER_TPL';
51             #![% shebang %]
52              
53             use strict;
54             use warnings;
55              
56             use Net::MQTT::Simple;
57             use DateTime;
58             use JSON;
59             use Capture::Tiny ':all';
60              
61             my $dt_start = DateTime->now();
62             my $mqtt = Net::MQTT::Simple->new("[% mqtt_server %]:[% mqtt_port %]");
63              
64             $mqtt->retain("[% base_topic %]/status/" . "[% task || "unknown" %]", encode_json({ status => "initiated", dt => "$dt_start", msg => "[% cmd %]" }) );
65             my $real_cmd = "[% job_dir %]/[% cmd %]";
66             my $real_args = "[% args %]";
67              
68             print STDERR "$dt_start: [MQTT TASK] $real_cmd $real_args\n";
69              
70             my ($output, $exit) = tee_merged {
71             my @args = split(" ", $real_args);
72             system($real_cmd, @args);
73             };
74              
75             my $msg = ($exit == 0) ? "ok" : "failed";
76              
77             my $dt_end = DateTime->now();
78             my $dt_elapsed_obj = $dt_end - $dt_start;
79             my $dt_elapsed = $dt_elapsed_obj->in_units("seconds");
80              
81             my @split_output = split("\n", $output);
82             my $last_line = $split_output[$#split_output];
83              
84             $output =~ s/\n//g;
85              
86             $mqtt->retain("[% base_topic %]/status/" . "[% task || "unknown" %]", encode_json({ status => "completed", dt => "$dt_end", last_line => "$last_line", output => "$output", elapsed => "$dt_elapsed", msg => "$msg" }) );
87             print STDERR "$dt_end: [MQTT TASK] $real_cmd $real_args (${dt_elapsed}s) Exit: $exit\n";
88              
89             $mqtt->disconnect;
90             [% UNLESS no_unlink %]unlink "[% wrapper_location %]";[% END %]
91             _RUNNER_TPL
92 1         8 return \$tpl;
93             }
94              
95             sub _ha_helper_cfg {
96 1     1   2 my $tpl = undef;
97 1         2 $tpl = <<'_HA_CFG_TPL';
98             =====================================================================
99             TRIGGER: (automation)
100             =====================================================================
101             alias: mqtt2job [% task %]
102             description: ""
103             triggers:
104             - seconds: "0"
105             trigger: time_pattern
106             enabled: true
107             conditions: []
108             actions:
109             - action: mqtt.publish
110             metadata: {}
111             data:
112             topic: [% base_topic %]
113             payload: |-
114             {
115             "cmd": "[% cmd %]",
116             "args": "[% args %]",
117             "dt": "{{ now().strftime("%Y-%m-%d %H:%M:%S") }}",
118             "task": "[% task %]",
119             "status": "queue"
120             }
121             mode: single
122              
123             =====================================================================
124             SENSORS: (configuration.yaml)
125             =====================================================================
126             sensor:
127             - name: "[% task %] status"
128             state_topic: "[% base_topic %]/status/[% task || "unknown" %]"
129             value_template: "{{ value_json.status }}"
130             - name: "[% task %] datetime"
131             state_topic: "[% base_topic %]/status/[% task || "unknown" %]"
132             value_template: "{{ value_json.dt }}"
133             - name: "[% task %] message"
134             state_topic: "[% base_topic %]/status/[% task || "unknown" %]"
135             value_template: "{{ value_json.msg }}"
136             - name: "[% task %] elapsed"
137             state_topic: "[% base_topic %]/status/[% task || "unknown" %]"
138             value_template: "{{ value_json.elapsed }}"
139             - name: "[% task %] output"
140             state_topic: "[% base_topic %]/status/[% task || "unknown" %]"
141             value_template: "{{ value_json.last_line }}"
142              
143             =====================================================================
144             CARD: (dashboard, uses button-card from HACS)
145             =====================================================================
146             type: custom:button-card
147             show_state: false
148             custom_fields:
149             [% task %]_status:
150             card:
151             type: custom:button-card
152             entity: sensor.[% task %]_message
153             name: [% task %]
154             show_icon: true
155             icon: mdi:circle
156             state:
157             - value: ok
158             color: green
159             icon: mdi:check-circle
160             - value: failed
161             color: red
162             icon: mdi:alert-circle
163             - operator: default
164             color: blue
165             icon: mdi:progress-clock
166             tap_action:
167             action: navigate
168             navigation_path: [% task %]
169             styles:
170             custom_fields:
171             [% task %]_status:
172             - padding: 5px
173             - font-size: 12px
174             grid:
175             - grid-template-areas: "\"[% task %]_status\""
176             - grid-template-columns: 1fr 1fr
177             - grid-template-rows: auto
178             card:
179             - padding: 10px
180             - width: auto
181             - height: auto
182              
183             =====================================================================
184             WIDGET: (hidden dashboard)
185             =====================================================================
186             title: [% task %]
187             path: [% task %]
188             icon: mdi:widgets
189             type: panel
190             cards:
191             - type: entities
192             entities:
193             - entity: sensor.[% task %]_datetime
194             - entity: sensor.[% task %]_elapsed
195             - entity: sensor.[% task %]_message
196             - entity: sensor.[% task %]_status
197             - entity: sensor.[% task %]_output
198             subview: true
199              
200             _HA_CFG_TPL
201 1         8 return \$tpl;
202             }
203              
204             1;
205              
206             __END__
207              
208             =pod
209              
210             =encoding UTF-8
211              
212             =head1 NAME
213              
214             App::mqtt2job - Helper module for mqtt2job
215              
216             =head1 VERSION
217              
218             version 0.03
219              
220             =head1 SYNOPSIS
221              
222             mqtt2job --mqtt_server mqtt.example.com --base_topic my/topic --job_dir /apps
223              
224             =head1 DESCRIPTION
225              
226             Subscribes to the my/topic/job mqtt topic and upon receiving a
227             correctly formatted json message will fork and run the requested
228             job in a wrapper script providing it is present and executable in
229             the job_dir directory.
230              
231             This wrapper will generate two child mqtt messages under the base
232             topic, at my/topic/status. Message one is sent when the job is
233             initiated. The second is sent when the job has completed (or timed
234             out). This second message will also include any output from the job
235             amongst various other metadata (e.g. execution datetime, duration,
236             timeout condition, etc.)
237              
238             =head1 NAME
239              
240             App::mqtt2job - Subscribe to an MQTT topic and trigger job execution
241              
242             =head1 FOR THE LOVE OF ALL THAT IS SACRED, WHY?
243              
244             This is part one of my "Cursed Solutions" series, URL to be added
245             later when I've uploaded it.
246              
247             =head1 COPYRIGHT
248              
249             Copyright 2024 -- Chris Carline
250              
251             =head1 LICENSE
252              
253             This software is licensed under the same terms as Perl.
254              
255             =head1 NO WARRANTY
256              
257             This software is provided "as is" without any express or implied
258             warranty. Using it for any reason whatsoever is probably an
259             extremely bad idea and it should only ever be considered if you
260             understand the potential consequences. In no event shall the
261             author be held liable for any damages arising from the use of
262             this software. It is provided for demonstration purposes only.
263              
264             =head1 AUTHOR
265              
266             Chris Carline <chris@carline.org>
267              
268             =head1 COPYRIGHT AND LICENSE
269              
270             This software is copyright (c) 2024 by Chris Carline.
271              
272             This is free software; you can redistribute it and/or modify it under
273             the same terms as the Perl 5 programming language system itself.
274              
275             =cut