Как стать автором
Обновить
148.15
SberDevices
Создаём умные устройства

Как устроены функции Мультирум и Стереопара на устройствах Sber

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров3K

В феврале 2023 года на части устройств SberDevices мы анонсировали поддержку двух новых режимов работы — Мультирум и Стереопара.

Мультирум позволяет прослушивать музыку на нескольких устройствах различного типа одновременно. Например, колонка SberBoom может находиться в спальне, а SberPortal — в гостиной, и в таком режиме музыка на этих устройствах будет играть синхронно. Стереопара отличается от Мультирума тем, что в Cтереопаре могут участвовать только две одинаковые колонки, при этом устройства, воспроизводя звук так же синхронно, делят его на каналы — левый и правый (каждое устройство проигрывает свой канал), в зависимости от выбранных пользователем настроек.

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

В этой статье мы с коллегами @kpvf2d и @Alergenхотим поделиться и, возможно, обсудить некоторые проблемы и их решения.

Основы работы Мультирума и Стереопары

Для того, чтобы Мультирум заработал, наши устройства должны уметь:

  • передавать команды друг другу по локальной сети;

  • высчитывать точную разницу времени между собой.

Первый пункт реализован при помощи нашего протокола SberCast, который позволяет устройствам обмениваться короткими сообщениями в локальной сети. Протокол работает через TCP/WSS и mDNS/Multicast (о Multicast'е мы поговорим подробнее позже), поддерживает его любое устройство SberDevices.

Для вычисления точной разницы во времени между устройствами мы используем PTP (Precision Time Protocol).

После подключения к сети и активации всех устройств происходят следующие события:

  • Устройства устанавливают соединение по SberCast и формируют список доступных устройств.

  • SberCast формирует список устройств, которые будут синхронизированы по PTP.

  • PTP выбирает master-устройство и начинает синхронизировать время остальных устройств относительно него. В данной реализации master выбирается по наименьшему MAC-адресу. Обычно до достижения порога синхронизации, достаточной для работы, проходит 2-3 минуты.

  • Формируется итоговый список доступных устройств для проигрывания в Мультирум-группе.

После обработки пользовательского запроса на включение Мультирум или Стереопары то устройство, с которого пользователь совершал запрос, становится master-устройством в Мультирум-группе. Master-устройство рассылает участникам Мультирум-группы команды на включение музыки, переключение треков, перемотку, громкость и другие. Если пользователь, к примеру, находится в другой комнате и делает запрос в устройство, которое не является master-устройством (т.е. в нашей терминологии называется slave-устройством), оно отправляет нужную команду на master-устройство, а master-устройство, в свою очередь, занимается в дальнейшем рассылкой команд на все устройства в Мультирум-группе.

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

Проблемы Multicast и как мы их решали

В процессе разработки Мультирума и Стереопары мы в очередной раз столкнулись с проблемами Multicast'а в домашних Wi-Fi сетях на наших устройствах. 

Впервые подобные проблемы были выявлены, особенно в части работы протокола mDNS, когда запускались наши первые устройства и SberCast на них. Во время дальнейшей разработки прошивок и правок кода, связанного с mDNS, многие проблемы были решены:

  • Мы перешли на использование IPv4 параллельно с IPv6. Это помогло нашим устройствам работать с некоторыми роутерами, не поддерживающими IPv6.

  • Для увеличения вероятности доставки пакетов внедрили множественные попытки отправки.

  • Иногда приходилось писать некоторым популярным провайдерам интернета, поскольку в прошивках их оборудования нами были найдены проблемы с Multicast.

Итогом нашей работы стало решение всех известных на тот момент проблем, а также реализация собственного API для discovering в наших устройствах. На какое-то время о проблемах мы забыли. 

И вот настал момент разработки Мультирума. Как писали ранее, в качестве протокола синхронизации был выбран PTP. По умолчанию, рекомендуется использовать PTP через multicast, и мы сделали так же.

После имплементации в наш код и адаптации его под наши нужды, стало понятно, что на столах всё работает отлично и мы готовы к выпуску этой прошивки в альфа-тесты на более широкий круг пользователей. Мы раздали прошивку избранным коллегам альфа-тестерам и стали ждать позитивных отзывов о работе функционала. Но у одного человека из нашей команды альфа-теста Мультирум не запускался совсем. Параллельно анализу тысяч записей логов и построения различных гипотез для проверки, мы, конечно же, пробовали стандартные способы решения проблем (вы пробовали выключить и включить?), но это не помогало (как сейчас думается, к счастью). После обогащения логов дополнительной информацией стало понятно, что причина оказалась простой — протокол PTP не работал как нужно, 9 из 10 пакетов синхронизации просто не доходили. Но при этом наш discovering поверх mDNS успешно работал в этой же сети. В этот момент мы решили собрать с альфа-пользователей наших устройств статистику по проходимости пакетов в multicast, написали средства по сбору такой статистики — каждую секунду с master-устройства отправляли простой пакет, размером, равным пакету синхронизации PTP, а на остальных устройствах в сети пытались их получить. После такого простого эксперимента мы получили наглядную статистику по масштабу проблемы: вероятность доставки пакета синхронизации колебалась в районе 10%, что абсолютно неприемлемо для работы Мультирума и Стереопары. При этом Multicast в IPv6 показывал примерно такой же процент и, самое странное, что у пользователей, у которых не работал IPv4, успешно работал IPv6.

В тот момент времени мы приняли продуктовые ограничения (максимальное количество устройств в Мультируме — 10) и решили перевести PTP на Unicast, который должен справится с заданным количеством устройств. Был риск, что реализация BMC (Best Master Clock) в PTP не сможет работать, но это мы учли, провели необходимые доработки и проверили их, таким образом немного адаптировав библиотеку PTP под себя. В итоге режим Unicast в PTP пошёл в реализацию, а проблему с Multicast мы временно отложили (но позже вернулись к ней, работая над другими задачами).

Какие были сделаны выводы из вышесказанного:

  • Если при работе устройств используются какие-то локальные сетевые взаимодействия, их необходимо проверять на самых ранних этапах и изначально добавлять максимальное количество метрик, где это возможно;

  • Multicast через Wi-Fi в домашних условиях — крайне ограниченная по применению технология;

  • Multicast через Wi-Fi в домашних условиях — не подходит для решения сложных технических задач (по крайней мере пока).

Параллельно с переводом на Unicast, мы продолжили изучать более предметно проблемы с Multicast. Все проблемы Multicast сводились к одной, а именно — потеря больших блоков пакетов. Да, мы изначально понимали, что UDP-Multicast не может работать в Wi-Fi сетях без потерь, поэтому изначально заложили алгоритм FEC (forward error correction) в нашу техническую реализацию, но результаты практических тестов нас категорически не устраивали. Какие проблемы мы явно видели:

  • часть пакетов просто пропадала где-то в цепочке: «доставка пакета до роутера → роутер → доставка пакета до приемного устройства», при этом данное поведение не зависело ни от скорости передачи, ни от размера пакетов. В какой-то момент мог не дойти большой блок пакетов;

  • были случаи, когда пакеты доходили до одного устройства, и в то же время не доходили на другое устройство в сети;

  • поведение сильно зависело от роутеров и набора устройств в сети.

В ходе поисков решений мы наткнулись на различные ссылки в интернете про Multicast и Wi-Fi и то, что его использование — не очень хорошая идея. Казалось, технология Multicast-to-Unicast Conversion была бы решением нашей проблемы, но мало какие домашние роутеры обладают этой функциональностью. В итоге, мы отказались от идеи использования Multicast'а для передачи важной информации. 

Устройства синхронизировались. Или нет?

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

Теперь немного глубже о том, как работает PTP.
Выбирается мастер (PTP_MASTER) по минимальному значению MAC-адреса. Он отправляет остальным устройствам (PTP_SLAVE) раз в некоторый период сообщение SYNC, затем передает еще одно сообщение (FOLLOW_UP) с временем отправки SYNC сообщения (sync_send_time_M). PTP_SLAVE устройства запоминают время получения SYNC (sync_receive_time_S), отправляют PTP_MASTER сообщения DELAY_REQ (время отправки на PTP_SLAVE delay_req_send_time_S) и ждут от PTP_MASTER сообщение DELAY_RESP, в котором указано время получения DELAY_REQ (delay_req_receive_time_M).

Устройства PTP_SLAVE меняют свое системное время так, чтобы оно совпадало с временем на PTP_MASTER и шло с такой же скоростью, используя эти 4 значения.

Таким образом на PTP_SLAVE-устройствах регулярно появляются две величины, с которыми оно работает:

  1. delay_{MS} = sync\_receive\_time_{S} - sync\_send\_time_{M}

  2. delay_{SM} = delay\_req\_receive\_time_{M} - delay\_req\_send\_time_{S}

При получении очередного FOLLOW_UP-сообщения:

  1. delay_{MS} = sync\_receive\_time_{S} - sync\_send\_time_{M}

  2. offset\_from\_Master = delay_{MS} - MeanPathDelay (далее OFM);

  3. Применяется фильтр к OFM (среднее из последних двух значений).

Если OFM больше секунды, то делается «stepCLock» (часы двигаются на -OFM), если нет, то делается корректировка частоты часов следующим образом —
OFM подается на вход PI-контроллеру, который работает так: 

frequencyForAdjust = OFM * kP + observedDrift[n]

observedDrift[n] = OFM * kI + observedDrift[n-1]

Далее на часах устройства устанавливается смещение частоты на -frequencyForAdjust (приведенному к ppm).

При получении очередного DELAY_RESP-сообщения:

meanPathDelay = \frac{delay_{MS}+ delay_{SM}}{2}


Применяется IIR фильтр к MeanPathDelay

s*y[n]-(s-1)*y[n-1] = \frac{x[n]}{2}+ \frac{x[n-1]}{2}

Также есть еще отдельные проверки на выбросы и варианты их обработки, но это выходит за рамки данной статьи.

В вышеописанной схеме у нас были следующие проблемы:

  • PI-контроллер мог довольно долго работать, в том числе и потому, что PTP с помощью подкрутки частоты пытался уменьшить сдвиг во времени. Несмотря на то, что в самом начале делается stepCLock, все равно часто возникает ситуация, при которой алгоритм слишком сильно ускоряет устройство (по 500ppm), вследствие этого происходит уход часов в другой знак относительно PTP_MASTER, затем происходит та же ситуация, т.е. PTP_SLAVE-устройство постоянно осциллирует по времени относительно PTP_MASTER. Могут потребоваться десятки минут, чтобы время стабилизировалось относительно друг друга.

  • Сохранение текущего drfit в файл тоже не сильно помогает потому, что в общем случае наборы устройств в PTP-группе всегда разные.

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

Почему вообще пришлось регулировать скорость проигрывания? Системный аудио драйвер (alsa, aaudio) в общем случае может в один отрезок времени (реального) забирать разное количество семплов на разных устройствах.


С чем это связано:

  1. У кварца в устройствах есть небольшая погрешность генерации импульсов.

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

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

Как мы пробовали решать эту проблему: пытались поиграться с параметрами PI-контроллера, сделали вариант PTP, где не стремимся привести к нулю OFM, а крутим частоту, основываясь на наклоне master_time(slave_time), потому что нам не важно, если абсолютное время разное (так как нам известен OFM), главное — чтобы оно шло с одинаковой скоростью (потому что мы в одну секунду всюду пытались отдать одно и то же количество семплов).  
Но в итоге все это не пригодилось, потому что нашли более простой вариант!

Из PTP берем только оценку OFM (offset from master), чтобы 1-й сепмл начал играть в одно и то же время, а дальше для того, чтобы отдавать равное количество семплов в секунду, ориентируемся на CLOCK_MONOTONIC_RAW. В теории оно не идет одинаково на разных устройствах, но практика показала достаточную для нас точность, чтобы рассинхрон не накапливался за несколько часов. Это работает лучше, чем подстройка c PTP по системному времени на каждом устройстве. 

То есть из двух потенциальных составляющих разной скорости проигрывания (пункт 1 и 2 выше), компенсируем 2 и получается, что играем синхронно.

Ты слышишь рассинхрон? И я нет, а он есть

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

  • критерии должны быть однозначны и иметь отсечку формата «пройдено» и «не пройдено»;

  • измерения не должны проводиться на программно отличающихся от продовых устройствах (т.е. обогащение логами, запись потока на диск или в RAM и др. нам не подходит);

  • результатом измерений должно быть понятное множество значений, по которому можно статистически значимо оценить попадание в принятые критерии качества;

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

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

Стенд представляет из себя пару устройств, которые подключены к микшеру аудио, формирующему из двух аудио потоков стереосигнал.

Данный стереосигнал, где каждый канал соответствует выходу отдельного устройства, записывается и обрабатывается скриптом на компьютере. После этого, используя кросс-корреляцию в частотной области, мы считаем значение разницы в сэмплах между левым и правым каналом, а после этого делим на sample rate аудиомикшера (поскольку именно он преобразует аналоговый сигнал в цифровой). Таким образом, на выходе мы получаем значение отставания аудиосигнала одной колонки от другой. Для конечного пользователя знак отставания не важен, поэтому в расчетах мы используем модуль значений. Для исследований доработок нового функционала используем значения со знаком, чтобы можно было отследить возможные проблемы с синхронизацией.

Как происходит тестирование:

  • на устройствах включается музыка в Мультируме или Стереопаре;

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

Раскрыть код
# audio capturing parameters ----
path_to_audio <- "files\\"
sample_rate <- 48000
rec_time <- 3 # in seconds
seconds_to_cut <- 0.5
number_of_channels <- 2
audio_device <- "Микрофон (USBAudio2.0)"
firmware_version <- "2022_10_10_experiment"

# measurements parameters
num_of_measurements <- 5

index <- integer()
filename <- character()
offset <- double()

for (i in 1:num_of_measurements) {
  audio_filename <- now() %>% as.integer() %>% paste0("audio_", ., ".wav")
  if (system_name == "win") {
    ffmpeg_command <- paste0('ffmpeg -f dshow -i audio="', audio_device,
                             '" -ar ', sample_rate,
                             " -ac ", number_of_channels,
                             " ", path_to_audio, audio_filename)
    lg$debug("Start recording audio")
    suppressWarnings(system(ffmpeg_command, timeout = rec_time,
                            show.output.on.console = FALSE))
    lg$debug("Stop recording audio")
    suppressWarnings(stereo_audio <- paste0("files/", audio_filename) %>%
                       readWave(from = seconds_to_cut, units = "seconds"))
    length_of_recorded_audio <- round((length(stereo_audio) / sample_rate),
                                      digits = 4)
    left_audio <- stereo_audio@left
    right_audio <- stereo_audio@right
  } else {
    audio_frame <- rep(NA_real_, sample_rate * rec_time * 2)
    lg$debug("Start recording audio")
    audio_data <- record(audio_frame, sample_rate, number_of_channels)
    wait(rec_time)
    lg$debug("Stop recording audio")
    stereo_audio <- audio_data$data
    close(audio_data)
    rm(audio_data)
    save.wave(stereo_audio, paste0("files/", audio_filename))
    stereo_audio <- stereo_audio[((sample_rate * seconds_to_cut) * 2 + 1):length(stereo_audio)]
    length_of_recorded_audio <- round((length(stereo_audio) / (2 * sample_rate)), digits = 4)
    left_audio <- stereo_audio[seq(from = 1, to = length(stereo_audio), by = 2)]
    right_audio <- stereo_audio[seq(from = 2, to = length(stereo_audio), by = 2)]
  }
  
  normalized_length <- max(length(left_audio), length(right_audio)) %>%
    nextn()
  
  left_audio <- c(left_audio, rep.int(0, (normalized_length - length(left_audio))))
  right_audio <- c(right_audio, rep.int(0, (normalized_length - length(right_audio))))
  
  delay_real <- convolve(left_audio, right_audio, conj = TRUE)
  delay_in_sec <- which.max(delay_real) / sample_rate
  delay_in_msec <- delay_in_sec * 1000
  
  index <- c(index, i)
  filename <- c(filename, audio_filename)
  
  if (delay_in_sec > length_of_recorded_audio / 2) {
    lg$info("Calculated offset is: %s milliseconds",
            round(abs(delay_in_sec - length_of_recorded_audio) * 1000,
                  digits = 3))
    offset <- c(offset,
                round((delay_in_sec - length_of_recorded_audio) * 1000,
                      digits = 3))
  } else
    if (delay_in_msec <= 1000 / sample_rate) {
      lg$info("Cannot calculate offset. Wrongly calculated offset is: %s ",
              round(delay_in_msec, digits = 3))
      offset <- c(offset, -1)
    } else {
      lg$info("Calculated offset is: %s milliseconds",
              abs(round(delay_in_msec, digits = 3)))
      offset <- c(offset, round(delay_in_msec, digits = 3))
    }
}

experiment_result <- tibble(index,
                            filename,
                            offset,
                            firmware_version,
                            now() %>% as.integer())

write_csv(experiment_result, "experiments/data.csv", append = TRUE) 

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

Раскрыть код
file1 <- read_csv("experiments/test.csv",
                  col_names = c("index",
                                "name_of_file",
                                "offset",
                                "firmware_version",
                                "time_of_experiment"),
                  col_select = c("offset",
                                 "firmware_version",
                                 "time_of_experiment"))
file1 <- file1 %>%
  sample_frac(1L) %>%
  mutate(index = seq_len(n()))

file2 <- read_csv("experiments/test2.csv",
                  col_names = c("index",
                                "name_of_file",
                                "offset",
                                "firmware_version",
                                "time_of_experiment"),
                  col_select = c("offset",
                                 "firmware_version",
                                 "time_of_experiment"))
file2 <- file2 %>%
  sample_frac(1L) %>%
  mutate(index = c((nrow(file1) + 1):(nrow(file1) + n())))

meas <- bind_rows(file1, file2) %>%
  dplyr::select(index, offset, firmware_version)
meas$firmware_version <- as.factor(meas$firmware_version)
meas <- meas %>% mutate(offset_abs = abs(offset))

write_csv(meas, "experiments/measurements.csv")

# построение графика значений рассинхрона со знаковым значением
ggplot(data = meas, aes(offset, firmware_version, color = firmware_version)) +
  geom_point(position = "jitter", alpha = 0.5, size = 2) +
  geom_vline(aes(xintercept = 20)) +
  geom_vline(aes(xintercept = -20)) +
  theme_light() +
  labs(title = "Значения рассинхрона", 
       x = "Рассинхрон, мс", 
       y = "Версия прошивки") + 
  guides(color = "none") +
  theme(plot.title = element_text(hjust = 0.5))

# построение граффика boxplot рассинхрона со абсолютным значением
ggplot(data = meas, aes(x = firmware_version, y = offset_abs,  fill = firmware_version)) +
  geom_boxplot(alpha = 0.5) + 
  theme_light() +
  labs(title = "Значения рассинхрона", 
       x = "Версия прошивки", 
       y = "Рассинхрон, мс",
       fill = "Версия прошивки") +
  theme(plot.title = element_text(hjust = 0.5))

ggsave(filename = "files/measurements_boxplot.png", 
       width = 2400,
       height = 2000,
       device = "png",
       dpi = 300,
       units = "px")
 

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

Таким образом, мы можем сравнить качество работы Стереопары и Мультирума при каждом новом эксперименте. Мы уверены в том, что эксперимент удался (или не удался, что тоже считается результатом) и можем защитить доработки перед продуктовой командой.

Не RTOS

Известно, что такие операционные системы, как Windows, MacOS, Unix-системы не являются системами реального времени. Для пользователя они совершают действия практически мгновенно, но под капотом ОС живет множество процессов, операции которых с разным приоритетом и в разной последовательности выполняются процессором.

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

Выше говорили о подходе к синхронизации — начинаем проигрывать в одно и то же время (используя значения offset from master от PTP), и далее в каждую CLOCK_MONOTONIC_RAW секунду отдаем в системный аудио драйвер (alsa, aaudio) одинаковое количество фреймов.

Но какая тут проблема? У нас есть время (уже на конкретном устройстве), в которое первый фрейм должен начать играть. Но как нам этого достичь? Мы управляем трактом до вывода в системный аудио драйвер. Пусть это будет alsa. Нам на программном уровне доступен размер буфера alsa и то, насколько он заполнен в тот момент, когда мы отдаем ему данные. Но что происходит дальше — для нас черный ящик, у нас есть уже скомпилированные плагины alsa, но мы не знаем, что происходит дальше и сколько времени пройдет от того момента, как мы отдали данные alsa до того момента, как они дойдут до динамиков.

Поэтому мы делаем следующее — считаем, сколько времени проходит от записи в alsa до внешнего лупбека (значение корректируется на заполенность буфера, информацию о котором можем получить программно). Для механизма VQE на устройствах присутствует loopback, который представляет из себя отдельный линейный вход, в который оцифровывается реальный звук (выход) с динамиков устройства.

На первой секунде трека считаем задержку сигнала, который получаем с лупбека относительно сигнала, который отдаем в alsa (с помощью метода gcc-phat — обобщенной кросс-корреляции в области частот, где для каждой спектральной компоненты делается преобразование v->v/|v| ) и, таким образом, получаем сдвиг на каждом устройстве, далее корректируем на этот сдвиг свой сигнал (добавляем нули или выкидываем немного данных из входного потока).

Заключение

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

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

Теги:
Хабы:
Всего голосов 24: ↑24 и ↓0+24
Комментарии3

Публикации

Информация

Сайт
sberdevices.ru
Дата регистрации
Дата основания
2019
Численность
501–1 000 человек
Местоположение
Россия
Представитель
Дмитрий Головин