Group
Extension

WebService-Tuya-IoT-API/lib/WebService/Tuya/IoT/API.pm

package WebService::Tuya::IoT::API;
use strict;
use warnings;
require Data::Dumper;
require Time::HiRes;
require Digest::SHA;
require Data::UUID;
require JSON::XS;
require HTTP::Tiny;
use List::Util qw{first}; #import required

our $VERSION = '0.03';
our $PACKAGE = __PACKAGE__;

=head1 NAME

WebService::Tuya::IoT::API - Perl library to access the Tuya IoT API

=head1 SYNOPSIS

  use WebService::Tuya::IoT::API;
  my $ws             = WebService::Tuya::IoT::API->new(client_id=>$client_id, client_secret=>$client_secret);
  my $access_token   = $ws->access_token;
  my $device_status  = $ws->device_status($deviceid);
  my $response       = $ws->device_commands($deviceid, {code=>'switch_1', value=>$boolean ? \1 : \0});

=head1 DESCRIPTION

Perl library to access the Tuya IoT API to control and read the state of Tuya compatible smart devices.

Tuya compatible smart devices include outlets, switches, lights, window covers, etc.

=head2 SETUP

Other projects have documented device setup, so I will not go into details here.  The L<TinyTuya|https://github.com/jasonacox/tinytuya#setup-wizard---getting-local-keys> 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.

=over

=item * You must configure your devices with the Smart Life (L<iOS|https://apps.apple.com/us/app/smart-life-smart-living/id1115101477>,L<Android|https://play.google.com/store/apps/details?id=com.tuya.smartlife>) app.

=item * You must create an account and project on the L<Tuya IoT Platform|https://iot.tuya.com/>.

=item * You must link the Smart Life app to the project with the QR code.

=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).

=item * You must use the host associated to your data center. The default host is the Americas which is set as openapi.tuyaus.com.

=back

=head1 CONSTRUCTORS

=head2 new

  my $ws = WebService::Tuya::IoT::API->new;

=cut

sub new {
  my $this  = shift;
  my $class = ref($this) ? ref($this) : $this;
  my $self  = {};
  bless $self, $class;
  %$self    = @_ if @_;
  return $self;
}

=head1 PROPERTIES

=head2 http_hostname

Sets and returns the host name for the API service endpoint.

  $ws->http_hostname("openapi.tuyaus.com"); #Americas
  $ws->http_hostname("openapi.tuyacn.com"); #China
  $ws->http_hostname("openapi.tuyaeu.com"); #Europe
  $ws->http_hostname("openapi.tuyain.com"); #India

default: openapi.tuyaus.com

=cut

sub http_hostname {
  my $self                 = shift;
  $self->{'http_hostname'} = shift if @_;
  $self->{'http_hostname'} = 'openapi.tuyaus.com' unless defined $self->{'http_hostname'};
  return $self->{'http_hostname'};
}

=head2 client_id

Sets and returns the Client ID found on L<https://iot.tuya.com/> project overview page.

=cut

sub client_id {
  my $self             = shift;
  $self->{'client_id'} = shift if @_;
  $self->{'client_id'} = die("Error: property client_id required") unless $self->{'client_id'};
  return $self->{'client_id'};
}

=head2 client_secret

Sets and returns the Client Secret found on L<https://iot.tuya.com/> project overview page.

=cut

sub client_secret {
  my $self                 = shift;
  $self->{'client_secret'} = shift if @_;
  $self->{'client_secret'} = die("Error: property client_secret required") unless $self->{'client_secret'};
  return $self->{'client_secret'};
}

sub _debug {
  my $self          = shift;
  $self->{'_debug'} = shift if @_;
  $self->{'_debug'} = 0 unless $self->{'_debug'};
  return $self->{'_debug'};
}

=head1 METHODS

=head2 api

Calls the Tuya IoT API and returns the parsed JSON data structure.  This method automatically handles access token and web request signatures.

  my $response = $ws->api(GET  => 'v1.0/token?grant_type=1');                                                             #get access token
  my $response = $ws->api(GET  => "v1.0/iot-03/devices/$deviceid/status");                                                #get status of $deviceid
  my $response = $ws->api(POST => "v1.0/iot-03/devices/$deviceid/commands", {commands=>[{code=>'switch_1', value=>\0}]}); #set switch_1 off on $deviceid

References:

=over

=item * L<https://developer.tuya.com/en/docs/iot/new-singnature?id=Kbw0q34cs2e5g>

=item * L<https://github.com/jasonacox/tinytuya/blob/ffcec471a9c4bba38d5bf224608e20bc148f1b86/tinytuya/Cloud.py#L130>

=item * L<https://bestlab-platform.readthedocs.io/en/latest/bestlab_platform.tuya.html>

=back

=cut

# Thanks to Jason Cox at https://github.com/jasonacox/tinytuya
# Copyright (c) 2022 Jason Cox - MIT License

sub api {
  my $self             = shift;
  my $http_method      = shift;                                                                                    #TODO: die on bad http methods
  my $api_destination  = shift;                                                                                    #TODO: sort query parameters alphabetically
  my $input            = shift; #or undef
  my $content          = defined($input) ? JSON::XS::encode_json($input) : '';                                     #Note: empty string stringifies to "" in JSON
  my $is_token         = $api_destination =~ m{v[0-9\.]+/token\b} ? 1 : 0;
  my $http_path        = '/' . $api_destination;
  my $url              = sprintf('https://%s%s', $self->http_hostname, $http_path);                                #e.g. "https://openapi.tuyaus.com/v1.0/token?grant_type=1"
  my $nonce            = Data::UUID->new->create_str;                                                              #Field description - nonce: the universally unique identifier (UUID) generated for each API request.
  my $t                = int(Time::HiRes::time() * 1000);                                                          #Field description - t: the 13-digit standard timestamp.
  my $content_sha256   = Digest::SHA::sha256_hex($content);                                                        #Content-SHA256 represents the SHA256 value of a request body
  my $headers          = '';                                                                                       #signature headers
  my @access_token     = ();
  if ($is_token) {
    $headers           = sprintf("secret:%s\n",  $self->client_secret);                                            #TODO: add support for area_id and request_id
  } else {
    $access_token[0]   = $self->access_token;                                                                      #Note: recursive call
  }
  my $stringToSign     = join("\n", $http_method, $content_sha256, $headers, $http_path);
  my $str              = join('',  $self->client_id, @access_token, $t, $nonce, $stringToSign); #Signature algorithm - str = client_id + @access_token + t + nonce + stringToSign
  my $sign             = uc(Digest::SHA::hmac_sha256_hex($str, $self->client_secret));                             #Signature algorithm - sign = HMAC-SHA256(str, secret).toUpperCase()
  my $options          = {
                          headers => {
                                      'Content-Type' => 'application/json',
                                      'client_id'    => $self->client_id,
                                      'sign'         => $sign,
                                      'sign_method'  => 'HMAC-SHA256',
                                      't'            => $t,
                                      'nonce'        => $nonce,
                                     },
                          content => $content,
                         };
  if ($is_token) {
    $options->{'headers'}->{'Signature-Headers'} = 'secret';
    $options->{'headers'}->{'secret'}            = $self->client_secret;
  } else {
    $options->{'headers'}->{'access_token'}      = $access_token[0];
  }

  local $Data::Dumper::Indent  = 1; #smaller index
  local $Data::Dumper::Terse   = 1; #remove $VAR1 header

  print Data::Dumper::Dumper({http_method => $http_method, url => $url, options => $options}) if $self->_debug > 1;
  my $response         = $self->ua->request($http_method, $url, $options);
  print Data::Dumper::Dumper({response => $response}) if $self->_debug;
  my $status           = $response->{'status'};
  die("Error: Web service request unsuccessful - dest: $api_destination, status: $status\n") unless $status eq '200';                     #TODO: better error handeling
  my $response_content = $response->{'content'};
  local $@;
  my $response_decoded = eval{JSON::XS::decode_json($response_content)};
  my $error            = $@;
  die("Error: API returned invalid JSON - dest: $api_destination, content: $response_content\n") if $error;
  print Data::Dumper::Dumper({response_decoded => $response_decoded}) if $self->_debug > 2;
  die("Error: API returned unsuccessful - dest: $api_destination, content: $response_content\n") unless $response_decoded->{'success'};
  return $response_decoded
}

=head2 api_get, api_post, api_put, api_delete

Wrappers around the C<api> method with hard coded HTTP methods.

=cut

sub api_get    {my $self = shift; return $self->api(GET    => @_)};
sub api_post   {my $self = shift; return $self->api(POST   => @_)};
sub api_put    {my $self = shift; return $self->api(PUT    => @_)};
sub api_delete {my $self = shift; return $self->api(DELETE => @_)};

=head2 access_token

Wrapper around C<api> method which calls and caches the token web service for a temporary access token to be used for subsequent web service calls.

  my $access_token = $ws->access_token; #requires client_id and client_secret

=cut

sub access_token {
  my $self = shift;
  if (defined $self->{'_access_token_data'}) {
    #clear expired access_token
    delete($self->{'_access_token_data'}) if Time::HiRes::time() > $self->{'_access_token_data'}->{'expire_time'};
  }
  unless (defined $self->{'_access_token_data'}) {
    #get access_token and calculate expire_time epoch
    my $api_destination           = 'v1.0/token?grant_type=1';
    my $output                    = $self->api_get($api_destination);

#{
#  "success":true,
#  "t":1678245450431,
#  "tid":"c2ad0c4abd5f11edb116XXXXXXXXXXXX"
#  "result":{
#    "access_token":"34c47fab3f10beb59790XXXXXXXXXXXX",
#    "expire_time":7200,
#    "refresh_token":"ba0b6ddc18d0c2eXXXXXXXXXXXXXXXXX",
#    "uid":"bay16149755RXXXXXXXX"
#  },
#}

    my $response_time             = $output->{'t'};                       #UOM: milliseconds from epoch
    my $expire_time               = $output->{'result'}->{'expire_time'}; #UOM: seconds ref https://bestlab-platform.readthedocs.io/en/latest/bestlab_platform.tuya.html
    $output->{'expire_time'}      = $response_time/1000 + $expire_time;   #TODO: Account for margin of error
    $self->{'_access_token_data'} = $output;
  }
  my $access_token = $self->{'_access_token_data'}->{'result'}->{'access_token'} or die("Error: access_token not set");
  return $access_token;
}

=head2 device_status

Wrapper around C<api> method to access the device status API destination.

  my $device_status = $ws->device_status($deviceid);

=cut

sub device_status {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.0/iot-03/devices/$deviceid/status";
  return $self->api_get($api_destination);
}

=head2 device_status_code_value

Wrapper around C<api> method to access the device status API destination and return the value for the given switch code.

  my $value = $ws->device_status_code_value($deviceid, $code); #isa JSON Boolean

default: code => switch_1

=cut

sub device_status_code_value {
  my $self     = shift;
  my $deviceid = shift;
  my $code     = shift; $code = 'switch_1' unless defined $code; #5.8 syntax
  my $response = $self->device_status($deviceid);
  my $result   = $response->{'result'};
  my $obj      = first {$_->{'code'} eq $code} @$result;
  my $value    = $obj->{'value'};
  return $value;
}

=head2 device_information

Wrapper around C<api> method to access the device information API destination.

  my $device_information = $ws->device_information($deviceid);

=cut

sub device_information {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.1/iot-03/devices/$deviceid";
  return $self->api_get($api_destination);
}

=head2 device_freeze_state

Wrapper around C<api> method to access the device freeze-state API destination.

  my $device_freeze_state = $ws->device_freeze_state($deviceid);

=cut

sub device_freeze_state {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.0/iot-03/devices/$deviceid/freeze-state";
  return $self->api_get($api_destination);
}

=head2 device_factory_infos

Wrapper around C<api> method to access the device factory-infos API destination.

  my $device_factory_infos = $ws->device_factory_infos($deviceid);

=cut

sub device_factory_infos {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.0/iot-03/devices/factory-infos?device_ids=$deviceid";

  return $self->api_get($api_destination);
}

=head2 device_specification

Wrapper around C<api> method to access the device specification API destination.

  my $device_specification = $ws->device_specification($deviceid);

=cut

sub device_specification {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.2/iot-03/devices/$deviceid/specification";
  return $self->api_get($api_destination);
}

=head2 device_protocol

Wrapper around C<api> method to access the device protocol API destination.

  my $device_protocol = $ws->device_protocol($deviceid);

=cut

sub device_protocol {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.0/iot-03/devices/protocol?device_ids=$deviceid";
  return $self->api_get($api_destination);
}

=head2 device_properties

Wrapper around C<api> method to access the device properties API destination.

  my $device_properties = $ws->device_properties($deviceid);

=cut

sub device_properties {
  my $self            = shift;
  my $deviceid        = shift;
  my $api_destination = "v1.0/iot-03/devices/$deviceid/properties";
  return $self->api_get($api_destination);
}

=head2 device_commands

Wrapper around C<api> method to access the device commands API destination.

  my $switch   = 'switch_1';
  my $value    = $boolean ? \1 : \0;
  my $response = $ws->device_commands($deviceid, {code=>$switch, value=>$value});

=cut

sub device_commands {
  my $self            = shift;
  my $deviceid        = shift;
  my @commands        = @_; #each command must be a hash reference
  my $api_destination = "v1.0/iot-03/devices/$deviceid/commands";
  return $self->api_post($api_destination, {commands=>\@commands});
}

=head2 device_command_code_value

Wrapper around C<device_commands> for one command with code and value keys;

  my $response = $ws->device_command_code_value($deviceid, $code, $value);

=cut

sub device_command_code_value {
  my $self     = shift;
  my $deviceid = shift or die('Error: method syntax device_command_code_value($deviceid, $code, $value);');
  my $code     = shift; #undef ok?
  my $value    = shift; #undef ok?
  return $self->device_commands($deviceid, {code=>$code, value=>$value});
}

=head1 ACCESSORS

=head2 ua

Returns an L<HTTP::Tiny> web client user agent

=cut

sub ua {
  my $self = shift;
  unless ($self->{'ua'}) {
    my %settinges = (
                     keep_alive => 0,
                     agent      => "Mozilla/5.0 (compatible; $PACKAGE/$VERSION; See rt.cpan.org 35173)",
                    );
    $self->{'ua'} = HTTP::Tiny->new(%settinges);
  }
  return $self->{'ua'};
}

=head1 SEE ALSO

=over

=item * L<Tuya IoT Platform|https://iot.tuya.com/>

=item * L<TinyTuya - Python|https://github.com/jasonacox/tinytuya>

=item * L<Smart Life - iOS|https://apps.apple.com/us/app/smart-life-smart-living/id1115101477>

=item * L<Smart Life - Android|https://play.google.com/store/apps/details?id=com.tuya.smartlife>

=back

=head1 AUTHOR

Michael R. Davis

=head1 COPYRIGHT AND LICENSE

MIT License

Copyright (c) 2023 Michael R. Davis

=cut

1;


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.