Pull to refresh

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

Reading time18 min
Views18K
Привет, Хабр!

Сегодня расскажу о том, как управлять компьютером с мобильного устройства. Нет, это не очередной аналог 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 '<span style="color: #%s;">%s</span>' % (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/<id_объекта> — используется для определения состояния объекта команды.

Для минимизации кода используем плюшки, поставляемые в комплекте с 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<pk>[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/<id_объекта>"
        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
Only registered users can participate in poll. Log in, please.
Стоит ли развивать проект «RemoteControlInterface»?
55.26% Однозначно да, в нем не хватает… (пожелания в комментариях или issue на github)21
5.26% Нет, проект уже содержит весь необходимый функционал.2
39.47% Да кому он нужен! Лучше использовать готовые решения (примеры — в комментариях).15
38 users voted. 37 users abstained.
Tags:
Hubs:
+9
Comments33

Articles

Change theme settings