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

Сегодня расскажу о том, как управлять компьютером с мобильного устройства. Нет, это не очередной аналог 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
Стоит ли развивать проект «RemoteControlInterface»?

Проголосовало 35 человек. Воздержалось 35 человек.

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

Поделиться с друзьями
-->

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


  1. maxp
    05.08.2016 05:42
    -1

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

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


    1. ophermit
      05.08.2016 15:36
      +1

      Для троллинга есть другие ресурсы, а это Хабр.

      На счет кругозора — все на свете уметь нельзя. А я не имею привычки «авторитетно заявлять» о тех отраслях \ технологиях, о которых знаю только по наслышке.

      К примеру, возьмем стандартный способ разработки под Android — не имея опыта создания приложений в Android Studio я не представляю, сколько времени уйдет на создание такого примитивного приложения с нуля. Включая установку и настройку среды.

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

      P.S. За совет — спасибо, начинающие программисты учтут. Все никак не могу отделаться от дурной привычки, которая въелась еще со времен DOS. Хоть и безболезненно переключаюсь на "/" в Linux-консоли.


      1. maxp
        06.08.2016 04:10

        Бэкслэши в DOSе завел Гейтс, когда ему понадобились вложенные директории, а нормальный слэш у него уже был занят под ключи для команд, которые в свою очередь были сделаны совместимыми с 8-битной CP/M. По иронии судьбы спустя три десятка лет пхпшники сделали аналогичный dirty hack.

        Вопрос относительно кругозора возник из-за довольно странного применения Django и еще более странного употребления термина «демон».

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

        Отдельно хотелось бы заметить, что в Питоне не стоит без особой необходимости начинать названия методов с подчеркивания, и особенно с двойного подчеркивания!


        1. ophermit
          06.08.2016 14:20

          Чтобы прояснить ситуацию, не могли бы вы описать на словах исходную задачу, которую решали?

          Все достаточно банально — доработка существующего проекта на Django и две порции требований. 1-я порция — реализовать управление скриптом из админки. После завершения работ над 1-й, поступила 2-я порция — сделать мобильный клиент.

          Отдельно хотелось бы заметить, что в Питоне не стоит без особой необходимости начинать названия методов с подчеркивания, и особенно с двойного подчеркивания!

          А вот отсюда, пожалуйста, поподробнее. Если не использовать подчеркивания, то какая альтернатива для разграничения доступа членам класса?

          … и еще более странного употребления термина «демон».

          Может быть, не совсем подходящий термин. Предлагайте свой вариант.


          1. maxp
            08.08.2016 08:08

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

            В Питоне нет разграничения доступа к членам класса, есть лишь некоторые достаточно условные соглашения.
            Про специальные случаи обработки двойного подчеркивания написано здесь — https://www.python.org/dev/peps/pep-0008/
            И это никак не то, что вы хотели.

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


            1. ophermit
              08.08.2016 09:31

              Не могу здесь судить о целесообразности написания отдельного клиента для реализации нажатия пары кнопок.
              Мыслите глобальнее — кнопок может быть произвольное количество. А для того, чтобы понять суть архитектуры, достаточно примера на одной кнопке. Так что, здесь я даже «перестарался» :)

              Про специальные случаи обработки двойного подчеркивания написано здесь — https://www.python.org/dev/peps/pep-0008/
              За это — спасибо. Понял, что происходит на самом деле.

              И это никак не то, что вы хотели.
              Хотя тут же пишут: «If your class is intended to be subclassed, and you have attributes that you do not want subclasses to use, consider naming them with double leading underscores and no trailing underscores.»

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

              А тут ради элементарного действие столько писанины…
              Вот и мы и вернулись к тому, с чего начали — к моему, как вы выразились, «тонкому троллингу». Так, собственно, как реализовать подобную архитектуру с меньшим количеством писанины? Не обязательно на Python.


              1. maxp
                08.08.2016 13:38

                Про какую-то архитектуру здесь как раз говорить не приходится, так как вместо нее здесь мы видим множество телодвижений для взаимодействия с джанго-админкой.

                Управление одним единственным процессом по хттп делается на Питоне строк в пару десятков строк не более:
                — берете штатный сервер https://docs.python.org/3.4/library/http.server.html
                — штатный json для параметров, чтобы было красиво https://docs.python.org/3.4/library/json.html
                — пускаете цикл обработки в отдельной нити https://docs.python.org/3/library/threading.html
                — через глобальную переменную передаете параметры из запроса в цикл.

                Пол часа делов, и в результате один маленький файл на чистом питоне без зависимостей.

                Если есть желание действительно подумать над архитектурой, а не над плясками вокруг джанго-админки, то попробуйте сделать корректную обработку очереди более, чем одним воркером — вот там уже есть над чем подумать!


                1. ophermit
                  08.08.2016 16:52

                  Про какую-то архитектуру здесь как раз говорить не приходится, так как вместо нее здесь мы видим множество телодвижений для взаимодействия с джанго-админкой.
                  Почему же так категорично? Если абстрагироваться от Django (и от всего остального), получаем:
                  • клиент — посылает команды;
                  • сервер — принимает команды, ставит в очередь;
                  • очередь;
                  • скрипт — выбирает команды из очереди.

                  Или это тоже нельзя назвать архитектурой?

                  В моем случае, для реализации очереди используется БД. Если я правильно понял, вы предлагаете архитектуру, в которой очередь хранится не в БД, а в памяти, так?


  1. Cobolorum
    05.08.2016 08:27

    Использовать стандартные IPC, наверное религия не позволяет?


    1. ophermit
      05.08.2016 15:40

      В связке Android-приложение — серверный демон? А можно поподробнее?


  1. bestfriend
    05.08.2016 10:44

    TL;DR:
    «как я научился из джанги посылать команды в терминал».

    скучно, сэр.


    1. ophermit
      05.08.2016 15:48

      Вы забыли о мобильном приложении, сэр. Абзац 3, строка 2.


  1. HeaTTheatR
    05.08.2016 10:53
    -1

    Сделайте нормальный интерфейс, не позорьте Kivy!


    1. ophermit
      05.08.2016 16:57

      Плюс одна идея в развитие проекта) А можно пример, как по-вашему должен выглядеть такой интерфейс? У меня, как back-end dev'a, с дизайном интерфейсов как-то не очень хорошо складывается.


      1. HeaTTheatR
        05.08.2016 17:32
        -1

        Ну, если вы считаете, что именно ТАК должно выглядеть приложение для Android...


        1. ophermit
          05.08.2016 18:30
          +1

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


  1. badgateway
    05.08.2016 15:49

    Помнится мне что подобные штуки для администрирования раньше делали через jabber, а теперь и telegram сгодится. Вроде как для админа gui не нужен.


    1. ophermit
      05.08.2016 17:57
      +1

      Согласен, хороший вариант. Если допустимо использование сторонних сервисов, получаем минус Android-app и минус админка.

      Вроде как для админа gui не нужен.

      Не спорю) Хоть и не админ, предпочитаю больше консоль, чем GUI. Когда на рабочем месте. А с мобильного девайса удобнее и быстрее сделать операцию в один клик, чем подключаться к терминалу.


      1. vechnoe
        05.08.2016 18:41

        Как то писал подобное для командной строки. Суть в том, что множество сервисов как в локальной сети так и удаленных управляются по некоему стандартизованному REST интерфейсу. Для админа получалась просто cli утилита — можно использовать в скриптах.


  1. vechnoe
    05.08.2016 15:49

    Джанго тут не нужна. Лучше всего такие вещи делать на Tornado/Aiohttp + Angular/React.


    1. ophermit
      05.08.2016 18:20

      Не знаком с этими технологиями, возьму на заметку. Если в двух словах — в чем преимущество?


      1. vechnoe
        05.08.2016 18:37

        1. Демоны/сервисы часто бывают ассинхронными, значит вам нужнео будет ожидание ответа от сервиса, синхронная джанга с этой задачей не справится
        2. (следует из п. 1) Обновление статусов на интерфейсе в реалтайме тоже можно сделать только на ассинхронном движке.
        3. Чаще всего вам не нужно хранить состояние сервиса, а настройки сохранять в текстовый json-файл. БД в данном случае может быть оверхэдом.


        1. ophermit
          05.08.2016 23:23

          Понял о чем речь, но не думаю, что для данной архитектуры это будет помехой:

          • клиент не держит соединение открытым до завершения выполнения команды — он отправляет запрос на добавление команды в очередь, а далее — с периодичностью в одну секунду отправляет запросы для проверки состояния;
          • запросы от клиента — довольно легковесные операции (добавить запись в БД, получить запись по ID);
          • демон работает отдельным процессом и взаимодействует с Django посредством ORM — его работа не зависит от Django-сервера.

          Узким местом здесь является обращение к БД. Но ее используем для реализации очереди задач, что позволяет достигнуть асинхронности. Если мы можем в реализации отказаться от очереди (админкой можно пожертвовать), то — да, БД действительно будет оверхэдом.

          Проблемы могут возникнуть разве что при использовании в промышленных масштабах, при огромном количестве соединений — здесь недостатки Django дадут о себе знать.

          В целом — спасибо за совет, Tornado — интересное решение, нужно будет повнимательней рассмотреть его под микроскопом.


          1. vechnoe
            06.08.2016 09:50
            +1

            Если с Торнадо еще не знакомы, смотрите сразу на aiohttp.


  1. bcmob
    05.08.2016 18:21

    Для подобных задач я использую бота для Telegram. Самого бота можете писать на практически любом, удобном вам языке, ну а сам Telegram уже готов на все популярные платформы.


    1. ophermit
      05.08.2016 18:21

      Ответ выше.


  1. Markes
    05.08.2016 18:32

    Тот кто знает C# могут сделать Android и/или IOS приложение на Xamarin.
    Серверную часть на VS.


    1. ophermit
      05.08.2016 18:33

      Ок, сколько времени займет разработка?


      1. Varkus
        05.08.2016 20:53

        Смотря что вы от этой разработки хотите.
        Hello world в телеграмм на с++ займёт 2-5 минут(сам проверял)
        остальное по желанию и возможностям.

        из печального опыта: уж казалось бы, ну сколько можно потратить времени на систему учета «Прохода сотрудников в столовую по rfid-карточкам»? День, неделя, месяц?
        я седьмой месяц пилю уже 5ю версию на bash,php,js,html,css…
        Ну ладно бы заказчики были неадекватные и с придирками, так НЕТ, они говорят: «ну нас в принципе всё устраивает, кроме пары багов в логике первых версий».
        Всё дело в том что я над каждой строчкой CSS могу часами медитировать:
        то border-radius какой-то не такой
        то цветовая гамма, хоть глаз выколи
        а вот этот кусок логики лучше на php оставить или на js переложить?
        а вот эти строки в bash работают чуть быстрее, чем первая версия, но выглядят ужасно
        3 часа искал инфу как в sqlite hex в int получить, пока не узнал, что это такая ЛЁГКАЯ эмбедед БД, что такой изврат ей ни к чему, нету его в коде sqlite. посоветовали писать своё расширение.

        и так в каждой мелочи :( знал бы что из этого будет… дизайн бы сразу отдал дизайнеру, логику архитектору, а код бы настрочил за неделю с тестами.


        1. maxp
          06.08.2016 04:17

          Нет, признание собственных недостатков — это правильно.
          Но скажите, как вы выживаете?

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


          1. Varkus
            08.08.2016 04:24

            Но скажите, как вы выживаете?

            лучше Вам этого не знать

            заказчик давным-давно имеет сильное желание убить исполнителя

            печальная констатация факта :(

            а вообще всё пошло не так, когда вместо ТЗ мне дали пачку 100 rfid карт с фразой:
            1) система должна просто фиксировать время прохода этих карт
            2) в любой момент система должна вывести сумму проходов конкретной карты в указанный период
            всё.

            Неделю их мучил, что это явно не полная инфа для ТЗ… вообщем остальное ТЗ пришлось писать самому, а я не бог и не могу предусмотреть всех очевидных и не очень очевидных подводных камней, например:
            — система должна управлять турникетом, т.е. давать ему сигнал на открытие
            — необходима возможность блокировать карту
            — пользователь может «потерять» карту, просит новую, а оказывается, что он прежнюю карту передал другому лицу
            — карт оказалось не 100, а уже за 600 перевалило
            — 1я версия работала на роутере mr3020: OpenWrt Atheros AR9331@400MHz 32MБ-рам 4MБ nand-флэш + 4ГБ usb-flash
            — 5я уже работает на нетбуке 2ядра 1,66ГГц 1ГБ-рам 500ГБ хдд + OpenWrt грузится с usb-flash за 7 секунд
            — пользователей нужно «сортировать» в группы
            ФАТАЛИТИ: нужно считать не проходы КАРТ — а проходы ПОЛЬЗОВАТЕЛЯ, у которого в профайле может быть и несколько карт

            Мелкие «баги» логики были устранены еще в 1й-2й версиях, но вот учёт не по картам, а по пользователям в корне ломает всю логику. Потому уже и 5ю версию пилю. Да еще и пользователей стало явно больше чем может «переварить» малыш mr3020.

            Спросите почему не взял одну из 100500 готовых систем? Да потому что: а чё там считать-то 100 карт, сам справлюсь + спортивный интерес.

            Почему не бросил проект, когда ТЗ начало «уходить в сторону»? Принципиальная упёртость: меня так просто не сломать, победа будет за мной!

            На всё про всё осталось 1-2 недели, далее за мной действительно «придут» гневные заказчики других проектов, т.е. мотивация более чем существенная, а первый заказчик к счастью не скупой и платит за все эти «форс-мажоры», ведь при нормальном ТЗ всё могло сложится иначе, по крайней мере быстрее.
            В редкие часы отдыха продумываю план статьи для хабра про эту «чудо-систему».


            1. maxp
              08.08.2016 14:48

              Описанная здесь ситуация довольно сильно отличается от изначальной! :)

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

              По опыту могу сказать, что не надо туда ставить разные «роутеры» — у них при интерсивной записи дохнет флэш.

              Какой-нибудь одноплатник может быть более удобным, чем нетбук.
              Лучше сразу ставить туда нормальную ОС, чтобы не париться с доработками/бэкапами/отчетами и т.п., так как 7 секунд оно будет грузиться или 20 — мало кого волнует в реальности (если свет выключали — то это надолго).

              Занятно выглядит в этом плане Raspberry Pi, только основную флэшку надо в ридонли монтировать (а то сдохнет!).
              В уличных условях тоже работает.


  1. AlexSuslov
    06.08.2016 19:00

    Мы для управления устройствами на ESP8266.ru сделали IoT Manager для телефончика. Работает по MQTT.
    Пользователи не только рулят устройствами, но и мониторят сервера.