[Перевод] Doom на дисковом телефоне Dialrhea

Dialrhea — это модифицированный дисковый телефон, переделанный для управления Doom«ом через Bluetooth. Мы собрали его за два дня во время хакатона «Internet Of Shit», организованного командой Technarium в Вильнюсе, Литва. Темой этого хакатона было создание устройств, которые совершенно бесполезны, но полностью функциональны. 

Dialrhea в действии:

Мы даже сняли для него рекламный ролик:  

Хотелось бы ещё провести мастер-класс, на котором мы собрали бы еще как минимум три устройства, а затем сыграли бы на них deathmatch в Doom. 

Предыстория

Устройство было создано во время хакатона «Internet Of Shit». Это потрясающее мероприятие, которое было организовано командой Technarium в Вильнюсе, Литва, в 2017 и 2018 годах. В число номинаций вошли такие, как «Если загорелось, значит работает», «Наименее приватный онлайн-гаджет», «Это, скорее всего, незаконно», и команды упорно соревновались, чтобы превратить эти концепции в работающую технологию.

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

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

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

Технические детали

Устройство построено на Arduino, а для беспроводной связи с компьютером использует Bluetooth LE. Оно представляет собой клавиатуру Bluetooth и может быть подключено к любому устройству, поддерживающему Bluetooth LE. Ниже вы можете увидеть разбивку компонентов, питающих Dialrhea.

479acc50493f67b0d9c7d0f7c0a24ed9.png

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

  • Doom — в этом режиме устройство действует как игровой контроллер и настроено на управление классической игрой Doom (используя Doomsday Engine)

  • Эмодзи — этот режим лучше всего использовать на мобильных телефонах, он позволяет вводить эмодзи и отправлять их друзьям.

  • Скукота — в этом режиме Dialrhea просто выводит набранные номера (не рекомендуем к использованию)

Считывание данных с поворотного диска

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

1539f7d3f72071fbbbd40c4dcf1d3e18.pngb09e7b1c66f50040a3889471ce1d3484.png

После того, как я понял, как работает этот механизм, реализовать его с помощью Arduino оказалось довольно просто.

Борьба с Bluetooth

Вероятно, самой большой проблемой было заставить Bluetooth работать должным образом. Мы решили использовать модуль Adafruit Bluefruit LE UART Friend, потому что он у меня был, и я уже пробовал работать с ним в другом проекте. Это очень эффективный модуль, но большинство проблем, с которыми мы столкнулись, были связаны со стабильностью и надежностью. Иногда все работало хорошо, иногда мы получали некоторые ошибки при запуске одного и того же кода. Мы прочитали много документации о протоколе рукопожатий, о том, как правильно выполнять сопряжение и т. д., но в итоге просто везде добавили циклы повторов и тайм-ауты, чтобы у чипа было время «прийти в себя» после каждой рискованной операции. Ниже вы можете увидеть полный исходный код Dialrhea.

#include 
#include 
#if not defined (_VARIANT_ARDUINO_DUE_X_) && not defined(ARDUINO_ARCH_SAMD)
  #include 
#endif
#include "Adafruit_BLE.h"
#include "Adafruit_BluefruitLE_UART.h"
#include "BluefruitConfig.h"

#define DEVICE_NAME "Dialrhea"

// Rotary dial input PIN
#define ROTARY_PIN 2
// Handset input PIN
#define HANDSET_PIN 3
// Operation mode potentiometer PIN
#define OPERATION_MODE_PIN A5

// How long to wait before sending keyup message for control keys in Gaming mode
#define CONTROL_KEY_HOLD_DURATION 200
// How long to wait before sending keyup message for fire button in Gaming mode
#define FIRE_KEY_HOLD_DURATION 200
// How long to wait before sending keyup message for keys that are supposed
// to be just one clicks
#define INSTANT_KEY_HOLD_DURATION 10

// Pins for status LED RGB legs
#define STATUS_LED_RED_PIN 4
#define STATUS_LED_GREEN_PIN 6
#define STATUS_LED_BLUE_PIN 5

// Constants for colors
#define COLOR_OFF 0
#define COLOR_RED 1
#define COLOR_GREEN 2
#define COLOR_BLUE 3

// Total number of keys that support timed presses
#define KEY_COUNT 14

// Index of handset button data in data arrays (Gaming mode, we need two because
//we are sending keys for both fire and open door)
#define KEY_GAMING_MODE_HANDSET_1_INDEX 10
#define KEY_GAMING_MODE_HANDSET_2_INDEX 11
// Index of handset button data in data arrays (Emoji mode)
#define KEY_EMOJI_MODE_HANDSET_INDEX 12
// Index of handset button data in data arrays (Boring mode)
#define KEY_BORING_MODE_HANDSET_INDEX 13

// Map of values for each type of dialed number and handset click
const int keyValues[KEY_COUNT] = {
  0x42, // Number 0 in gaming mode (Currently quick load)
  0x52, // Number 1 in gaming mode (Currently "up" arrow)
  0x4F, // Number 2 in gaming mode (Currently "right" arrow)
  0x50, // Number 3 in gaming mode (Currently "left" arrow)
  0x51, // Number 4 in gaming mode (Currently "down" arrow)
  0x2A, // Number 5 in gaming mode (Currently ?, next weapon)
  0x00, // Number 6 in gaming mode
  0x00, // Number 7 in gaming mode
  0x00, // Number 8 in gaming mode
  0x00, // Number 9 in gaming mode
  0x10, // Handset click in gaming mode (KEY_GAMING_MODE_HANDSET_1_INDEX) (Currently space)
  0x2C, // Handset click in gaming mode (KEY_GAMING_MODE_HANDSET_2_INDEX) (Currently 'm')
  0x28, // Handset click in emoji mode (KEY_EMOJI_MODE_HANDSET_INDEX) (Currently Enter)
  0x29  // Handset click in boring mode (KEY_BORING_MODE_HANDSET_INDEX) (Currently Esc)
};

// Durations for each type of mey (mapping the same as for keyValues array)
const int keyHoldDurations[KEY_COUNT] = {
  INSTANT_KEY_HOLD_DURATION, 
  CONTROL_KEY_HOLD_DURATION, 
  CONTROL_KEY_HOLD_DURATION,
  CONTROL_KEY_HOLD_DURATION,
  CONTROL_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  FIRE_KEY_HOLD_DURATION,
  FIRE_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION,
  INSTANT_KEY_HOLD_DURATION
};

// Array for storing times each key was pressed
unsigned long keyPressTimes[KEY_COUNT];
// Array for storing states for each key
bool keyPressStates[KEY_COUNT];

// Variables required for handling input from rotary dial
int rotaryHasFinishedRotatingTimeout = 100;
int rotaryDebounceTimeout = 10;
int rotaryLastValue = LOW;
int rotaryTrueValue = LOW;
unsigned long rotaryLastValueChangeTime = 0;
bool rotaryNeedToEmitEvent = 0;
int rotaryPulseCount;

// Operation modes
#define OPERATION_MODE_GAMING 0 // Gaming controls fine tuned for the best game of all times: "Doom"
#define OPERATION_MODE_EMOJI 1 // Emojis + Enter
#define OPERATION_MODE_BORING 2 // Numbers + Esc

// Current operation mode
int operationMode;

// Emojis for each dialed number
const char* emojis[] = {":-O", ":poop:",  ":-)", ":-(", ":-D", ":-\\", ";-)", ":-*", ":-P", ">:-("};

// Variables for handling handset clicker button
bool isHandsetPressed = false;
unsigned long handsetPressStartTime = 0;
unsigned long handsetPressStartTimeout = 60;

// Variable that determines weather the state of keys changed during processing of the loop (so we
// can send commands just once in the end of the loop if it is needed)
bool keyPressStateChanged;

// Config settings for Bluetooth LE module
#define FACTORYRESET_ENABLE         0
#define VERBOSE_MODE                false  // If set to 'true' enables debug output
#define MINIMUM_FIRMWARE_VERSION    "0.6.6"
#define BLUEFRUIT_HWSERIAL_NAME      Serial1

// Bluetooth LE module object
Adafruit_BluefruitLE_UART ble(BLUEFRUIT_HWSERIAL_NAME, BLUEFRUIT_UART_MODE_PIN);

void setup(void) {
  pinMode(ROTARY_PIN, INPUT);
  pinMode(HANDSET_PIN, INPUT_PULLUP);
  pinMode(STATUS_LED_RED_PIN, OUTPUT);
  pinMode(STATUS_LED_GREEN_PIN, OUTPUT);
  pinMode(STATUS_LED_BLUE_PIN, OUTPUT);

  setStatusLEDColor(COLOR_GREEN);

  // Wait while serial connection is established (required for Flora & Micro or when you want to
  // halt initialization till you open serial monitor)
  // while (!Serial);
  
  // Give some time for chip to warm up or whatever
  delay(1000);

  initializeSerialConnection();
  initializeBLEModule();

  // Delay a bit because good devices always take some time to start
  delay(100);

  setStatusLEDColor(COLOR_BLUE);
}

void loop(void) {
  keyPressStateChanged = false;
  refreshOperationMode();
  handleHandset();
  handleRotary();
  processKeyUps();

  // If state of pressed keys changed - send the new state
  if (keyPressStateChanged)
    sendCurrentlyPressedKeys();
}

// Sets the color of status LED
void setStatusLEDColor(int colorID) {
  digitalWrite(STATUS_LED_RED_PIN, colorID == COLOR_RED ? HIGH : LOW);
  digitalWrite(STATUS_LED_GREEN_PIN, colorID == COLOR_GREEN ? HIGH : LOW);
  digitalWrite(STATUS_LED_BLUE_PIN, colorID == COLOR_BLUE ? HIGH : LOW);
}

// Outputs error message and bricks the revolutionary shitty machine
void error(const __FlashStringHelper*err) {

  setStatusLEDColor(COLOR_RED);
  
  Serial.println(err);
  while (1);
}

// Blinks the status LED (only green supported for now)
void blink() {
  setStatusLEDColor(COLOR_OFF);
  delay(100);
  setStatusLEDColor(COLOR_GREEN);
}

// Opens serial connection for debugging
void initializeSerialConnection() {
  Serial.begin(9600);
  Serial.println(F("Hello, I am the Dialrhea! Ready for some dialing action?"));
  Serial.println(F("8-------------------------------------D"));
}

// Initializes Bluetooth LE module
void initializeBLEModule() {
  // Buffer for holding commands that have to be sent to BLE module
  char commandString[64];

  setStatusLEDColor(COLOR_GREEN);

  Serial.print(F("Initialising the Bluefruit LE module: "));
  if (!ble.begin(VERBOSE_MODE)) error(F("Couldn't find Bluefruit, make sure it's in CoMmanD mode & check wiring?"));
  Serial.println( F("Easy!") );

  blink();

  if (FACTORYRESET_ENABLE)
  {
    Serial.println(F("Performing a factory reset: "));
    if (!ble.factoryReset()) error(F("Couldn't factory reset. Have no idea why..."));
    Serial.println(F("Done, feeling like a virgin again!"));
  }

  blink();

  // Disable command echo from Bluefruit
  ble.echo(false);

  blink();

  Serial.println("Requesting Bluefruit info:");
  ble.info();

  blink();

  // Change the device name so the whole world knows it as Dialrhea
  Serial.print(F("Setting device name to '"));
  Serial.print(DEVICE_NAME);
  Serial.print(F("': "));
  sprintf(commandString, "AT+GAPDEVNAME=%s", DEVICE_NAME);
  if (!ble.sendCommandCheckOK(commandString)) error(F("Could not set device name for some reason. Sad."));
  Serial.println(F("It's beautiful!"));

  blink();

  Serial.print(F("Enable HID Service (including Keyboard): "));
  strcpy(commandString, ble.isVersionAtLeast(MINIMUM_FIRMWARE_VERSION) ? "AT+BleHIDEn=On" : "AT+BleKeyboardEn=On");
  if (!ble.sendCommandCheckOK(commandString)) error(F("Could not enable Keyboard, we're in deep shit..."));
  Serial.println(F("I'm now officially a keyboard!"));

  blink();

  // Make software reset (add or remove service requires a reset)
  Serial.print(F("Performing a SW reset (service changes require a reset): "));
  if (!ble.reset()) error(F("Couldn't reset?? Lame."));
  Serial.println(F("Baby I'm ready to go!"));
  Serial.println();
}

// Reads the position of operation mode select potentiometer and determines current operation mode
void refreshOperationMode() {
  operationMode = floor((float)analogRead(OPERATION_MODE_PIN) / 342.0);
}

// Handles tracking the handset state
void handleHandset() {
  // Ignore input until last action timeout passes (to filter out noise)
  if (millis() - handsetPressStartTime > handsetPressStartTimeout) {
    int ragelisCurrentValue = digitalRead(HANDSET_PIN);
    
    if (!isHandsetPressed && ragelisCurrentValue == HIGH) {
      isHandsetPressed = true;
      handsetPressStartTime = millis();
      onHandsetClicked();
    }

    else if (isHandsetPressed && ragelisCurrentValue == LOW) {
      isHandsetPressed = false;
      handsetPressStartTime = millis(); 
    }
  }
}

// Handles tracking of the rotary dial state
void handleRotary() {
  int rotaryCurrentValue = digitalRead(ROTARY_PIN);

  // If rotary isn't being dialed or it just finished being dialed
  if ((millis() - rotaryLastValueChangeTime) > rotaryHasFinishedRotatingTimeout) {
    // If rotary just finished being dialed - we need to emit the event
    if (rotaryNeedToEmitEvent) {
      // Emit the event (we mod the count by 10 because '0' will send 10 pulses)
      onRotaryNumberDialed(rotaryPulseCount % 10);
      rotaryNeedToEmitEvent = false;
      rotaryPulseCount = 0;
    }
  }

  // If rotary value has changed - register the time when it happened
  if (rotaryCurrentValue != rotaryLastValue) {
    rotaryLastValueChangeTime = millis();
  }

  // Start analyzing data only when signal stabilizes (debounce timeout passes)
  if ((millis() - rotaryLastValueChangeTime) > rotaryDebounceTimeout) {
    // This means that the switch has either just gone from closed to open or vice versa.
    if (rotaryCurrentValue != rotaryTrueValue) {
      // Register actual value change 
      rotaryTrueValue = rotaryCurrentValue;

      // If it went to HIGH - increase pulse count
      if (rotaryTrueValue == HIGH) {
        rotaryPulseCount++; 
        rotaryNeedToEmitEvent = true;
      } 
    }
  }

  // Store current value as last value
  rotaryLastValue = rotaryCurrentValue;
}

// Event handler triggered when click of the handset button is registered
void onHandsetClicked() {
  // Register state changes for handset button keys depending on the mode
  if (operationMode == OPERATION_MODE_GAMING) {
    if (keyPressStates[KEY_GAMING_MODE_HANDSET_1_INDEX] == false || keyPressStates[KEY_GAMING_MODE_HANDSET_2_INDEX] == false)
      keyPressStateChanged = true;
  
    keyPressStates[KEY_GAMING_MODE_HANDSET_1_INDEX] = true;
    keyPressTimes[KEY_GAMING_MODE_HANDSET_1_INDEX] = millis();
  
    keyPressStates[KEY_GAMING_MODE_HANDSET_2_INDEX] = true;
    keyPressTimes[KEY_GAMING_MODE_HANDSET_2_INDEX] = millis();
  } else if (operationMode == OPERATION_MODE_EMOJI) {
    if (keyPressStates[KEY_EMOJI_MODE_HANDSET_INDEX] == false)
      keyPressStateChanged = true;
  
    keyPressStates[KEY_EMOJI_MODE_HANDSET_INDEX] = true;
    keyPressTimes[KEY_EMOJI_MODE_HANDSET_INDEX] = millis();
  } else if (operationMode == OPERATION_MODE_BORING) {
    if (keyPressStates[KEY_BORING_MODE_HANDSET_INDEX] == false)
      keyPressStateChanged = true;
  
    keyPressStates[KEY_BORING_MODE_HANDSET_INDEX] = true;
    keyPressTimes[KEY_BORING_MODE_HANDSET_INDEX] = millis();
  }
}

// Event handler triggered when number was dialed on rotary dial
void onRotaryNumberDialed(int number) {
  if (operationMode == OPERATION_MODE_GAMING) {
    // Set key state for dialed key
    if (keyPressStates[number] == false)
      keyPressStateChanged = true;
  
    keyPressStates[number] = true;
    keyPressTimes[number] = millis();
  } else if (operationMode == OPERATION_MODE_EMOJI) {
    // Send emoji to happy device
    sendCharArray(emojis[number]);
  } else if (operationMode == OPERATION_MODE_BORING) {
    // Form string from number and send it to device
    char numberString[1];
    sprintf(numberString, "%d", number);
    sendCharArray(numberString);
  }
}

// Sends raw command to BLE module and prints debug output
void sendBluetoothCommand(char *commandString) {
  setStatusLEDColor(COLOR_OFF);

  Serial.print(commandString);

  ble.println(commandString);
  if (ble.waitForOK()) {
    Serial.println(F(" <- OK!")); 
    setStatusLEDColor(COLOR_BLUE);
  }
  else {
    Serial.println(F(" <- FAILED!"));
    setStatusLEDColor(COLOR_RED);
  };
}

// Sends char array (string) to BLE module
void sendCharArray(char* charArray) {
  char commandString[64];
  sprintf(commandString, "AT+BleKeyboard=%s", charArray);
  sendBluetoothCommand(commandString);
}

// Checks which keys are currently pressed and sends keycodes to BLE module
void sendCurrentlyPressedKeys() {
  char commandString[64] = "AT+BleKeyboardCode=00-00";

  for (int i=0; i keyHoldDurations[i]) {
        keyPressStates[i] = false;
        keyPressStateChanged = true;
      }
    }
  }
}

На самом деле в этом устройстве есть очень интересный баг, который позволяет игроку очень быстро «прыгать» вперед. Я видел, как это происходило несколько раз, обычно после того, как кто-то некоторое время отчаянно колотил по устройству. Я понятия не имею, почему это происходит или как это воспроизвести, но это довольно прикольно, так я решил просто оставить это и назвать фичей.

Doom 

Неслучайно в качестве объекта для управления с помощью Dialrhea был выбран классический Doom. Я безумно люблю эту игру и был одержим ею в детстве. Мой отец однажды принес ее с работы на 10 дискетах. Мне пришлось научиться писать пользовательские файлы  autoexec.bat config.sys и загружать систему со специальной дискеты, содержащей минимальную версию MS-DOS и высокооптимизированный драйвер мыши, чтобы памяти было достаточно для запуска Doom на моей машине Intel 386 33 МГц, на которой было всего 4 Мб ОЗУ. «LH A:\MOUSE.COM» — волшебная строка, которая заставляла DOS загружать драйвер мыши в недоступную иным образом верхнюю память, тем самым выигрывая несколько дополнительных килобайт ОЗУ для Doom.

7a63a4f26b4fc1faf9c7828ba42e2499.png9ff1e7230c5e704b4b5427c538a165c0.png

Еще одной причиной выбрать Doom было то, что что он может работать практически на чём угодно: на калькуляторах, микроволновках, беговых дорожках, вейпах и даже тестах на беременность! Если у устройства есть процессор и экран, найдется какой-нибудь гик, который попытается запустить Doom на этой машине. ПО сравнению с этим Doom на дисковом телефоне выглядит не так уж безумно :)

Спасибо за внимание!

© Habrahabr.ru