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

«Если это действительно важно, то отмечайте даты на упаковке, используйте таблетницу или заведите будильник на телефоне», — скажете вы. Да, это решение. Но у меня есть фитнес-браслет, который, как заявляется, следит за моим здоровьем. Почему бы не научить его отвечать на вопрос «Не забыл ли я “…”»?

Привет, Хабр! Меня зовут Вова, я разработчик в Selectel. В тексте я расскажу про тернистый путь разработки под Mi Smart Band 7 и объясню важные детали для быстрого старта. В результате получится циферблат, который позволяет считать количество наиболее важных рутинных действий за день. Бонусом расскажу о приложении для броска кубиков 1d20 и 2d20.

Используйте навигацию, если не хотите читать полностью:

Постановка задачи
Программно-аппаратная платформа
Разработка красивого приложения
Разработка функционального приложения
Модификация циферблата
Заключение

Постановка задачи


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

  1. При выполнении ежедневного действия человек вводит информацию о нем в фитнес-браслет. К этому нужно привыкнуть.
  2. Когда возникнет мысль «Я забыл сделать …», можно посмотреть на браслет и точно определить, забыл или нет.
  3. Ночью фитнес-браслет автоматически сбрасывает занесенные действия, чтобы после пробуждения можно было отмечать их «с чистого листа».

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

  • К сожалению или счастью, браслет не может достоверно определять микродействия человека, поэтому вносить информацию нужно вручную. Однако хочется сократить количество дополнительных действий до минимума.
  • Решение должно быть относительно универсальным. Сегодня, например, актуально отмечать утреннюю разминку, завтра — количество чисток зубов, а послезавтра — посчитать, сколько раз коллеги позвали на чай.

Посмотрим, что у нас есть и как с этим работать.



Программно-аппаратная платформа


В моем распоряжении есть фитнес-браслет Mi Smart Band 7, который называют по старой памяти Mi Band’ом. Он работает на операционной системе ZeppOS, как и другие современные продукты бренда Amazfit. Эта ОС позволяет разрабатывать циферблаты и отдельные приложения на браслеты и часы. Восьмое и актуальное девятое поколения Mi Smart Band работают на собственном решении Xiaomi, где пока нет такого разнообразия.

Официально седьмое поколение не поддерживает ZeppOS, так что сложилась достаточно грустная ситуация для энтузиастов. Браслет Xiaomi работает с Mi Fitness и Zepp Life, которые не используют все доступные функции ZeppOS, в том числе возможность устанавливать приложения, а Zepp и инструментарий разработки неохотно работают со Smart Band 7.

Два года назад Vadim170 в обзоре описывал «техническую магию», которая обманывает приложение Zepp и выдает Mi Smart Band 7 за Amazfit Band 7. У этих браслетов разное разрешение экрана, так что через официальное приложение ставить циферблаты больше не получится. Однако можно снять некоторые ограничения устройства, в том числе использовать режим разработчика — Bridge.

Внимательное изучение форума 4pda привело меня к следующим выводам.

  • Писать придется на языке JavaScript.
  • Файлы ресурсов (изображения) кодируются в специфичных форматах.
  • Умные люди уже давно сделали инструменты для разработки под Mi Smart Band 7.
  • Официальная документация ZeppOS местами некорректна и требует уточнений.
  • Некоторые функции работают неочевидно и непривычно. Например, поворот виджета-изображения поворачивает не сам виджет, а картинку внутри, обрезая ее края. Нужно самостоятельно вычислить размер виджета, который вместит повернутое изображение.
  • Использование «ограниченных» функций ZeppOS возможно, но будут сюрпризы.

Для знакомства с инструментарием я поставил отвлеченную цель: разработать «с нуля» приложение, которое будет генерировать случайные числа от 1 до 20. Иначе говоря, имитировать бросок кубика 1d20. Звучит легко, приступаем.

Разработка красивого приложения



Zepp OS Simulator c Mi Smart Band 7.

Сперва я посмотрел в сторону официальных инструментов, в том числе симулятора устройств на ZeppOS. Приложение называется simulator и запускает QEMU с прошивкой устройства. По умолчанию тут нет Mi Smart Band 7, но после модернизации файлов симулятора его можно скачать.

При использовании официальных инструментов я почти сразу столкнулся с проблемами.

  • Приложение simulator «падает» на Ubuntu 24.04, но на Windows запускается.
  • Чтобы скачать симулятор устройства, нужно войти с профилем Zepp. В веб-версии есть кнопки логина через другие сервисы, например, Xiaomi, а внутри simulator вход только по логину и паролю. Пришлось создавать отдельный аккаунт.
  • Сама виртуальная машина создает некоторые неудобства: при фокусе в «браслет» иногда залипает нажатие по экрану. Решение — снять фокус комбинацией клавиш Ctrl+Alt+G.
  • На 4pda отмечают, что поведение JS в эмуляторе и на реальном устройстве отличается.


ZeppPlayer.

К счастью, существует альтернатива, разработанная пользователем 4pda MelianMikoZeppPlayer. Симулятор предоставляет API, доступные циферблатам и приложениям, но выполняет их полностью в браузерном движке JS. В сравнении с официальным симулятором, ZeppPlayer обладает рядом преимуществ. Рассмотрим ключевые.

  • Не нужны пересборка и деплой приложения: ZeppPlayer работает с исходными файлами напрямую и автоматически применяет обновления.
  • Можно не перекодировать изображения в формат, совместимый с ZeppOS.
  • Удобно создавать и тестировать циферблаты: время, дату и показания датчиков можно задавать самостоятельно. Также есть отдельная кнопка для автоматического создания анимированного предпросмотра.
  • Есть возможность заглянуть в структуру циферблата или приложения.

Однако, несмотря на удобства, нужно помнить, что из аппаратной части браслета здесь только скругленная форма экрана. На «реальном железе» будет другой интерпретатор JS и ограниченные вычислительные ресурсы.

В своем Telegram-канале я писал заметку про JavaScript Mi Smart Band 7. Подписывайтесь, там можно увидеть заметки по темам статей, над которыми я работаю, и небольшие познавательные посты.

Помимо ZeppPlayer, MelianMiko создал утилиту zmake, которая конвертирует ресурсы в пригодный для ZeppOS формат и собирает «бинарный» файл для установки на браслет. Утилита также может создавать шаблоны приложений и циферблатов.

Создадим приложение в каталоге для ZeppPlayer.

E:\sb7\zmake>mkdir E:\sb7\ZeppPlayer\projects\habr
E:\sb7\zmake>zmake.exe E:\sb7\ZeppPlayer\projects\habr
Use config files:
  E:\sb7\zmake\zmake.json
We think that you want to create new project in this empty dir
Select new project type:
w - Watchface
a - Application
['w', 'a'] > a
E:\sb7\zmake>

Выбираем проект в ZeppPlayer и… Получаем ошибку из-за повторного определения идентификатора __$$module$$__.


Ошибка в шаблоне проекта.

Однако решение есть. Помещаем весь код файла habr/page/index.js в анонимную лямбда-функцию:

(() => {
  // Здесь код, который был в файле.
})();

Этот вызов анонимной лямбда-функции встречается во многих проектах и присутствует во всех js-файлах, кроме точки входа — app.js. Кратко структура проекта выглядит так:

habr/
  assets/ - тут ресурсы (изображения), допускается вложенность
  page/ - тут js-файлы, реализующие страницы приложения или циферблата
  app.js - точка входа в приложение
  app.json - «манифест», описание приложения или циферблата

Подробно и понятно структуру проекта объяснил пользователь nusuth в своем посте на 4pda. Приложение от циферблата отличается значениями параметров в app.json и доступностью виджетов. Для приложения доступны только виджеты приложений, а для циферблатов — виджеты циферблатов и приложений.

Моя первоначальная задумка — анимация броска кубика, как в игре Baldur’s Gate 3. Некоторых злит эта механика, но мне она показалась интересной. Анимацию броска можно разделить на две части: заготовленная анимация с размытием кубика и проявление результата. Для прототипа достаточно одной, которая показывает фиксированный результат. Будем решать проблемы итеративно, по мере их поступления.

В документации есть виджет IMG_ANIM, который проигрывает анимацию из отдельных кадров. Отлично, добавляем его в page/index.js.

(() => {
        let __$$app$$__ = __$$hmAppManager$$__.currentApp;
        let __$$module$$__ = __$$app$$__.current;
        __$$module$$__.module = DeviceRuntimeCore.Page({
          onInit() {
                hmUI.createWidget(hmUI.widget.IMG_ANIM, {
                        x: 0, 
                        y: 0, 
                        anim_path: "dice",
                        anim_prefix: "roll",
                        anim_ext: "png",
                        anim_fps: 30,
                        repeat_count: 1,
                        anim_size: 91,
                        anim_status: hmUI.anim_status.START,
                });
          },
          onDestroy() {
                // On destroy, remove if not required
          }
        });
})();

Виджет создает анимацию из файлов, имя которых формируется так: assets/{anim_path}/{anim_prefix}_{i}.{anim_ext}, причем число i меняется от 0 до anim_size-1. Я щедро выставил анимации на 30 кадров в секунду, надеясь, что браслет справится.


Пришлось открыть Blender.

К сожалению, не получилось найти анимацию броска кубика, которую я бы мог переиспользовать в проекте. Взял 3D-модель двадцатигранника, которая распространяется по лицензии CC BY-SA 4.0, по паре видеоуроков сделал анимацию в Blender и отрендерил 90 кадров анимации + 91 кадр с результатом.


Рендер анимации.

Здесь у меня закрались первые подозрения. ZeppPlayer позволяет выставить любое значение для параметра fps. Можно поставить даже 60 или 120 и браузер без проблем отрисует анимацию, в то время как у браслета по субъективным ощущениям экран способен выдавать около 30 кадров в секунду. Проверим на практике и соберем приложение для деплоя на браслет.

E:\sb7\zmake>zmake.exe e:\sb7\ZeppPlayer\projects\dice
Use config files:
  E:\sb7\zmake\zmake.json
We think that you want... build this project
Processing app.json:
  Done
Processing assets:
  91 saved in TGA-32 format
Copying common files:
  Done
Processing app.js:
Done
Processing "page" JS files:
  Copied 1 files
  Post-processed 1 files
Packaging:
Skip: \.gitignore
  Created BIN/ZIP files
Completed without error.
E:\sb7\zmake>

После сборки в проекте появляются каталоги build и dist. В первом проект, готовый к упаковке, а во втором — bin-файл и zip-архив с bin-файлом.

После первой сборки и обновления страницы ZeppPlayer будет обращаться не к исходному коду проекта, а к проекту, собранному в каталоге build. Удалите каталог build и обновите страницу, чтобы продолжать отладку без постоянной сборки.

Установка приложений и сторонних циферблатов осуществляется одинаково — через приложение «Mi Band 7 Циферблаты от Mi Band Watch Face Makers».

  1. Перекидываем bin-файл на телефон.
  2. Запускаем Zepp Life и ожидаем подключения к браслету.
  3. Заходим в стороннее приложение, выбираем bin-файл и прямую установку.

Мне так и не удалось узнать, способен ли Mi Band воспроизвести такую анимацию. Браслет выводил предупреждение о нехватке места на накопителе каждые 30 секунд, а само приложение запускалось только в виде черного экрана.

Здесь я попался в еще одну ловушку «неофициальности» ZeppOS: приложения можно ставить, но не удалять! Решения два: сделать полный сброс браслета или установить Toolbox от MelianMiko и с его помощью удалить свое приложение. Я удалил пару циферблатов и поймав момент, когда браслет перестал жаловаться на память, поставил Toolbox и избавился от своего приложения.

Реалистичные анимации — это не про Mi Smart Band 7.

Разработка функционального приложения



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

  • Бросок с преимуществом, два кубика, подсвечивается больший результат.
  • Обычный бросок.
  • Бросок с помехой, два кубика, подсвечивается меньший результат.

Второй экран — случайно сгенерированные числа с соответствующим оформлением. Начнем с первого.

(() => {
        let __$$app$$__ = __$$hmAppManager$$__.currentApp;
        let __$$module$$__ = __$$app$$__.current;
        __$$module$$__.module = DeviceRuntimeCore.Page({
          onInit() {
           // Повторить три раза...     
                hmUI.createWidget(hmUI.widget.IMG, {
                        x: 29, 
                        y: 50, // ...с разными y-координатами, ...
                        src: "roll/d20-adv.png",
                }).addEventListener(hmUI.event.CLICK_DOWN, (info) => {
                        hmApp.gotoPage({
                                url: "page/d20",
                                param: "adv" // ... и аргументами
                        });
                })
          },
          onDestroy() {}
        });
})();

Примерно так выглядит основная механика: создаем виджет и, если необходимо, добавляем для него реагирование на событие. Для открытия новой страницы нужно вызвать функцию gotoPage из глобального объекта hmApp. Часть глобальных объектов задокументирована в Watchface API, а вот с hmApp придется подглядывать в чужие решения. К счастью, тут все просто: url — это путь до js-файла без расширения внутри каталога page, а param — аргумент, который передается в функцию onInit.

Переходим ко второму экрану: создаем файл page/d20.js.

(() => {
        let __$$app$$__ = __$$hmAppManager$$__.currentApp;
        let __$$module$$__ = __$$app$$__.current;
        __$$module$$__.module = DeviceRuntimeCore.Page({
          onInit(arg) { // функция принимает аргумент от прошлой страницы
                if(arg == "adv" || arg === "dis") {
                        const result = [
                                Math.ceil((Math.random() * 20)),
                                Math.ceil((Math.random() * 20))
                        ];
                        // Рисуем два виджета
                } else {
                        const result = Math.ceil((Math.random() * 20));
                        let color;
                        if(result === 1) {
                                color = 0xff0000; 
                        } else if (result === 20) {
                                color = 0x00ff00;
                        } else {
                                color = 0x000000; 
                        }
                 hmUI.createWidget(hmUI.widget.IMG, {
                                x: 49, 
                                y: 193, 
                                src: "roll/d20-blue.png",
                        })
                 // Каждый следующий виджет перекрывает предыдущий.
                 // Рисуем текст после картинки
                        hmUI.createWidget(hmUI.widget.TEXT, {
                          x: 49,  // Растягиваем текстовое поле
                          y: 193, // на всю картинку
                          w: 93,
                          h: 103,
                          color: color,
                          text_size: 36,
                   // просим виджет центрировать текст
                          align_h: hmUI.align.CENTER_H, // по горизонтали
                          align_v: hmUI.align.CENTER_V, // и вертикали
                          text: result
                        })
                }
          },
          onDestroy() {}
        });
})();

Возврат в главное меню продумывать не нужно: свайп слева направо возвращает на предыдущий экран. Собираем, запускаем и вроде бы все работает. Исходный код приложения доступен на Github, а бинарник для установки на браслет — в релизах. Основы изучили, так что вернемся к исходной задаче.

Модификация циферблата



Понравившийся циферблат. Источник.

Напомню, что одно из пожеланий — минимальное количество действий для занесения информации в браслет, так что разрабатывать приложение — долгий путь. Нужно открыть меню, найти приложение и что-то в нем сделать. Хочется вносить информацию на главном экране, желательно в одно касание.

Я давно использую циферблат Handy от пользователя itBAIT. Особенно мне нравится три круга, на которые можно вывести значения любого из одиннадцати датчиков, а по нажатию на элемент открывается соответствующее приложение. Сюда максимально органично можно вписать счетчик-кликер, который будет увеличивать свое значение на единицу при нажатии.

Я обратился к автору циферблата за разрешением сделать и опубликовать модификацию и получил положительный ответ. Уважайте чужой труд!

Циферблат распространяется в виде bin-файла, который на самом деле zip-архив. Меняем расширение и распаковываем его в каталог projects. Внутри уже известные assets и watchface вместо page. В каталоге watchface лежит index.js — файл, который описывает все виджеты циферблата и его настройки.

В некоторых случаях вместо js-файла будут bin-файлы. Это байт-код интерпретатора, на данный момент нет возможности декомпилировать в JavaScript.

Как оказалось, навыки, полученные при разработке приложения, оказались очень полезны: циферблат — это инициализация различных виджетов в правильном порядке, за исключением нескольких моментов.

  • У циферблата есть настройки (WATCHFACE_EDIT_GROUP) и ОС сама занимается сохранением данных.
  • Большинство виджетов обладают полем type, которое позволяет указать «источник» данных. В этом случае операционная система сама обновляет показания виджетов. Исключение — прогресс-бар для метрики сна. Автор определил функцию, которая считывает метрику сна и задает количество процентов для шкалы прогресса. Она вызывается по таймеру раз в четыре секунды.

Автор «причесал» код перед тем, как опубликовать циферблат, тем самым упростив мне работу. Сперва определяем новый тип виджета и рисуем три пиктограммы: для настроек, цветную и белую.

const widgets = [
    /* id 0-10 удалены для краткости */
    {
        id: 11,
        name: "sleep",
        title: "Sleep time",
        "ru-RU": "\u0412\u0440\u0435\u043C\u044F \u0441\u043D\u0430",
        color: 6291711,
        background: 1900621,
        type: hmUI.data_type.SLEEP,
        url: "Sleep_HomeScreen"
    },<br>    // Новый виджет
    {
        id: 12,
        name: "counter",
        title: "Counter",
        "ru-RU": "\u0421\u0447\u0451\u0442\u0447\u0438\u043a",
        color: 0x9999FF,
        background: 0x5A5A96
    },
    {
        id: 99,
        // empty widget
        title: "Empty",
        "ru-RU": "\u041F\u0443\u0441\u0442\u043E"
    }
];


Пустой виджет.

Теперь виджет можно выбрать в настройках, но у него нет показаний. Их хранение — вопрос, который не освещается в классических циферблатах, так как ОС абстрагирует нас от этого. Использовать глобальные переменные не получится: открытие страницы приводит к полному сбросу закрываемой.

В документации Watchface API есть описание глобального объекта hmFS, который предоставляет функции для работы с файловой системой и временным хранилищем. Затрагивать первую не хочется, а вот временное хранилище ключ-значение — это то, что нужно.

// инициализация круговых виджетов
widgetsEditables.forEach((edit, index) => {
    /* Удалено для краткости */
    const bar = hmUI.createWidget(hmUI.widget.ARC_PROGRESS, /* params */)
    const text = hmUI.createWidget(hmUI.widget.TEXT_IMG, /* params */)
    
    if (name === "sleep") {
        /* Особый обработчик для метрики сна */
    } else if(name === "counter") {
        /* Добавляем свой обработчик для счетчика */
        const callback = () => {
            /* Если день сменился, сбрасываем счетчик */
            const time = hmSensor.createSensor(hmSensor.id.TIME)
            if (hmFS.SysProGetInt("watchface_handy_mod_counter_data_day") !== time.day) {
                hmFS.SysProSetInt("watchface_handy_mod_counter_data_payload", 0)
            }

            /* Выводим значение из хранилища на виджет-цифру и на шкалу прогресса */
            const payload = hmFS.SysProGetInt("watchface_handy_mod_counter_data_payload");
            text.setProperty(hmUI.prop.TEXT, String(payload))
            bar.setProperty(hmUI.prop.MORE, {
                ...barProps,
                level: payload * 10 < 100 ? payload * 10 : 100
            });
        };
        callback();
        timer.createTimer(0, 4e3, callback);
    }
}

Аналогично прописываем нажатие на виджеты
if (options.tapzones) {
    if(name === "counter") {
        onClick(
            () => {
                /* Обнуляем счетчик при смене даты, если этого не произошло ранее */
                const time = hmSensor.createSensor(hmSensor.id.TIME)
                let counter = 0;
                if (hmFS.SysProGetInt("watchface_handy_mod_counter_data_day") === time.day) {
                    counter = hmFS.SysProGetInt("watchface_handy_mod_counter_data_payload")
                }
                /* Увеличиваем на единицу*/
                counter += 1;
                /* Сохраняем в хранилище */
                hmFS.SysProSetInt("watchface_handy_mod_counter_data_day", time.day)
                hmFS.SysProSetInt("watchface_handy_mod_counter_data_payload", counter)
                /* Отображаем на виджетах, не дожидаясь срабатывания таймера */
                text.setProperty(hmUI.prop.TEXT, String(counter))
                bar.setProperty(hmUI.prop.MORE, {
                    ...barProps,
                    level: counter * 10 < 100 ? counter * 10 : 100
                });
            },
            bar,
            icon,
            text
        );
    }
}


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


Демонстрация. При смене времени меняется день и счетчик сбрасывается автоматически.

При модификации циферблата я еще раз столкнулся с отличием ZeppPlayer и ZeppOS:

let counter = 5;

/* Назначение типом number выводит 0 на браслете */
text.setProperty(hmUI.prop.TEXT, counter);

/* Назначение с явной конвертацией к строке выводит 5, как и ожидалось */
text.setProperty(hmUI.prop.TEXT, String(counter));

/* Для ZeppPlayer оба варианта выводят 5 */

Заключение


Получился достаточно универсальный счетчик. Однако хоть он находится на видном месте, все равно теряется во множестве показателей фитнес-браслета. А еще необходимость отмечать на браслете утреннюю рутину приводит к фиксации действия в памяти, так что оригинальная идея реализуется плохо. Но, может, через месяц что-то изменится…

Скачать модифицированный циферблат можно по ссылке. Возможно, позднее появится на 4pda.

Комментарии (9)


  1. Kalobok
    10.10.2024 11:42

    А нельзя было ужать заглавную картинку раз в 10? А еще лучше убрать совсем. Почти 2М бесполезного трафика сэкономилось бы.


    1. Firemoon Автор
      10.10.2024 11:42
      +10

      Жаль, что потраченного трафика уже не вернуть. Но картинку сжали и перезалили!


  1. Alex_Malin
    10.10.2024 11:42
    +2

    Занятно, надо попробовать. А то использую фитнес-браслет только как часы, шагомер и секундомер.


  1. Dudackof
    10.10.2024 11:42
    +2

    Крутяк, взял на заметку


  1. WaterSmith
    10.10.2024 11:42

    Интересно, но не возникнет ли вопроса: "Я забыл выпить таблетку, или просто забыл это отметить?"


  1. inetstar
    10.10.2024 11:42
    +1

    Вот работающая ссылка на исходный циферблат


    https://4pda.to/forum/index.php?showtopic=1048744&st=3180#entry120536624

    А вот ещё один любопытный:

    https://4pda.to/forum/index.php?showtopic=1048744&st=3140#entry120503258


    1. Firemoon Автор
      10.10.2024 11:42
      +1

      Ссылки поправлены, спасибо!


  1. inetstar
    10.10.2024 11:42
    +1

    И ещё нужна информация как установить кастомный циферблат. Записать в папку уже не работает с Zepp Life.


    1. Firemoon Автор
      10.10.2024 11:42

      В самом конце блока «Разработка красивого приложения»:

      Установка приложений и сторонних циферблатов осуществляется одинаково — через приложение «Mi Band 7 Циферблаты от Mi Band Watch Face Makers».

      По крайней мере я так ставлю. Но на 4pda можно найти и более специфичные варианты.