File Coverage

blib/lib/WebService/Tuya/IoT/API.pm
Criterion Covered Total %
statement 20 109 18.3
branch 8 60 13.3
condition n/a
subroutine 5 18 27.7
pod 15 15 100.0
total 48 202 23.7


line stmt bran cond sub pod time code
1             package WebService::Tuya::IoT::API;
2 1     1   68937 use strict;
  1         2  
  1         31  
3 1     1   5 use warnings;
  1         2  
  1         1539  
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              
11             our $VERSION = '0.01';
12             our $PACKAGE = __PACKAGE__;
13              
14             =head1 NAME
15              
16             WebService::Tuya::IoT::API - Perl library to access the Tuya IoT API
17              
18             =head1 SYNOPSIS
19              
20             use WebService::Tuya::IoT::API;
21             my $ws = WebService::Tuya::IoT::API->new(client_id=>$client_id, client_secret=>$client_secret);
22             my $access_token = $ws->access_token;
23             my $device_status = $ws->device_status($deviceid);
24             my $response = $ws->device_commands($deviceid, {code=>'switch_1', value=>$boolean ? \1 : \0});
25              
26             =head1 DESCRIPTION
27              
28             Perl library to access the Tuya IoT API to control and read the state of Tuya compatible smart devices.
29              
30             Tuya compatible smart devices include outlets, switches, lights, window covers, etc.
31              
32             =head2 SETUP
33              
34             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.
35              
36             =over
37              
38             =item * You must configure your devices with the Smart Life (L) app.
39              
40             =item * You must create an account and project on the L.
41              
42             =item * You must link the Smart Life app to the project with the QR code.
43              
44             =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).
45              
46             =item * You must use the host associated to your data center. The default host is the Americas which is set as openapi.tuyaus.com.
47              
48             =back
49              
50             =head1 CONSTRUCTORS
51              
52             =head2 new
53            
54             my $ws = WebService::Tuya::IoT::API->new;
55            
56             =cut
57            
58             sub new {
59 1     1 1 85 my $this = shift;
60 1 50       4 my $class = ref($this) ? ref($this) : $this;
61 1         3 my $self = {};
62 1         3 bless $self, $class;
63 1 50       10 %$self = @_ if @_;
64 1         3 return $self;
65             }
66              
67             =head1 PROPERTIES
68              
69             =head2 http_hostname
70              
71             Sets and returns the host name for the API service endpoint.
72              
73             $ws->http_hostname("openapi.tuyaus.com"); #Americas
74             $ws->http_hostname("openapi.tuyacn.com"); #China
75             $ws->http_hostname("openapi.tuyaeu.com"); #Europe
76             $ws->http_hostname("openapi.tuyain.com"); #India
77              
78             default: openapi.tuyaus.com
79              
80             =cut
81              
82             sub http_hostname {
83 0     0 1 0 my $self = shift;
84 0 0       0 $self->{'http_hostname'} = shift if @_;
85 0 0       0 $self->{'http_hostname'} = 'openapi.tuyaus.com' unless defined $self->{'http_hostname'};
86 0         0 return $self->{'http_hostname'};
87             }
88              
89             =head2 client_id
90              
91             Sets and returns the Client ID found on https://iot.tuya.com/ project overview page.
92              
93             =cut
94              
95             sub client_id {
96 2     2 1 2546 my $self = shift;
97 2 100       8 $self->{'client_id'} = shift if @_;
98 2 50       9 $self->{'client_id'} = die("Error: property client_id required") unless $self->{'client_id'};
99 2         11 return $self->{'client_id'};
100             }
101              
102             =head2 client_secret
103              
104             Sets and returns the Client Secret found on https://iot.tuya.com/ project overview page.
105              
106             =cut
107              
108             sub client_secret {
109 2     2 1 6 my $self = shift;
110 2 100       6 $self->{'client_secret'} = shift if @_;
111 2 50       7 $self->{'client_secret'} = die("Error: property client_secret required") unless $self->{'client_secret'};
112 2         10 return $self->{'client_secret'};
113             }
114              
115             =head2 api_version
116              
117             Sets and returns the API version string used in the URL on API web service calls.
118              
119             my $api_version = $ws->api_version;
120              
121             default: v1.0
122              
123             =cut
124              
125             sub api_version {
126 0     0 1   my $self = shift;
127 0 0         $self->{'api_version'} = shift if @_;
128 0 0         $self->{'api_version'} = 'v1.0' unless $self->{'api_version'};
129 0           return $self->{'api_version'};
130             }
131              
132             sub _debug {
133 0     0     my $self = shift;
134 0 0         $self->{'_debug'} = shift if @_;
135 0 0         $self->{'_debug'} = 0 unless $self->{'_debug'};
136 0           return $self->{'_debug'};
137             }
138              
139             =head1 METHODS
140              
141             =head2 api
142              
143             Calls the Tuya IoT API and returns the parsed JSON data structure. This method automatically handles access token and web request signatures.
144              
145             my $response = $ws->api(GET => 'token?grant_type=1'); #get access token
146             my $response = $ws->api(GET => "iot-03/devices/$deviceid/status"); #get status of $deviceid
147             my $response = $ws->api(POST => "iot-03/devices/$deviceid/commands", {commands=>[{code=>'switch_1', value=>\0}]}); #set switch_1 off on $deviceid
148              
149             References:
150              
151             =over
152              
153             =item * https://developer.tuya.com/en/docs/iot/new-singnature?id=Kbw0q34cs2e5g
154              
155             =item * https://github.com/jasonacox/tinytuya/blob/ffcec471a9c4bba38d5bf224608e20bc148f1b86/tinytuya/Cloud.py#L130
156              
157             =item * https://bestlab-platform.readthedocs.io/en/latest/bestlab_platform.tuya.html
158              
159             =back
160              
161             =cut
162              
163             # Thanks to Jason Cox at https://github.com/jasonacox/tinytuya
164             # Copyright (c) 2022 Jason Cox - MIT License
165              
166             sub api { #TODO: wrappers like api_get and api_post
167 0     0 1   my $self = shift;
168 0           my $http_method = shift; #TODO: die on bad http methods
169 0           my $api_destination = shift; #TODO: sort query parameters alphabetically
170 0           my $input = shift; #or undef
171 0 0         my $content = defined($input) ? JSON::XS::encode_json($input) : ''; #Note: empty string stringifies to "" in JSON
172 0 0         my $is_token = $api_destination =~ m/\Atoken\b/ ? 1 : 0;
173 0 0         my $access_token = $is_token ? undef : $self->access_token; #Note: recursive call
174 0           my $http_path = sprintf('/%s/%s', $self->api_version, $api_destination);
175 0           my $url = sprintf('https://%s%s', $self->http_hostname, $http_path); #e.g. "https://openapi.tuyaus.com/v1.0/token?grant_type=1"
176 0           my $nonce = Data::UUID->new->create_str; #Field description - nonce: the universally unique identifier (UUID) generated for each API request.
177 0           my $t = int(Time::HiRes::time() * 1000); #Field description - t: the 13-digit standard timestamp.
178 0           my $content_sha256 = Digest::SHA::sha256_hex($content); #Content-SHA256 represents the SHA256 value of a request body
179 0           my $headers = ''; #signature headers
180 0 0         $headers = sprintf("secret:%s\n", $self->client_secret) if $is_token; #TODO: add support for area_id and request_id
181 0           my $stringToSign = join("\n", $http_method, $content_sha256, $headers, $http_path);
182 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
183 0           my $sign = uc(Digest::SHA::hmac_sha256_hex($str, $self->client_secret)); #Signature algorithm - sign = HMAC-SHA256(str, secret).toUpperCase()
184 0           my $options = {
185             headers => {
186             'Content-Type' => 'application/json',
187             'client_id' => $self->client_id,
188             'sign' => $sign,
189             'sign_method' => 'HMAC-SHA256',
190             't' => $t,
191             'nonce' => $nonce,
192             },
193             content => $content,
194             };
195 0 0         if ($is_token) {
196 0           $options->{'headers'}->{'Signature-Headers'} = 'secret';
197 0           $options->{'headers'}->{'secret'} = $self->client_secret;
198             } else {
199 0           $options->{'headers'}->{'access_token'} = $access_token;
200             }
201              
202 0           local $Data::Dumper::Indent = 1; #smaller index
203 0           local $Data::Dumper::Terse = 1; #remove $VAR1 header
204              
205 0 0         print Data::Dumper::Dumper({http_method => $http_method, url => $url, options => $options}) if $self->_debug > 1;
206 0           my $response = $self->ua->request($http_method, $url, $options);
207 0 0         print Data::Dumper::Dumper({response => $response}) if $self->_debug;
208 0           my $status = $response->{'status'};
209 0 0         die("Error: Web service request unsuccessful - status: $status\n") unless $status eq '200'; #TODO: better error handeling
210 0           my $response_content = $response->{'content'};
211 0           local $@;
212 0           my $response_decoded = eval{JSON::XS::decode_json($response_content)};
  0            
213 0           my $error = $@;
214 0 0         die("Error: API returned invalid JSON - content: $response_content\n") if $error;
215 0 0         print Data::Dumper::Dumper({response_decoded => $response_decoded}) if $self->_debug > 2;
216 0 0         die("Error: API returned unsuccessful - content: $response_content\n") unless $response_decoded->{'success'};
217 0           return $response_decoded
218             }
219              
220             =head2 api_get, api_post, api_put, api_delete
221              
222             Wrappers around the C method with hard coded HTTP methods.
223              
224             =cut
225              
226 0     0 1   sub api_get {my $self = shift; return $self->api(GET => @_)};
  0            
227 0     0 1   sub api_post {my $self = shift; return $self->api(POST => @_)};
  0            
228 0     0 1   sub api_put {my $self = shift; return $self->api(PUT => @_)};
  0            
229 0     0 1   sub api_delete {my $self = shift; return $self->api(DELETE => @_)};
  0            
230              
231             =head2 access_token
232              
233             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.
234              
235             =cut
236              
237             sub access_token {
238 0     0 1   my $self = shift;
239 0 0         if (defined $self->{'_access_token_data'}) {
240             #clear expired access_token
241 0 0         delete($self->{'_access_token_data'}) if Time::HiRes::time() > $self->{'_access_token_data'}->{'expire_time'};
242             }
243 0 0         unless (defined $self->{'_access_token_data'}) {
244             #get access_token and calculate expire_time epoch
245 0           my $api_destination = 'token?grant_type=1';
246 0           my $output = $self->api_get($api_destination);
247              
248             #{
249             # "success":true,
250             # "t":1678245450431,
251             # "tid":"c2ad0c4abd5f11edb116XXXXXXXXXXXX"
252             # "result":{
253             # "access_token":"34c47fab3f10beb59790XXXXXXXXXXXX",
254             # "expire_time":7200,
255             # "refresh_token":"ba0b6ddc18d0c2eXXXXXXXXXXXXXXXXX",
256             # "uid":"bay16149755RXXXXXXXX"
257             # },
258             #}
259              
260 0           my $response_time = $output->{'t'}; #UOM: milliseconds from epoch
261 0           my $expire_time = $output->{'result'}->{'expire_time'}; #UOM: seconds ref https://bestlab-platform.readthedocs.io/en/latest/bestlab_platform.tuya.html
262 0           $output->{'expire_time'} = $response_time/1000 + $expire_time; #TODO: Account for margin of error
263 0           $self->{'_access_token_data'} = $output;
264             }
265 0 0         my $access_token = $self->{'_access_token_data'}->{'result'}->{'access_token'} or die("Error: access_token not set");
266 0           return $access_token;
267             }
268              
269             =head2 device_status
270              
271             Wrapper around C method to access the device status API destination.
272              
273             my $device_status = $ws->device_status($deviceid);
274              
275             =cut
276              
277             sub device_status {
278 0     0 1   my $self = shift;
279 0           my $deviceid = shift;
280 0           my $api_destination = "iot-03/devices/$deviceid/status";
281 0           return $self->api_get($api_destination);
282             }
283              
284             =head2 device_commands
285              
286             Wrapper around C method to access the device commands API destination.
287              
288             my $switch = 'switch_1';
289             my $value = $boolean ? \1 : \0;
290             my $response = $ws->device_commands($deviceid, {code=>$switch, value=>$value});
291              
292             =cut
293              
294             sub device_commands {
295 0     0 1   my $self = shift;
296 0           my $deviceid = shift;
297 0           my @commands = @_; #each command must be a hash reference
298 0           my $api_destination = "iot-03/devices/$deviceid/commands";
299 0           return $self->api_post($api_destination, {commands=>\@commands});
300             }
301              
302             =head2 device_command_code_value
303              
304             Wrapper around C for one command with code and value keys;
305              
306             my $response = $ws->device_command_code_value($deviceid, $code, $value);
307              
308             =cut
309              
310             sub device_command_code_value {
311 0     0 1   my $self = shift;
312 0 0         my $deviceid = shift or die('Error: method syntax device_command_code_value($deviceid, $code, $value);');
313 0           my $code = shift; #undef ok?
314 0           my $value = shift; #undef ok?
315 0           return $self->device_commands($deviceid, {code=>$code, value=>$value});
316             }
317              
318             =head1 ACCESSORS
319              
320             =head2 ua
321              
322             Returns an L web client user agent
323              
324             =cut
325              
326             sub ua {
327 0     0 1   my $self = shift;
328 0 0         unless ($self->{'ua'}) {
329 0           my %settinges = (
330             keep_alive => 0,
331             agent => "Mozilla/5.0 (compatible; $PACKAGE/$VERSION; See rt.cpan.org 35173)",
332             );
333 0           $self->{'ua'} = HTTP::Tiny->new(%settinges);
334             }
335 0           return $self->{'ua'};
336             }
337              
338             =head1 SEE ALSO
339              
340             https://iot.tuya.com/, https://github.com/jasonacox/tinytuya, https://apps.apple.com/us/app/smart-life-smart-living/id1115101477,
341              
342             =head1 AUTHOR
343              
344             Michael R. Davis
345              
346             =head1 COPYRIGHT AND LICENSE
347              
348             MIT License
349              
350             Copyright (c) 2023 Michael R. Davis
351              
352             =cut
353              
354             1;