Привет, Хабр!
Сегодня расскажу о том, как управлять компьютером с мобильного устройства. Нет, это не очередной аналог radmin'a, и не пример того, как можно поиздеваться над компьютером друга. Речь пойдет об удаленном управлении демоном, а точнее — о создании интерфейса для управления демоном, написанном на Python.
Архитектура довольно простая:
Заинтересовавшимся — добро пожаловать под кат.
Перед тем, как приступать к реализации, предлагаю «подготовить» каталог проекта. Нужно:
Начнем с серверной части — Django-приложения. Создадим новое приложение и добавим superuser'а:
Рекомендую сразу же его добавить в используемые Django-проектом приложения (web\settings.py или вместо «web» — имя вашего Djnago-проекта):
Создадим БД и superuser'а:
Настройки завершены, приступаем к реализации приложения.
Модель в архитектуре одна — это Команда, на которую должен отреагировать демон. Поля модели:
Подробнее о командах и статусах — см. ниже.
Немного «проапгрейдим» модель:
1. Расширим стандартный менеджер. Добавим методы для получения команд в состоянии «Создана» и в состоянии «В обработке».
И добавим его в модель:
2. Добавим методы проверки состояния и методы установки состояния команды:
Примечание: Конечно, можно обойтись и без этих методов. В таком случае в коде, работающим с Django ORM, потребуется использовать константы и описывать логику (хоть двухстрочную, но все же) обновления команды, что, имхо, не совсем удобно. Намного удобнее дергать необходимые методы. Но если такой подход противоречит концепции — с удовольствием выслушаю аргументы в комментариях.
Примечание: Здесь и далее нам понадобится приложение «django-ipware» для определения IP клиента, установим:
Здесь все проходит нативно: регистрируем модель в админ-панели, описываем отображаемые столбцы в таблице и поля на форме. Единственный нюанс — для сохранения IP клиента в объекте необходимо переопределить метод сохранения:
Не забываем применить изменения в моделях к базе данных:
Приступим к реализации логики обработки команд.
Как было написано выше, в нашем распоряжении 4 команды:
При создании, команде присваивается состояние «Создана» (спасибо, Кэп!). В процессе обработки команда может быть «Выполнена» (если состояние системы удовлетворяет всем необходимым условиям) или «Отклонена» (в противном случае). Состояние «В обработке» применимо для «долгоиграющих» команд — на их выполнение может потребоваться продолжительный период времени. К примеру, получив команду «Приостановить» код всего лишь меняет значение флага, а команда «Перезапуск» инициирует выполнение более комплексной логики.
Логика обработки команд следующая:
«Точкой входа» в классе является метод .check_commands() — в нем реализована описанная выше логика. Этот же метод будем вызывать в основном цикле демона. В случае получения команды «Приостановить», в методе создается цикл, условием выхода из которого является получение команды «Возобновить» — таким образом достигается желаемый эффект паузы в работе демона.
Модуль, в котором опишем реализацию IRemoteControl, предлагаю разместить в каталоге приложения. Так мы получим удобно транспортируемое Django-app.
Если модель сферического демона в вакууме можно представить в таком виде:
то внедрение интерфейса пульта управления происходит безболезненно:
В результате призванная нечисть управляется, но только с админ-панели.
Поместим данный код в файл, к примеру, daemon.py и пойдем дальше — напишем мобильный клиент.
Но для начала неплохо было бы реализовать интерфейс для общения мобильного клиента и серверной части. Приступим.
Установим Django REST framework:
Начнем с описания набора возвращаемых данных интерфейсом REST. Здесь нам пригодятся те загадочные методы из описания модели (.status_dsp() и .code_dsp()), которые возвращают текстовое название состояния и кода команды соответственно:
Методы REST API в архитектуре Django-приложения — это те же представления, только… вы поняли.
Для общения с клиентом достаточно трехбукв слов API-методов (эхх, идеальный мир...):
Для минимизации кода используем плюшки, поставляемые в комплекте с Django REST framework:
Опишем end-point'ы реализованных API-методов.
И подключим их к проекту (web\urls.py):
Интерфейс для общения реализован. Переходим к самому вкусному.
Для общения с сервером используем UrlRequest (kivy.network.urlrequest.UrlRequest). Из всех его достоинств нам понадобятся следующие:
Для простоты реализации будем использовать схему аутентификации Basic. При желании, можно одну из следующих статей посвятить другим способам аутентификации на web-ресурсах с помощью UrlRequest — пишите в комментариях.
Надеюсь, комментариев в коде достаточно для понимания. Если все же недостаточно — сообщайте, буду вносить правки.
На этом баловство с кодом завершается и на сцену выходит
О Buildozer'е можно говорить долго, потому что о нем сказано мало. Есть и статьи на хабре (об установке и настройке и о сборке релиз-версии и публикации на Google Play), конечно же, есть и документация… Но есть и нюансы,о которых можно написать целую статью которые разбросаны по разным источникам. Постараюсь собрать основные моменты здесь.
Ну и немного кода в помощь счастливым обладателям Debian и Ubuntu (остальным потребуется «тщательно обработать напильником»)
Теперь, когда Buildozer установлен, инициализируем его:
В результате работы этой команды в каталоге создастся файл конфигурации сборки (buildozer.spec). В нем находим указанные ниже ключи и присваиваем им соответствующие значения:
Активируем wunderwaffe:
и на выходе имеем .apk, который можно установить на Android-девайс.
Готово. С чем я вас и поздравляю!
И давайте посмотрим, как все это работает. Не зря же так долго старались :)
Запускаем Django-сервер, параметром указываем IP вашей машины в локальной сети:
Призываем нечисть:
Стартуем приложение на Android-девайсе и видим нечто подобное:
Примечание: Для записи видео использовалась финальная версия проекта, которую можно найти на github. От кода, приведенного в статье, отличается расширением функционала. В серверную часть добавлена поддержка пользовательских команд и отладочные сообщения (для наглядности), а в клиент добавлены: форма авторизации, запрос на подтверждение выполнения команды и некоторые удобства в интерфейс.
Что мы получили в результате?
Может это слишком громко сказано, но… Теперь меня мучает вопрос — а можно ли реализовать аналогичную архитектуру, используя другие языки и технологии (кроме Python), приложив при этом (хотя бы) не больше усилий и написав не больше кода?
На этом все.
Всем приятного кодинга и удачных сборок.
«RemoteControlInterface» на github
Доки по Django
Доки по Django REST framework
Доки по Kivy
Установка Kivy
Установка Buildozer
Сегодня расскажу о том, как управлять компьютером с мобильного устройства. Нет, это не очередной аналог radmin'a, и не пример того, как можно поиздеваться над компьютером друга. Речь пойдет об удаленном управлении демоном, а точнее — о создании интерфейса для управления демоном, написанном на Python.
Архитектура довольно простая:
- «Remote control App» — Kivy-приложение, реализующее клиентскую часть для мобильных устройств.
- «Remote control» — Django-приложение, реализующее REST API и взаимодействие с БД;
- IRemoteControl — Класс, реализующий логику обработки поступивших команд (будет использован в демоне);
Заинтересовавшимся — добро пожаловать под кат.
Перед тем, как приступать к реализации, предлагаю «подготовить» каталог проекта. Нужно:
- создать отдельный Python virtual environment
virtualenv .env
- создать новый Django-проект (например — web)
Все операции с Django будем выполнять относительно этого каталога;django-admin startproject web
- создать каталог для 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
В результате имеем возможность создавать\редактировать объекты...![Создание\редактирование объекта команды](https://habrastorage.org/r/w1560/files/d93/d96/077/d93d9607778648669a9f68f5faed33c3.png)
![Создание\редактирование объекта команды](https://habrastorage.org/files/d93/d96/077/d93d9607778648669a9f68f5faed33c3.png)
...и просматривать список объектов в админ-панели:![список объектов](https://habrastorage.org/r/w1560/files/5b1/c44/da7/5b1c44da752a4b489a306595e0e6d396.png)
![список объектов](https://habrastorage.org/files/5b1/c44/da7/5b1c44da752a4b489a306595e0e6d396.png)
Приступим к реализации логики обработки команд.
Класс 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-приложения — это те же представления, только… вы поняли.
Для общения с клиентом достаточно трех
- 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.