Une machine basée sur Arduino contrôlée par un appareil Android via Bluetooth - code d'application et mk (partie 2)

À propos de la première partie


Dans la première partie, j'ai décrit la partie physique de la construction et juste un petit morceau de code. Considérons maintenant le composant logiciel - une application Android et un croquis Arduino.

Tout d'abord, je vais donner une description détaillée de chaque moment, et à la fin je vais laisser des liens vers l'ensemble du projet + une vidéo du résultat, ce qui devrait vous décevoir .

Application Android


Le programme pour Android est divisé en deux parties: la première consiste à connecter l'appareil via Bluetooth, la seconde est le joystick de contrôle.

Je vous préviens - la conception de l'application n'a pas du tout été élaborée et a été faite sur une erreur, si seulement cela fonctionnait. L'adaptabilité et l'UX n'attendent pas, mais ne devraient pas sortir de l'écran.

Disposition


Le démarrage de l'activité repose sur la disposition, les éléments: boutons et la disposition d'une liste d'appareils. Le bouton démarre le processus de recherche d'appareils avec Bluetooth actif. Le ListView affiche les périphériques trouvés.

<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" > <Button android:layout_width="wrap_content" android:layout_height="60dp" android:layout_alignParentStart="true" android:layout_alignParentTop="true" android:layout_marginStart="40dp" android:layout_marginTop="50dp" android:text="@string/start_search" android:id="@+id/button_start_find" /> <Button android:layout_width="wrap_content" android:layout_height="60dp" android:layout_marginEnd="16dp" android:layout_marginBottom="16dp" android:id="@+id/button_start_control" android:text="@string/start_control" android:layout_alignParentBottom="true" android:layout_alignParentEnd="true"/> <ListView android:id="@+id/list_device" android:layout_width="300dp" android:layout_height="200dp" android:layout_marginEnd="10dp" android:layout_marginTop="10dp" android:layout_alignParentEnd="true" android:layout_alignParentTop="true" /> </RelativeLayout> 

L'écran de contrôle est basé sur une disposition, dans laquelle il n'y a qu'un bouton, qui deviendra à l'avenir un joystick. Un bouton est attaché au bouton via l'attribut background, ce qui le rend rond.
TextView n'est pas utilisé dans la version finale, mais il a été ajouté à l'origine pour le débogage: les numéros envoyés via Bluetooth étaient affichés. Au stade initial, je vous conseille d'utiliser. Mais ensuite, les nombres commenceront à être calculés dans un flux séparé à partir duquel il est difficile d'accéder à TextView.

 <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <Button android:layout_width="200dp" android:layout_height="200dp" android:layout_alignParentStart="true" android:layout_alignParentBottom="true" android:layout_marginBottom="25dp" android:layout_marginStart="15dp" android:id="@+id/button_drive_control" android:background="@drawable/button_control_circle" /> <TextView android:layout_height="wrap_content" android:layout_width="wrap_content" android:layout_alignParentEnd="true" android:layout_alignParentTop="true" android:minWidth="70dp" android:id="@+id/view_result_touch" android:layout_marginEnd="90dp" /> </RelativeLayout> 

Le fichier button_control_circle.xml (style), il doit être placé dans le dossier drawable:

 <?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <solid android:color="#00F" /> <corners android:bottomRightRadius="100dp" android:bottomLeftRadius="100dp" android:topRightRadius="100dp" android:topLeftRadius="100dp"/> </shape> 

Vous devez également créer le fichier item_device.xml, il est nécessaire pour chaque élément de la liste:

 <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:layout_width="150dp" android:layout_height="40dp" android:id="@+id/item_device_textView"/> </LinearLayout> 

Manifeste


Au cas où, je donnerai le code manifeste complet. Vous devez obtenir un accès complet au bluetooth via les autorisations d'utilisation et n'oubliez pas d'indiquer la deuxième activité via la balise d'activité.

 <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.bluetoothapp"> <uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" /> <application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <activity android:name="com.arproject.bluetoothworkapp.MainActivity" android:theme="@style/Theme.AppCompat.NoActionBar" android:screenOrientation="landscape"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <activity android:name="com.arproject.bluetoothworkapp.ActivityControl" android:theme="@style/Theme.AppCompat.NoActionBar" android:screenOrientation="landscape"/> </application> </manifest> 

L'activité principale, l'association d'Arduino et d'Android


Nous héritons la classe d'AppCompatActivity et déclarons les variables:

 public class MainActivity extends AppCompatActivity { private BluetoothAdapter bluetoothAdapter; private ListView listView; private ArrayList<String> pairedDeviceArrayList; private ArrayAdapter<String> pairedDeviceAdapter; public static BluetoothSocket clientSocket; private Button buttonStartControl; } 

Je décrirai la méthode onCreate () ligne par ligne:

 @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); //  //    setContentView(R.layout.activity_main); //    Button buttonStartFind = (Button) findViewById(R.id.button_start_find); // layout,       listView = (ListView) findViewById(R.id.list_device); //    buttonStartFind.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //   ( ) if(permissionGranted()) { //    bluetoothAdapter = BluetoothAdapter.getDefaultAdapter(); if(bluetoothEnabled()) { //   ( ) findArduino(); //   ( ) } } } }); //      buttonStartControl = (Button) findViewById(R.id.button_start_control); buttonStartControl.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //     Intent intent = new Intent(); //    intent.setClass(getApplicationContext(), ActivityControl.class); //  ,    startActivity(intent); } }); } 

Les fonctions ci-dessous vérifient si l'autorisation d'utiliser le Bluetooth est obtenue (sans la permission de l'utilisateur, nous ne pourrons pas transférer de données) et si le Bluetooth est activé:

 private boolean permissionGranted() { //   ,  true if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH) == PermissionChecker.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.BLUETOOTH_ADMIN) == PermissionChecker.PERMISSION_GRANTED) { return true; } else { ActivityCompat.requestPermissions(this, new String[] {Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN}, 0); return false; } } private boolean bluetoothEnabled() { //  ,  true,  ,      if(bluetoothAdapter.isEnabled()) { return true; } else { Intent enableBtIntent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE); startActivityForResult(enableBtIntent, 0); return false; } } 

Si tous les contrôles sont réussis, la recherche de périphérique commence. Si l'une des conditions n'est pas remplie, une notification s'affichera, disant "autoriser \ activer?", Et cela sera répété jusqu'à ce que la vérification soit réussie.

La recherche d'appareils est divisée en trois parties: préparation de la liste, ajout à la liste des appareils trouvés, établissement d'une connexion avec l'appareil sélectionné.

 private void findArduino() { //    Set<BluetoothDevice> pairedDevice = bluetoothAdapter.getBondedDevices(); if (pairedDevice.size() > 0) { //     pairedDeviceArrayList = new ArrayList<>(); //  for(BluetoothDevice device: pairedDevice) { //      //: " /" pairedDeviceArrayList.add(device.getAddress() + "/" + device.getName()); } } //  ,    item_device.xml pairedDeviceAdapter = new ArrayAdapter<String>(getApplicationContext(), R.layout.item_device, R.id.item_device_textView, pairedDeviceArrayList); listView.setAdapter(pairedDeviceAdapter); //      listView.setOnItemClickListener(new AdapterView.OnItemClickListener() { @Override public void onItemClick(AdapterView<?> adapterView, View view, int i, long l) { //    String itemMAC = listView.getItemAtPosition(i).toString().split("/", 2)[0]; //      BluetoothDevice connectDevice = bluetoothAdapter.getRemoteDevice(itemMAC); try { // socket - ,      Method m = connectDevice.getClass().getMethod( "createRfcommSocket", new Class[]{int.class}); clientSocket = (BluetoothSocket) m.invoke(connectDevice, 1); clientSocket.connect(); if(clientSocket.isConnected()) { //  ,   bluetoothAdapter.cancelDiscovery(); } } catch(Exception e) { e.getStackTrace(); } } }); } 

Lorsqu'un module Bluetooth suspendu à un Arduino (plus d'informations à ce sujet plus tard) est trouvé, il apparaîtra dans la liste. En cliquant dessus, vous commencerez à créer un socket (vous devrez peut-être attendre 3-5 secondes après un clic ou cliquez à nouveau). Vous comprendrez que la connexion est établie par les LED du module Bluetooth: sans connexion, elles clignotent rapidement, s'il y a une connexion, la fréquence diminue sensiblement.


Gérer et envoyer des commandes


Une fois la connexion établie, vous pouvez passer à la deuxième activité - ActivityControl. Il n'y aura qu'un cercle bleu sur l'écran - le joystick. Il est fabriqué à partir du bouton habituel, le balisage est donné ci-dessus.

 public class ActivityControl extends AppCompatActivity { //,   private Button buttonDriveControl; private float BDCheight, BDCwidth; private float centerBDCheight, centerBDCwidth; private String angle = "90"; //0, 30, 60, 90, 120, 150, 180 private ConnectedThread threadCommand; private long lastTimeSendCommand = System.currentTimeMillis(); } 

Dans la méthode onCreate (), toute l'action principale a lieu:

 //        performClick() //    @SuppressLint("ClickableViewAccessibility") @Override protected void onCreate(Bundle savedInstanceState) { //  super.onCreate(savedInstanceState); // ,    setContentView(R.layout.activity_control); //  buttonDriveControl = (Button) findViewById(R.id.button_drive_control); //    final ViewTreeObserver vto = buttonDriveControl.getViewTreeObserver(); vto.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { //      (!) BDCheight = buttonDriveControl.getHeight(); BDCwidth = buttonDriveControl.getWidth(); //    (!) centerBDCheight = BDCheight/2; centerBDCwidth = BDCwidth/2; // GlobalListener,     buttonDriveControl.getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); // ,     //    buttonDriveControl.setOnTouchListener(new ControlDriveInputListener()); //  ,      //    ,     //    threadCommand = new ConnectedThread(MainActivity.clientSocket); threadCommand.run(); } 

Faites attention (!) - nous découvrirons combien de pixels le bouton occupe. Grâce à cela, nous obtenons l'adaptabilité: la taille du bouton dépendra de la résolution de l'écran, mais le reste du code s'adaptera facilement à cela, car nous ne fixons pas les tailles à l'avance. Plus tard, nous apprendrons à l'application à savoir où était le toucher, puis à le traduire en valeurs compréhensibles pour l'arduinki de 0 à 255 (après tout, le toucher peut être à 456 pixels du centre, et MK ne fonctionnera pas avec ce nombre).

Voici le code de ControlDriveInputListener (), cette classe se trouve dans la classe de l'activité elle-même, après la méthode onCreate (). Étant dans le fichier ActivityControl, la classe ControlDriveInputListener devient un enfant, ce qui signifie qu'elle a accès à toutes les variables de la classe principale.

Ne faites pas attention aux fonctions qui sont appelées lorsque vous cliquez dessus. Maintenant, nous sommes intéressés par le processus de prise de contact: à quel moment la personne a mis son doigt et quelles données nous obtiendrons à ce sujet.

Veuillez noter que j'utilise la classe java.util.Timer: elle vous permet de créer un nouveau thread qui peut avoir un délai et sera répété un nombre infini de fois après chaque nième nombre de secondes. Il devrait être utilisé dans la situation suivante: la personne a mis un doigt, la méthode ACTION_DOWN a fonctionné, l'information est allée à l'arduino, et après cela, la personne a décidé de ne pas bouger le doigt, car la vitesse lui convenait. La deuxième fois, la méthode ACTION_DOWN ne fonctionnera pas, car vous devez d'abord appeler ACTION_UP (pour lever le doigt de l'écran).

Eh bien, nous démarrons la boucle de classe Timer () et commençons à envoyer les mêmes données toutes les 10 millisecondes. Lorsque le doigt est déplacé (ACTION_MOVE fonctionnera) ou levé (ACTION_UP), le cycle du minuteur doit être interrompu pour que les données de l'ancienne presse ne recommencent pas à être envoyées.

 public class ControlDriveInputListener implements View.OnTouchListener { private Timer timer; @Override public boolean onTouch(View view, MotionEvent motionEvent) { //     //      (!) final float x = motionEvent.getX(); final float y = motionEvent.getY(); //,     switch(motionEvent.getAction()) { //  //  ,      case MotionEvent.ACTION_DOWN: //  timer = new Timer(); //  // :    0, //  10  timer.schedule(new TimerTask() { @Override public void run() { //   calculateAndSendCommand(x, y); } }, 0, 10); break; //    (  ACTION_DOWN) case MotionEvent.ACTION_MOVE: // (!) //     Timer(),   if(timer != null) { timer.cancel(); timer = null; } //   timer = new Timer(); //     ,    ACTION_UP timer.schedule(new TimerTask() { @Override public void run() { calculateAndSendCommand(x, y); } }, 0, 10); break; //     case MotionEvent.ACTION_UP: //  if(timer != null) { timer.cancel(); timer = null; } break; } return false; } } 

Faites de nouveau attention: les méthodes x et y comptent sur la touche tactile () part du coin supérieur gauche de la vue. Dans notre cas, le point (0; 0) est situé à Button ici:



Maintenant que nous avons appris comment obtenir l'emplacement actuel du doigt sur les boutons, nous allons voir comment convertir les pixels (car x et y ne sont que la distance en pixels) en valeurs de travail. Pour ce faire, j'utilise la méthode CalculateAndSendCommand (x, y), qui doit être placée dans la classe ControlDriveInputListener. Vous aurez également besoin de quelques méthodes auxiliaires, nous les écrivons dans la même classe après CalculateAndSendCommand (x, y).

 private void calculateAndSendCommand(float x, float y) { //    //   // - 1, 2, 3, 4 // ,   ,      // ,     ,     int quarter = identifyQuarter(x, y); //       // y,        int speed = speedCalculation(centerBDCheight - y); //   //   ,    7   String angle = angleCalculation(x); //     ,     //      ,      /*String resultDown = "x: "+ Float.toString(x) + " y: " + Float.toString(y) + " qr: " + Integer.toString(quarter) + "\n" + "height: " + centerBDCheight + " width: " + centerBDCwidth + "\n" + "speed: " + Integer.toString(speed) + " angle: " + angle; */ //viewResultTouch.setText(resultDown); //  ,    //      (  ),   100  if((System.currentTimeMillis() - lastTimeSendCommand) > 100) { //   threadCommand.sendCommand(Integer.toString(speed), angle); //     lastTimeSendCommand = System.currentTimeMillis(); } } private int identifyQuarter(float x, float y) { //,      //  if(x > centerBDCwidth && y > centerBDCheight) { return 4; } else if (x < centerBDCwidth && y >centerBDCheight) { return 3; } else if (x < centerBDCwidth && y < centerBDCheight) { return 2; } else if (x > centerBDCwidth && y < centerBDCheight) { return 1; } return 0; } private int speedCalculation(float deviation) { //  //      float coefficient = 255/(BDCheight/2); //    //   int speed = Math.round(deviation * coefficient); //    70,    // ,    ,    if(speed > 0 && speed < 70) speed = 0; if(speed < 0 && speed > - 70) speed = 0; //     120 // ,     if(speed < 120 && speed > 70) speed = 120; if(speed > -120 && speed < -70) speed = -120; //     , ACTION_MOVE   //    ,     //      if(speed > 255 ) speed = 255; if(speed < - 255) speed = -255; //:  > 0 -  , < 0 -  return speed; } private String angleCalculation(float x) { //    7  //0 -  , 180 -  //90 -    if(x < BDCwidth/6) { angle = "0"; } else if (x > BDCwidth/6 && x < BDCwidth/3) { angle = "30"; } else if (x > BDCwidth/3 && x < BDCwidth/2) { angle = "60"; } else if (x > BDCwidth/2 && x < BDCwidth/3*2) { angle = "120"; } else if (x > BDCwidth/3*2 && x < BDCwidth/6*5) { angle = "150"; } else if (x > BDCwidth/6*5 && x < BDCwidth) { angle = "180"; } else { angle = "90"; } return angle; } 

Lorsque les données sont calculées et transférées, le deuxième flux entre en jeu. Il est responsable de l'envoi des informations. Vous ne pouvez pas vous en passer, sinon le socket transmettant des données ralentira la capture des touches, une file d'attente sera créée et toute la fin sera plus courte.

La classe ConnectedThread se trouve également dans la classe ActivityControl.

 private class ConnectedThread extends Thread { private final BluetoothSocket socket; private final OutputStream outputStream; public ConnectedThread(BluetoothSocket btSocket) { //  this.socket = btSocket; //  -       OutputStream os = null; try { os = socket.getOutputStream(); } catch(Exception e) {} outputStream = os; } public void run() { } public void sendCommand(String speed, String angle) { //    ,   byte[] speedArray = speed.getBytes(); byte[] angleArray = angle.getBytes(); //    //  ,  ,       String a = "#"; String b = "@"; String c = "*"; try { outputStream.write(b.getBytes()); outputStream.write(speedArray); outputStream.write(a.getBytes()); outputStream.write(c.getBytes()); outputStream.write(angleArray); outputStream.write(a.getBytes()); } catch(Exception e) {} } } 

Résumé de l'application Android


Résumez brièvement tous les inconvénients ci-dessus.

  1. Dans ActivityMain, nous configurons le Bluetooth, nous établissons une connexion.
  2. Dans ActivityControl, nous attachons le bouton et obtenons des données à ce sujet.
  3. Nous accrochons sur le bouton OnTouchListener, il attrape le toucher, le mouvement et le lever du doigt.
  4. Les données obtenues (point avec coordonnées x et y) sont converties en angle de rotation et en vitesse
  5. Nous envoyons des données, en les séparant avec des caractères spéciaux

Et la compréhension finale vous viendra lorsque vous examinerez le code entier - github.com/IDolgopolov/BluetoothWorkAPP.git . Il n'y a pas de code de commentaire, il semble donc beaucoup plus propre, plus petit et plus simple.

Croquis Arduino


L'application Android est démontée, écrite, comprise ... et ici ce sera plus simple. Je vais essayer de tout considérer par étapes, puis je donnerai un lien vers le dossier complet.

Variables


Tout d'abord, considérez les constantes et les variables dont vous aurez besoin.

 #include <SoftwareSerial.h> //  \  //          SoftwareSerial BTSerial(8, 9); //    int speedRight = 6; int dirLeft = 3; int speedLeft = 11; int dirRight = 7; // ,   int angleDirection = 4; int angleSpeed = 5; //,     ,   //      int pinAngleStop = 12; //    String val; //  int speedTurn = 180; //,    //       int pinRed = A0; int pinWhite = A1; int pinBlack = A2; //   long lastTakeInformation; //, ,     boolean readAngle = false; boolean readSpeed = false; 

Méthode Setup ()


Dans la méthode setup (), nous définissons les paramètres des broches: elles fonctionneront en entrée ou en sortie. Nous avons également défini la vitesse de communication de l'ordinateur avec l'arduino, le bluetooth avec l'arduino.

 void setup() { pinMode(dirLeft, OUTPUT); pinMode(speedLeft, OUTPUT); pinMode(dirRight, OUTPUT); pinMode(speedRight, OUTPUT); pinMode(pinRed, INPUT); pinMode(pinBlack, INPUT); pinMode(pinWhite, INPUT); pinMode(pinAngleStop, OUTPUT); pinMode(angleDirection, OUTPUT); pinMode(angleSpeed, OUTPUT); //      HC-05 //     ,   BTSerial.begin(38400); //   Serial.begin(9600); } 

Méthode Loop () et fonctions supplémentaires


Dans la méthode loop () à répétition constante, les données sont lues. Considérons d'abord l'algorithme principal, puis les fonctions qui y sont impliquées.

 void loop() { //    if(BTSerial.available() > 0) { //    char a = BTSerial.read(); if (a == '@') { //   @ (   ) //  val val = ""; //,     readSpeed = true; } else if (readSpeed) { //         //   val if(a == '#') { //   ,     //      Serial.println(val); //,      readSpeed = false; //      go(val.toInt()); // val val = ""; //  ,     return; } val+=a; } else if (a == '*') { //    readAngle = true; } else if (readAngle) { // ,     //  ,    val if(a == '#') { Serial.println(val); Serial.println("-----"); readAngle = false; //     turn(val.toInt()); val= ""; return; } val+=a; } //     lastTakeInformation = millis(); } else { //   ,      150  //  if(millis() - lastTakeInformation > 150) { lastTakeInformation = 0; analogWrite(angleSpeed, 0); analogWrite(speedRight, 0); analogWrite(speedLeft, 0); } } } 

: "@##" (, "@200#60#". 100 , . , , , .

delay(), , - , . , , ( ).

, .

 void go(int mySpeed) { //   0 if(mySpeed > 0) { //  digitalWrite(dirRight, HIGH); analogWrite(speedRight, mySpeed); digitalWrite(dirLeft, HIGH); analogWrite(speedLeft, mySpeed); } else { //   0,   digitalWrite(dirRight, LOW); analogWrite(speedRight, abs(mySpeed) + 30); digitalWrite(dirLeft, LOW); analogWrite(speedLeft, abs(mySpeed) + 30); } delay(10); } void turn(int angle) { //      digitalWrite(pinAngleStop, HIGH); // ,     delay(5); //  150  ,   // 30  ,   //  31  149     if(angle > 149) { //  ,      //   ,    //    return if( digitalRead(pinWhite) == HIGH && digitalRead(pinBlack) == LOW && digitalRead(pinRed) == LOW) { return; } //      //  digitalWrite(angleDirection, HIGH); analogWrite(angleSpeed, speedTurn); } else if (angle < 31) { if(digitalRead(pinRed) == HIGH && digitalRead(pinBlack) == HIGH && digitalRead(pinWhite) == HIGH) { return; } digitalWrite(angleDirection, LOW); analogWrite(angleSpeed, speedTurn); } //  digitalWrite(pinAngleStop, LOW); delay(5); } 

Tournez lorsque l'androïde envoie des données selon lesquelles l'utilisateur a serré l'angle de 60, 90, 120, n'en vaut pas la peine, sinon vous ne pourrez pas aller droit. Oui, vous ne devriez peut-être pas envoyer immédiatement une commande de virage depuis l'androïde si l'angle est trop petit, mais c'est quelque peu maladroit à mon avis.

Résultats d'esquisse


Un croquis ne comporte que trois étapes importantes: lire une commande, traiter les limites de rotation et fournir du courant aux moteurs. Tout semble simple, et en exécution, il est plus facile que facile, bien qu'il ait été créé pendant longtemps et avec des contours. La version complète du croquis .

En fin de compte


Un inventaire complet de plusieurs mois de travail est terminé. La partie physique est démontée, le logiciel d'autant plus. Le principe reste le même - contact pour des phénomènes incompréhensibles, nous comprendrons ensemble.

Et les commentaires sous la première partie sont intéressants, ils ont conseillé une montagne de conseils utiles, merci à tous.

Vidéo du résultat


Source: https://habr.com/ru/post/fr424813/


All Articles