Как стать автором
Обновить

Arduino DIY Watch — самодельные часы на Arduino

Время на прочтение 13 мин
Количество просмотров 55K


После нескольких лет знакомства с Arduino захотелось сделать что-то действительно интересное и полезное. Было решено сделать наручные часы. Но не просто часы, а действительно компактные, удобные, внешне не очень страшные и самое главное с длительным временем автономной работы часы.
И так встречайте самодельные часы на Arduino или DIY Arduino Watch!


DIY Arduino Watch – это компактные и лёгкие наручные часы с дисплеем размером 1,3 дюйма выполненным по технологии OLED. Часы можно легко программировать в среде Arduino через micro USB разъем, с помощью внешнего программатора. При программировании часов в настройках среды необходимо выбрать плату Arduino pro mini 3.3V 8MHz. Часы обладают низким энергопотреблением, что позволяет проработать более 6 месяцев от одной батарейки CR2032 ( при условии, что время отображается в течении 3 секунд, а в сутки владелец просматривает время 12 раз). Ток в режиме ожидания составляет около 7-8 мкA, в режиме отображения времени 10-12 мA. Так же часы позволяют измерять напряжение питания используя встроенный источник опорного напряжения на 1,1В.

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

Поговорим о задействованных пинах микроконтроллера:
А0 – измерение напряжения батареи часов
2 — Кнопка 1 ( будит часы из режима сна)
А1 – Кнопка 2
3 — Кнопка 3

image

Компоненты необходимые для изготовления часов:
1. Микроконтроллер atmega328p в корпусе QFP32;
2. OLED Display 128x64 1.3” (SH1106);
3. Часы реального времени DS1337 в корпусе SOP-8;
4. Тактовые кнопки;
5. Разьёмы micro USb мама и папа;
6. Батарейка CR2032;
7. SMD конденсаторы, резисторы и кварцы на 8MHz и 32kHz;
8. Двухсторонняя печатная плата;
9. Корпус, состоящий из 3-х деталей, напечатанный на 3D-принтере.



Принципиальная схема часов


Когда в наличии есть все необходимые детали, можно приступать к изготовлению печатной платы. Я делал плату методом ЛУТ, но только с одной оговоркой – вместо глянцевой бумаги я использовал прозрачную (можно и цветную, просто с прозрачной удобнее) виниловую самоклеющуюся пленку Oracl, которая позволяет идеально перенести весь тонер на текстолит. Технология следующая. Сначала на обычной бумаге печатаете рисунок платы, затем вырезаете виниловую пленку размером чуть больше чем сам рисунок и наклеиваете на напечатанный рисунок платы на бумаге. Затем снова на этом же листе бумаги печатаете рисунок платы, так, что бы печать получилась на самой виниловой пленке. Ну а дальше по отработанной схеме. Держать рисунок под утюгом около минуты — как правило за это время тонер успевает прилипнуть к текстолиту, а пленка успевает размягчиться, благодаря чему тонер полностью отстает от винила. В итоге получается отличный перенос рисунка без ворсинок и белого налета.

Схема расположения элементов на плате


На рисунке печатной платы сверху и снизу есть точки, которые позволяют точно совместить рисунки на разных сторонах платы.

Следующий шаг – прошивка бутлоадера в микроконтроллер. Бутлоадер используется от Arduino pro mini 3.3V 8MHz с небольшими изменениями. Для того что бы сделать потребление микроконтроллера в режиме сна около 1мкА а также позволить микроконтроллеру работать при напряжении ниже 1.8В необходимо отключить Brown-out Detection (BOD). Для этого необходимо открыть файл boards.txt, который лежит в папке с Arduino по следующему пути hardware/arduino/avr. В файле нужно найти строку

«## Arduino Pro or Pro Mini (3.3V, 8 MHz) w/ ATmega328»

ниже которой указаны настройки фьюзов для бутлоадера для Arduino Pro or Pro Mini (3.3V, 8 MHz). Ниже находим строку:
pro.menu.cpu.8MHzatmega328.bootloader.extended_fuses=0x05
И заменяем её на
pro.menu.cpu.8MHzatmega328.bootloader.extended_fuses=0x07

Сохраняем boards.txt и прошиваем загрузчик в микроконтроллер.

Напаиваем все детали на печатную плату согласно рисунку с расположением элементов.

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

Корпус печатался на 3D-принтере ABS пластиком. Для часов подойдут любые подходящие ремешки шириной 20мм.

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

Скетч
// Arduino DIY Watch
// Ivan Grishin
// e-mail: arduinowatch@mail.ru
//
//
// Button 1 >>>> 2
// Button 2 >>>> A1
// Button 3 >>>> 3
// Vbat >>>>>>>> A0;

#include «LowPower.h»
#include <SPI.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SH1106.h>
#include <DS1337.h>
#define OLED_RESET 4
Adafruit_SH1106 display(OLED_RESET);

#include <stdio.h>
DS1337 RTC = DS1337();

int val=0;
const int b1=2;
const int b2=A1;
const int b3=3;
const int vb=A0;
int mode=1;
int modest=0;
int h=0;
int m=0;
int s=0;
int d=0;
int mo=0;
int y=0;
int lock=0;
int volt=0;
float vbat=0;
int timebat=1;
int voltTime=1;

//#define BAT_HEIGHT 16
//#define BAT_GLCD_WIDTH 7
static const unsigned char PROGMEM bat_bmp0[] =
{ B00111111,B11111111,
B00100000,B00000001,
B11100000,B00000001,
B11100000,B00000001,
B11100000,B00000001,
B00100000,B00000001,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp1[] =
{ B00111111,B11111111,
B00100000,B00000011,
B11100000,B00000011,
B11100000,B00000011,
B11100000,B00000011,
B00100000,B00000011,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp2[] =
{ B00111111,B11111111,
B00100000,B00000111,
B11100000,B00000111,
B11100000,B00000111,
B11100000,B00000111,
B00100000,B00000111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp3[] =
{ B00111111,B11111111,
B00100000,B00001111,
B11100000,B00001111,
B11100000,B00001111,
B11100000,B00001111,
B00100000,B00001111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp4[] =
{ B00111111,B11111111,
B00100000,B00011111,
B11100000,B00011111,
B11100000,B00011111,
B11100000,B00011111,
B00100000,B00011111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp5[] =
{ B00111111,B11111111,
B00100000,B00111111,
B11100000,B00111111,
B11100000,B00111111,
B11100000,B00111111,
B00100000,B00111111,
B00111111,B11111111,};
static const unsigned char PROGMEM bat_bmp6[] =
{ B00111111,B11111111,
B00100000,B01111111,
B11100000,B01111111,
B11100000,B01111111,
B11100000,B01111111,
B00100000,B01111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp7[] =
{ B00111111,B11111111,
B00100000,B11111111,
B11100000,B11111111,
B11100000,B11111111,
B11100000,B11111111,
B00100000,B11111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp8[] =
{ B00111111,B11111111,
B00100001,B11111111,
B11100001,B11111111,
B11100001,B11111111,
B11100001,B11111111,
B00100001,B11111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp9[] =
{ B00111111,B11111111,
B00100011,B11111111,
B11100011,B11111111,
B11100011,B11111111,
B11100011,B11111111,
B00100011,B11111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp10[] =
{ B00111111,B11111111,
B00100111,B11111111,
B11100111,B11111111,
B11100111,B11111111,
B11100111,B11111111,
B00100111,B11111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp11[] =
{ B00111111,B11111111,
B00100111,B11111111,
B11100111,B11111111,
B11100111,B11111111,
B11100111,B11111111,
B00100111,B11111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp12[] =
{ B00111111,B11111111,
B00101111,B11111111,
B11101111,B11111111,
B11101111,B11111111,
B11101111,B11111111,
B00101111,B11111111,
B00111111,B11111111,};

static const unsigned char PROGMEM bat_bmp13[] =
{ B00111111,B11111111,
B00111111,B11111111,
B11111111,B11111111,
B11111111,B11111111,
B11111111,B11111111,
B00111111,B11111111,
B00111111,B11111111,};

void wakeUp(){}

void setup() {
display.begin(SH1106_SWITCHCAPVCC, 0x3c);
RTC.start();
pinMode(b1, INPUT);
pinMode(b2, INPUT);
pinMode(vb, INPUT);
analogReference(INTERNAL);
display.setRotation(2); // поворот изображения на 180 градусов

}

void loop() {

display.SH1106_command(SH1106_DISPLAYOFF);
mode=1;
attachInterrupt(0,wakeUp, HIGH);
LowPower.powerDown(SLEEP_FOREVER, ADC_OFF, BOD_OFF);
detachInterrupt(0);
display.clearDisplay();
delay(10);
display.SH1106_command(SH1106_DISPLAYON);

for ( val; val<=150;)
{
RTC.readTime();
int state1=digitalRead(b1);
int state2=digitalRead(b2);
int state3=digitalRead(b3);


if (state2==1&&lock==0)
{
mode++;
delay(130);
state2=0;
}

if (state3==1)
{
display.begin(SH1106_SWITCHCAPVCC, 0x3c);
display.setRotation(2);
display.clearDisplay();
delay(100);
}

if (mode==1){
time();
battery();
display.display();
val++;
}

// ++++++++++++++++ MODE SET TIME +++++++++++++++++
while (mode==2)
{
int state1=digitalRead(b1);
int state2=digitalRead(b2);
int state3=digitalRead(b3);

lock=1;
if (state2==1)
{
modest++;
delay(150);
state2=0;
}
//+++++++++++++++++++ Set HOURS++++++++++++++++++++
if (modest==1)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(BLACK,WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.setTextColor(WHITE);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
battery();
display.setTextSize(1);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.print(mo);
display.print('/');
display.print(y);
display.display();
if (state1==1)
{
h++;
delay(150);
}
if (state3==1)
{
h--;
delay(150);
}

if (h>=24)
{ h=0;}

if (h<0)
{ h=23;}

if (state2==1)
{
modest++;
delay(150);
state2=0;
}

}
// +++++++++++++++++++++++++++++ SET MIN ++++++++++++++++++
if (modest==2)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
display.setTextColor(BLACK,WHITE);
if (m<=9)
{display.print(«0»);}

display.print(m);
display.setTextColor(WHITE);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
battery();
display.setTextSize(1);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.print(mo);
display.print('/');
display.print(y);
display.display();
if (state1==1)
{
m++;
delay(100);
}
if (state3==1)
{
m--;
delay(150);
}

if (m>=60)
{ m=0;}

if (m<0)
{ m=59;}

if (state2==1)
{
modest++;
delay(150);
state2=0;
}
}

// +++++++++++++++++++++++ SET SEC +++++++++++++++++++++
if (modest==3)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
display.setTextColor(BLACK,WHITE);
if (s<=9)
{display.print(«0»); }
display.print(s);
display.setTextColor(WHITE);
battery();
display.setTextSize(1);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.print(mo);
display.print('/');
display.print(y);
display.display();
if (state1==1)
{
s++;
delay(100);
}
if (state3==1)
{
s--;
delay(150);
}

if (s>=60)
{ s=0;}

if (s<0)
{ s=59;}

if (state2==1)
{
modest++;
delay(150);
state2=0;
}
}
// +++++++++++++++++++++++ END SET SEC +++++++++++++++++++++

// +++++++++++++++++++++++ SET DAY +++++++++++++++++++++
if (modest==4)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
display.setTextColor(WHITE);
battery();
display.setTextSize(1);
display.setCursor(40,50);
display.setTextColor(BLACK,WHITE);
display.print(d);
display.setTextColor(WHITE);
display.print('/');
display.print(mo);
display.print('/');
display.print(y);
display.display();
if (state1==1)
{
d++;
delay(100);
}
if (state3==1)
{
d--;
delay(150);
}

if (d>=32)
{ d=1;}

if (d<0)
{ d=31;}

if (state2==1)
{
modest++;
delay(150);
state2=0;
}
}
// +++++++++++++++++++++++ END SET DAY +++++++++++++++++++++

// +++++++++++++++++++++++ SET MON +++++++++++++++++++++
if (modest==5)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
display.setTextColor(WHITE);
battery();
display.setTextSize(1);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.setTextColor(BLACK,WHITE);
display.print(mo);
display.setTextColor(WHITE);
display.print('/');
display.print(y);
display.display();
if (state1==1)
{
mo++;
delay(100);
}
if (state3==1)
{
mo--;
delay(150);
}

if (mo>=13)
{ mo=1;}

if (mo<0)
{ mo=12;}

if (state2==1)
{
modest++;
delay(150);
state2=0;
}
}
// +++++++++++++++++++++++ END SET DAY +++++++++++++++++++++

// +++++++++++++++++++++++ SET YEAR +++++++++++++++++++++
if (modest==6)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
display.setTextColor(WHITE);
battery();
display.setTextSize(1);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.print(mo);
display.print('/');
display.setTextColor(BLACK,WHITE);
display.print(y);
display.display();
if (state1==1)
{
y++;
delay(100);
}
if (state3==1)
{
y--;
delay(150);
}

if (y>=2020)
{ y=2000;}

if (state2==1)
{
modest++;
delay(150);
state2=0;
}
}
// +++++++++++++++++++++++ END SET YEAR +++++++++++++++++++++

// ++++++++++++++++++++++ WRITE to DS1337 +++++++++++++++

if (modest==7)
{
RTC.setSeconds(s);
RTC.setMinutes(m);
RTC.setHours(h);
RTC.setDays(d);
RTC.setMonths(mo);
RTC.setYears(y);
RTC.writeTime();
delay(1);
lock=0;
modest=1;
mode=1;
val=0;
}
}
val++;

}
val=0;
display.clearDisplay();
//delay(50);
}

void battery(void)
{
if (voltTime==1)
{
volt=analogRead(vb);
vbat=volt*0.0045;
voltTime=5;
}
voltTime--;

if (2>vbat && vbat>=1) {display.drawBitmap(0,0,bat_bmp0, 16, 7, 1);}
if (2.1>vbat && vbat>=2) {display.drawBitmap(0,0,bat_bmp1, 16, 7, 1);}
if (2.2>vbat && vbat>=2.1) {display.drawBitmap(0,0,bat_bmp2, 16, 7, 1);}
if (2.3>vbat && vbat>=2.2) {display.drawBitmap(0,0,bat_bmp3, 16, 7, 1);}
if (2.4>vbat && vbat>=2.3) {display.drawBitmap(0,0,bat_bmp4, 16, 7, 1);}
if (2.5>vbat && vbat>=2.4) {display.drawBitmap(0,0,bat_bmp5, 16, 7, 1);}
if (2.6>vbat && vbat>=2.5) {display.drawBitmap(0,0,bat_bmp6, 16, 7, 1);}
if (2.7>vbat && vbat>=2.6) {display.drawBitmap(0,0,bat_bmp7, 16, 7, 1);}
if (2.8>vbat && vbat>=2.7) {display.drawBitmap(0,0,bat_bmp8, 16, 7, 1);}
if (2.9>vbat && vbat>=2.8) {display.drawBitmap(0,0,bat_bmp9, 16, 7, 1);}
if (3>vbat && vbat>=2.9) {display.drawBitmap(0,0,bat_bmp10, 16, 7, 1);}
if (3.1>vbat && vbat>=3) {display.drawBitmap(0,0,bat_bmp11, 16, 7, 1);}
if (3.2>vbat && vbat>=3.1) {display.drawBitmap(0,0,bat_bmp12, 16, 7, 1);}
if (4>vbat && vbat>=3.2) {display.drawBitmap(0,0,bat_bmp13, 16, 7, 1);}
/*
if (2>vbat && vbat>=1) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);}
if (2.1>vbat && vbat>=2) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(13,1,13,1,1);}
if (2.2>vbat && vbat>=2.1) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(13,1,2,6,1);}
if (2.3>vbat && vbat>=2.2) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(12,1,3,6,1);}
if (2.4>vbat && vbat>=2.3) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(11,1,4,6,1);}
if (2.5>vbat && vbat>=2.4) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(10,1,5,6,1);}
if (2.6>vbat && vbat>=2.5) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(9,1,6,6,1);}
if (2.7>vbat && vbat>=2.6) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(8,1,7,6,1);}
if (2.8>vbat && vbat>=2.7) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(7,1,8,6,1);}
if (2.9>vbat && vbat>=2.8) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(6,1,9,6,1);}
if (3>vbat && vbat>=2.9) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(5,1,10,6,1);}
if (3.1>vbat && vbat>=3) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(4,1,11,6,1); }
if (3.2>vbat && vbat>=3.1){display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(3,1,12,6,1);}
if (4>vbat && vbat>=3.2) {display.drawRect(0,2,2,4,1);display.drawRect(2,0,14,8,1);display.fillRect(2,1,13,6,1);}
*/
display.setTextSize(1);
display.setCursor(90,0);
display.print(vbat);
display.print(" V");
}

void time(void)
{
h=RTC.getHours();
m=RTC.getMinutes();
s=RTC.getSeconds();
d=RTC.getDays();
mo=RTC.getMonths();
y=RTC.getYears();

display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
display.setTextSize(1);
display.setTextColor(WHITE);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.print(mo);
display.print('/');
display.print(y);
}

void show_time(void)
{
display.clearDisplay();
display.setTextSize(3);
display.setTextColor(WHITE);
display.setCursor(0,20);
if (h<=9)
{display.print(«0»);}
display.print(h);
display.print(":");
if (m<=9)
{display.print(«0»);}
display.print(m);
display.setTextSize(2);
display.setCursor(90,28);
display.print(":");
if (s<=9)
{display.print(«0»); }
display.print(s);
display.setTextSize(1);
display.setTextColor(WHITE);
display.setCursor(40,50);
display.print(d);
display.print('/');
display.print(mo);
display.print('/');
display.print(y);
}


В конце статьи будет ссылка на все необходимые материалы для самостоятельного изготовления часов. В эти материалы внесены несколько изменений, которые должны исправить мелкие недочёты найденные в уже сделанных часах. Например, исправлена печатная плата и теперь она не требует дополнительных перемычек, так же немного увеличен размер самой платы, что позволило сместить разъем micro USB ближе к корпусу часов.

В перечень материалов входит:
— скетч
— печатная плата (Sprint Layout)
— 3D-модель корпуса часов (stl)
— необходимые библиотеки для Arduino
— схема электрическая принципиальная

СКАЧАТЬ ФАЙЛЫ
ФАЙЛ ПЕЧАТНОЙ ПЛАТЫ

Планы на будущее:
— заменить ds1337 на ds3231
— добавить контроллер для зарядки аккумулятора
— добавить вибромотор
— реализовать работу будильников
— заменить подключение дисплея с I2C на SPI
— добавить BLE

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

Видео, где можно посмотреть на часы и их работу, а так же подробнее изучить их устройство.




Вот собственно пока всё. Ожидаю ваши вопросы и здоровую критику…

UPD. Добавил файл печатной платы будет добавлен в понедельник 29 февраля.
Теги:
Хабы:
+35
Комментарии 89
Комментарии Комментарии 89

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн