Сделай сам: котик манеки-неко, который сделает бэкап в облако

Сопровождаем сохранение резервных копий протяжным «мяу».

Сделай сам: котик манеки-неко, который сделает бэкап в облако

В серии DIY-статей «Пространство для изобретений» мы пробуем в домашних условиях разработать необычные гаджеты и оставляем все необходимые инструкции, чтобы любой желающий мог повторить наш опыт. Серию статей поддерживает Selectel — провайдер ИТ-инфраструктуры, которая помогает в решении рабочих задач и разработке личных проектов. Посмотреть, что интересного есть у провайдера, и выбрать для себя подходящие решения можно на сайте.

В этом материале Сергей Исмулин, разработчик электронных устройств, собирает фигурку котика манеки-неко — если дать котику «пять», он сделает бэкап выбранного файла в облачное хранилище.

Для тех, кто хочет автоматизировать бэкапы, но не готов к полноценной сборке фигурки, Selectel предлагает протестировать бета-версию новой услуги «Бэкапы по расписанию». Она позволит настроить автоматические бэкапы для сетевых дисков виртуальных машин.

Данные будут защищены от сбоев и человеческих ошибок, а клиент сможет в любой момент восстановить диск и откатиться на момент до возникновения проблем. Правила бэкапирования настраиваются один раз, дальше сервис автоматически создаёт бэкапы и удаляет ранние версии.

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Многим знакома ситуация: важный проект существует в одной-единственной версии и возможности откатить изменения нет. Новички не всегда осознают, насколько важен бэкап, а понимание в этом вопросе прямо пропорционально количеству набитых шишек. Вот и я созрел, чтобы использовать облачные сервисы для сохранения проделанной работы. Итак, задача: создать инструмент «в железе», который поможет в конце работы сохранять все данные на облаке.

Моя кнопка будет в виде японского кота манэки-нэко — это талисман, который приманивает деньги. Для создания бэкапа ему нужно будет «дать пять». Мой кот выглядит вот так.

Сделай сам: котик манеки-неко, который сделает бэкап в облако

В задней части у него встроен солнечный элемент, который питает небольшую схему с электромагнитной катушкой — она и заставляет кота махать лапкой на свету. Эта часть нам не важна, а значит, подробно на ней останавливаться не будем. Зато важна подвижная лапка — она будет физической кнопкой сохранения.

Для связи кота с компьютером используем плату Arduino. Плата подойдёт любая, но из-за небольшого размера фигурки мы имплантируем Arduino Nano. Заливать бэкапы будем в облако Selectel.

Помимо компонентов устройства понадобятся отвёртки, термоклей, пружинка, алюминиевый скотч, Arduino IDE, Visual Studio.

Собираем «железную» кнопку

Логика работы следующая: при нажатии на лапку срабатывает кнопка, и Arduino, подключённая к компьютеру, отправляет уведомление программе (её мы напишем) — и та отсылает на сервер указанный в настройках файл. В самого кота достаточно встроить кнопку с Arduino и вывести USB-вход. Красная подушка, на которой восседает кот, — полая, и туда отлично помещается Arduino Nano. Для этого нужно выкрутить четыре винта в нижней части.

Сделай сам: котик манеки-неко, который сделает бэкап в облако
Сделай сам: котик манеки-неко, который сделает бэкап в облако

Зачем нужен динамик с усилителем, расскажу позже, а пока обращу внимание, что USB-порт Nano я вывел наружу, прорезав отверстие в боксе.

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Провода для подключения к кнопке я вывел в тело кота через отверстие в коврике.

Сделай сам: котик манеки-неко, который сделает бэкап в облако

А вот схема с «кнопкой» в деталях.

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Кнопка представляет собой два куска фольги: один закреплён на корпусе кота (фиолетовый квадрат), другой — на конце качалки (фиолетовый шарик). Когда мы «даём пять» коту, лапка замыкает между собой два кусочка фольги, которые проводами соединены с Arduino Nano. Схема устройства:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Кнопка подключена между пином D4 (серый провод) и землёй (синий провод). При нажатии на входе будет установлен низкий уровень. С пина D11 (жёлтый провод) снимается аудиосигнал, но, если подключить телефонный динамик напрямую, звук будет недостаточно громким, потому я дополнительно подключил монофонический усилитель на микросхеме TDA7052.

Это мостовой усилитель с напряжением питания от 3 вольт и минимальным набором внешних компонентов. Схема его подключения выглядит так:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Теперь напишем микропрограмму и прошьём плату — для этого необходимо установить и настроить Arduino IDE. Процесс повторять не буду, поделюсь каноничной ссылкой, где это описано подробно.

Итак, IDE установлена, плата подключена, blink прошит. Возвращаемся к нашей задаче. В самом простом варианте скетч выглядит так:

#define BTN 4 // Кнопка void setup() { Serial.begin(9600); pinMode(BTN, INPUT_PULLUP); // Инициализируем кнопку как вход и вешаем резистор подтяжки } void loop() { if (digitalRead(BTN) == 0) // Если прижали к земле... { Serial.println(2); //...отправляем цифру два... delay(500); //...и ждём полсекунды, чтобы исключить дребезг } }

В цикле слушаем вход D4, к которому подключили кнопку. При её нажатии отправляем в serial port цифру 2 (0 и 1 займём чуть позже).

Банальная отправка программе сообщения о необходимости сделать бэкап — это скучно. Добавим немного интерактива: при успешной отправке сообщения наш кот будет довольно мяукать, а если что-то пошло не так — издавать звук ошибки.

Для этого и понадобится динамик. Помимо приёма Arduino сообщения об успешной или неудачной отправке, нам нужно заставить плату воспроизводить звук. Первое, что приходит на ум, — использовать шилд mp3-плеера, но места в коте не так много. Поскольку ЦАПа (цифро-аналогового преобразователя, который превращает нули и единицы в сигнал, пригодный для воспроизведения) на борту Arduino Nanо нет, будем жёстко зашивать в исходный код звуки природы ШИМом (широтно-импульсной модуляцией, которая позволяет с помощью цифрового устройства «эмулировать» аналоговый сигнал). Чтобы не изобретать велосипед, воспользуемся готовой инструкцией.

Если коротко, нужно подготовить аудиофайл в формате wav со следующими настройками: битрейт 8 бит, частота дискретизации 8 кГц (сконвертировать можно онлайн). Далее при помощи конвертера преобразуем wav в двоичный формат, задав такие параметры:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

После чего программа выдаст нам C-файл примерно такого вида:

const char dataerror[ /* 1903 */ ] = { 0x80, 0x80, 0x7F, 0x80, 0x80, 0x7E, 0x81, 0x7E, ..... 0x76, 0x76, 0x9A, 0x60, 0x9B, 0x75, 0x78, 0x98, };

С этими данными Arduino уже может работать. Файл представляет собой простой массив сэмплов. Дискретизация 8 кГц означает, что аудиофайл длиною в секунду будет поделён на 8 тысяч частей, где каждая часть представлена в виде уровня в восьмибитном разрешении. При воспроизведении на один из выводов Arduino будет подана последовательность этих сэмплов в виде изменяющегося ШИМ-сигнала, где ширина каждого импульса будет пропорциональна уровню семпла.

#include "PCM.h" // Импортируем библиотеку для работы с ШИМ #include "myau.h" // Массив звука «Мяу» #include "error.h" // Массив звука «Что-то не то» #define BTN 4 // Кнопка byte succes = 1; // Символ успешной отправки byte denied = 0; // Символ ошибки byte mess; // Принятое сообщение void setup() { Serial.begin(9600); pinMode(BTN, INPUT_PULLUP); } void loop() { if (digitalRead(BTN) == 0) { Serial.println(2); delay(500); } if (Serial.available() > 0) { mess = Serial.read(); if (mess == '1') startPlayback(myau, sizeof(myau)); else if (mess == '0') startPlayback(error, sizeof(error)); } }

После конвертации звуков я вынес их в отдельные файлы и переименовал расширение в заголовочное – «.h». Точно так же в функцию воспроизведения

startPlayback(myau, sizeof(myau));

я передаю массив и размер массива sizeof(myau) — нам не нужно указывать размер, как в примере по ссылке.

Теперь наш кот может не только отправлять цифру 2 после того, как ему «дали пять», но и реагировать на пришедшее в порт сообщение. Если пришла единица — говорить «мяу»:

if (mess == '1') startPlayback(myau, sizeof(myau));

А на ноль — ругаться:

else if (mess == '0') startPlayback(error, sizeof(error));

Проверяем, верно ли выполнили действия:

  1. Загружаем скетч в Arduino.
  2. Подключаем динамик одним концом к земле, а другим — к порту D11.
  3. Открываем «Монитор порта» в Arduino IDE, выбираем скорость 9600 бод.
  4. Отправляем 1 или 0 — получаем либо мяуканье, либо звук ошибки.

Подготовим облако и десктоп к бэкапам

И вот, когда «железо» у нас уже есть, стоит разобраться, куда и как отсылать бэкап. В разработке я буду использовать облачное хранилище Selectel, регистрируюсь и выбираю тариф. Техподдержка рассказала, что для бэкапов стоит использовать холодное хранилище. Документация для начала работы с сервисом понятна и новичку: первым делом нужно создать контейнер, присвоить ему имя, тип (приватный, открытый), выбрать «холодное хранилище» (заточенное под бэкапы). И всё готово — можно перетягивать файлы вот сюда:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Но процесс можно и автоматизировать:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Раздел «Работа с API Облачного хранилища» — то, что нам нужно. Выбираю способ передачи файлов через протокол FTP, довольно предсказуемые входные данные:

Сделай сам: котик манеки-неко, который сделает бэкап в облако
Сделай сам: котик манеки-неко, который сделает бэкап в облако

Десктопное приложение буду писать в среде Visual Studio на языке C#. По ссылке можно почитать, как поставить Visual Studio на свой компьютер, здесь видеоверсия, а по этой ссылке вы найдёте отличный курс по C#.

Итак, нам ясна архитектура приложения по части работы с COM-портом — отсылаем и принимаем цифры. Также ясно, что приложение должно отправлять на сервер указанный нами файл. А если у нас проект состоит из множества файлов? Логично было бы отправить папку с проектом целиком, ещё логичнее перед этим её заархивировать.

Также было бы неплохо иметь возможность выбора: заменять старый файл новым или создать его копию, добавив к имени текущие дату и время. Приложение будет использовать Windows Forms в качестве оболочки. Вот, что получилось после некоторых раздумий:

Сделай сам: котик манеки-неко, который сделает бэкап в облако
  • В левом верхнем углу расположен выпадающий список портов. Кота нужно подключить к компьютеру и, выбрав соответствующий порт, нажать кнопку «Подключиться». В случае успеха кот довольно мяукнет.
  • Чекбокс «Заменять файл» отвечает за то, будет ли файл перезаписан или создан заново с датой и временем в имени файла.
  • Если нажать «Папка целиком», то указанная папка будет отправлена целиком со всем её содержимым в виде zip-архива.
  • Текстовое поле «Путь к файлу» указывает на файл или папку для отправки. Можно не вводить адрес вручную, достаточно кликнуть по текстовому полю — и откроется диалоговое окно, где можно будет выбрать файл или папку.
  • Строки ниже: «Сервер», «Пользователь», «Пароль» и «Контейнер» содержат нужные для входа и авторизации данные. Сервер указан в документации, пароль выбираете вы сами, а пользователь присваивается автоматически, найти его можно тут:
Сделай сам: котик манеки-неко, который сделает бэкап в облако
  • Строка «Контейнер» содержит имя контейнера, который вы создаёте сами на сервере и куда будут сохраняться файлы.

  • Справа указаны служебные данные: имя файла, размер, расширение (если указана папка — будет .zip) и количество файлов в папке, если отправляется папка целиком.

  • Внизу строка состояния. После закрытия программы введённые данные сохраняются в настройках приложения (в том числе состояния чекбоксов и указанные пути). Утилита прячется в трей (область уведомлений) при сворачивании, чтобы она не мешала на панели задач и работала всегда в фоновом режиме.

Инициализация программы происходит так:

public Main_window() { InitializeComponent(); // Функции для сворачивания в трей notifyIcon.Visible = false; // Прячем иконку из трея // Добавляем событие по второму клику мышки, вызывая функцию notifyIcon1_MouseDoubleClick this.notifyIcon.MouseDoubleClick += new MouseEventHandler(notifyIcon1_MouseDoubleClick); this.Resize += new System.EventHandler(this.Form1_Resize); // Добавляем событие на изменение окна // Получаем сохранённые ранее данные из настроек _ftpServerIp = (string) Settings.Default["Server"]; // Адрес сервера _user = (string) Settings.Default["User"]; // Имя пользователя _password = (string) Settings.Default["Password"]; // Пароль _ftpContiner = (string) Settings.Default["Container"]; // Название контейнера _path = (string) Settings.Default["Path"]; // Путь к файлу/папке _name = (string) Settings.Default["FileName"]; // Имя файла/архива _extension = (string) Settings.Default["Extension"]; // Расширение // Присваиваем соответствующим текстовым полям сохранённые ранее значения textServer.Text = _ftpServerIp; textUser.Text = _user; textPass.Text = _password; textContainer.Text = _ftpContiner; textPath.Text = _path; textFileName.Text = _name; textExtension.Text = _extension; replace = (bool) Settings.Default["Replace"]; // Получаем значение «Заменить файл» folder = (bool) Settings.Default["Folder"]; // Получаем значение «Папка целиком» // Устанавливаем сохранённые значения чекбоксов checkReplace.Checked = replace; checkFolder.Checked = folder; // В зависимости от значения чекбокса «Папка целиком» устанавливаем соответствующую подпись и включаем или отключаем надписи if (folder) { labelPath.Text = "ПУТЬ К ПАПКЕ"; textCount.Enabled = true; labelCount.Enabled = true; } else { labelPath.Text = "ПУТЬ К ФАЙЛУ"; textCount.Enabled = false; labelCount.Enabled = false; } }

Сразу отправляем программу в трей и скрываем её иконку, если программа развёрнута. Если свёрнута — иконка висит в трее. Получаем записанные значения из настроек для всех вводимых нами данных, а именно: сервер, имя пользователя, пароль, контейнер, путь и так далее.

Дальше — магия подключения к Arduino.

Выбор порта

// Передаём в комбобокс список портов по клику по самому комбобоксу private void comboBox1_DropDown(object sender, EventArgs e) // При открытии комбобокса обновляем список портов { portList.Items.Clear(); // Очищаем список портов в комбобоксе string[] ports = SerialPort.GetPortNames(); // Создаём список портов foreach(string port in ports) portList.Items.Add(port); // И отправляем его в комбобокс } // По клику по кнопке «Подключить» срабатывает код ниже private void btn_connect_Click(object sender, EventArgs e) // Клик по кнопке { if (!port.IsOpen) // Если порт закрыт { try // Пробуем { port.PortName = portList.SelectedItem.ToString(); // Используем выбранный порт port.BaudRate = 9600; // Задаём скорость порта port.Open(); // Открываем порт // Создаём слушатель принятого сообщения port.DataReceived += new SerialDataReceivedEventHandler(Port_DataReceived); } catch // Не получилось { textStatus.Text = "Невозможно подключиться. Порт занят"; } if (port.IsOpen) // Если порт открыли { btn_connect.Text = "ОТКЛЮЧИТЬСЯ"; // Меняем надпись на кнопке textStatus.Text = "Подключено к " + port.PortName; // Обновляем текст статуса port.Write("1"); // При подключении котик мяукает } } else // Если порт открыт { port.Close(); // Закрываем порт btn_connect.Text = "ПОДКЛЮЧИТЬСЯ"; // Меняем надпись на кнопке textStatus.Text = "Отключено"; // Обновляем текст статуса } }

Указываем в настройках ту же скорость порта 9600, что и в скетче Arduino. При подключении надпись на кнопке «Подключить» меняется на «Отключиться», как и её функция. Также меняется надпись в статусной строке. Код ниже реализует приём сообщения через COM-порт и его обработку.

Приём сообщения

// Слушатель приёма сообщения, который мы создали выше private void Port_DataReceived(object sender, System.IO.Ports.SerialDataReceivedEventArgs e) { if (port.IsOpen) // Если порт открыт { Thread.Sleep(10); // Задержка для приёма всего сообщения String str = port.ReadExisting(); // Читаем все пришедшие байты // Вырезаем символы переноса строки и новой строки String[] strArr = str.Split(separator, StringSplitOptions.RemoveEmptyEntries); LabelsRefreshSafe(strArr); // Передаём строку в другой поток } } private void LabelsRefreshSafe(String[] strArr) // Потоково безопасный метод { if (this.InvokeRequired) this.Invoke(new Action < String[] > ((d) => LabelsRefresh(d)), new Object[] { strArr }); else LabelsRefresh(strArr); } private void LabelsRefresh(String[] str) // Обычный метод { for (int i = 0; i < str.Length; i++) // Собираем строку по символам { data = str[i].ToString(); // Наша строка if (data == "2") sendFTP(); // Если пришла цифра 2, вызываем функцию отправки файла на сервер } }

Ключевая строка:

if (data == "2") sendFTP()

В самом начале в коде Arduino мы прописали отправку символа 2 по нажатию на лапку. В этой строке мы его принимаем и реагируем вызовом функции sendFTP(), которая будет ниже.

Формируем строку (string uri), в которой будет содержаться хост, имя папки и имя отправляемого файла. Затем эту строку мы передаём в функцию

WebRequest.Create(uri)

которая создаёт FTP-запрос. После чего открываем соединение

NetworkCredential(this._user, this._password)

с аргументами в виде имени пользователя и пароля и запускаем отправку файла потоком FileStream, передав аргументом путь к файлу.

Отправка файла

private void sendFTP() { string temp_path = _path; // Путь к файлу // Если отправляем папку if (folder) { if (File.Exists(_path + ".zip")) File.Delete(_path + ".zip"); // Проверяем, есть ли такой архив, и если да, то удаляем ZipFile.CreateFromDirectory(_path, _path + ".zip"); // Создаём новый архив temp_path = _path + ".zip"; // Обновляем путь к архиву _extension = ".zip"; // Присваиваем переменной значение ".zip" // Получаем информацию об имени и размере архива, для вывода в текстовые блоки FileInfo fileInfo = new FileInfo(temp_path); // Создаём новый объект FileInfo _name = fileInfo.Name.Substring(0, fileInfo.Name.IndexOf(".")); // Присваиваем переменной имя архива без расширения _size = fileInfo.Length; // Присваиваем переменной размер архива в байтах textFileName.Text = _name; // Присваиваем текстовому блоку имя архива textExtension.Text = ".zip"; // Присваиваем текстовому блоку расширение архива // Присваиваем текстовому блоку размер архива в соответствии с его размером: байты, Кб, Мб, Гб if (_size < 1000) textSize.Text = _size.ToString("0.#") + "Байт"; if (_size > 1000) textSize.Text = (_size / 1000).ToString("0.#") + "Кб"; if (_size > 1000000) textSize.Text = (_size / 1000000).ToString("0.#") + "Мб"; if (_size > 1000000000) textSize.Text = (_size / 1000000000).ToString("0.#") + "Гб"; string[] files = Directory.GetFiles(_path); // Получаем количество файлов в корневой директории textCount.Text = files.Length.ToString(); // Присваиваем текстовому блоку количество файлов } // Присваиваем текстовому блоку размер архива в соответствии с его размером: байты, Кб, Мб, Гб if (_size < 1000) textSize.Text = _size.ToString("0.#") + "Байт"; if (_size > 1000) textSize.Text = (_size / 1000).ToString("0.#") + "Кб"; if (_size > 1000000) textSize.Text = (_size / 1000000).ToString("0.#") + "Мб"; if (_size > 1000000000) textSize.Text = (_size / 1000000000).ToString("0.#") + "Гб"; try { //Запрос if (replace) _date = ""; // Пустая строка else _date = DateTime.Now.ToString("_MM:dd_HH:mm:ss"); // Строка с датой и временем string uri = "ftp://" + this._ftpServerIp // Строка + "/" + this._ftpContiner // Имя хоста + "/" + this._name // Имя файла + this._date // Строка с датой и временем + this._extension; // Строка с расширением файла FtpWebRequest FTPR = (FtpWebRequest) WebRequest.Create(uri); // Создаём запрос FTPR.Credentials = new NetworkCredential(this._user, this._password); // Отправляем учётные данные FTPR.Method = WebRequestMethods.Ftp.UploadFile; // Загрузка файла FTPR.Proxy = null; // Не используем прокси FTPR.UseBinary = true; // Вариант либо ASCII (текст) либо Binary (что угодно) FTPR.KeepAlive = true; // Закрывать ли подключение после завершения запроса // Запись файла: в качестве аргумента передаём полный путь к файлу using(FileStream fileStream = File.OpenRead(temp_path)) { FTPR.ContentLength = fileStream.Length; using(Stream ftpRequestStream = FTPR.GetRequestStream()) { byte[] buffer = new byte[4096]; int cnt = 0; do { cnt = fileStream.Read(buffer, 0, buffer.Length); if (cnt == 0) { break; } ftpRequestStream.Write(buffer, 0, cnt); } while (true); } } using(FtpWebResponse ftpResp = (FtpWebResponse) FTPR.GetResponse()) // Запрос на ответ { if (port.IsOpen) // Если порт открыт { if (ftpResp.StatusCode == FtpStatusCode.ClosingData) { textStatus.Text = "Отправка произведена успешно"; // Обновляем текст статуса port.Write("1"); // Всё хорошо — отправляем «1» } else port.Write("0"); // Что-то не так на сервере — отправляем «0» } } } catch (WebException) { textStatus.Text = "Проблемы с ответом от сервера"; // Обновляем текст статуса port.Write("0"); // Что-то не так с интернетом — отправляем «0» } catch (Exception) { textStatus.Text = "Проблемы с программой"; // Обновляем текст статуса port.Write("0"); // Что-то не так с приложением — отправляем «0» }; if (folder) File.Delete(temp_path); }

Если в качестве отправляемого файла выбрана папка, а не документ, то здесь же мы её предварительно архивируем:

ZipFile.CreateFromDirectory(_path, _path + ".zip")

В качестве аргумента указывается папка для архивирования и путь, куда сохранить архив. После отправки архив будет автоматически удалён с диска. Исходник программы подробным образом прокомментирован — можно ознакомиться с полной функциональностью приложения.

Тестируем

Для компиляции и запуска приложения нажмём F5 или зелёную стрелочку в Visual Studio. Если мы не совершили ошибок, программа запустится, и нужно будет выбрать порт, к которому подключён кот, и нажать «Подключиться». Кот должен мяукнуть. Если звука нет — выбран неверный порт.

Теперь настроим подключение к серверу, введя необходимые данные. Не забываем выбрать файл или папку для отправки. Для начала рекомендую выбрать файл размером поменьше. «Даём пять», и кот говорит: «Мяу». Если звука нет, то дело может быть в качестве интернет-соединения.

С каждыми ударом по лапке на сервере прибавляется по одному новому файлу:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

К оригинальному имени прибавляется время отправки. Если в программе мы поставим галочку «Заменять файл», то появится файл, как на скриншоте ниже. При каждом новом ударе он будет заменяться свежей версией:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Укажем в настройках «Папку целиком», и вот что будет с разными вариантами галочки «Заменять файл»:

Сделай сам: котик манеки-неко, который сделает бэкап в облако

Добавились новые файлы с расширением .zip, как с версией без даты (а значит, она заменяема), так и уникальные — с датами.

Вот как выглядит этот процесс со звуком:

Исходники клиента и прошивки Arduino — на GitHub. В исходниках добавлены различные варианты исключений, которые можно обрабатывать по своему усмотрению. Например, можно заставить кота озвучивать случай неподключённого приложения.

2323
6 комментариев

Котики - это всегда хорошо )

3
Ответить

К каждой строке кода по комментарию))) побольше б таких программистов! 

3
Ответить

"потому что могу") классная штука!

1
Ответить

Фольга? Геркон не дано было поставить?

Ответить

Мне нравится, что-то производить своими руками, вот еще неплохо на этом зарабатывать. Хочу производить такие вот https://graverton.com.ua/nagrady-iz-stekla/ изделия из стекла, возможно еще добавить разнообразия и креатива. Давайте продолжим приводить примеры. Чтобы еще из стекла или может не из стекла, вы бы предложили? 

Ответить

Интересная статья!

Ответить