dimanche 22 septembre 2019

Retrogaming: sauve ta recalbox dans le "Claude" !

Bon je n'ai pas fait de tuto step by step pour GPi Case finalement... je vous conseil d'aller sur la page du GPi Case de RecalBox: https://forum.recalbox.com/topic/18158/gpi-case-recalbox-6-1-beta-4-pre-release-disponible

Mais aujourd'hui je vais donc vous expliquer comment synchroniser vos sauvegardes dans le cloud (désolé pour le jeu de mot pourri, désolé pour les Claude !), et donc dans mon cas avec Dropbox.

Voici comment j'ai procédé... c'est pas si simple :-(

1) Je ne suis pas parti de zéro.... Il va falloir se connecter en ssh à la recalbox et j'ai suivi ce tuto mais que la step 1 pour l'installation de rcloud sur recalbox de mon GPi Case.: https://forum.recalbox.com/topic/13840/tutorial-how-to-sync-saves-roms-to-the-cloud.

Donc je vous remet la step 1 ici.
Attention, il ne faut pas oublier de commencer à faire cette commande sinon on ne peut pas mettre à jour l'OS qui est verrouillé par défaut même si on a compte root !
mount -o remount, rw /
Je le laisse donc en anglais pour cette partie:

open ssh and run those commands:
cd ~
mkdir -p rpi-sync    
wget -O rclone-install.sh https://raw.github.com/pageauc/rclone4pi/master/rclone-install.sh   
wget -O rclone-sync.sh https://raw.github.com/pageauc/rclone4pi/master/rclone-sync.sh   
wget -O rpi-sync/Readme.md https://raw.github.com/pageauc/rclone4pi/master/Readme.md
now we have all the necessary files, but we have to modify rclone-install.sh
run: nano ./rclone-install.sh
Delete all the sudo on the file, at the end you should have something like that:
0_1524321467750_Screenshot from 2018-04-21 16-33-36.png
now let's finish with the commands
chmod +x rclone-install.sh
chmod +x rclone-sync.sh
./rclone-install.sh 
edit: 06/01/2020, sur la version GPI Case, pas de problème mais sur ma borne avec la 6.1.1 sur un Raspberry Pi 3B+, j'ai du lancer le script ainsi (la command chmod n'a pas d'effet je crois :-(
bash rclone-install.sh

2) A partir de la conf du compte de cloud (dropbox ou autre), c'est à vous d'improviser et de s'adapter ;-)
Pour mon cas, j'ai utilisé une dropbox, donc je vais vous expliquez comment j'ai fait (mais je ne peux pas vous faire pour les autres clouds mais cela sera très proche en terme de commande à utiliser) .

Dans le cas de Dropbox, j'ai créé une application "recalbox-lesv2" sur https://www.dropbox.com/developers/apps  de mon compte dropbox (je n'ai pas mis "recalbox" parce qu'existe déjà et donc à vous de trouver la votre en rajoutant un pseudo à la fin par exemple)

On créé un app (on pourra rester en dev, pas de soucis) :



On rajoute le lien "Redirect URIs" comme conseillé par rclone (j'ai vu cela dans certaines options mais c'est peut être inutile ?!):




Puis on devra généré un token pour plus tard, donc on peut garder la page ouverte dans le browser.




C'est rclone 1.6 que j'ai installé dans mon cas, donc dans "rclone config" j'ai fait une nouvelle conf avec le nom "remote", je n'est pas mis de client_id ou client_secret, j'ai dis non au "advanced settings" et puis non pour pourvoir rentrer le token à la main.


# rclone config
n) New remote
s) Set configuration password
q) Quit config
n/s/q> n
name> remote
Type of storage to configure.
Enter a string value. Press Enter for the default ("").
Choose a number from below, or type in your own value
 1 / 1Fichier
   \ "fichier"
 2 / Alias for an existing remote
   \ "alias"
 3 / Amazon Drive
   \ "amazon cloud drive"
 4 / Amazon S3 Compliant Storage Provider (AWS, Alibaba, Ceph, Digital Ocean, Dreamhost, IBM COS, Minio, etc)
   \ "s3"
 5 / Backblaze B2
   \ "b2"
 6 / Box
   \ "box"
 7 / Cache a remote
   \ "cache"
 8 / Dropbox
   \ "dropbox"
 9 / Encrypt/Decrypt a remote
   \ "crypt"
10 / FTP Connection
   \ "ftp"
11 / Google Cloud Storage (this is not Google Drive)
   \ "google cloud storage"
12 / Google Drive
   \ "drive"
13 / Google Photos
   \ "google photos"
14 / Hubic
   \ "hubic"
15 / JottaCloud
   \ "jottacloud"
16 / Koofr
   \ "koofr"
17 / Local Disk
   \ "local"
18 / Mega
   \ "mega"
19 / Microsoft Azure Blob Storage
   \ "azureblob"
20 / Microsoft OneDrive
   \ "onedrive"
21 / OpenDrive
   \ "opendrive"
22 / Openstack Swift (Rackspace Cloud Files, Memset Memstore, OVH)
   \ "swift"
23 / Pcloud
   \ "pcloud"
24 / Put.io
   \ "putio"
25 / QingCloud Object Storage
   \ "qingstor"
26 / SSH/SFTP Connection
   \ "sftp"
27 / Union merges the contents of several remotes
   \ "union"
28 / Webdav
   \ "webdav"
29 / Yandex Disk
   \ "yandex"
30 / http Connection
   \ "http"
31 / premiumize.me
   \ "premiumizeme"
Storage> 8
** See help for dropbox backend at: https://rclone.org/dropbox/ **

Dropbox App Client Id
Leave blank normally.
Enter a string value. Press Enter for the default ("").
client_id>
Dropbox App Client Secret
Leave blank normally.
Enter a string value. Press Enter for the default ("").
client_secret>
Edit advanced config? (y/n)
y) Yes
n) No
y/n> n
Remote config
Use auto config?
 * Say Y if not sure
 * Say N if you are working on a remote or headless machine
y) Yes
n) No
y/n> n
For this to work, you will need rclone available on a machine that has a web browser available.
Execute the following on your machine (same rclone version recommended) :
        rclone authorize "dropbox"
Then paste the result below:
result> {"access_token":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","token_type":"bearer","expiry":"0001-01-01T00:00:00Z"}
--------------------
[remote]
type = dropbox
token = {"access_token":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","token_type":"bearer","expiry":"0001-01-01T00:00:00Z"}
--------------------
y) Yes this is OK
e) Edit this remote
d) Delete this remote
y/e/d> Y
Current remotes:

Name                 Type
====                 ====
remote               dropbox

e) Edit existing remote
n) New remote
d) Delete remote
r) Rename remote
c) Copy remote
s) Set configuration password
q) Quit config
e/n/d/r/c/s/q> q


Attention dans result il faut mettre sous ce format JSON:
{"access_token":"XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX","token_type":"bearer","expiry":"0001-01-01T00:00:00Z"}

Le token d'accès doit être généré manuellement à partir de la page app de "recalbox-lesv2" (je ne peux pas vous montrer quand c'est généré parce qu'il disparaît ensuite... mais vous allez avoir une chaine en base64 à recopier dans le la variable "access_token" dans le JSON ci-dessus.




3) pour tester, j'ai lancer la commande:
rclone lsd remote:
ou
rclone ls remote:

Si c'est OK, c'est vide....que dale !!!


Mais si votre token est faut, il vous le dira aussi :-(

4) pour tester la copy, j'ai créé un repertoire /saves dans ma dropbox sur mon PC donc dans /Applications/recalbox-lesv2/
et j'ai lancé la commande suivante :
rclone copy /recalbox/share/saves/ remote:saves

Magic !!! sur mon PC, j'avais mon répertoire "saves" complet de ma recalbox:



5) Lancer le backup des sauvegardes quand on éteint le GPi Case (si wifi OK !)

Si pas déjà fait, ou si reboot, il faudra relancer cette commande:
mount -o remount, rw /
Il faut aller modifier le script python pour le safe shutdown du gpi case de retroflag  maintenant :
cd /recalbox/system/hardware/case/installers/gpi/assets/ 

Il faut editer le fichier python suivant mais avant c'est bien de faire un petit dos2unix parce que des "^M" traine dans le fichier ;-) :
dos2unix -u recalbox_SafeShutdown_gpi.py 
nano recalbox_SafeShutdown_gpi.py 
Le contenu du fichier est ainsi (dans recalbox 6.1 Beta 4 pour GPi Case) et il faut ajouter/modifier le code en surligner en jaune :


import RPi.GPIO as GPIO
import socket
from multiprocessing import Process

# Initialize pins
powerPin = 26
powerenPin = 27

# Initialize GPIO settings
def init():
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(powerPin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
        GPIO.setup(powerenPin, GPIO.OUT)
        GPIO.output(powerenPin, GPIO.HIGH)
        GPIO.setwarnings(False)

# Check if internet is available
def is_internet_available():
        try:
                socket.create_connection(("www.google.com", 80))
                return True
        except OSError:
                return False
# Lookup emulationstation def lookupEmulationStation(): import psutil # Run through all process for p in psutil.process_iter(): if "emulationstation" in p.cmdline(): print("Emultion-station found") return p print("Emultion-station NOT found") return None # Kill process recursively def killEmulationStationTree(): import psutil try: # Get emulationstation process parent = lookupEmulationStation() if parent is None: return allProcess = { parent } # Get all childrens recursively children = parent.children(recursive=True) print("Got children: {}".format(children)) # Tell EmulationStation to quit demo/mode and/or to exit gracefully try: with open("/tmp/emulationstation.quitnow", "w") as sf: sf.write("exit") sf.flush() sf.close() except IOError: pass # Can't wait or retry # Quit emulationstation gracefully parent.terminate() print("Emulationstation terminated") # Kill all children for child in children: allProcess.add(child) child.kill() print("Children killed") # Wait for everyone _, stillAlive = psutil.wait_procs(allProcess, 20) print("Stillalive: {}".format(stillAlive)) # if emulation is still alive, give it a last chance to quit if parent in stillAlive: psutil.wait_procs(stillAlive, 20) print("Last chance end") except psutil.NoSuchProcess: print("exception in killEmulationStationTree") pass # Waits for user to hold button up to 1 second before issuing poweroff command def poweroff(): while True: GPIO.wait_for_edge(powerPin, GPIO.FALLING) # Stop all killEmulationStationTree()

                import os
                # Backup saves in the cloud if wifi available
                if is_internet_available():
                        os.system("/usr/bin/rclone copy /recalbox/share/saves/ remote:saves")
# Fast stop import os os.system("reboot -f") if __name__ == "__main__": #initialize GPIO settings init() #create a multiprocessing.Process instance for each function to enable parallelism powerProcess = Process(target = poweroff) powerProcess.start() powerProcess.join() GPIO.cleanup()

Vous sauvegarder le code ainsi par CTRL+X + Y (cours rapide de sauvegarde dans nano ;-) 

Puis pour vérifier la syntaxe, vous pouvez le recompiler :
python -m py_compile recalbox_SafeShutdown_gpi.py 
Ensuite pour finir, il faut rebooter pour que cela soit pris en compte pour les prochains power off.

6) Mais dans l'autre sens ??? et dans le cas où on serait en Raspberry Pi 3 B+ sans bouton d'arrêt ??? (edit: 09/01/2020). Il va falloir aussi être capable de lancer la restauration des sauvegardes qui sont dans le cloud quand on rallume (si wifi OK et accès internet bien sur !) et la sauvegarde quand on éteint sans le bouton (edit: 09/01/2020).

Si pas déjà fait, ou si reboot, il faudra relancer cette commande:
mount -o remount, rw /
Et ensuite on va créer un fichier pour lancer la récupération des sauvegardes du cloud.
cd /etc/init.d/
nano ./S99sync
Voici le contenu du fichier que j'ai créé:
Edit 09/02/2020: attention, la partie en rouge que j'ai rajouté n'est que pour la version RPI 2/3 sans bouton de stop. Pour le GPI Case on utilisera plutôt le script précédemment présenté pour gérer le backup lors du shutdown : "recalbox_SafeShutdown_gpi.py")

Edit 23/02/2020: mise à jour du script suivant pour mieux gérer en cas de non connexion à internet (j'essai 3 fois et j'arrête pour ne pas bloquer l'arrêt du système)


#!/bin/bash
case "$1" in
start)
COUNTER=0
# check that is online to run the first sync command
while [ $COUNTER -lt 3 ]
do
        if [[ "$(ping -c 1 8.8.8.8 | grep ' 0% packet loss' )" == "" ]]; then
                echo "Internet isn't present"
        else
                echo "Internet is present"
                # Run the command that will execute only once at the start
                /usr/bin/rclone copy remote:saves /recalbox/share/saves/ &
                wait $!
                break
        fi
        COUNTER= [$COUNTER +1]
done
;;

stop)
COUNTER=0
# check that is online to run the first sync command while [ $COUNTER -lt 3 ] do if [[ "$(ping -c 1 8.8.8.8 | grep ' 0% packet loss' )" == "" ]]; then echo "Internet isn't present" else echo "Internet is present" # Run the command that will execute only once at the start /usr/bin/rclone copy /recalbox/share/saves/ remote:saves & wait $! break fi
        COUNTER= [$COUNTER +1]
done
;; restart|reload) ;; *)
# by default for standard "start" without parameter
COUNTER=0
# check that is online to run the first sync command
while [ $COUNTER -lt 3 ]
do
        if [[ "$(ping -c 1 8.8.8.8 | grep ' 0% packet loss' )" == "" ]]; then
                echo "Internet isn't present"
        else
                echo "Internet is present"
                # Run the command that will execute only once at the start
                /usr/bin/rclone copy remote:saves /recalbox/share/saves/ &
                wait $!
                break
        fi
        COUNTER= [$COUNTER +1]
done

esac

exit $?


Puis il va falloir le rendre executable:
chmod +x S99sync
Et ensuite pour que cela marche au boot, il faut copier la conf de rclone dans le repertoire racine ainsi:
mkdir /.config
cd /.config
mkdir rclone
cp -i /recalbox/share/system/.config/rclone/rclone.conf /.config/rclone
Et on peut rebooter mais via le bouton du GPi Case de préférence ;-).

Conclusion:
Ainsi vous avez vos sauvegardes dans le cloud, recalbox va démarrer plus ou moins vite maintenant parce qu'il devra checker le réseau avant et il faudra backuper des fichiers ou les restaurer. Cela reste raisonable, j'ai vu au maximum 10 à 15 secondes de plus à l'arrêt et au démarrage.

Et le jour où j'aurai mon panel arcade sous recalbox, je pourrais commencer une partie sur le panel et finir sur le GPi Case et vice-versa, magique ! non ?!

Enjoy ! ;-) (dans mon cas j'ai mis 3 soirées et un samedi matin pour finir, je suis lent et j'ai du comprendre des choses et debugguer les différents codes pour finaliser, j'espère que pour vous cela durera au max une soirée ;-)

P.S: j'ai essayé de le faire cet article surtout pour le refaire par la suite sur ma recalbox de mon futur panel ;-), mais il faudra peut être aussi pouvoir déclencher pas que sur le poweroff/poweron mais voir aussi avec ma domotique et Alexa ;-)

jeudi 12 septembre 2019

Retrogaming: comment bien commencer !

On me l'a demandé plusieurs fois, donc j'ai décidé de vous faire cette article pour vous expliquer mon expérience et sans prétention (ce n'est pas un tuto mais juste des bases pour commencer !)

Ici, je ne vais pas vous parler de chaque emulateur mais plus d'une solution simple et indépendante.

Le besoin est d'avoir une box que l'on peut mettre sous la télé HD (je ne parle pas aujourd'hui de la possibilité d'utiliser une TV "Cathodique") sans la contrainte du démarrage d'un PC avec une solution assez ouverte pour faire plus qu'une NES mini ou autre device propriétaire.

J'ai essayé différente chose mais finalement durant ces dernières années je trouve que RecalBox fait de plus en plus la différence depuis la 4.1 et maintenant avec la 6.0.

Donc pour cela, il y a des tutos super sur Youtube : https://www.youtube.com/results?search_query=recalbox+tuto

donc je ne vais pas vous faire un step by step mais plus une checklist des choses nécessaires en terme de hardware et d'après mon expérience (toujours !!! c'est la miènne et j'ai pas la science infuse !) :

1) Le Rapsberry Pi, c'est le device au meilleur rapport/qualité prix pour l'instant, je propose la version 3 B+ qui est compatible avec RecalBox. la version 4 du raspberry pi n'est pas encore compatible :-( et surtout plus cher !!!). Donc on trouve des bonnes offres sur la version 3B+ en ce moment.

https://www.amazon.fr/Raspberry-Pi-3-Mod%C3%A8le-B-Carte-m%C3%A8re/dp/B07BDR5PDW

Je donne un lien amazon pour les impatients (il y a Kubii aussi qui est un site français sympa sur le Raspberry Pi) sinon en chine, on peut trouver moins cher mais cela peut mettre plusieurs semaines à venir.



2) Obligatoire: la microSD, prendre du SANDISK est un minimum de 16Go voir 128go si vous ne voulez pas utiliser de périphérique de stockage USB.

https://www.amazon.fr/SanDisk-SDSQUAR-016G-GZFMA-M%C3%A9moire-MicroSDHC-Nouvelle/dp/B073S9SFK2/


3) Optionnel mais conseillé fortement par tous: la clé ou le disque USB pour  mettre les ROMS/BIOS/JAQUETTES/METADATA/etc..., ainsi si la SD meurt, on reflash une SD et cela repart.
De plus pour le scrapping des metdata, jaquettes, video de présentation, cela sera plus simple de scrapper sur un PC directement, le Pi est lent pour le scrapping !



4) Obligatoire pour l'alimentation, il faut prendre un truc sérieux, une mauvaise alim cela peut tuer la MicroSD.
Je propose toujours des alimentations 5V - 3 Ampères mini comme celle-ci :

 https://www.amazon.fr/Aukru-Chargeur-Adaptateur-Alimentation-Raspberry/dp/B01566WOAG

5) Le boitier... et là je ne conseille pas de prendre le premier venu... pour moi il y a que 2 choix (et oui je suis pas trés ouvert sur cette partie ;-) :

Le boitier officiel de Raspberry le "Pi Desktop", avec bouton marche arrêt / possibilité de rajouter un disque mSSD :


https://www.kubii.fr/boitiers-raspberry-pi/1961-raspberry-pi-desktop-kubii-713179634067.html



ou un boitier de chez RetroFlag qui reprend le design de nintendo, sega..., le top du boitier rétro "qualitatif" qui ont aussi un bouton on/off/reset :



6) un cable HDMI, je vous laisse prendre dans vos stocks ou en trouver un par vous même.



7) une ou des manettes...

- Une manette XBOX 360 peut faire l'affaire avec son dongle USB (j'ai testé cela marche bien, je crois que les manettes PS3 ou des Wiiimotes peuvent aussi se connecter en bluetooth) :


- mais on peut utiliser aussi des manettes sympa sans fil au aspect retro (avec des sticks en plus) comme ceux de la 8BitDo mais qui commence à disparaître parce que Nintendo fait la chasse à cela ;-): https://www.8bitdo.fr/


ou la version Sega:


8) Il vous faudra peut être pour la maintenance et les premiers paramétrages prévoir un clavier et une souris USB ;-) 
9) Quand le matos sera dispo pour vous, il faut aller sur le site de recalbox: https://www.recalbox.com/fr/
Et récupérer la distrib pour RPI 3, c'est là : 

Et flasher cet image avec Etcher sur votre MicroSD: https://www.balena.io/etcher/



La dernière chose sera d'uploader les ROMs/Bios sur votre SD via le server Samba du raspberry pi ou sur votre device USB à partir d'un PC.
Pour les ROMs/Bios, google est votre ami ! ;-)

Vous pourrez aussi scrapper pour l'habillage de l'interface dans RecalBox en utilisant Skraper :




Enjoy !!!!












lundi 9 septembre 2019

Retrogaming: j'en ai une !!! GPi Case !!!

Encore du retrogaming, je viens de recevoir ma "gameboy" à base de Raspberry Pi 0...



Super finition et super proche de l'original... il faudrait que je vous montre mais d'autre l'on déjà fait :


Commandé récemment sur Amazon, j'ai trouvé du stock à 70€ !!! (prix public et raisonnable) Dépêchez vous peut être...

Et pour ma part j'ai commandé un PI Zero W (pour avoir le wifi et pas la version WH parce que l'on veut pas les PINs du GPIO) et la consommation d'énergie est équivalente en la version avec ou sans wifi.




J'ai commandé sur Kubi !!! Pas besoin de commandé en chine, les prix sont raisonables
https://www.kubii.fr/les-cartes-raspberry-pi/1851-raspberry-pi-zero-w-kubii-3272496006997.html

J'avais déjà la carte microSD, je conseille au moins 16Go voir 32Go pour avoir de la place...
Il faudra aussi prévoir des piles !!! rechargeable de préférence !



Et donc je dois maintenant installé une distribution dessus... je vais testé une recalbox en 6.1 Beta... je vais essayer et je vous ferais un retour avec un petit tuto step by step.



A bientôt... ou pas ;-)





Retrogaming: ma première fabrication - introduction

En fait, au départ du blog, je pensais parler de domotique et de bricolage sur ce blog mais finalement le retrogaming est aussi du bricolage parfois... donc je vais vous en parler... j'ai ce projet en tête depuis cette année...

Je vais commencer petit mais je sais que si cette expérience se passe bien je ferais plus par la suite...

Pour ce bricolage sur le retrogaming, j’ai décidé de me lancer dans la fabrication d’un panel arcade 4 joueurs... pas moins ;-)...



Je veux un panel pour le poser sur un piano droit au niveau des touches mais sans abimer le piano....
Mais le mien n'est pas si beau :-( et c'est le bordel dessus en ce moment, donc je ne peux vous prendre une photo ;-)...

...utiliser des émulateurs sur un raspberry pi avec recalbox !!! Allez voir cet distrib, elle est énorme: https://www.recalbox.com/


...un écran plat au dessus (et oui je ne pourrais pas mettre de tube cathodique pour l'instant dans ce cas de figure :-(...

et pour finir... intégré dans une étagère modifié pour avoir ma borne d’arcade sympa et a encombrement limité (pour être “waf” ;-).


Voilà... Sympa sur le papier .... non ?

Je ne me suis pas encore lancé mais j'ai commencé à me commander des consommables et des outils... je vous en dirais plus bientôt...










dimanche 30 décembre 2018

Domotique: TUYA... de l'IoT pas cher ?!

En fait, j'avais un besoin... je voulais un thermostat autonome qui puisse piloter ma chaudière même si un jour mon serveur Jeedom devait avoir un problème. Ainsi je serais WAF ;-).
Je voulais donc un thermostat connecté sympa et connectable à Jeedom par un plug-in voir un script.
Dernière chose, je ne voulais pas me faire avoir à payer 100€ pour un relai connecté en fait :-(.
J'ai donc trouvé celui-ci parce que je savais aussi qu'il était potentiellement "hackable" pour avoir une connection local et sans cloud, et pour moi il supportait potentiellement le protocol Broadlink ou j'avais déjà le plugin sous jeedom, c'est le BECA BHT-6000GCLW :
Descriptif technique
Dimensions du produit (L x l x h)9 x 9 x 1,4 cm
RéférenceBHT-6000GCLW
TailleChaudière Chauffage
CouleurNoir
Tension220 Volts
Style d'écranNumérique
Fonctions spécialesSans fil
Composants inclus1*Thermostat 2*Vis 1*Guide de l'utilisateur 1*capteur de sol (2,5 m) (capteur de sol en option) 1*QC passé
Piles incluses ?Non
Batterie(s) / Pile(s) requise(s)Non

Il est encore sur Amazon (lien non sponsorisé bien sur ;-) :     https://www.amazon.fr/gp/product/B0793Q3F62/

Mais finalement, j'ai commencé par une déception :-(, après l'avoir reçu, je me suis rendu compte que ce n'était pas ce que je croyais, il n'était pas compatible Broadlink finalement :-(....

Mais il était bien connecté et wifi... donc j'ai fait des recherches, j'ai même contacté la société BECA pour avoir des infos et si une API existait... et ils ont répondu positivement par une API... Cloud :-(... ce que je voulais éviter. Cela néanmoins permis de savoir plus ce que je cherchais, j'ai compris que ce fabricant utilisait un framework complet (https://en.tuya.com/) :



Il est fourni à pleins d'autres fabricant et basé sur la même plateforme à base d'ESP8266 modifié  !!!! Et oui de l'ESP8266... j'aurais pu décider donc de le flasher et de changer le code interne du device mais j'ai voulu voir si certains n'avait pas essayer de se connecter en local finalement et sans le flasher surtout, je ne voulais pas réinventer la roue et je voulais garder le device le plus "intègre" possible ;-)

Et donc en cherchant une API TUYA "locale", j'ai trouvé plusieurs projets (et j'ai bien galèré finalement :-( !) et finalement je me suis attardé sur celui-ci qui avait été modifié en 2018 et en python (pratique pour mes scripts Jeedom ;-) :
https://pypi.org/project/pytuya/ (le code: https://github.com/clach04/python-tuya)

Pour mon cas, j'ai donc expérimenté cette lib python, mais ce ne fut pas simple, il fallait dans un premier temps récupérer la clé et l'id du device en question. Pour cela il faut récupérer les infos pendant l'enrôlement, vu que j'ai des rapsberry pi pour jeedom, j'ai pris la solution utilisant linux.
De plus, Il ne faut pas forcement utiliser l'application du fabricant, j'ai donc opté pour TuyaSmart:

et dans mon cas sur iOS :
https://itunes.apple.com/us/app/tuyasmart/id1034649547?mt=8
mais cela existe aussi sur Android :
https://play.google.com/store/apps/details?id=com.tuya.smart&hl=fr

Et donc j'ai suivi le processus suivant  qui fut finalement super simple mais j'ai mis du temps à comprendre comment trusté le certificat dans mon téléphone, le boulet ;-):

Get Local ID/KEY of a Tuya Device (on Raspbian)
  1. Add any devices you want to use with tuyapi to the Tuya Smart app.
  2. Install the CLI tool by running « npm i @tuyapi/cli -g ». If it returns an error, you may need to prefix the command with sudo. (Tip: using sudo to install global packages is not considered best practice. See this NPM article for some help.)
  3. Install AnyProxy by running « sudo npm i anyproxy -g ». Then run « anyproxy-ca » to create ca and run « sudo anyproxy-ca » to trust it  .
  4. Run tuya-cli list-app. It will print out a QR code; scan it with your phone and install the root certificate. 
  5. After installation, trust the installed root certificate from the phone.
  6. Configure the proxy on your phone with the parameters provided in the console.
  7. Open Tuya Smart and refresh the list of devices by "pulling down".
  8. A list of ID and key pairs should appear in the console.
  9. It's recommended to untrust the root certificate after you're done for security purposes.

Ensuite, j'ai installé la lib sur mon raspberry pi avec la commande suivante :


pi@raspberry:~ $ sudo pip install pytuya

Et j'ai créé un script pour jeedom (j'ai aussi galéré à comprendre les interfaces... il en manquait en fait pour mon besoin), voici la dernière version que j'ai mis en place pour moi (j'ai mis plusieurs soirée avant d'avoir ce que je voulais), c'est le contenu du fichier "tuya_api.py" que l'on voit après dans les screenshots Jeedom:

# install using : sudo pip install pytuya
# need a pytuya version with this new function :
#    def set_value(self, index, value):
#        """
#        Set int value of any index.
#
#       Args:
#            index(int): index to set
#            value(int): new value for the index
#        """
#        
#        payload = self.generate_payload(SET, {
#            index: value})
#        data = self._send_receive(payload)
#        return data
      
import pytuya
import sys
import argparse

def str2bool(v):
  return v.lower() in ("yes", "true", "t", "1")

parser = argparse.ArgumentParser()
parser.add_argument("-i","--index", type=str, required=True, help="DPS Index of TUYA device");
parser.add_argument("-v","--value", type=int, help="value to set (decimal value) of TUYA device");
parser.add_argument("-s","--status", type=str2bool, help="status to set to False or True of TUYA device");


args = parser.parse_args()
#print('index: %r' % args.index);
#print('value: %r' % args.value);
#print('status: %r' % args.status);


#******part to modify for your product/case***********
IP='IP of you device';
ID='your device ID';
KEY='your device key';
#*****************************************************

index = args.index
value = args.value
status = args.status

d = pytuya.Device(ID, IP , KEY, 'device')

# increase consigne for one degree
data = d.status()
#print('Dictionary %r' % data)
#example:
#Dictionary {u'devId': u'XXXXXXXXXXXXXXXXXXX', u'dps': {u'1': True, u'3': 41, u'2': 39, u'5': False, u'4': u'1', u'6': False, u'102': 0, u'103': u'1', u'104': True}}
# index values for BECA Thermostat: 
#    DPS_INDEX_ON            = '1'
#    DPS_INDEX_TEMPERATURE   = '3'
#    DPS_INDEX_SETPOINT      = '2'
#    DPS_INDEX_4_UNKNOWN     = '4'
#    DPS_INDEX_5_BOOL        = '5'
#    DPS_INDEX_6_BOOL        = '6'
#    DPS_INDEX_102_NUM       = '102'
#    DPS_INDEX_103_UNKNOW    = '103'
#    DPS_INDEX_104_BOOL      = '104'
    
if value is not None:
   data = d.set_value(index, value)  
elif status is not None:
   data = d.set_status(status, index)      
else:
   print('%r' % data['dps'][index])  # Show status or value 

Voici des example d'appel à mon script dans jeedom :

"tuya_api.py -i 2 -v #slider#" => pour le cas où je voulez régler la consigne du thermostat qui se trouve en index 2
"tuya_api.py -i 2" => juste pour lire la consigne du thermostat (toujours en index 2)
"tuya_api.py -i 3" => pour lire la température mesuré par le thermostat (en index 3)
"tuya_api.py -i 1 -s True" => pour allumer l'écran du thermostat (en index 1)
"tuya_api.py -i 1 -s False" => pour éteindre l'écran du thermostat (en index 1)
"tuya_api.py -i 1" => pour lire l'état de l'écran du thermostat (allumé ou éteint et en index 1 aussi)

Voici un screen shot dans jeedom pour ceux qui n'ont pas encore compris comment configurer un script dans jeedom ;-) :


Donc ce script permet de lire les status du device, de les mettre à jour quand c'est possible mais finalement dans mon cas, il manquait la possibilité aussi de mettre une valeur décimal à jour comme la consigne du thermostat.

Donc j'ai du regarder le code de la librairie et finalement j'ai du créer une nouvelle commande "set_value" lié à la class "Device" pour être le plus générique possible.
J'ai donc commencé à contribué et à faire un fork du projet PYTUYA: https://github.com/bozothegeek/python-tuya
A ce jour, ce n'est pas encore "releasé" (on est en 7.0.2) mais vous pouvez utiliser mon fork de la lib ;-)
Si vous voulez utiliser mon code à court terme, je conseille de faire ainsi :
1) Vous installé comme d'hab

pi@raspberry:~ $ sudo pip install pytuya

2) Vous récupérer mon fichier python modifié "___init__.py" ci-après:
je conseille de télécharger le projet en ligne de commande à partir de votre raspbian ainsi:
pi@raspberry:~ $ git fetch https://github.com/bozothegeek/python-tuya.git

# Python module to interface with Shenzhen Xenon ESP8266MOD WiFi smart devices
# E.g. https://wikidevi.com/wiki/Xenon_SM-PW701U
#   SKYROKU SM-PW701U Wi-Fi Plug Smart Plug
#   Wuudi SM-S0301-US - WIFI Smart Power Socket Multi Plug with 4 AC Outlets and 4 USB Charging Works with Alexa
#
# This would not exist without the protocol reverse engineering from
# https://github.com/codetheweb/tuyapi by codetheweb and blackrozes
#
# Tested with Python 2.7 and Python 3.6.1 only


import base64
from hashlib import md5
import json
import logging
import socket
import sys
import time
import colorsys

try:
    #raise ImportError
    import Crypto
    from Crypto.Cipher import AES  # PyCrypto
except ImportError:
    Crypto = AES = None
    import pyaes  # https://github.com/ricmoo/pyaes


version_tuple = (7, 0, 2)
version = version_string = __version__ = '%d.%d.%d' % version_tuple
__author__ = 'clach04'

log = logging.getLogger(__name__)
logging.basicConfig()  # TODO include function name/line numbers in log
#log.setLevel(level=logging.DEBUG)  # Debug hack!

log.info('Python %s on %s', sys.version, sys.platform)
if Crypto is None:
    log.info('Using pyaes version %r', pyaes.VERSION)
    log.info('Using pyaes from %r', pyaes.__file__)
else:
    log.info('Using PyCrypto %r', Crypto.version_info)
    log.info('Using PyCrypto from %r', Crypto.__file__)

SET = 'set'

PROTOCOL_VERSION_BYTES = b'3.1'

IS_PY2 = sys.version_info[0] == 2

class AESCipher(object):
    def __init__(self, key):
        #self.bs = 32  # 32 work fines for ON, does not work for OFF. Padding different compared to js version https://github.com/codetheweb/tuyapi/
        self.bs = 16
        self.key = key
    def encrypt(self, raw):
        if Crypto:
            raw = self._pad(raw)
            cipher = AES.new(self.key, mode=AES.MODE_ECB)
            crypted_text = cipher.encrypt(raw)
        else:
            _ = self._pad(raw)
            cipher = pyaes.blockfeeder.Encrypter(pyaes.AESModeOfOperationECB(self.key))  # no IV, auto pads to 16
            crypted_text = cipher.feed(raw)
            crypted_text += cipher.feed()  # flush final block
        #print('crypted_text %r' % crypted_text)
        #print('crypted_text (%d) %r' % (len(crypted_text), crypted_text))
        crypted_text_b64 = base64.b64encode(crypted_text)
        #print('crypted_text_b64 (%d) %r' % (len(crypted_text_b64), crypted_text_b64))
        return crypted_text_b64
    def decrypt(self, enc):
        enc = base64.b64decode(enc)
        #print('enc (%d) %r' % (len(enc), enc))
        #enc = self._unpad(enc)
        #enc = self._pad(enc)
        #print('upadenc (%d) %r' % (len(enc), enc))
        if Crypto:
            cipher = AES.new(self.key, AES.MODE_ECB)
            raw = cipher.decrypt(enc)
            #print('raw (%d) %r' % (len(raw), raw))
            return self._unpad(raw).decode('utf-8')
            #return self._unpad(cipher.decrypt(enc)).decode('utf-8')
        else:
            cipher = pyaes.blockfeeder.Decrypter(pyaes.AESModeOfOperationECB(self.key))  # no IV, auto pads to 16
            plain_text = cipher.feed(enc)
            plain_text += cipher.feed()  # flush final block
            return plain_text
    def _pad(self, s):
        padnum = self.bs - len(s) % self.bs
        return s + padnum * chr(padnum).encode()
    @staticmethod
    def _unpad(s):
        return s[:-ord(s[len(s)-1:])]


def bin2hex(x, pretty=False):
    if pretty:
        space = ' '
    else:
        space = ''
    if IS_PY2:
        result = ''.join('%02X%s' % (ord(y), space) for y in x)
    else:
        result = ''.join('%02X%s' % (y, space) for y in x)
    return result


def hex2bin(x):
    if IS_PY2:
        return x.decode('hex')
    else:
        return bytes.fromhex(x)

# This is intended to match requests.json payload at https://github.com/codetheweb/tuyapi
payload_dict = {
  "device": {
    "status": {
      "hexByte": "0a",
      "command": {"gwId": "", "devId": ""}
    },
    "set": {
      "hexByte": "07",
      "command": {"devId": "", "uid": "", "t": ""}
    },
    "prefix": "000055aa00000000000000",    # Next byte is command byte ("hexByte") some zero padding, then length of remaining payload, i.e. command + suffix (unclear if multiple bytes used for length, zero padding implies could be more than one byte)
    "suffix": "000000000000aa55"
  }
}

class XenonDevice(object):
    def __init__(self, dev_id, address, local_key=None, dev_type=None, connection_timeout=10):
        """
        Represents a Tuya device.
        
        Args:
            dev_id (str): The device id.
            address (str): The network address.
            local_key (str, optional): The encryption key. Defaults to None.
            dev_type (str, optional): The device type.
                It will be used as key for lookups in payload_dict.
                Defaults to None.
            
        Attributes:
            port (int): The port to connect to.
        """
        self.id = dev_id
        self.address = address
        self.local_key = local_key
        self.local_key = local_key.encode('latin1')
        self.dev_type = dev_type
        self.connection_timeout = connection_timeout

        self.port = 6668  # default - do not expect caller to pass in

    def __repr__(self):
        return '%r' % ((self.id, self.address),)  # FIXME can do better than this

    def _send_receive(self, payload):
        """
        Send single buffer `payload` and receive a single buffer.
        
        Args:
            payload(bytes): Data to send.
        """
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        s.settimeout(self.connection_timeout)
        s.connect((self.address, self.port))
        s.send(payload)
        data = s.recv(1024)
        s.close()
        return data

    def generate_payload(self, command, data=None):
        """
        Generate the payload to send.

        Args:
            command(str): The type of command.
                This is one of the entries from payload_dict
            data(dict, optional): The data to be send.
                This is what will be passed via the 'dps' entry
        """
        json_data = payload_dict[self.dev_type][command]['command']

        if 'gwId' in json_data:
            json_data['gwId'] = self.id
        if 'devId' in json_data:
            json_data['devId'] = self.id
        if 'uid' in json_data:
            json_data['uid'] = self.id  # still use id, no seperate uid
        if 't' in json_data:
            json_data['t'] = str(int(time.time()))

        if data is not None:
            json_data['dps'] = data

        # Create byte buffer from hex data
        json_payload = json.dumps(json_data)
        #print(json_payload)
        json_payload = json_payload.replace(' ', '')  # if spaces are not removed device does not respond!
        json_payload = json_payload.encode('utf-8')
        log.debug('json_payload=%r', json_payload)

        if command == SET:
            # need to encrypt
            #print('json_payload %r' % json_payload)
            self.cipher = AESCipher(self.local_key)  # expect to connect and then disconnect to set new
            json_payload = self.cipher.encrypt(json_payload)
            #print('crypted json_payload %r' % json_payload)
            preMd5String = b'data=' + json_payload + b'||lpv=' + PROTOCOL_VERSION_BYTES + b'||' + self.local_key
            #print('preMd5String %r' % preMd5String)
            m = md5()
            m.update(preMd5String)
            #print(repr(m.digest()))
            hexdigest = m.hexdigest()
            #print(hexdigest)
            #print(hexdigest[8:][:16])
            json_payload = PROTOCOL_VERSION_BYTES + hexdigest[8:][:16].encode('latin1') + json_payload
            #print('data_to_send')
            #print(json_payload)
            #print('crypted json_payload (%d) %r' % (len(json_payload), json_payload))
            #print('json_payload  %r' % repr(json_payload))
            #print('json_payload len %r' % len(json_payload))
            #print(bin2hex(json_payload))
            self.cipher = None  # expect to connect and then disconnect to set new


        postfix_payload = hex2bin(bin2hex(json_payload) + payload_dict[self.dev_type]['suffix'])
        #print('postfix_payload %r' % postfix_payload)
        #print('postfix_payload %r' % len(postfix_payload))
        #print('postfix_payload %x' % len(postfix_payload))
        #print('postfix_payload %r' % hex(len(postfix_payload)))
        assert len(postfix_payload) <= 0xff
        postfix_payload_hex_len = '%x' % len(postfix_payload)  # TODO this assumes a single byte 0-255 (0x00-0xff)
        buffer = hex2bin( payload_dict[self.dev_type]['prefix'] + 
                          payload_dict[self.dev_type][command]['hexByte'] + 
                          '000000' +
                          postfix_payload_hex_len ) + postfix_payload
        #print('command', command)
        #print('prefix')
        #print(payload_dict[self.dev_type][command]['prefix'])
        #print(repr(buffer))
        #print(bin2hex(buffer, pretty=True))
        #print(bin2hex(buffer, pretty=False))
        #print('full buffer(%d) %r' % (len(buffer), buffer))
        return buffer
    
class Device(XenonDevice):
    def __init__(self, dev_id, address, local_key=None, dev_type=None):
        super(Device, self).__init__(dev_id, address, local_key, dev_type)
    
    def status(self):
        log.debug('status() entry')
        # open device, send request, then close connection
        payload = self.generate_payload('status')

        data = self._send_receive(payload)
        log.debug('status received data=%r', data)

        result = data[20:-8]  # hard coded offsets
        log.debug('result=%r', result)
        #result = data[data.find('{'):data.rfind('}')+1]  # naive marker search, hope neither { nor } occur in header/footer
        #print('result %r' % result)
        if result.startswith(b'{'):
            # this is the regular expected code path
            if not isinstance(result, str):
                result = result.decode()
            result = json.loads(result)
        elif result.startswith(PROTOCOL_VERSION_BYTES):
            # got an encrypted payload, happens occasionally
            # expect resulting json to look similar to:: {"devId":"ID","dps":{"1":true,"2":0},"t":EPOCH_SECS,"s":3_DIGIT_NUM}
            # NOTE dps.2 may or may not be present
            result = result[len(PROTOCOL_VERSION_BYTES):]  # remove version header
            result = result[16:]  # remove (what I'm guessing, but not confirmed is) 16-bytes of MD5 hexdigest of payload
            cipher = AESCipher(self.local_key)
            result = cipher.decrypt(result)
            log.debug('decrypted result=%r', result)
            if not isinstance(result, str):
                result = result.decode()
            result = json.loads(result)
        else:
            log.error('Unexpected status() payload=%r', result)

        return result

    def set_status(self, on, switch=1):
        """
        Set status of the device to 'on' or 'off'.
        
        Args:
            on(bool):  True for 'on', False for 'off'.
            switch(int): The switch to set
        """
        # open device, send request, then close connection
        if isinstance(switch, int):
            switch = str(switch)  # index and payload is a string
        payload = self.generate_payload(SET, {switch:on})
        #print('payload %r' % payload)

        data = self._send_receive(payload)
        log.debug('set_status received data=%r', data)

        return data

    def set_value(self, index, value):
        """
        Set int value of any index.

        Args:
            index(int): index to set
            value(int): new value for the index
        """
        # open device, send request, then close connection
        if isinstance(index, int):
            index = str(index)  # index and payload is a string

        payload = self.generate_payload(SET, {
            index: value})
        
        data = self._send_receive(payload)
        
        return data

    def turn_on(self, switch=1):
        """Turn the device on"""
        self.set_status(True, switch)

    def turn_off(self, switch=1):
        """Turn the device off"""
        self.set_status(False, switch)

    def set_timer(self, num_secs):
        """
        Set a timer.
        
        Args:
            num_secs(int): Number of seconds
        """
        # FIXME / TODO support schemas? Accept timer id number as parameter?

        # Dumb heuristic; Query status, pick last device id as that is probably the timer
        status = self.status()
        devices = status['dps']
        devices_numbers = list(devices.keys())
        devices_numbers.sort()
        dps_id = devices_numbers[-1]

        payload = self.generate_payload(SET, {dps_id:num_secs})

        data = self._send_receive(payload)
        log.debug('set_timer received data=%r', data)
        return data

class OutletDevice(Device):
    def __init__(self, dev_id, address, local_key=None):
        dev_type = 'device'
        super(OutletDevice, self).__init__(dev_id, address, local_key, dev_type)

class BulbDevice(Device):
    DPS_INDEX_ON         = '1'
    DPS_INDEX_MODE       = '2'
    DPS_INDEX_BRIGHTNESS = '3'
    DPS_INDEX_COLOURTEMP = '4'
    DPS_INDEX_COLOUR     = '5'

    DPS             = 'dps'
    DPS_MODE_COLOUR = 'colour'
    DPS_MODE_WHITE  = 'white'

    def __init__(self, dev_id, address, local_key=None):
        dev_type = 'device'
        super(BulbDevice, self).__init__(dev_id, address, local_key, dev_type)

    @staticmethod
    def _rgb_to_hexvalue(r, g, b):
        """
        Convert an RGB value to the hex representation expected by tuya.
        
        Index '5' (DPS_INDEX_COLOUR) is assumed to be in the format:
        rrggbb0hhhssvv
        
        While r, g and b are just hexadecimal values of the corresponding
        Red, Green and Blue values, the h, s and v values (which are values
        between 0 and 1) are scaled to 360 (h) and 255 (s and v) respectively.
        
        Args:
            r(int): Value for the colour red as int from 0-255.
            g(int): Value for the colour green as int from 0-255.
            b(int): Value for the colour blue as int from 0-255.
        """
        rgb = [r,g,b]
        hsv = colorsys.rgb_to_hsv(rgb[0]/255, rgb[1]/255, rgb[2]/255)

        hexvalue = ""
        for value in rgb:
            temp = str(hex(int(value))).replace("0x","")
            if len(temp) == 1:
                temp = "0" + temp
            hexvalue = hexvalue + temp

        hsvarray = [int(hsv[0] * 360), int(hsv[1] * 255), int(hsv[2] * 255)]
        hexvalue_hsv = ""
        for value in hsvarray:
            temp = str(hex(int(value))).replace("0x","")
            if len(temp) == 1:
                temp = "0" + temp
            hexvalue_hsv = hexvalue_hsv + temp
        if len(hexvalue_hsv) == 7:
            hexvalue = hexvalue + "0" + hexvalue_hsv
        else:
            hexvalue = hexvalue + "00" + hexvalue_hsv

        return hexvalue

    @staticmethod
    def _hexvalue_to_rgb(hexvalue):
        """
        Converts the hexvalue used by tuya for colour representation into
        an RGB value.
        
        Args:
            hexvalue(string): The hex representation generated by BulbDevice._rgb_to_hexvalue()
        """
        r = int(hexvalue[0:2], 16)
        g = int(hexvalue[2:4], 16)
        b = int(hexvalue[4:6], 16)

        return (r, g, b)

    @staticmethod
    def _hexvalue_to_hsv(hexvalue):
        """
        Converts the hexvalue used by tuya for colour representation into
        an HSV value.
        
        Args:
            hexvalue(string): The hex representation generated by BulbDevice._rgb_to_hexvalue()
        """
        h = int(hexvalue[7:10], 16) / 360
        s = int(hexvalue[10:12], 16) / 255
        v = int(hexvalue[12:14], 16) / 255

        return (h, s, v)

    def set_colour(self, r, g, b):
        """
        Set colour of an rgb bulb.

        Args:
            r(int): Value for the colour red as int from 0-255.
            g(int): Value for the colour green as int from 0-255.
            b(int): Value for the colour blue as int from 0-255.
        """
        if not 0 <= r <= 255:
            raise ValueError("The value for red needs to be between 0 and 255.")
        if not 0 <= g <= 255:
            raise ValueError("The value for green needs to be between 0 and 255.")
        if not 0 <= b <= 255:
            raise ValueError("The value for blue needs to be between 0 and 255.")

        print(BulbDevice)
        hexvalue = BulbDevice._rgb_to_hexvalue(r, g, b)

        payload = self.generate_payload(SET, {
            self.DPS_INDEX_MODE: self.DPS_MODE_COLOUR,
            self.DPS_INDEX_COLOUR: hexvalue})
        data = self._send_receive(payload)
        return data

    def set_white(self, brightness, colourtemp):
        """
        Set white coloured theme of an rgb bulb.

        Args:
            brightness(int): Value for the brightness (25-255).
            colourtemp(int): Value for the colour temperature (0-255).
        """
        if not 25 <= brightness <= 255:
            raise ValueError("The brightness needs to be between 25 and 255.")
        if not 0 <= colourtemp <= 255:
            raise ValueError("The colour temperature needs to be between 0 and 255.")

        payload = self.generate_payload(SET, {
            self.DPS_INDEX_MODE: self.DPS_MODE_WHITE,
            self.DPS_INDEX_BRIGHTNESS: brightness,
            self.DPS_INDEX_COLOURTEMP: colourtemp})

        data = self._send_receive(payload)
        return data

    def set_brightness(self, brightness):
        """
        Set the brightness value of an rgb bulb.

        Args:
            brightness(int): Value for the brightness (25-255).
        """
        if not 25 <= brightness <= 255:
            raise ValueError("The brightness needs to be between 25 and 255.")

        payload = self.generate_payload(SET, {self.DPS_INDEX_BRIGHTNESS: brightness})
        data = self._send_receive(payload)
        return data

    def set_colourtemp(self, colourtemp):
        """
        Set the colour temperature of an rgb bulb.

        Args:
            colourtemp(int): Value for the colour temperature (0-255).
        """
        if not 0 <= colourtemp <= 255:
            raise ValueError("The colour temperature needs to be between 0 and 255.")

        payload = self.generate_payload(SET, {self.DPS_INDEX_COLOURTEMP: colourtemp})
        data = self._send_receive(payload)
        return data

    def brightness(self):
        """Return brightness value"""
        return self.status()[self.DPS][self.DPS_INDEX_BRIGHTNESS]

    def colourtemp(self):
        """Return colour temperature"""
        return self.status()[self.DPS][self.DPS_INDEX_COLOURTEMP]

    def colour_rgb(self):
        """Return colour as RGB value"""
        hexvalue = self.status()[self.DPS][self.DPS_INDEX_COLOUR]
        return BulbDevice._hexvalue_to_rgb(hexvalue)

    def colour_hsv(self):
        """Return colour as HSV value"""
        hexvalue = self.status()[self.DPS][self.DPS_INDEX_COLOUR]
        return BulbDevice._hexvalue_to_hsv(hexvalue)

    def state(self):
        status = self.status()
        state = {
            'is_on'      : status[self.DPS][self.DPS_INDEX_ON],
            'mode'       : status[self.DPS][self.DPS_INDEX_MODE],
            'brightness' : status[self.DPS][self.DPS_INDEX_BRIGHTNESS],
            'colourtemp' : status[self.DPS][self.DPS_INDEX_COLOURTEMP],
            'colour'     : status[self.DPS][self.DPS_INDEX_COLOUR],
            }
        return state

3) puis le recopier de votre répertoire home vers le répertoire de la lib installée, vous pouvez vérifiez le chemin avant (regardez le chemin en rouge dans mon exemple) :

pi@raspberry:~ $ sudo pip show pytuya
Name: pytuya
Version: 7.0.2
Summary: Python interface to ESP8266MOD WiFi smart devices from Shenzhen Xenon
Home-page: https://github.com/clach04/python-tuya
Author: clach04
Author-email: UNKNOWN
License: MIT
Location: /usr/local/lib/python2.7/dist-packages
Requires: pyaes

Puis allez dans le projet téléchargé et copiez donc la version que vous aurez en local avec la commande sudo vers le répertoire identifié dans la commande précédente:


pi@raspberry:~ $ cd python-tuya/pytuya 

pi@raspberry:~ $ sudo cp __init__.py /usr/local/lib/python2.7/dist-packages/pytuya



Conclusion:
Dans tout les cas, si vous avez un autre device "Tuya", c'est toujours la même procedure, et pour voir ce que peux faire votre device, vous pouvez déjà regarder ce que retourne la commande "data = d.status()" qui en général renvoi un JSON avec des infos en binary, string ou integer. Et ainsi, vous pouvez faire vous même le reverse engineering de votre device.
Allez voir aussi le wiki pour plus d'info sur les devices "Tuya": https://github.com/clach04/python-tuya/wiki

J'espère que mes recherches pourront vous aider dans votre projet.

Enjoy !!!!