Дисклеймер: весь код проекта написан Claude в режиме диалога. Данная статья процентов на 20 написана им же — по результатам работы над проектом. Не пугайтесь и не плачьте, увидев типичное для ИИ форматирование и слог — если они остались, значит меня устроили его формулировки.
Предыстория и постановка задачи
Однажды жизнь сложилась так, что количество выкуриваемых за день сигарет выпало за грани разумного. Ну то есть совсем за грани — 2+ пачки в день. Поскольку здоровье не казенное, решил немножко сбавить пыл и вернуться к вменяемому количеству. Так я познакомился с трекерами сигарет. Кто не в курсе — это такой класс программ для смартов, который позволяет:
-
посмотреть как давно ты курил
-
посмотреть сколько на сегодня уже скурил
-
отметить очередную сигарету. Ну или не отметить, если тебе не нравятся предыдущие два пункта.
Надо сказать, штука оказалась эффективной. Не чтобы бросить, конечно, хотя авторы программ их обычно так и позиционируют — но чтобы не закуривать «раньше чем через 40-50-60-70 минут от прошлой сигареты» — вполне. В результате загнал количество выкуриваемого в устраивающий меня диапазон и там и держусь.
Практически с первых месяцев использования я осознал недостатки примерное всех программ на рынке (перебрал с десяток). Главное — скорость и необходимость доступа к телефону. Ни одна не поддерживала виджетов, для всех надо как минимум достать телефон, разблокировать, найти программу, запустить, найти кнопку, найти время, кликнуть. Была (да и осталась) мысль сделать навык Сири, но мы же не ищем легких путей.
Поэтому я начал искать альтернативы. Часы — нет, не умеют. По крайней мере бюджетные не умеют. Думал отреверсить что-нибудь, но мои навыки хака и программирования кажется не очень. Следующими по списку были тасбих-счетчики, они же электронные четки. На рынке есть симпатичные, у них работает кликер, но они предназначены только для количества и не регистрируют интервалы. Мультитаймеры для спортсменов — я не нашел с нормальной эргономикой, и у них тоже так себе с интервалами.
И вот пришло начало июня. И на меня внезапно выпал из ящичка зачем-то когда-то купленный nice!nano nrf52840. Из соседнего ящичка выпал OLED-дисплей 0,91″, ну и я вспомнил что у меня давно валяется аккум 161040. 2 недели до ДР любимой жены, которая тоже пользуется трекером сигарет. А почему бы и не да — подумал я. И понеслось
Концепция
Итак, что должно получиться.
Устройство. Компактный брелок — чтобы лежал в кармане рядом с ключами. Одна кнопка, маленький OLED-экран, батарея на несколько недель автономной работы. Симпатичное и тактильно приятное — жена будет пользоваться. Стимпанк/постапок приветствуетеся, но не обязателен.
Экран. Включается по нажатию и гаснет через несколько секунд — чтобы не жечь батарею. Показывает две цифры: сколько сигарет сегодня и сколько времени прошло с последней. Шрифт логотипный моноширинный, похожий на терминалы из «Матрицы».
По отдельному жесту — экран часов, крупные цифры на всю высоту. За рамками функционала, но раз уж есть RTC — надо использовать.
Отдельная история — треугольники. Рядом с каждой цифрой стоит маленький ▲ или ▼. Аналитика в зачаточном состоянии: треугольник у количества сигарет показывает сравнение с вчерашним днём в аналогичный момент времени: ▼ — сегодня курю меньше чем вчера к этому часу, ▲ — больше. Треугольник у таймера — сравнение текущего перерыва с предыдущим: ▼ — перерыв короче чем прошлый, ▲ — длиннее.
В таком виде считывать именно цифры необязательно — для «подсознательной аналитики» принятия решения — курить иль не курить, достаточно треугольника.
Справа — индикатор заряда батареи, шесть горизонтальных полосок снизу вверх.
Управление одной кнопкой. Вся логика строится на трёх типах нажатий и состоянии экрана (вкл/выкл):
|
Жест |
Экран выкл |
Экран вкл |
|---|---|---|
|
Тап |
Показать статистику |
Отметить сигарету |
|
Даблтап |
Показать статистику |
Отменить последнюю |
|
Длинное |
Показать часы |
Включить BLE |
Состояние экрана фиксируется в момент нажатия — чтобы не было путаницы если экран успел погаснуть пока держишь кнопку.
Синхронизация. По BLE (Nordic UART Service) брелок отдаёт лог — список unix-timestamp’ов, по одному на сигарету. Приложение на ноутбуке или телефоне забирает лог и отправляет на сервер. Заодно синхронизирует время на брелоке — RTC держит время пока есть питание, но при разряде сбрасывается.
Бэкенд и PWA. Простой REST API на FastAPI, SQLite, пара эндпоинтов. PWA с Web Bluetooth — открываешь в Chrome, подключаешь брелок, синхронизируешь. Статистика по дням, график, дельта к предыдущему дню.
Статистика в PWA. Ещё одна вещь которую я привык видеть в телефонных трекерах — статистика. График по дням, сколько в среднем, динамика по неделям. Зачем это надо — ну я не очень понимаю, возможно при постановке целей уровня «за три месяца увеличить промежутки на N минут или снижать количество сигарет на 1 каждые 2 недели», но я этим не пользуюсь и не планирую. В PWA добавил на всякий случай аналитику: переключатель сегодня/неделя/месяц, столбчатый график (по часам для текущего дня, по дням для периода), список с дельтой к предыдущему дню.
Железо
Микроконтроллер. Nice!nano — клон с nRF52840, купленный давно и не очень понятно почём. BLE из коробки, встроенный контроллер заряда LiPo, компактный форм-фактор а-ля Pro Micro. Умение глубоко и крепко спать. Плюс Adafruit делает для nRF52840 нормальный Arduino BSP, что для меня было важно.
Дисплей. OLED 0.91″, 128×32 пикселя, I2C. Тоже из ящика.
Аккумулятор. LiPo 101640 от электронной сигареты — плоский, ~200 мАч. Тоже из ящика. Зарядка через USB-C платы, контроллер заряда встроенный.
Кнопка. Тактовая, припаяна к свободному пину. Плюс отдельная кнопка на RST — надоело тыкать пинцетом при прошивке. В корпусе она доступна через отверстие под скрепку.
Итого компонентов на руках — четыре штуки. Казалось бы, садись и паяй.
Детектив с железом.
Первый nice!nano из ящика оказался нерабочим. Симптомы: USB-диск не появляется, двойной ресет не помогает, плата просто мигает. Попытался реанимировать через SWD — взял у знакомого на погонять J-Link клон, подпаял, установил nRF Connect Programmer, OpenOCD, пытался прошивать драйверы через Zadig. Выяснилось что чип живой, но напряжение питания проседает до 1.8В — аппаратная неисправность, лечению не поддаётся. Плата в мусор, заказываем новую. Дедлайн перестал казаться далеким, а вечер — томным.
Новая плата приехала через неделю, завелась, но экран молчал. Сканер I2C не находил устройство ни на каких пинах. Ну ладно, экран из ящика — что с него взять. Купил еще один, благо было в ближнем магазине. Но не может же так свезти!
Клод настаивал что может. Я решил пойти другим путем: нашел какой-то проект для nice!nano с таким дисплеем, он оказался на circuitPy, и экран заработал! Начал разбираться с маппингом пинов — оказалось что в Adafruit BSP для Feather nRF52840 пины SDA/SCL по умолчанию смотрят на P0.12 и P0.11, а физические пины платы — P0.17 и P0.20. В CircuitPython, в REPL запросил board.SDA и board.SCL — получил правильные номера. Вручную исправил variant.h в BSP PlatformIO и экран показал долгожданное «Hello». Ну и сразу вгрузил визуал-концепцию

Прошивка
Еще один дисклеймер: поскольку прошивку, бэкенд и PWA писал не я, не мне и разделы эти заполнять. Мой уровень программирования примерно соответствует «понимаю, но сказать не могу». Поэтому слово Claude, текст не правлю из уважения к автору, свои примечания — курсивом. Если возникнут вопросы по данному разделу (а еще лучше — советы что переделать), пишите обязательно, обсужу с автором и может внедрю, автор сговорчивый.
Стек: PlatformIO + Arduino framework, Adafruit nRF52 BSP, библиотеки U8g2 (дисплей), RTClib (часы), Adafruit LittleFS (файловая система во Flash), Bluefruit (BLE).
Кнопка. Самая нетривиальная часть для одной кнопки — научить её различать три типа нажатий: тап, даблтап и длинное. Плюс учитывать состояние экрана в момент нажатия, а не отпускания — иначе если экран гаснет пока держишь кнопку, команда срабатывает не та.
Логика простая: фиксируем состояние экрана и время нажатия, при отпускании смотрим длительность. Длинное (>1 сек) — обрабатываем сразу. Короткое — ждём 500мс, не пришёл ли второй тап. Дребезг убираем не задержкой при отпускании (она съедала второй тап даблтапа), а минимальной длительностью нажатия: короче 20мс — игнорируем.
Дисплей. U8g2 вместо стандартного Adafruit SSD1306 — ради шрифтов. Конкретно u8g2_font_logisoso24_tn — моноширинный, только цифры, крупный, с нужной эстетикой. Треугольники рисуем примитивами drawTriangle — в шрифтах их нет. Индикатор батареи — шесть прямоугольников справа, заполняются снизу вверх. Напряжение батареи читаем с пина P0.04 через аналоговый вход, коэффициент делителя подобрали эмпирически через CircuitPython.
Хранение. Каждая сигарета — unix-timestamp в текстовом файле на внутренней Flash (LittleFS). Одна строка — одна запись. При старте читаем сохранённое время RTC из отдельного файла — чтобы часы не сбрасывались при перезагрузке. Статистика считается на лету при каждом открытии экрана: количество за сегодня, за аналогичный период вчера, время последней записи и длительность предыдущего перерыва — для треугольников.
BLE. Nordic UART Service поверх Bluefruit. Брелок принимает текстовые команды:
-
TIME:<timestamp>— синхронизация времени, сохраняется во Flash -
GET— отдать весь лог, завершить строкойEND -
ADD:<timestamp>— добавить запись -
CLEAR— очистить лог
Реклама включается по жесту (длинное нажатие с включённым экраном), идёт 60 секунд с обратным отсчётом на дисплее. После подключения браузера — показывает «BLE OK».
Сон. __WFE() (Wait For Event) через SoftDevice — процессор останавливается до следующего прерывания от кнопки. Не настоящий deep sleep, но потребление падает существенно. Уходим в сон после гашения экрана, если нет активного BLE.
Бэкенд
Тут всё скромно — задача простая, решение соответствующее.
FastAPI + SQLite + uvicorn, развёрнуто на домашнем сервере за Apache с Let’s Encrypt. Три эндпоинта:
-
POST /smoke— добавить запись (timestamp, источник: brелок/веб/ручной ввод) -
DELETE /smoke/{id}— удалить запись -
GET /stats?days=30— статистика за период, сгруппированная по дням
Авторизация — Bearer токен в заголовке, один на всё. Не OAuth, не JWT — просто строка в .env. Личный инструмент, не продукт.
CORS открыт — чтобы PWA с GitHub Pages могла стучаться на домашний сервер без плясок с заголовками.
Systemd юнит, запускается при старте сервера, перезапускается при падении. Всё.
Суммарно — около 100 строк Python. Меньше времени на написание чем на придумывание названия эндпоинтов. (Меня эта строка так умилила, что я даже попросил оценить время на то и другое, предсказуемо нормального ответа не получил — автор так шутить изволил)
PWA
Веб-приложение живёт на домашнем сервере рядом с бэкендом — один хост, один nginx, один сертификат чтобы править всеми. При желании легко портируется на GitHub Pages, код не привязан к инфраструктуре.
Один HTML файл, никаких фреймворков. CSS переменные для тёмной темы, canvas для графиков, Web Bluetooth API для связи с брелоком.
Главный экран. Большая круглая кнопка «ЗАКУРИЛ» — одно нажатие, запись уходит на сервер. Счётчик за сегодня, время с последней сигареты, кнопка отмены последней записи.

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

Синхронизация с брелоком. Web Bluetooth работает только в Chrome и только на HTTPS или localhost — важное ограничение. На iPhone не работает вообще, Apple не поддерживает Web Bluetooth.
Основной сценарий синхронизации — ноутбук с Chrome. Нажимаешь «Подключить брелок» → Chrome показывает диалог выбора BLE устройства → выбираешь SmokeFob → приложение отправляет TIME:<timestamp> для синхронизации часов → затем GET для получения лога → новые записи отправляются на сервер. Занимает секунд десять.
Добавить сигарету вручную можно с любого устройства через браузер — смартфон, планшет, рабочий компьютер. Это оказался неожиданно востребованный сценарий: не всегда брелок под рукой, зато браузер всегда.
Кэш. Данные кэшируются в localStorage — при перезагрузке страницы счётчик не сбрасывается в ноль пока не придёт ответ от сервера.
Корпус
А вот тут Claude мне не поможет. К сожалению или к счастью.
В анамнезе я инженер-станочник, ну и немножко навыков осталось, поэтому метод изготовления был понятен сразу — фрезеровка. Но что фрезеровать? Как? Зачем? Доколе? Едят ли курицу руками? Вопросов больше чем ответов. Первая мысль была — взять алюминиевую пластину и фрезернуть два полукорпуса.
Воркфлоу понятен — отрисовываем имеющиеся в наличии детальки примитивами хотя бы примерно в SolidWorks, компануем, прикидываем корпус. Получаем наружные размеры и из бруска пластелина штангенциркулем обрезаем габаритный макет — штука тактильная, размеры с формой важны. Уже на этом этапе я понял, что максимально плотная компоновка неэргономична — ощущения «маешь вещь» не возникает, надо удлинять.

Вторая компановка показалась удачней:

Ну или если возвращаться к первой — то может приспособить дубовую палку, которая непонятно зачем несколько лет лежит в цеху?

Просто выборка, внутрь выборки — электронные потроха, снаружи…. черт, что же снаружи? А, медная пластина. Ладно, пошел искать медную или латунную пластину толщиной в миллиметр. И тут факап. Нет у меня в хозяйтве таких. И где взять непонятно, уже пятница вечер.
Поехал в сантехмагазин посмотреть на трубы. Дай, думаю, разрежу и раскатаю — будет мне пластина. Но в процессе разглядывания разного обнаружил соединительную медную муфту 28 мм под пайку и прям влюбился. На удивление она оказалась 39,2 мм в длину — ровно по размеру экрана. И это определило её участь.
Обратно в цех, втулку в тиски, нежно гнем — и очередной факап (ну пятница же вечер, куда без этого), втулка гнется в восьмерку. Ок, время солида. Считаем внутренние размеры корпуса, сохраняя периметр втулки, получается 11*38 мм, примерно в размер из той самой дубовой палки вырезаю оснастку — разрезанный на клинья брусок, и потихоньку на нем обстукиваю.

Получилось, но конечно следы от молотка (тоже медного). Шлифмашинка и точило, шкурка и надфиль. Ок, можно спать.
В субботу с утра — снова солид. Отрисовываю уже все полностью:

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

Спиливаю с заготовки ножовкой и меряю. Все подходит. Оставляю на сладкое на понедельник.
Сборка
В понедельник я обнаруживаю 3 вещи:
-
У меня нет радиусной обгонной фрезы R3. Ну точней есть, но не у меня. Или у меня, но не знаю где.
-
Я забыл про кнопку. Ну то есть кнопка есть как электронный компонент, но кнопки нет как пипки, на которую жать. А ведь я её любовно отрисовал в солиде. И станка токарного для обработки медного стержня 3-6 мм тоже нет.
-
Имеющаяся уже работающая электроника собрана на огрызках витой пары и мне стыдно знать как это будет выглядеть внутри.
Ладно, решаем.
-
Фаски — это красиво. Очень. Лучше скруглений. Да, я очень убедительный, особенно когда фрезы все равно нет.
-
Две попытки обточить медную жилу 10мм2 в шуруповерте об надфиль не дали радости — некрасиво, шершаво, погано. Выручил дедовский гвоздильный метод: зажимаем в тиски две стальные пластины, просверливаем в стыке отверстие на глубину нужного «гвоздя», вставляем туда пруток и обплющиваем головку. Первый раз в жизни так делал, но получилось и получилось неплохо.
-
На замену сгоревшей плате из ящика я купил сразу пару nRFок. Дисплей решил попробовать первый, который не заводился. На монтажный провод разобрал кусок сломанного USB. Ну и аккум в последний момент решил поменять на 5*20*30, тоже из загашника.
Ок, все проблемы решены. Собираем, матерясь — естественно, я обрезал провода коротковато и сборка представляет из себя тетрис на высокой сложности. Естественно, я забыл элемент, поджимающий экран — на его место втыкаем кусок уплотнения. Естественно, последняя крышка встает с матюками. Но встаёт. И вот — победоносен.
Заключение
Жена одарена и относительно в восторге. Кнопка только (тот самый гвоздь, предмет моей гордости) оказалась для нее туговата, так что будет еще итерация тюнинга. Но в общем — хороший проект. Себе наверное повторю, только с корпусом еще надо подумать.
Бюджет
Чуть не забыл, в тексте есть упоминания, но сгруппирую материальные затраты:
ссылка на оригинал статьи https://habr.com/ru/articles/1061100/