File Coverage

blib/lib/WebService/Tuya/IoT/API.pm
Criterion Covered Total %
statement 23 142 16.2
branch 8 58 13.7
condition n/a
subroutine 6 26 23.0
pod 21 21 100.0
total 58 247 23.4


line stmt bran cond sub pod time code
1             package WebService::Tuya::IoT::API;
2 4     4   252443 use strict;
  4         30  
  4         100  
3 4     4   16 use warnings;
  4         8  
  4         263  
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   22 use List::Util qw{first}; #import required
  4         5  
  4         6219  
11              
12             our $VERSION = '0.02';
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 73 my $this = shift;
61 1 50       4 my $class = ref($this) ? ref($this) : $this;
62 1         2 my $self = {};
63 1         3 bless $self, $class;
64 1 50       9 %$self = @_ if @_;
65 1         3 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 1774 my $self = shift;
98 2 100       6 $self->{'client_id'} = shift if @_;
99 2 50       6 $self->{'client_id'} = die("Error: property client_id required") unless $self->{'client_id'};
100 2         7 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 4 my $self = shift;
111 2 100       7 $self->{'client_secret'} = shift if @_;
112 2 50       5 $self->{'client_secret'} = die("Error: property client_secret required") unless $self->{'client_secret'};
113 2         8 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 0         my $access_token = $is_token ? undef : $self->access_token; #Note: recursive call
158 0           my $http_path = '/' . $api_destination;
159 0           my $url = sprintf('https://%s%s', $self->http_hostname, $http_path); #e.g. "https://openapi.tuyaus.com/v1.0/token?grant_type=1"
160 0           my $nonce = Data::UUID->new->create_str; #Field description - nonce: the universally unique identifier (UUID) generated for each API request.
161 0           my $t = int(Time::HiRes::time() * 1000); #Field description - t: the 13-digit standard timestamp.
162 0           my $content_sha256 = Digest::SHA::sha256_hex($content); #Content-SHA256 represents the SHA256 value of a request body
163 0           my $headers = ''; #signature headers
164 0 0         $headers = sprintf("secret:%s\n", $self->client_secret) if $is_token; #TODO: add support for area_id and request_id
165 0           my $stringToSign = join("\n", $http_method, $content_sha256, $headers, $http_path);
166 0 0         my $str = join('', $self->client_id, ($is_token ? () : $access_token), $t, $nonce, $stringToSign); #Signature algorithm - str = client_id + [access_token]? + t + nonce + stringToSign
167 0           my $sign = uc(Digest::SHA::hmac_sha256_hex($str, $self->client_secret)); #Signature algorithm - sign = HMAC-SHA256(str, secret).toUpperCase()
168 0           my $options = {
169             headers => {
170             'Content-Type' => 'application/json',
171             'client_id' => $self->client_id,
172             'sign' => $sign,
173             'sign_method' => 'HMAC-SHA256',
174             't' => $t,
175             'nonce' => $nonce,
176             },
177             content => $content,
178             };
179 0 0         if ($is_token) {
180 0           $options->{'headers'}->{'Signature-Headers'} = 'secret';
181 0           $options->{'headers'}->{'secret'} = $self->client_secret;
182             } else {
183 0           $options->{'headers'}->{'access_token'} = $access_token;
184             }
185              
186 0           local $Data::Dumper::Indent = 1; #smaller index
187 0           local $Data::Dumper::Terse = 1; #remove $VAR1 header
188              
189 0 0         print Data::Dumper::Dumper({http_method => $http_method, url => $url, options => $options}) if $self->_debug > 1;
190 0           my $response = $self->ua->request($http_method, $url, $options);
191 0 0         print Data::Dumper::Dumper({response => $response}) if $self->_debug;
192 0           my $status = $response->{'status'};
193 0 0         die("Error: Web service request unsuccessful - dest: $api_destination, status: $status\n") unless $status eq '200'; #TODO: better error handeling
194 0           my $response_content = $response->{'content'};
195 0           local $@;
196 0           my $response_decoded = eval{JSON::XS::decode_json($response_content)};
  0            
197 0           my $error = $@;
198 0 0         die("Error: API returned invalid JSON - dest: $api_destination, content: $response_content\n") if $error;
199 0 0         print Data::Dumper::Dumper({response_decoded => $response_decoded}) if $self->_debug > 2;
200 0 0         die("Error: API returned unsuccessful - dest: $api_destination, content: $response_content\n") unless $response_decoded->{'success'};
201 0           return $response_decoded
202             }
203              
204             =head2 api_get, api_post, api_put, api_delete
205              
206             Wrappers around the C method with hard coded HTTP methods.
207              
208             =cut
209              
210 0     0 1   sub api_get {my $self = shift; return $self->api(GET => @_)};
  0            
211 0     0 1   sub api_post {my $self = shift; return $self->api(POST => @_)};
  0            
212 0     0 1   sub api_put {my $self = shift; return $self->api(PUT => @_)};
  0            
213 0     0 1   sub api_delete {my $self = shift; return $self->api(DELETE => @_)};
  0            
214              
215             =head2 access_token
216              
217             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.
218              
219             my $access_token = $ws->access_token; #requires client_id and client_secret
220              
221             =cut
222              
223             sub access_token {
224 0     0 1   my $self = shift;
225 0 0         if (defined $self->{'_access_token_data'}) {
226             #clear expired access_token
227 0 0         delete($self->{'_access_token_data'}) if Time::HiRes::time() > $self->{'_access_token_data'}->{'expire_time'};
228             }
229 0 0         unless (defined $self->{'_access_token_data'}) {
230             #get access_token and calculate expire_time epoch
231 0           my $api_destination = 'v1.0/token?grant_type=1';
232 0           my $output = $self->api_get($api_destination);
233              
234             #{
235             # "success":true,
236             # "t":1678245450431,
237             # "tid":"c2ad0c4abd5f11edb116XXXXXXXXXXXX"
238             # "result":{
239             # "access_token":"34c47fab3f10beb59790XXXXXXXXXXXX",
240             # "expire_time":7200,
241             # "refresh_token":"ba0b6ddc18d0c2eXXXXXXXXXXXXXXXXX",
242             # "uid":"bay16149755RXXXXXXXX"
243             # },
244             #}
245              
246 0           my $response_time = $output->{'t'}; #UOM: milliseconds from epoch
247 0           my $expire_time = $output->{'result'}->{'expire_time'}; #UOM: seconds ref https://bestlab-platform.readthedocs.io/en/latest/bestlab_platform.tuya.html
248 0           $output->{'expire_time'} = $response_time/1000 + $expire_time; #TODO: Account for margin of error
249 0           $self->{'_access_token_data'} = $output;
250             }
251 0 0         my $access_token = $self->{'_access_token_data'}->{'result'}->{'access_token'} or die("Error: access_token not set");
252 0           return $access_token;
253             }
254              
255             =head2 device_status
256              
257             Wrapper around C method to access the device status API destination.
258              
259             my $device_status = $ws->device_status($deviceid);
260              
261             =cut
262              
263             sub device_status {
264 0     0 1   my $self = shift;
265 0           my $deviceid = shift;
266 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/status";
267 0           return $self->api_get($api_destination);
268             }
269              
270             =head2 device_status_code_value
271              
272             Wrapper around C method to access the device status API destination and return the value for the given switch code.
273              
274             my $value = $ws->device_status_code_value($deviceid, $code); #isa JSON Boolean
275              
276             default: code => switch_1
277              
278             =cut
279              
280             sub device_status_code_value {
281 0     0 1   my $self = shift;
282 0           my $deviceid = shift;
283 0 0         my $code = shift; $code = 'switch_1' unless defined $code; #5.8 syntax
  0            
284 0           my $response = $self->device_status($deviceid);
285 0           my $result = $response->{'result'};
286 0     0     my $obj = first {$_->{'code'} eq $code} @$result;
  0            
287 0           my $value = $obj->{'value'};
288 0           return $value;
289             }
290              
291             =head2 device_information
292              
293             Wrapper around C method to access the device information API destination.
294              
295             my $device_information = $ws->device_information($deviceid);
296              
297             =cut
298              
299             sub device_information {
300 0     0 1   my $self = shift;
301 0           my $deviceid = shift;
302 0           my $api_destination = "v1.1/iot-03/devices/$deviceid";
303 0           return $self->api_get($api_destination);
304             }
305              
306             =head2 device_freeze_state
307              
308             Wrapper around C method to access the device freeze-state API destination.
309              
310             my $device_freeze_state = $ws->device_freeze_state($deviceid);
311              
312             =cut
313              
314             sub device_freeze_state {
315 0     0 1   my $self = shift;
316 0           my $deviceid = shift;
317 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/freeze-state";
318 0           return $self->api_get($api_destination);
319             }
320              
321             =head2 device_factory_infos
322              
323             Wrapper around C method to access the device factory-infos API destination.
324              
325             my $device_factory_infos = $ws->device_factory_infos($deviceid);
326              
327             =cut
328              
329             sub device_factory_infos {
330 0     0 1   my $self = shift;
331 0           my $deviceid = shift;
332 0           my $api_destination = "v1.0/iot-03/devices/factory-infos?device_ids=$deviceid";
333              
334 0           return $self->api_get($api_destination);
335             }
336              
337             =head2 device_specification
338              
339             Wrapper around C method to access the device specification API destination.
340              
341             my $device_specification = $ws->device_specification($deviceid);
342              
343             =cut
344              
345             sub device_specification {
346 0     0 1   my $self = shift;
347 0           my $deviceid = shift;
348 0           my $api_destination = "v1.2/iot-03/devices/$deviceid/specification";
349 0           return $self->api_get($api_destination);
350             }
351              
352             =head2 device_protocol
353              
354             Wrapper around C method to access the device protocol API destination.
355              
356             my $device_protocol = $ws->device_protocol($deviceid);
357              
358             =cut
359              
360             sub device_protocol {
361 0     0 1   my $self = shift;
362 0           my $deviceid = shift;
363 0           my $api_destination = "v1.0/iot-03/devices/protocol?device_ids=$deviceid";
364 0           return $self->api_get($api_destination);
365             }
366              
367             =head2 device_properties
368              
369             Wrapper around C method to access the device properties API destination.
370              
371             my $device_properties = $ws->device_properties($deviceid);
372              
373             =cut
374              
375             sub device_properties {
376 0     0 1   my $self = shift;
377 0           my $deviceid = shift;
378 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/properties";
379 0           return $self->api_get($api_destination);
380             }
381              
382             =head2 device_commands
383              
384             Wrapper around C method to access the device commands API destination.
385              
386             my $switch = 'switch_1';
387             my $value = $boolean ? \1 : \0;
388             my $response = $ws->device_commands($deviceid, {code=>$switch, value=>$value});
389              
390             =cut
391              
392             sub device_commands {
393 0     0 1   my $self = shift;
394 0           my $deviceid = shift;
395 0           my @commands = @_; #each command must be a hash reference
396 0           my $api_destination = "v1.0/iot-03/devices/$deviceid/commands";
397 0           return $self->api_post($api_destination, {commands=>\@commands});
398             }
399              
400             =head2 device_command_code_value
401              
402             Wrapper around C for one command with code and value keys;
403              
404             my $response = $ws->device_command_code_value($deviceid, $code, $value);
405              
406             =cut
407              
408             sub device_command_code_value {
409 0     0 1   my $self = shift;
410 0 0         my $deviceid = shift or die('Error: method syntax device_command_code_value($deviceid, $code, $value);');
411 0           my $code = shift; #undef ok?
412 0           my $value = shift; #undef ok?
413 0           return $self->device_commands($deviceid, {code=>$code, value=>$value});
414             }
415              
416             =head1 ACCESSORS
417              
418             =head2 ua
419              
420             Returns an L web client user agent
421              
422             =cut
423              
424             sub ua {
425 0     0 1   my $self = shift;
426 0 0         unless ($self->{'ua'}) {
427 0           my %settinges = (
428             keep_alive => 0,
429             agent => "Mozilla/5.0 (compatible; $PACKAGE/$VERSION; See rt.cpan.org 35173)",
430             );
431 0           $self->{'ua'} = HTTP::Tiny->new(%settinges);
432             }
433 0           return $self->{'ua'};
434             }
435              
436             =head1 SEE ALSO
437              
438             =over
439              
440             =item * L
441              
442             =item * L
443              
444             =item * L
445              
446             =item * L
447              
448             =back
449              
450             =head1 AUTHOR
451              
452             Michael R. Davis
453              
454             =head1 COPYRIGHT AND LICENSE
455              
456             MIT License
457              
458             Copyright (c) 2023 Michael R. Davis
459              
460             =cut
461              
462             1;