File Coverage

blib/lib/WebService/Tuya/IoT/API.pm
Criterion Covered Total %
statement 23 144 15.9
branch 8 54 14.8
condition n/a
subroutine 6 26 23.0
pod 21 21 100.0
total 58 245 23.6


line stmt bran cond sub pod time code
1             package WebService::Tuya::IoT::API;
2 4     4   293293 use strict;
  4         33  
  4         112  
3 4     4   19 use warnings;
  4         15  
  4         318  
4             require Data::Dumper;
5             require Time::HiRes;
6             require Digest::SHA;
7             require Data::UUID;
8             require JSON::XS;
9             require HTTP::Tiny;
10 4     4   29 use List::Util qw{first}; #import required
  4         14  
  4         7965  
11              
12             our $VERSION = '0.03';
13             our $PACKAGE = __PACKAGE__;
14              
15             =head1 NAME
16              
17             WebService::Tuya::IoT::API - Perl library to access the Tuya IoT API
18              
19             =head1 SYNOPSIS
20              
21             use WebService::Tuya::IoT::API;
22             my $ws = WebService::Tuya::IoT::API->new(client_id=>$client_id, client_secret=>$client_secret);
23             my $access_token = $ws->access_token;
24             my $device_status = $ws->device_status($deviceid);
25             my $response = $ws->device_commands($deviceid, {code=>'switch_1', value=>$boolean ? \1 : \0});
26              
27             =head1 DESCRIPTION
28              
29             Perl library to access the Tuya IoT API to control and read the state of Tuya compatible smart devices.
30              
31             Tuya compatible smart devices include outlets, switches, lights, window covers, etc.
32              
33             =head2 SETUP
34              
35             Other projects have documented device setup, so I will not go into details here. The L setup documentation is the best that I have found. Please note some setup instructions step through the process of creating an app inside the Tuya IoT project, but I was able to use the Smart Life app for device discovery and pair the app with the API by scanning the QR code.
36              
37             =over
38              
39             =item * You must configure your devices with the Smart Life (L,L) app.
40              
41             =item * You must create an account and project on the L.
42              
43             =item * You must link the Smart Life app to the project with the QR code.
44              
45             =item * You must configure the correct project data center to see your devices in the project (Note: My devices call the Western America Data Center even though I'm located in Eastern America).
46              
47             =item * You must use the host associated to your data center. The default host is the Americas which is set as openapi.tuyaus.com.
48              
49             =back
50              
51             =head1 CONSTRUCTORS
52              
53             =head2 new
54              
55             my $ws = WebService::Tuya::IoT::API->new;
56              
57             =cut
58              
59             sub new {
60 1     1 1 82 my $this = shift;
61 1 50       6 my $class = ref($this) ? ref($this) : $this;
62 1         2 my $self = {};
63 1         3 bless $self, $class;
64 1 50       10 %$self = @_ if @_;
65 1         4 return $self;
66             }
67              
68             =head1 PROPERTIES
69              
70             =head2 http_hostname
71              
72             Sets and returns the host name for the API service endpoint.
73              
74             $ws->http_hostname("openapi.tuyaus.com"); #Americas
75             $ws->http_hostname("openapi.tuyacn.com"); #China
76             $ws->http_hostname("openapi.tuyaeu.com"); #Europe
77             $ws->http_hostname("openapi.tuyain.com"); #India
78              
79             default: openapi.tuyaus.com
80              
81             =cut
82              
83             sub http_hostname {
84 0     0 1 0 my $self = shift;
85 0 0       0 $self->{'http_hostname'} = shift if @_;
86 0 0       0 $self->{'http_hostname'} = 'openapi.tuyaus.com' unless defined $self->{'http_hostname'};
87 0         0 return $self->{'http_hostname'};
88             }
89              
90             =head2 client_id
91              
92             Sets and returns the Client ID found on L project overview page.
93              
94             =cut
95              
96             sub client_id {
97 2     2 1 2243 my $self = shift;
98 2 100       9 $self->{'client_id'} = shift if @_;
99 2 50       6 $self->{'client_id'} = die("Error: property client_id required") unless $self->{'client_id'};
100 2         11 return $self->{'client_id'};
101             }
102              
103             =head2 client_secret
104              
105             Sets and returns the Client Secret found on L project overview page.
106              
107             =cut
108              
109             sub client_secret {
110 2     2 1 6 my $self = shift;
111 2 100       7 $self->{'client_secret'} = shift if @_;
112 2 50       7 $self->{'client_secret'} = die("Error: property client_secret required") unless $self->{'client_secret'};
113 2         7 return $self->{'client_secret'};
114             }
115              
116             sub _debug {
117 0     0     my $self = shift;
118 0 0         $self->{'_debug'} = shift if @_;
119 0 0         $self->{'_debug'} = 0 unless $self->{'_debug'};
120 0           return $self->{'_debug'};
121             }
122              
123             =head1 METHODS
124              
125             =head2 api
126              
127             Calls the Tuya IoT API and returns the parsed JSON data structure. This method automatically handles access token and web request signatures.
128              
129             my $response = $ws->api(GET => 'v1.0/token?grant_type=1'); #get access token
130             my $response = $ws->api(GET => "v1.0/iot-03/devices/$deviceid/status"); #get status of $deviceid
131             my $response = $ws->api(POST => "v1.0/iot-03/devices/$deviceid/commands", {commands=>[{code=>'switch_1', value=>\0}]}); #set switch_1 off on $deviceid
132              
133             References:
134              
135             =over
136              
137             =item * L
138              
139             =item * L
140              
141             =item * L
142              
143             =back
144              
145             =cut
146              
147             # Thanks to Jason Cox at https://github.com/jasonacox/tinytuya
148             # Copyright (c) 2022 Jason Cox - MIT License
149              
150             sub api {
151 0     0 1   my $self = shift;
152 0           my $http_method = shift; #TODO: die on bad http methods
153 0           my $api_destination = shift; #TODO: sort query parameters alphabetically
154 0           my $input = shift; #or undef
155 0 0         my $content = defined($input) ? JSON::XS::encode_json($input) : ''; #Note: empty string stringifies to "" in JSON
156 0 0         my $is_token = $api_destination =~ m{v[0-9\.]+/token\b} ? 1 : 0;
157 0           my $http_path = '/' . $api_destination;
158 0           my $url = sprintf('https://%s%s', $self->http_hostname, $http_path); #e.g. "https://openapi.tuyaus.com/v1.0/token?grant_type=1"
159 0           my $nonce = Data::UUID->new->create_str; #Field description - nonce: the universally unique identifier (UUID) generated for each API request.
160 0           my $t = int(Time::HiRes::time() * 1000); #Field description - t: the 13-digit standard timestamp.
161 0           my $content_sha256 = Digest::SHA::sha256_hex($content); #Content-SHA256 represents the SHA256 value of a request body
162 0           my $headers = ''; #signature headers
163 0           my @access_token = ();
164 0 0         if ($is_token) {
165 0           $headers = sprintf("secret:%s\n", $self->client_secret); #TODO: add support for area_id and request_id
166             } else {
167 0           $access_token[0] = $self->access_token; #Note: recursive call
168             }
169 0           my $stringToSign = join("\n", $http_method, $content_sha256, $headers, $http_path);
170 0           my $str = join('', $self->client_id, @access_token, $t, $nonce, $stringToSign); #Signature algorithm - str = client_id + @access_token + t + nonce + stringToSign
171 0           my $sign = uc(Digest::SHA::hmac_sha256_hex($str, $self->client_secret)); #Signature algorithm - sign = HMAC-SHA256(str, secret).toUpperCase()
172 0           my $options = {
173             headers => {
174             'Content-Type' => 'application/json',
175             'client_id' => $self->client_id,
176             'sign' => $sign,
177             'sign_method' => 'HMAC-SHA256',
178             't' => $t,
179             'nonce' => $nonce,
180             },
181             content => $content,
182             };
183 0 0         if ($is_token) {
184 0           $options->{'headers'}->{'Signature-Headers'} = 'secret';
185 0           $options->{'headers'}->{'secret'} = $self->client_secret;
186             } else {
187 0           $options->{'headers'}->{'access_token'} = $access_token[0];
188             }
189              
190 0           local $Data::Dumper::Indent = 1; #smaller index
191 0           local $Data::Dumper::Terse = 1; #remove $VAR1 header
192              
193 0 0         print Data::Dumper::Dumper({http_method => $http_method, url => $url, options => $options}) if $self->_debug > 1;
194 0           my $response = $self->ua->request($http_method, $url, $options);
195 0 0         print Data::Dumper::Dumper({response => $response}) if $self->_debug;
196 0           my $status = $response->{'status'};
197 0 0         die("Error: Web service request unsuccessful - dest: $api_destination, status: $status\n") unless $status eq '200'; #TODO: better error handeling
198 0           my $response_content = $response->{'content'};
199 0           local $@;
200 0           my $response_decoded = eval{JSON::XS::decode_json($response_content)};
  0            
201 0           my $error = $@;
202 0 0         die("Error: API returned invalid JSON - dest: $api_destination, content: $response_content\n") if $error;
203 0 0         print Data::Dumper::Dumper({response_decoded => $response_decoded}) if $self->_debug > 2;
204 0 0         die("Error: API returned unsuccessful - dest: $api_destination, content: $response_content\n") unless $response_decoded->{'success'};
205 0           return $response_decoded
206             }
207              
208             =head2 api_get, api_post, api_put, api_delete
209              
210             Wrappers around the C method with hard coded HTTP methods.
211              
212             =cut
213              
214 0     0 1   sub api_get {my $self = shift; return $self->api(GET => @_)};
  0            
215 0     0 1   sub api_post {my $self = shift; return $self->api(POST => @_)};
  0            
216 0     0 1   sub api_put {my $self = shift; return $self->api(PUT => @_)};
  0            
217 0     0 1   sub api_delete {my $self = shift; return $self->api(DELETE => @_)};
  0            
218              
219             =head2 access_token
220              
221             Wrapper around C method which calls and caches the token web service for a temporary access token to be used for subsequent web service calls.
222              
223             my $access_token = $ws->access_token; #requires client_id and client_secret
224              
225             =cut
226              
227             sub access_token {
228 0     0 1   my $self = shift;
229 0 0         if (defined $self->{'_access_token_data'}) {
230             #clear expired access_token
231 0 0         delete($self->{'_access_token_data'}) if Time::HiRes::time() > $self->{'_access_token_data'}->{'expire_time'};
232             }
233 0 0         unless (defined $self->{'_access_token_data'}) {
234             #get access_token and calculate expire_time epoch
235 0           my $api_destination = 'v1.0/token?grant_type=1';
236 0           my $output = $self->api_get($api_destination);
237              
238             #{
239             # "success":true,
240             # "t":1678245450431,
241             # "tid":"c2ad0c4abd5f11edb116XXXXXXXXXXXX"
242             # "result":{
243             # "access_token":"34c47fab3f10beb59790XXXXXXXXXXXX",
244             # "expire_time":7200,
245             # "refresh_token":"ba0b6ddc18d0c2eXXXXXXXXXXXXXXXXX",
246             # "uid":"bay16149755RXXXXXXXX"
247             # },
248             #}
249              
250 0           my $response_time = $output->{'t'}; #UOM: milliseconds from epoch
251 0           my $expire_time = $output->{'result'}->{'expire_time'}; #UOM: seconds ref https://bestlab-platform.readthedocs.io/en/latest/bestlab_platform.tuya.html
252 0           $output->{'expire_time'} = $response_time/1000 + $expire_time; #TODO: Account for margin of error
253 0           $self->{'_access_token_data'} = $output;
254             }
255 0 0         my $access_token = $self->{'_access_token_data'}->{'result'}->{'access_token'} or die("Error: access_token not set");
256 0           return $access_token;
257             }
258              
259             =head2 device_status
260              
261             Wrapper around C method to access the device status API destination.
262              
263             my $device_status = $ws->device_status($deviceid);
264              
265             =cut
266              
267             sub device_status {
268 0     0 1   my $self = shift;
269 0           my $deviceid = shift;
270 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/status";
271 0           return $self->api_get($api_destination);
272             }
273              
274             =head2 device_status_code_value
275              
276             Wrapper around C method to access the device status API destination and return the value for the given switch code.
277              
278             my $value = $ws->device_status_code_value($deviceid, $code); #isa JSON Boolean
279              
280             default: code => switch_1
281              
282             =cut
283              
284             sub device_status_code_value {
285 0     0 1   my $self = shift;
286 0           my $deviceid = shift;
287 0 0         my $code = shift; $code = 'switch_1' unless defined $code; #5.8 syntax
  0            
288 0           my $response = $self->device_status($deviceid);
289 0           my $result = $response->{'result'};
290 0     0     my $obj = first {$_->{'code'} eq $code} @$result;
  0            
291 0           my $value = $obj->{'value'};
292 0           return $value;
293             }
294              
295             =head2 device_information
296              
297             Wrapper around C method to access the device information API destination.
298              
299             my $device_information = $ws->device_information($deviceid);
300              
301             =cut
302              
303             sub device_information {
304 0     0 1   my $self = shift;
305 0           my $deviceid = shift;
306 0           my $api_destination = "v1.1/iot-03/devices/$deviceid";
307 0           return $self->api_get($api_destination);
308             }
309              
310             =head2 device_freeze_state
311              
312             Wrapper around C method to access the device freeze-state API destination.
313              
314             my $device_freeze_state = $ws->device_freeze_state($deviceid);
315              
316             =cut
317              
318             sub device_freeze_state {
319 0     0 1   my $self = shift;
320 0           my $deviceid = shift;
321 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/freeze-state";
322 0           return $self->api_get($api_destination);
323             }
324              
325             =head2 device_factory_infos
326              
327             Wrapper around C method to access the device factory-infos API destination.
328              
329             my $device_factory_infos = $ws->device_factory_infos($deviceid);
330              
331             =cut
332              
333             sub device_factory_infos {
334 0     0 1   my $self = shift;
335 0           my $deviceid = shift;
336 0           my $api_destination = "v1.0/iot-03/devices/factory-infos?device_ids=$deviceid";
337              
338 0           return $self->api_get($api_destination);
339             }
340              
341             =head2 device_specification
342              
343             Wrapper around C method to access the device specification API destination.
344              
345             my $device_specification = $ws->device_specification($deviceid);
346              
347             =cut
348              
349             sub device_specification {
350 0     0 1   my $self = shift;
351 0           my $deviceid = shift;
352 0           my $api_destination = "v1.2/iot-03/devices/$deviceid/specification";
353 0           return $self->api_get($api_destination);
354             }
355              
356             =head2 device_protocol
357              
358             Wrapper around C method to access the device protocol API destination.
359              
360             my $device_protocol = $ws->device_protocol($deviceid);
361              
362             =cut
363              
364             sub device_protocol {
365 0     0 1   my $self = shift;
366 0           my $deviceid = shift;
367 0           my $api_destination = "v1.0/iot-03/devices/protocol?device_ids=$deviceid";
368 0           return $self->api_get($api_destination);
369             }
370              
371             =head2 device_properties
372              
373             Wrapper around C method to access the device properties API destination.
374              
375             my $device_properties = $ws->device_properties($deviceid);
376              
377             =cut
378              
379             sub device_properties {
380 0     0 1   my $self = shift;
381 0           my $deviceid = shift;
382 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/properties";
383 0           return $self->api_get($api_destination);
384             }
385              
386             =head2 device_commands
387              
388             Wrapper around C method to access the device commands API destination.
389              
390             my $switch = 'switch_1';
391             my $value = $boolean ? \1 : \0;
392             my $response = $ws->device_commands($deviceid, {code=>$switch, value=>$value});
393              
394             =cut
395              
396             sub device_commands {
397 0     0 1   my $self = shift;
398 0           my $deviceid = shift;
399 0           my @commands = @_; #each command must be a hash reference
400 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/commands";
401 0           return $self->api_post($api_destination, {commands=>\@commands});
402             }
403              
404             =head2 device_command_code_value
405              
406             Wrapper around C for one command with code and value keys;
407              
408             my $response = $ws->device_command_code_value($deviceid, $code, $value);
409              
410             =cut
411              
412             sub device_command_code_value {
413 0     0 1   my $self = shift;
414 0 0         my $deviceid = shift or die('Error: method syntax device_command_code_value($deviceid, $code, $value);');
415 0           my $code = shift; #undef ok?
416 0           my $value = shift; #undef ok?
417 0           return $self->device_commands($deviceid, {code=>$code, value=>$value});
418             }
419              
420             =head1 ACCESSORS
421              
422             =head2 ua
423              
424             Returns an L web client user agent
425              
426             =cut
427              
428             sub ua {
429 0     0 1   my $self = shift;
430 0 0         unless ($self->{'ua'}) {
431 0           my %settinges = (
432             keep_alive => 0,
433             agent => "Mozilla/5.0 (compatible; $PACKAGE/$VERSION; See rt.cpan.org 35173)",
434             );
435 0           $self->{'ua'} = HTTP::Tiny->new(%settinges);
436             }
437 0           return $self->{'ua'};
438             }
439              
440             =head1 SEE ALSO
441              
442             =over
443              
444             =item * L
445              
446             =item * L
447              
448             =item * L
449              
450             =item * L
451              
452             =back
453              
454             =head1 AUTHOR
455              
456             Michael R. Davis
457              
458             =head1 COPYRIGHT AND LICENSE
459              
460             MIT License
461              
462             Copyright (c) 2023 Michael R. Davis
463              
464             =cut
465              
466             1;