Пульт управления серверным демоном своими руками

Привет, Хабр!

Сегодня расскажу о том, как управлять компьютером с мобильного устройства. Нет, это не очередной аналог radmin’a, и не пример того, как можно поиздеваться над компьютером друга. Речь пойдет об удаленном управлении демоном, а точнее — о создании интерфейса для управления демоном, написанном на Python.

Архитектура довольно простая:

  • «Remote control App» — Kivy-приложение, реализующее клиентскую часть для мобильных устройств.
  • «Remote control» — Django-приложение, реализующее REST API и взаимодействие с БД;
  • IRemoteControl — Класс, реализующий логику обработки поступивших команд (будет использован в демоне);

Заинтересовавшимся — добро пожаловать под кат.

Перед тем, как приступать к реализации, предлагаю «подготовить» каталог проекта. Нужно:

  • создать отдельный Python virtual environment
    virtualenv .env
    
  • создать новый Django-проект (например — web)
    django-admin startproject web
    
    Все операции с Django будем выполнять относительно этого каталога;
  • создать каталог для Android-приложения (например — ui_app). Все операции касательно мобильного приложения будем выполнять относительно этого каталога.

«Remote control»


Начнем с серверной части — Django-приложения. Создадим новое приложение и добавим superuser’а:
python manage.py startapp remotecontrol

Рекомендую сразу же его добавить в используемые Django-проектом приложения (web\settings.py или вместо «web» — имя вашего Djnago-проекта):
INSTALLED_APPS = [
    .......
    'remotecontrol',
] 

Создадим БД и superuser’а:
python manage.py migrate
python manage.py createsuperuser

Настройки завершены, приступаем к реализации приложения.

Модели (remotecontrol\models.py)


Модель в архитектуре одна — это Команда, на которую должен отреагировать демон. Поля модели:
  • Код команды — будем использовать 4 команды: «Приостановить», «Возобновить», «Перезапуск», «Отключить пульт управления»
  • Состояние команды — возможны 4 состояния: «Создана», «В обработке», «Выполнена», «Отклонена».
  • IP
  • Дата создания объекта

Подробнее о командах и статусах — см. ниже.
Опишем модель:
# -*- coding: utf-8 -*-
from django.db import models

# Константы команд
CODE_PAUSE = 1    # код команды "Приостановить"
CODE_RESUME = 2    # код команды "Возобновить"
CODE_RESTART = 3    # код команды "Перезапуск"
CODE_REMOTE_OFF = 4    # код команды "Отключить пульт управления"

COMMANDS = (
    (CODE_RESTART, 'Restart'),
    (CODE_PAUSE, 'Pause'),
    (CODE_RESUME, 'Resume'),
    (CODE_REMOTE_OFF, 'Disable remote control'),
)

class Command(models.Model):
    # Константы состояний
    STATUS_CREATE = 1    # код статуса "Создана"
    STATUS_PROCESS = 2    # код статуса "В обработке"
    STATUS_DONE = 3    # код статуса "Выполнена"
    STATUS_DECLINE = 4    # код статуса "Отклонена"

    STATUS_CHOICES = (
        (STATUS_CREATE, 'Created'),
        (STATUS_PROCESS, 'In progress...'),
        (STATUS_DONE, 'DONE'),
        (STATUS_DECLINE, 'Declined'),
    )

    # Поля модели
    created = models.DateTimeField(auto_now_add=True)
    ip = models.GenericIPAddressField()
    code = models.IntegerField(choices=COMMANDS)
    status = models.IntegerField(choices=STATUS_CHOICES, default=STATUS_CREATE)

Немного «проапгрейдим» модель:

1. Расширим стандартный менеджер. Добавим методы для получения команд в состоянии «Создана» и в состоянии «В обработке».

Опишем свой менеджер:
class CommandManager(models.Manager):
    # Команды в состоянии "Создана", сортированы по дате создания в порядке возрастания
    def created(self):
        return super(CommandManager, self).get_queryset().filter(
            status=Command.STATUS_CREATE).order_by('created')

    # Команды в состоянии "В обработке", сортированы по дате создания в порядке возрастания
    def processing(self):
        return super(CommandManager, self).get_queryset().filter(
            status=Command.STATUS_PROCESS).order_by('created')

И добавим его в модель:
class Command(models.Model):
    .......

    objects = CommandManager()

2. Добавим методы проверки состояния и методы установки состояния команды:

Доп. методы:
class Command(models.Model):
    .......

    #  Методы проверки состояния
    def is_created(self):
        return self.status == self.STATUS_CREATE

    def is_processing(self):
        return self.status == self.STATUS_PROCESS

    def is_done(self):
        return self.status == self.STATUS_DONE

    def is_declined(self):
        return self.status == self.STATUS_DECLINE

    #  Методы установки состояния
    def __update_command(self, status):
        self.status = status
        self.save()

    def set_process(self):
        self.__update_command(Command.STATUS_PROCESS)

    def set_done(self):
        self.__update_command(Command.STATUS_DONE)

    def set_decline(self):
        self.__update_command(Command.STATUS_DECLINE)

Примечание: Конечно, можно обойтись и без этих методов. В таком случае в коде, работающим с Django ORM, потребуется использовать константы и описывать логику (хоть двухстрочную, но все же) обновления команды, что, имхо, не совсем удобно. Намного удобнее дергать необходимые методы. Но если такой подход противоречит концепции — с удовольствием выслушаю аргументы в комментариях.
Полный листинг models.py:
# -*- coding: utf-8 -*-
from django.db import models

# Константы команд
CODE_PAUSE = 1    # код команды "Приостановить"
CODE_RESUME = 2    # код команды "Возобновить"
CODE_RESTART = 3    # код команды "Перезапуск"
CODE_REMOTE_OFF = 4    # код команды "Отключить пульт управления"

COMMANDS = (
    (CODE_RESTART, 'Restart'),
    (CODE_PAUSE, 'Pause'),
    (CODE_RESUME, 'Resume'),
    (CODE_REMOTE_OFF, 'Disable remote control'),
)


class CommandManager(models.Manager):
    # Команды в состоянии "Создана", сортированы по дате создания в порядке возрастания
    def created(self):
        return super(CommandManager, self).get_queryset().filter(
            status=Command.STATUS_CREATE).order_by('created')

    # Команды в состоянии "В обработке", сортированы по дате создания в порядке возрастания
    def processing(self):
        return super(CommandManager, self).get_queryset().filter(
            status=Command.STATUS_PROCESS).order_by('created')


class Command(models.Model):
    # Константы состояний
    STATUS_CREATE = 1    # код статуса "Создана"
    STATUS_PROCESS = 2    # код статуса "В обработке"
    STATUS_DONE = 3    # код статуса "Выполнена"
    STATUS_DECLINE = 4    # код статуса "Отклонена"

    STATUS_CHOICES = (
        (STATUS_CREATE, 'Created'),
        (STATUS_PROCESS, 'In progress...'),
        (STATUS_DONE, 'DONE'),
        (STATUS_DECLINE, 'Declined'),
    )

    # Поля модели
    created = models.DateTimeField(auto_now_add=True)
    ip = models.GenericIPAddressField()
    code = models.IntegerField(choices=COMMANDS)
    status = models.IntegerField(choices=STATUS_CHOICES, default=STATUS_CREATE)

    objects = CommandManager()

    #  Методы проверки состояния
    def is_created(self):
        return self.status == self.STATUS_CREATE

    def is_processing(self):
        return self.status == self.STATUS_PROCESS

    def is_done(self):
        return self.status == self.STATUS_DONE

    def is_declined(self):
        return self.status == self.STATUS_DECLINE

    #  Методы установки состояния
    def set_process(self):
        self.__update_command(Command.STATUS_PROCESS)

    def set_done(self):
        self.__update_command(Command.STATUS_DONE)

    def set_decline(self):
        self.__update_command(Command.STATUS_DECLINE)

    def __update_command(self, status):
        self.status = status
        self.save()

    # Оформление для админ-панели
    STATUS_COLORS = {
        STATUS_CREATE: '000000',
        STATUS_PROCESS: 'FFBB00',
        STATUS_DONE: '00BB00',
        STATUS_DECLINE: 'FF0000',
    }

    def colored_status(self):
        return '%s' % (self.STATUS_COLORS[self.status], self.get_status_display())
    colored_status.allow_tags = True
    colored_status.short_description = 'Status'

    # Эти методы понадобятся для REST API
    def status_dsp(self):
        return self.get_status_display()

    def code_dsp(self):
        return self.get_code_display()

Админ-панель (remotecontrol\admin.py)


Примечание: Здесь и далее нам понадобится приложение «django-ipware» для определения IP клиента, установим:
pip install django-ipware

Здесь все проходит нативно: регистрируем модель в админ-панели, описываем отображаемые столбцы в таблице и поля на форме. Единственный нюанс — для сохранения IP клиента в объекте необходимо переопределить метод сохранения:
Листинг admin.py:
# -*- coding: utf-8 -*-
from django.contrib import admin
from ipware.ip import get_ip
from .models import Command


@admin.register(Command)
class CommandAdmin(admin.ModelAdmin):
    # Отображаемые поля на странице списка объектов
    list_display = ('created', 'code', 'colored_status', 'ip')
    # Допустимые фильтры на странице списка объектов
    list_filter = ('code', 'status', 'ip')
    # Допустимые поля для формы создания\редактирования объекта
    fields = (('code', 'status'), )

    # Переопределяем метод сохранения объекта 
    def save_model(self, request, obj, form, change):
        if obj.ip is None:
            # Определяем и запоминаем IP только при отсутствии такового
            obj.ip = get_ip(request)
        obj.save()

Не забываем применить изменения в моделях к базе данных:
python manage.py makemigrations remotecontrol
python manage.py migrate remotecontrol

В результате имеем возможность создавать\редактировать объекты…
Создание\редактирование объекта команды

…и просматривать список объектов в админ-панели:
список объектов

Приступим к реализации логики обработки команд.

Класс IRemoteControl


Как было написано выше, в нашем распоряжении 4 команды:
  • «Приостановить» — приостанавливает основной цикл демона и игнорирует все команды, кроме «Возобновить», «Перезапуск» и «Отключить пульт»;
  • «Возобновить» — возобновляет основной цикл демона;
  • «Перезапуск» — выполняет ре-инициализацию демона, повторное считывание конфигурации итд. Данная команда выполняется и в случае действия команды «Приостановить», но после перезапуска возобновляет основной цикл;
  • «Отключить пульт управления» — прекращает обрабатывать поступающие команды (все дальнейшие команды будут игнорироваться). Данная команда выполняется и в случае действия команды «Приостановить».

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

Логика обработки команд следующая:

  • За одну итерацию обрабатывается одна команда;
  • Получаем самую «старую» команду в состоянии «В обработке». Если таких нет — получаем самую «старую» в состоянии «Создана». Если нет — итерация завершена;
  • Если команда получена с недопустимого IP — устанавливаем состояние «Отклонена». Итерация завершена;
  • Если пульт управления отключен — устанавливаем команде состояние «Отклонена». Итерация завершена;
  • Если команда недопустима для текущего состояния демона — устанавливаем состояние «Отклонена». Итерация завершена;
  • Устанавливаем состояние «В обработке» (если требуется), выполняем команду, устанавливаем состояние «Выполнена». Итерация завершена.

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

Модуль control.py (remotecontrol\control.py)


Модуль, в котором опишем реализацию IRemoteControl, предлагаю разместить в каталоге приложения. Так мы получим удобно транспортируемое Django-app.
Листинг control.py
# -*- coding: utf-8 -*-
import django
django.setup()

from time import sleep
from remotecontrol.models import *


class IRemoteControl(object):
    # Список допустимых IP. Оставьте список пустым, если хотите отключить ограничение.
    IP_WHITE_LIST = ['127.0.0.1']

    # Флаг используемый командой CODE_REMOTE_OFF
    REMOTE_ENABLED = True

    # Метод для получения объектов команд
    def __get_command(self):
        commands = Command.objects.processing()
        if len(commands) == 0:
            commands = Command.objects.created()

        if len(commands) == 0:
            return None

        command = commands[0]

        if self.IP_WHITE_LIST and command.ip not in self.IP_WHITE_LIST:
            print('Wrong IP: %s' % command.ip)
        elif not self.REMOTE_ENABLED:
            print('Remote is disabled')
        else:
            return command

        self.__update_command(command.set_decline)

    # Эмуляция логики команды "Перезапуск"
    def __restart(self, command):
        if command.is_created():
            self.__update_command(command.set_process)
            print('... Restarting ...')
            sleep(5)
        self.__update_command(command.set_done)
        print('... Restart complete ...')

    # Обертка для выполнения методов установки состояния
    def __update_command(self, method):
        try:
            method()
        except Exception as e:
            print('Cannot update command. Reason: %s' % e)

    # Логика обработки поступающих команд
    def check_commands(self):
        pause = False
        enter = True
        while enter or pause:
            enter = False
            command = self.__get_command()
            if command is not None:
                if command.code == CODE_REMOTE_OFF:
                    self.__update_command(command.set_done)
                    print('... !!! WARNING !!! Remote control is DISABLED ...')
                    self.REMOTE_ENABLED = False
                elif command.code == CODE_RESTART:
                    self.__restart(command)
                    pause = False
                elif pause:
                    if command.code == CODE_RESUME:
                        self.__update_command(command.set_done)
                        print('... Resuming ...')
                        pause = False
                    else:
                        self.__update_command(command.set_decline)
                else:
                    if command.code == CODE_PAUSE:
                        self.__update_command(command.set_done)
                        print('... Waiting for resume ...')
                        pause = True
            elif pause:
                sleep(1)

Черная магия


Если модель сферического демона в вакууме можно представить в таком виде:
# -*- coding: utf-8 -*-
class MyDaemon(object):
        def magic(self):
            # логика демона
            .......

        def summon(self):
            # основной цикл
            while True:
                self.magic()

MyDaemon().summon()

то внедрение интерфейса пульта управления происходит безболезненно:
# -*- coding: utf-8 -*-
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "web.settings")

# Импорт модуля control возможен только после установки DJANGO_SETTINGS_MODULE
# т.к. при инициализации модуля вызывается django.setup()
from remotecontrol.control import *
class MyDaemon(IRemoteControl):
        def magic(self):
            .......

        def summon(self):
            while True:
                # Делаем прививку
                self.check_commands()
                self.magic()

MyDaemon().summon()

В результате призванная нечисть управляется, но только с админ-панели.
Поместим данный код в файл, к примеру, daemon.py и пойдем дальше — напишем мобильный клиент.

REST API


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

Подготовительный этап


Установим Django REST framework:
pip install djangorestframework
подключим (web\settings.py):
INSTALLED_APPS = [
    .......
    'rest_framework',
] 
и настроим (там же, добавляем в конец файла):
REST_FRAMEWORK = {
    # Разрешаем доступ пользователю с правами superuser'а
    'DEFAULT_PERMISSION_CLASSES': ('rest_framework.permissions.IsAdminUser',),
    # Запрещаем использовать встроенный браузер API, оставляем только JSON
    'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',),
} 

Сериализаторы (remotecontrol\serializers.py)


Начнем с описания набора возвращаемых данных интерфейсом REST. Здесь нам пригодятся те загадочные методы из описания модели (.status_dsp () и .code_dsp ()), которые возвращают текстовое название состояния и кода команды соответственно:
Листинг serializers.py:
from rest_framework import serializers
from .models import Command


class CommandSerializer(serializers.ModelSerializer):
    class Meta:
        model = Command
        fields = ('status', 'code', 'id', 'status_dsp', 'code_dsp', 'ip')

Представления данных (remotecontrol\views.py)


Методы REST API в архитектуре Django-приложения — это те же представления, только… вы поняли.
Для общения с клиентом достаточно трех букв слов API-методов (эхх, идеальный мир…):
  • commands_available — возвращает список доступных кодов команд и список кодов состояний, в которых команда считается обработанной;
  • commands — используется для создания нового объекта команды. Список имеющихся в БД объектов не потребуется;
  • commands/ — используется для определения состояния объекта команды.

Для минимизации кода используем плюшки, поставляемые в комплекте с Django REST framework:
  • @api_view — декоратор для function based view, параметром указывается список допустимых http-методов;
  • generics.CreateAPIView — класс для методов создания объектов, поддерживает только POST;
  • generics.RetrieveAPIView — класс для получения подробной информации об объекте, поддерживает только GET.

Листинг views.py:
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework import generics
from ipware.ip import get_ip
from .models import Command
from .serializers import CommandSerializer


@api_view(['GET'])
def commands_available(request):
    # API-метод "список доступных кодов команд"
    response = {
        # Список доступных кодов команд. При желании CODE_REMOTE_OFF можно
        # исключить, чтобы не отображать "красную кнопку" в мобильном клиенте.
        'commands': dict(Command.COMMAND_CHOICES),
        # Список кодов состояний, в которых команда считается обработанной.
        'completed': [Command.STATUS_DONE, Command.STATUS_DECLINE],
    }
    return Response(response)


class CommandList(generics.CreateAPIView):
    # API-метод "создать команду"
    serializer_class = CommandSerializer

    def post(self, request, *args, **kwargs):
        # Определяем и запоминаем IP клиента
        request.data[u'ip'] = u'' + get_ip(request)
        return super(CommandList, self).post(request, *args, **kwargs)


class CommandDetail(generics.RetrieveAPIView):
    # API-метод "получить состояние команды"
    queryset = Command.objects.all()
    serializer_class = CommandSerializer

End-point’ы (remotecontrol\urls.py)


Опишем end-point’ы реализованных API-методов.
Листинг urls.py:
from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^commands_available/$', views.commands_available),
    url(r'^commands/$', views.CommandList.as_view()),
    url(r'^commands/(?P[0-9]+)/$', views.CommandDetail.as_view()),
]

И подключим их к проекту (web\urls.py):
urlpatterns = [
    .......
    url(r'^remotecontrol/', include('remotecontrol.urls')),
] 

Интерфейс для общения реализован. Переходим к самому вкусному.

«Remote Control App»


Для общения с сервером используем UrlRequest (kivy.network.urlrequest.UrlRequest). Из всех его достоинств нам понадобятся следующие:
  • поддержка асинхронного режима;
  • автоматическая конвертация полученного в ответ корректного JSON в Python dict.

Для простоты реализации будем использовать схему аутентификации Basic. При желании, можно одну из следующих статей посвятить другим способам аутентификации на web-ресурсах с помощью UrlRequest — пишите в комментариях.
Листинг main.py
# -*- coding: utf-8 -*-
import kivy
kivy.require('1.9.1')

from kivy.network.urlrequest import UrlRequest
from kivy.properties import StringProperty, Clock
from kivy.uix.button import Button
from kivy.app import App
from kivy.uix.boxlayout import BoxLayout
try:
    from kivy.garden.xpopup import XError, XProgress
except:
    from xpopup import XError, XProgress
from json import dumps
import base64


class RemoteControlUI(BoxLayout):
    """ Реализация основного виджета приложения
    """

    # Свойства для аутентификации на сервере
    login = StringProperty(u'')
    password = StringProperty(u'')
    host = StringProperty('')

    def __init__(self, **kwargs):
        # ID текущего обрабатываемого объекта команды
        self._cmd_id = None

        # Список кодов "завершенных" состояний
        self._completed = []

        # Флаг потребности ожидания завершения обработки команды.
        # Сбрасывается при получении "завершенного" состояния или
        # при закрытии окна прогресса.
        self._wait_completion = False

        super(RemoteControlUI, self).__init__(
            orientation='vertical', spacing=2, padding=3, **kwargs)

        # Панель для командных кнопок
        self._pnl_commands = BoxLayout(orientation='vertical')
        self.add_widget(self._pnl_commands)

    # ============= Отправка http-запроса ==============
    def _get_auth(self):
        # Подготовка данных для заголовка "Authorization"
        cred = ('%s:%s' % (self.login, self.password))
        return 'Basic %s' %\
               base64.b64encode(cred.encode('ascii')).decode('ascii')

    def _send_request(self, url, success=None, error=None, params=None):
        # Отправка асинхронного запроса
        headers = {
            'User-Agent': 'Mozilla/5.0',
            'Content-type': 'application/json',
            'Authorization': self._get_auth()
        }

        UrlRequest(
            url=self.host + url, timeout=30, req_headers=headers,
            req_body=None if params is None else dumps(params),
            on_success=success, on_error=error, on_failure=error)

    # =========== Получение списка доступных кодов команд ===========
    def _get_commands(self, instance=None):
        # Реализация обращения к API-методу "commands_available"
        self._progress_start('Trying to get command list')
        self._send_request(
            'commands_available/',
            success=self._get_commands_result, error=self._get_commands_error)

    def _get_commands_result(self, request, response):
        # callback для парсинга ответа
        try:
            self._pnl_commands.clear_widgets()

            # Для каждого доступного кода команды создаем кнопку
            for code, command in sorted(
                    response['commands'].items(),
                    key=lambda x: int(x[0])):
                btn = Button(
                    id=code, text=command, on_release=self._btn_command_click)
                self._pnl_commands.add_widget(btn)

            self._completed = response['completed']
            self._progress_complete('Command list received successfully')
        except Exception as e:
            self._get_commands_error(request, str(e))

    def _get_commands_error(self, request, error):
        # callback для обработки ошибки
        self._progress_complete()
        XError(text=str(error)[:256], buttons=['Retry', 'Exit'],
               on_dismiss=self._get_commands_error_dismiss)

    def _get_commands_error_dismiss(self, instance):
        # callback для окна ошибки
        if instance.button_pressed == 'Exit':
            App.get_running_app().stop()
        elif instance.button_pressed == 'Retry':
            self._get_commands()

    # ============= Отправка команды =============
    def _btn_command_click(self, instance):
        # Реализация обращения к API-методу "commands"
        self._cmd_id = None
        self._wait_completion = True
        self._progress_start('Processing command "%s"' % instance.text)
        self._send_request(
            'commands/', params={'code': instance.id},
            success=self._send_command_result, error=self._send_command_error)

    def _send_command_result(self, request, response):
        # callback для парсинга ответа
        try:
            if response['status'] not in self._completed:
                # Команда обрабатывается - запоминаем ID объекта
                self._cmd_id = response['id']
                # Запрос на проверку состояния будет отправляться до тех пор,
                # пока открыто окно с прогрессом
                if self._wait_completion:
                    # Отправляем запрос для проверки состояния
                    Clock.schedule_once(self._get_status, 1)
            else:
                # Команда обработана
                self._progress_complete(
                    'Command "%s" is %s' %
                    (response['code_dsp'], response['status_dsp']))
        except Exception as e:
            XError(text=str(e)[:256])

    def _send_command_error(self, request, error):
        # callback для обработки ошибки
        self._progress_complete()
        XError(text=str(error)[:256])

    # ========== Получение кода состояния команды ==========
    def _get_status(self, pdt=None):
        # Реализация обращения к API-методу "commands/"
        if not self._cmd_id:
            return

        self._send_request(
            'commands/%s/' % self._cmd_id, success=self._send_command_result,
            error=self._send_command_error)

    # ============= Методы для работы с окном прогресса ==============
    def _progress_start(self, text):
        self.popup = XProgress(
            title='RemoteControl', text=text, buttons=['Close'],
            on_dismiss=self._progress_dismiss)
        self.popup.autoprogress()

    def _progress_dismiss(self, instance):
        self._wait_completion = False

    def _progress_complete(self, text=''):
        if self.popup is not None:
            self.popup.complete(text=text, show_time=0 if text is None else 1)

    # =========================================
    def start(self):
        self._get_commands()


class RemoteControlApp(App):
    """ Реализация приложения
    """
    
    remote = None

    def build(self):
        # Инициализируем интерфейс приложения
        self.remote = RemoteControlUI(
            login='test', password='qwerty123',
            host='http://localhost:8000/remotecontrol/')
        return self.remote

    def on_start(self):
        self.remote.start()


# Запускаем приложение
RemoteControlApp().run()

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

На этом баловство с кодом завершается и на сцену выходит

Тяжелая артиллерия


О Buildozer’е можно говорить долго, потому что о нем сказано мало. Есть и статьи на хабре (об установке и настройке и о сборке релиз-версии и публикации на Google Play), конечно же, есть и документация… Но есть и нюансы, о которых можно написать целую статью которые разбросаны по разным источникам. Постараюсь собрать основные моменты здесь.
Несколько практических советов по борьбе с этим wunderwaffe:
  • Для сборки Android-приложения все же потребуется Linux, можно обойтись и виртуальной машиной. Обусловлено это тем, что python-for-android (необходимый для сборки пакет) в текущей версии использует более свежую версию пакета sh (ранее pbs), в которой отсутствует поддержка Windows;
  • На самом деле, процесс сборки затягивается надолго только в первый раз — здесь Buildozer устанавливает и настраивает необходимые Android-dev зависимости. Все последующие сборки (с учетом, что в конфигурации сборки не менялись параметры ndk, sdk или requirements) выполняются за 30–40 секунд;
  • Перед установкой Buildozer убедитесь, что корректно установлен Kivy и Kivy-garden (последний должен установится автоматически с Kivy);
  • Также, перед установкой Buildozer необходимо установить зависимости (подробнее — здесь). Сам Buildozer их не устанавливает, но могут возникнуть нештатные ситуации при установке или (что хуже) в процессе сборки.
  • НИКОГДА не запускайте Buildozer под правами root;

Ну и немного кода в помощь счастливым обладателям Debian и Ubuntu (остальным потребуется «тщательно обработать напильником»)
kivy-install.sh
# Create virtualenv
virtualenv --python=python2.7 .env

# Activate virtualenv
source .env/bin/activate

# Make sure Pip, Virtualenv and Setuptools are updated
pip install --upgrade pip virtualenv setuptools

# Use correct Cython version here
pip install --upgrade Cython==0.20

# Install necessary system packages
sudo apt-get install --upgrade build-essential mercurial git python-dev libsdl-image1.2-dev libsdl-mixer1.2-dev libsdl-ttf2.0-dev libsmpeg-dev libsdl1.2-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev zlib1g-dev

# Install kivy
pip install --upgrade kivy
buildozer-install.sh
# Activate virtualenv
source .env/bin/activate

# Android SDK has 32bit libs
sudo dpkg --add-architecture i386

# add system dependencies
sudo apt-get update
sudo apt-get install --upgrade ccache
sudo apt-get install --upgrade libncurses5:i386 libstdc++6:i386 zlib1g:i386
sudo apt-get install --upgrade openjdk-7-jdk
sudo apt-get install --upgrade unzip

# Install buildozer
pip install --upgrade buildozer

Теперь, когда Buildozer установлен, инициализируем его:
buildozer init

В результате работы этой команды в каталоге создастся файл конфигурации сборки (buildozer.spec). В нем находим указанные ниже ключи и присваиваем им соответствующие значения:
Правки для buildozer.spec
# (list) Garden requirements
garden_requirements = xpopup

# (str) Supported orientation (one of landscape, portrait or all)
orientation = portrait

# (bool) Indicate if the application should be fullscreen or not
fullscreen = 0

# (list) Permissions
android.permissions = INTERNET

# (int) Minimum API required
android.minapi = 13

# (int) Android SDK version to use
android.sdk = 21


Активируем wunderwaffe:
buildozer android debug

и на выходе имеем .apk, который можно установить на Android-девайс.

Готово. С чем я вас и поздравляю!

Тестирование


И давайте посмотрим, как все это работает. Не зря же так долго старались :)
Запускаем Django-сервер, параметром указываем IP вашей машины в локальной сети:
python manage.py 192.168.xxx.xxx:8000

Призываем нечисть:
python daemon.py

Стартуем приложение на Android-девайсе и видим нечто подобное:

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

Подведем итоги


Что мы получили в результате?
  • Легко встраиваемый класс, реализующий логику реакции на удаленные команды;
  • Серверное приложение, позволяющее управлять произвольным скриптом из web-интерфейса, и предоставляющее REST API;
  • Android-приложение для управления скриптом посредством REST API.

Может это слишком громко сказано, но… Теперь меня мучает вопрос — а можно ли реализовать аналогичную архитектуру, используя другие языки и технологии (кроме Python), приложив при этом (хотя бы) не больше усилий и написав не больше кода?

На этом все.
Всем приятного кодинга и удачных сборок.

Полезные ссылки


«RemoteControlInterface» на github
Доки по Django
Доки по Django REST framework
Доки по Kivy
Установка Kivy
Установка Buildozer

Комментарии (1)

  • 5 августа 2016 в 05:42

    0

    Извините, а в Итогах там тонкий троллинг насчет «других языков и технологий» или автор действительно обладает именно таким кругозором?

    p.s.
    Совет начинающим программистам —
    Даже если вы виндузятник или пхпист, учитесь сразу пользоваться нормальным прямым слэшем — »/» (он же «дробь»), так как это традиционный способ разделения компонент в тексте, используемый человечеством уже не одну сотню лет. Обратный слэш »\» (он же «забой», хоть это и млао кто помнит) был придуман вовсе не для этого.

© Habrahabr.ru