Сам себе игровая консоль: превращаем планшет с нерабочим тачскрином в игровой девайс из 8 кнопок и микроконтроллера

image

К сожалению, в наше время многие старые, но весьма неплохие по характеристикам гаджеты отправляются напрямую в помойку, и их владельцы не подозревают, что им можно найти применение. Сервер, мультимедийная-станция, да даже просто как TV-приставка — люди в упор не замечают сфер, где старенький планшет мог бы быть полезен. Но как быть, если посвящаешь жизнь портативным гаджетам, кодингу и копанию в железе? Правильно: сделать довольно мощную игровую консоль из старого планшета самому! Сегодня вам расскажу, как я сделал свою портативную приставку из планшета с нерабочим тачскрином, Raspberry Pi Pico и 8 кнопок! За рабочим результатом прячется несколько дней работы: поиск UART на плате, разработка контроллера геймпада на базе RPi Pico, написание приложения-сервиса, которое слушает события и отправляет их в подсистему ввода Linux в обход Android. Интересно? Тогда жду вас под катом!

Мотивация


Прошло уже практически 10 лет с того момента, как у меня появилась моя первая портативная консоль. Несмотря на то, что я был заядлым ПК-игроком, я уже успел посмотреть на PS3 и PSP, но денег на их покупку у меня особо не было, да и к тому времени уже был в наличии Android-планшет. Но к моему 13-летию в 2014 году, когда я ходил и выбирал себе будущий девайс на день рождения, отец и мама решили подарить мне мою первую портативную консоль. Изначально, я уговаривал её купить мне целых два девайса, но бюджет был ограничен 4.000 рублей, а я хотел взять смартфон Fly IQ239 и консоль JXD S601 одновременно:

image


Однако, увидев здоровую 7-дюймовую консоль в магазине TREC (думаю, жители южной части РФ помнят такой), мама уговорила меня взять именно её, мотивируя это «ну и чего ты будешь тыкаться в этот мелкий экран? Возьми большую». После покупки гаджета, я был доволен: играл какие-то игрушки с ретро-платформ, устанавливал игры на Android, сидел в ВК через Kate Mobile. Что еще нужно было школяру? Однако, планшет прожил у меня недолго: с очередного лага я психанул и ударил по нему кулачком, унеся на тот свет и дисплей и тачскрин. Так консолька и пролежала в подвале около 8 лет. Впрочем, мне продолжали импонировать подобные устройства и в прошлом году я купил и написал про несколько подобных девайсов.

image

Несколько месяцев назад, мой читатель Кирилл Севостьянов с Хабра прислал мне HTC HD2 в качестве донора и планшет Prestigio PMP7170B3G, который был рабочим, но… у него отказал тачскрин. Я всё думал, чего бы с ним сделать и решил реализовать игровую консольку своими руками из подручных средств. Идея крутилась в голове довольно давно, но реализовал я её только сейчас.

Что нам нужно сделать?


Итак, что должно быть у портативной консоли? Чипсет, дисплей, звук, ОС — это всё нам уже предоставляет планшет. Нам остаётся лишь сделать свой геймпад. Давайте подумаем, что нам будет нужно для того, чтобы его сделать и передавать от него события на планшет:

  • Контроллер для геймпада: тут нам подойдет практически любой микроконтроллер, который работает от 3.3в. Выбор большой: Arduino Pro Mini 3.3v, ESP32, RPi Pico. Я остановился на последнем: недавно я взял себе две штучки «пощупать» их — и они мне очень понравились!
  • Физический интерфейс: с планшетом нужно как-то общаться. У нас есть три варианта: USB (не факт, что поддержка преобразователей включена в ядре), UART и SPI/I2C на пятачках тачскрина (потребуют написания драйвера т. к. в android-устройствах нет прямого доступа к SPI/I2C из userland’а). Я остановился на UART: его легко найти на большинстве китайских планшетов, а если не получилось — то на помощь может прийти схема платы.
  • Программная реализация: как это будет работать? Я решил реализовать геймпад в виде сервиса на Android, который слушает состояния кнопок с UART и «инжектит» события напрямую в драйвер ввода. Таким образом, поддержка нашего геймпада появляется даже в самой системе — можно управлять менюшкой или приложениями как с клавиатуры!


С планом определились, пора начать с программной части: сначала нам обязательно понадобится ROOT-доступ. Его получение на разных девайсах отличается — на prestigio уже был порт CWM и я просто поставил SuperSU. Без ROOT доступа мы не сможем использовать UART!

image

Теперь нам нужно найти пятачки UART на плате. Разведен он не везде, но в случае устройств на MediaTek — почти всегда, ещё и пятачки подписаны. На моём планшете он нашёлся сразу: был между двух металлических экранов и соответствовал 4-ому каналу UART. Получить к нему доступ можно в /dev/ttyMT3. Я использую ESP32 в качестве UART преобразователя: подпаиваемся к RX/TX, запускаем putty и заходим в adb shell. Определяем бодрейт (скорость) нашего UART порта — на MediaTek он обычно равен 921600, на других чипсетах — 115200. Пытаемся что-то вывести и хоба — мы уже можем «поболтать» с планшетом!

image

Приложение-сервис


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

  1. InputManager.injectInputEvent — именно этим методом пользуется команда input, которую вы можете использовать через adb. Но увы, он работает только при наличие разрешения INJECT_EVENTS, который доступен только системным приложениям — находятся они в /system/app и подписаны тем же сертификатом, что и остальная прошивка.
  2. Модуль uinput дает возможность создать виртуальное устройство ввода и посылать события из userland’а — т. е. из прикладного приложения. У моего планшета было устройство /dev/uinput, но lsmod показывал, что сам модуль не загружен. Так что отметаем — он есть не везде.
  3. Прямой инжект событий в character устройство — весьма грязный хак, который позволяет инжектить события, не притворяясь системным приложением, но имеет некоторые ограничения. Именно его я и выбрал и о ограничениях ниже.


Сначала нам нужно узнать, какие кнопки поддерживают загруженные устройства ввода в системе. Для этого используем команду getevent -li. Там есть разные устройства ввода, в том числе и тачскрин (если вам нужно симулировать нажатия на экран), мне же подошёл драйвер физических кнопок mtk-kpd. Он занимается обработкой кнопок громкости, включения и т. п. Тут важно обратить внимание на то, что если попытаться послать кнопку, которое устройство не реализует (например пробел), то ничего не произойдет:

image

Инжект событий я писал на C, т. к. это требовало прямой записи input_event, а в Java прокинул его через Jni. Концепция простая: открываем устройство /dev/input/event2 и посылаем в него события ввода и синхронизации (это обязательно!), которые затем Android читает и обрабатывает:

#include 
#include 
#include 
#include 
#include 
#include 
#include 

int uinput;

extern "C"
JNIEXPORT void JNICALL
Java_com_monobogdan_inputservicebridge_InputNative_init(JNIEnv *env, jclass clazz) {
    uinput = open("/dev/input/event2", O_WRONLY);

    __android_log_print(ANDROID_LOG_DEBUG  , "Test", uinput >= 0 ? "Open event OK" : "Failed to open event");
}

void emit(int fd, int type, int code, int val)
{
    struct input_event ie;
    ie.type = type;
    ie.code = code;
    ie.value = val;
    /* timestamp values below are ignored */
    ie.time.tv_sec = 0;
    ie.time.tv_usec = 0;
    write(fd, &ie, sizeof(ie));
}

extern "C"
JNIEXPORT void JNICALL
Java_com_monobogdan_inputservicebridge_InputNative_sendKeyEvent(JNIEnv *env, jclass clazz,
                                                                jint key_code, jboolean pressed) {
    __android_log_print(ANDROID_LOG_DEBUG  , "Test", "Send");
    emit(uinput, EV_KEY, key_code, (bool)pressed  ? 1 : 0);
    emit(uinput, EV_SYN, SYN_REPORT, 0);
}


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

U L где U/D — нажато, не нажато, а L — однобайтовый идентификатор кнопки. В случае L — это влево, R — вправо и т. п. Вся доступная раскладка хранится в словаре. Причём само чтение из UART реализовано костылем с чтением «чужого» stdout, т. к. android-приложения не умеют сами по себе работать с root правами. В теории, это могло дать неприятный оверхед, но на практике никакого серьезного инпут лага это не создает. Не забываем сделать устройство event записываемым — ставим ему права 777:

package com.monobogdan.inputservicebridge;

public class InputListener extends Service {

    private static final int tty = 3;

    private InputManager iManager;
    private Map keyMap;
    private Method injectMethod;

    private Process runAsRoot(String cmd)
    {
        try {
            return Runtime.getRuntime().exec(new String[] { "su", "-c", cmd });
        }
        catch (IOException e)
        {
            e.printStackTrace();

            return null;
        }
    }

    @Override
    public void onCreate() {
        super.onCreate();

        // According to linux key map (input-event-codes.h)
        keyMap = new HashMap<>();
        keyMap.put('U', 103);
        keyMap.put('D', 108);
        keyMap.put('L', 105);
        keyMap.put('R', 106);
        keyMap.put('E', 115);
        keyMap.put('B', 158);
        keyMap.put('A', 232);
        keyMap.put('C', 212);

        InputNative.init();

        try {
            runAsRoot("chmod 777 /dev/input/event2").waitFor();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        Executors.newSingleThreadExecutor().execute(new Runnable() {
            @Override
            public void run() {
                Process proc = runAsRoot("cat /dev/ttyMT" + tty);
                BufferedReader reader = new BufferedReader(new InputStreamReader(proc.getInputStream()));

                while(true)
                {
                    try {
                        String line = reader.readLine();

                        if(line != null && line.length() > 0) {
                            Log.i("Hi", "run: " + line);

                            boolean pressing = line.charAt(0) == 'D';
                            int keyCode = keyMap.get(line.charAt(2));

                            Log.i("TAG", "run: " + keyCode);
                            InputNative.sendKeyEvent(keyCode, pressing);
                        }
                    }
                    catch(IOException e)
                    {
                        e.printStackTrace();
                    }

                    /*try {
                        Thread.sleep(1000 / 30);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }*/
                }
            }
        });
    }

    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }
}


Таким образом, если мы отправляем с ПК «D L» — система считает, что мы зажали стрелку влево, а U L — считает что мы отпустили. Но если mtk-kpd поддерживает стрелки и еще некоторые действия без каких либо проблем, то enter в список обрабатываемых кнопок не входит: придется мудрить! И тут нам приходит на помощь механизм трансляции кодов кнопок в действия: они хранятся в специальных файлах .kl в /system/usr/keylayout/. Я назначил DPAD_CENTER на… кнопку регулировки громкости звука! Ну, а почему бы и нет. :) Таким образом можно переназначить уже имеющиеся кнопки громкости на, например, start/select.

image

Геймпад


После того, как сервис был готов и отлажен, нужно было реализовать хардварную часть проекта — сам геймпад. В качестве контроллера я, как уже говорил, выбрал Raspberry Pi Pico на базе МК RP2040 — бодреньком контроллере с двумя ARM Cortex-M0 ядрами. Стоит копейки, а в отличии от ESP’шек, его SDK не такое перегруженное и выглядит более приближенным к bare-metal.

image

На данный момент, я решил развести все кнопки на бредборде — макетной плате без пайки, т. к. макеток для пайки у меня под рукой не было. Сделал примитивный геймпад:

image

Развел на соответствующие GPIO:

image

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

#include 
#include 
#include 
#include "pico/stdlib.h"
#include "pico/time.h"
#include "hardware/uart.h"

struct keyMap
{
    int gpio;
    char key;
    bool pressed;
    int lastTick;
};

keyMap keys[] = {
    {
        15,
        'L',
        false,
        0
    },
    {
        14,
        'U',
        false,
        0
    },
    {
        13,
        'D',
        false,
        0
    },
    {
        12,
        'R',
        false,
        0
    },
    {
        11,
        'E',
        false,
        0
    },
    {
        10,
        'B',
        false,
        0
    },

    {
        20,
        'A',
        false,
        0
    },
    {
        21,
        'C',
        false,
        0
    }
};

#define KEY_NUM 8

int main() {
    stdio_init_all();

    uart_init(uart0, 921600);
    gpio_set_function(PICO_DEFAULT_UART_TX_PIN, GPIO_FUNC_UART);
    gpio_set_function(PICO_DEFAULT_UART_RX_PIN, GPIO_FUNC_UART);
    sleep_ms(1000); // Allow serial monitor to settle

    for(int i = 0; i <  KEY_NUM; i++)
    {
        gpio_init(keys[i].gpio);
        gpio_set_dir(keys[i].gpio, false);
        gpio_pull_up(keys[i].gpio);
    }

    while(true)
    {
        int now = time_us_32();

        for(int i = 0; i < KEY_NUM; i++)
        {
            char buf[5];
            buf[1] = ' ';
            buf[3] = '\n';
            buf[4] = 0;

            if(!gpio_get(keys[i].gpio) && !keys[i].pressed && now - keys[i].lastTick > 15500)
            {
                buf[0] = 'D';
                buf[2] = keys[i].key;
                puts(buf);

                keys[i].lastTick = now;
                keys[i].pressed = true;
                continue;
            }

            if(gpio_get(keys[i].gpio) && keys[i].pressed && now - keys[i].lastTick > 15500)
            {
                buf[0] = 'U';
                buf[2] = keys[i].key;
                puts(buf);

                keys[i].pressed = false;
                keys[i].lastTick = now;
            }
        }
    }
}


Собираем всё вместе и тестируем. Хоба, всё работает, мы можем перемещаться по менюшке используя наш геймпад!

image

А почему бы не попробовать поиграть в какую-нибудь игру? Ну мы же консоль вроде делаем: берём эмулятор NES, биндим кнопки в настройках и наслаждаемся игрой в Марио!

Заключение


Реализация этого проекта заняла у меня не так уж и много времени: всего около 3-х дней работы по вечерам. Вероятно кто-то спросит: «а чего ты просто Bluetooth геймпад не купил?». Так это не прикольно ведь. Гораздо приятнее играть в девайс, к которому ты приложил руку сам. Более того, не у всех старых планшетов есть BT. Обошёлся на данной стадии проект недорого: планшет мне подарили бесплатно (точно также у вас дома может лежать подобный), RPi Pico — 350 рублей, кнопки по 10 рублей/штучка.

В целом, я сам по себе обожаю копаться в различных железках и их софтварной части (вспомнить хотя-бы статью про перекомпиляцию u-boot из вендорских исходников для нонейм консоли), а созидать что-то свое вообще вызывает какие-то нереальные всплески эндорфина — оно и понятно! :)

Однако несмотря на то, что мы уже имеем рабочий «прототип», проект далёк от завершения: я намерен довести его до конца и окончательно перевоплотить старый планшет в автономную игровую консоль (и рассказать об этом во второй части статьи). Для этого мне понадобится распечатать корпус и кнопки на 3D-принтере. К сожалению, у меня в городе ни у кого особо нет 3D-принтеров, поэтому начну копить на Ender 3, а от вас, читателей, с удовольствием почитаю мнение в комментариях и советы касательно выбора принтера!

mxuanbovcusqgmqdgugvpnql8vq.jpeg

© Habrahabr.ru