bobaflu — программируем аксессуары на flutter


    В данной статье речь пойдет о реализации мобильного клиента Flutter.


    Какого именно мобильного клиента?


    В предыдущей публикации описана система программных аксессуаров:
    bobaoskit — аксессуары, dnssd и WebSocket.


    Аналог программного аксессуара — реальный объект. Лампочка, переключатель, cd/кассетный проигрыватель, радио плеер, термостат, датчик температуры, датчик движения и т.д… Набор аксессуаров определяется фантазией и программным кодом. Можно реализовать хоть шахматную доску. Для такой доски надо иметь поле управления(control) move, принимающее объект { from: "e2", to: "e4" } для примера и сервисные поля для сброса фигур и т.д… Скрипт аксессуара обработает запрос на управление полем move, примет решение можно ли перемещать фигуру, и вернет(или нет) статус с положением фигур на всем поле.


    На текущий момент поддерживаемые типы аксессуаров с минимальным функционалом следующие: "switch", "temperature sensor", "thermostat", "radio player".


    Про шахматы далее речи не пойдет. Если интересно и в таком случае, добро пожаловать под кат.


    Итак, bobaoskit.worker запущен. Объекты аксессуаров существуют в памяти компьютера, можно прочитать информацию о них, можно вручную отправить JSON запрос на WebSocket порт, видеть входящие события.


    Для управления я сделал простейшее мобильное приложение.


    Почему Flutter?


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


    Appcelerator. Начинал изучение с него. Если не изменяет память, SDK открыто, а вот IDE с различными тарифами.
    NativeScript. Тут уже создал простое приложение, показывающее список с картинками. Дальше дело не пошло.
    ReactNative. Самый длинный штурм из перечисленных до этого момента фреймворков. Самая большая сложность — начать. Посмотрел курс. Поначалу понятно, интересно, получается. А вот redux и после осилить не удалось. Потом регулярно пытался начать писать, но redux упорно не давал себя осилить.


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


    Ближе к осени прошлого(2018го) уже шла работа над sdk для программных аксессуаров. Естественно, нужно мобильное приложение. Все началось с mdns. Как-то в свободную минуту обновил ReactNative, нашел плагин react-native-zeroconf, создал приложение. По инструкции установил, сделал link, запустил. Запустилось приложение Expo для отладки, которое не поддерживает нативные модули, и, соответственно, плагин для mdns не работал. К этому моменту свободного времени не хватало на то, чтобы создать чистое(без expo) react-native приложение и протестировать с ним. Работа была отложена на пару месяцев.


    В это же время все больше и больше материалов появлялось про flutter в сети. Установил себе. Установка проста: git clone и добавить в PATH. Остальное — уже настройка Android SDK/Xcode(в моем случае Android SDK уже давно был настроен. Для iOS разрабатывать не могу, поскольку не являюсь пользователем macOS) и Dart SDK(можно установить отдельно, но не обязательно, поскольку есть в составе flutter).


    Принцип/схема работы


    • При запуске приложение ищет сервисы _bobaoskit._tcp в локальной сети с помощью плагина flutter_mdns. Есть несколько версий этого плагина, все берут корни от опубликованного, но он не совместим с новыми версиями Dart SDK, соответственно, многие форкнули и добавили совместимость. Я выбрал именно эту версию, поскольку другие не определяли(resolve) хосты сразу нескольких обнаруженных сервисов.
      При обнаружении и определении(onResolve), хост добавляется в список.
      Страница со списком обнаруженных сервисов — StatefulWidget, соответственно, при обнаружении/потере сервисов вызывается setState() {...}
    • При выборе хоста из списка создается новая страница(также StatefulWidget), которой передается host и port выбранного сервиса.
      Создается объект BobaosKit, ответственный за коммуникации. Ответы обрабатываются посредством коллбеков, т.к. пока асинхронный dart я сильно не изучал. Но, судя по просмотренной документации, Futures — аналог Promise-ов в JS.
      Регистрируются функции для входящий событий(не ответов). Здесь искал EventEmitter для Dart. Написал очень простой свой.

      void registerListener(String name, Function cb) {
        this._events.add(new BobaosKitCallback(name, cb));
      }
    
      void removeAllListeners() {
        this._events = [];
      }
    
      void emitEvent(String name, dynamic params) {
        // call all listeners
        List<BobaosKitCallback> foundCallbacks =
                         this._events.where((t) => t.name == name).toList();
        foundCallbacks.forEach((f) => f.cb(params));
      }
      ...
      ...
      void listen() {
        this.ws.listen((text) {
          var json = jsonDecode(text);
          if (json.containsKey('response_id')) {
             ....
          } else {
            // без поля response_id - событие
            this.emitEvent(json['method'], json['payload']);
          }
        });
      }

    Входящие события — если аксессуар был удален, добавлен, обновил статус. Либо если все аксессуары удалены(clear accessories).


    Регистрируемые функции — для обновления списков, виджетов по этим событиям.


    • Отправляется запрос на получение информации о всех аксессуарах.

    Для каждого аксессуара создается объект AccessoryInfo:


    import 'package:scoped_model/scoped_model.dart';
    
    // AccessoryInfo extends Model
    // so, when accessory value is updated it descends down to
    // all widgets inside ScopedModelDescendant
    class AccessoryInfo extends Model {
      dynamic id;
      dynamic type;
      String name;
      String job_channel;
      List control;
      List status;
      bool selected;
      Map<dynamic, dynamic> currentState;
    
      AccessoryInfo(Map<dynamic, dynamic> obj) {
        this.id = obj['id'];
        this.type = obj['type'];
        this.name = obj['name'];
        this.job_channel = obj['job_channel'];
        this.control = obj['control'];
        this.status = obj['status'];
        this.currentState = {};
      }
    
      void updateCurrentState(key, value) {
        currentState[key] = value;
        notifyListeners();
      }
    
      void notify() {
        notifyListeners();
      }
    }

    этот объект уже — модель. Изначально я писал везде StatefulWidget и setState() {}, но setState() {} работает только для виджета, внутри которого регистрировались слушатели. Но для детального управления аксессуаром я создавал изначально новые Stateful страницы, и заметил, что статус не обновляется. Как решение — использовал ScopedModel.


    После того как список аксессуаров получен, для каждого из них отправляем запрос на получение состояния и добавляем в список List <AccessoryInfo>. Вызываем setState() {}, таким образом добавляя поддерживаемый аксессуар в интерфейс. Поддерживаемые типы аксессуаров определены в ListView.builder и в ./lib/widgets/*.dart. Пока поддерживаются switch/temperature sensor/radio player/thermostat. Основная работа впереди — добавлять новые, улучшать существующие виджеты.


    • Теперь о том как создаются отдельные элементы для каждого аксессуара. Рассмотрим для примера переключатель(switch).

      @override
      Widget build(BuildContext context) {
        return new ScopedModel<AccessoryInfo>(
            model: info,
            child: ScopedModelDescendant<AccessoryInfo>(
              builder: (context, child, model) {
                var cardColor = Theme.of(context).cardColor;
                dynamic switchState = model.currentState['state'];
                if (switchState is bool) {
                  if (switchState) {
                    cardColor = Colors.deepPurple;
                  } else {
                    cardColor = Theme.of(context).cardColor;
                  }
                }
                return Card(
                    color: cardColor,
                    child: ListTile(
                      selected: false,
                      leading: new Icon(Icons.lightbulb_outline),
                      title: new Text("${model.name}"),
                      onTap: () {
                        // to control accessory value
                        // get status value at first
                        bobaos.getStatusValue(
                         model.id, "state", (bool err, Object payload) {
    
                          if (err) {
                            return print('error ocurred $payload');
                          }
    
                          if (payload is Map) {
                            dynamic currentValue = payload['status']['value'];
                            bool newValue;
                            if (currentValue is bool) {
                              // invert
                              newValue = !currentValue;
                            } else {
                              newValue = false;
                            }
                            // then send new value
                            bobaos.controlAccessoryValue(
                              model.id, {"state": newValue}, (bool err, Object payload) {  
    
                              if (err) {
                                return print('error ocurred $payload');
                              }
                            });
                          }
                        });
                      },
                      onLongPress: () {
                        // TODO: dialog with additional funcs
                     },
                    ));
              }));
        }

    Для аксессуара типа switch создается элемент в общем списке аксессуаров, при взаимодействии с которым(onTap) отправляется запрос на получение текущего значения, затем на переключение этого значения. ScopedModel позволяет перерисовать виджет при входящих обновлениях статуса.


    Для данного аксессуара не реализован обработчик длинного нажатия.


    Для радио проигрывателя оно выглядит так:


      onLongPress: () {
        Navigator.of(context).push(MaterialPageRoute(
          builder: (context) => AccRadioPlayerControl(
            info: info,
            bobaos: bobaos,
          )));
        },

    Открывается страница AccRadioPlayerControl, которая также использует ScopedModel для управления состоянием.


    На этом весь описание алгоритма работы программы исчерпывается. Никаких дополнительных возможностей, таких как запоминание последнего хоста, сортировки аксессуаров по категориям/комнатам не реализовано. На текущий момент все просто.


    Проблемы


    Опишу основную проблему, которая есть сейчас. Так и не понял как обнаружить разрыв WebSocket соединения.


    Использую: WebSocket class.


    При длительном нахождении приложения во сне/устройства в заблокированном режиме соединение разрывается. Приходится возвращаться на самую первую страницу и заново открывать обнаруженный сервис.


    Послесловие


    С одной стороны, Flutter довольно быстр в изучении и разработке. ScopedModel для меня оказалось понятнее redux.
    Dart оказался похожим на привычный JavaScript. Типизация + динамичные типы позволят каждому писать как удобно.


    Сложности при написании кода: большая вложенность виджетов. На всем известный callback hell после flutter-а смотришь уже по другому. Vim-mode и % будут полезны.


    Теперь немного мыслей насчет IoT. В последнее время все больше умных устройств/сервисов, которые требуют регистрации в облаке. Китайские розетки, для пользования которыми необходимо установить приложение, создать аккаунт, и лишь после этого можно пользоваться.


    Голосовые помощники. Алиса от Яндекса требует своего облака в которое отправляется распознанный текст. Alexa от Amazon работает похожим образом.


    Удачнее всего, по моему мнению, сделан Apple HomeKit в связке с Siri. Облако используется для распознавания текста. Взаимодействие с устройствами — в локальной сети.


    Мое мнение состоит в том, что облако должно существовать для своей цели: удаленное управление, обновление, и т.д… Если устройством можно управлять в локальной сети, то надо так и делать.


    Ссылки


    1. Репозиторий приложения
    2. Документация по bobaoskit — описано как установить bobaoskit.worker и запустить аксессуар radio player.
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 2

      +1
      Сложности при написании кода: большая вложенность виджетов.

      Я например разбиваю на функции возвращающие Widget и все никакого кошмара.


      @override
        Widget build(BuildContext context) {
         return
           new Column(
           children: [new Container(
           padding: const EdgeInsets.only(top:8.0),
             child: Row(
             mainAxisAlignment: MainAxisAlignment.spaceAround,
               crossAxisAlignment:CrossAxisAlignment.start,
             children: [
               rbuildButtonColumn(context,'img/data.png', 'Загрузить',pressdownload),
               rbuildButtonColumn(context,'img/doc_pokupka.png', 'Выгрузить',pressupload),
                   ],
           ),
         )
           ,getprogress(context)]
           );
        }

      UI Загружающая выгружающая sqlite базу.
      rbuildButtonColumn Строит кнопку с рисунком с логикой pressdownload/pressupload Загрузить /Выгрузить
      а ,getprogress(context)] Рисует Прогрес индикатор, а также, что загружено успешно, или ошибку.(с рисуночками соответствующими)
      И ,getprogress(context)] Тоже разбит


       Widget getprogress(BuildContext context){
          switch (downloadstate){
            case 1:return getdownloading(context);break;
            case -1:return geterror(context);break;
            case 2:return getcompleted(context);break;
            default: return new Container();
          }
        0
        Спасибо за подсказку. Основная сложность для меня в том, что некоторые виджеты типа Row, SizedBox и т.д. надо проверять и изучать как делать лучше, какой элемент должен быть внутри, какой снаружи. Оборачивать весь виджет, либо перемещать его уровнем выше. vim-mode вместе с vim-регистрами очень полезны в этом случае.

      Only users with full accounts can post comments. Log in, please.