Pull to refresh

Transcend WiFi. Пишем клиент Shoot&View для Windows, Mac и Linux

Reading time11 min
Views29K
На хабре неоднократно упоминали о карте памяти формата SDHC со встроенным WiFi передатчиком. Купив эту карту, я был разочарован ужасным программным обеспечением, которое идет «в комплекте» с картой. Если приложением для iOS и Android хоть как то можно пользоваться, то отсутствие клиента под windows и macos, лишает карту возможности использования ее профессионалами. Точнее сказать, на PC есть веб интерфейс, но кроме ужасного внешнего вида, меня разочаровало отсутствие востребованной у фотографов функции Shoot&View, которая позволяет практически мгновенно видеть на большом экране компьютера результат съемки.

Любители geek-porno скорее всего разочаруются — мы не будет модифицировать прошивку, хакать ее, вскрывать саму карту памяти. Мы будет работать со «стоковой» картой памяти, без каких либо модификаций.

Итак, в этой статье, мы разберем с вами протокол Shoot&View карт памяти Transcend WiFi и напишем на python кроссплатформенный клиент, который запустится на windows, linux и MacOS. А для самых нетерпеливых, в конце статьи вас ожидает готовый python модуль для своих проектов, консольный клиент, а так же GUI утилита, которая работает на windows, linux и macos.



Поиск карты памяти в сети.


Карта памяти может работать в двух режимах — режим точки доступа, когда карта создает свою точку достапа, и режим подключения к точке доступа, когда карта «цепляется» к заранее прописанным в ее настройках точкам доступа. Для наших экспериментов, лучше включить режим подключения к точке доступа, предварительно настроив подключение из приложения на android или ios. Так же не забудьте настроить «Turn Off WiFi», установив Never. Эта опция отвечает за отключение WiFi, если никто не подключился к карте. На первом этапе, советую подключить карту к кард-ридеру, либо настроить фотоаппарат так, чтоб он не отключался при бездействии.

Пожалуй начнем программировать. Для консольного клиента нам не потребуются какие либо дополнительные модули, только «батарейки в комплекте». А начнем мы с:

import socket

class SDCard:
	def __init__(self,home_dir=''):
		self.home_dir=home_dir
		# узнаем ip адрес интерфейса, к которому в данный момент подключены
		self.ip=socket.gethostbyname(socket.gethostname())
                # переменная для ip карты
		self.card_ip=None


if __name__=='__main__':

	# подготовим папку для принимаемых фотографий
	HOME_DIR=os.path.expanduser('~')
	if not os.path.exists(HOME_DIR+'/'+'ShootAndView'):
		os.mkdir(HOME_DIR+'/'+'ShootAndView')
	HOME_DIR=HOME_DIR+'/ShootAndView/'

	sd=SDCard(home_dir=HOME_DIR)


Если карта подключена к точке доступа, ее ip-адрес можно посмотреть, например, в web интерфейсе роутера, а если же у нас прямое подключение к карте, то ее ip-адрес равен 192.168.11.254 (в соответствии с дефолтными настройками).
Но не хотелось бы искать ее вручную, тем более создатели карты предусмотрели поиск ее в сети, как это сделано в мобильном приложении. Для этого нам нужно:
  1. Создать сокет на порту 58255
  2. Отправить с него пустой широковещательный запрос на порт 55777
  3. Ожидать чуда ответа карты

Если нам повезет, то в ответ мы получим вот такой текст:
Transcend WiFiSD - interface=mlan0
ip=192.168.0.16
netmask=255.255.255.0
router=192.168.0.1
mode=client
essid=WiFiSDCard
apmac=CE:5D:4E:5B:70:48

Из этого всего, нам понадобится только ip адрес. Теперь осталось запрограммировать все это дело:
import os
import socket
import thread
import time

class SDCard:
	def __init__(self,home_dir=''):
		self.home_dir=home_dir
		self.ip=socket.gethostbyname(socket.gethostname())
		self.card_ip=None
				
	def find_card(self,callback=None):
		"""запускаем поиск в отдельном потоку"""
		thread.start_new_thread(self.find_card_thread,(callback,))
		
	def find_card_thread(self,callback=None):
		
		while not self.card_ip:
			"""создаем UDP сокет """
			s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
			s.settimeout(5)
			s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
			s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)	
			""" и биндимся на нужный порт """
			try:s.bind((self.ip, 58255))
			except socket.error:
				s.close()
				time.sleep(1)
				continue

			"""отправляем пустой широковещательный запрос на порт 55777"""
			s.sendto('', ('<broadcast>', 55777))
			try:				
				resp=s.recv(400)
				s.close()
				try:
					"""пробуем в лоб распарсить результат"""
					self.card_ip=resp.split('ip=')[1].split('\n')[0]
				except IndexError:
					"""если не получилось сообщаем об этом"""
					if callback:callback(None)					
				
				"""если получилось сообщаем ip"""
				if callback:callback(self.card_ip)			
			except socket.timeout:
				callback(self.card_ip)
			finally:
				time.sleep(2)

def monitor(ip):
	if not ip:return
	print 'Find card on ip:',ip
	
		
if __name__=='__main__':
	HOME_DIR=os.path.expanduser('~')
	if not os.path.exists(HOME_DIR+'/'+'ShootAndView'):
		os.mkdir(HOME_DIR+'/'+'ShootAndView')
	HOME_DIR=HOME_DIR+'/ShootAndView/'
	if options.dir:HOME_DIR=options.dir	
	
	sd=SDCard(home_dir=HOME_DIR)
	# мне удобнее сделать все на "коллбэках",
	# так как с GUI потом будет проще
	sd.find_card(callback=monitor)
	
	# так как поиск запускаем в отдельном потоке, 
	# приложение не должно завершаться
	while 1:
		time.sleep(1)


На самом деле, самое сложное уже позади. Осталось только узнать, как нам получать информацию о «поступлении» новых фотографий и скачивать их.

Получение новых фотографий.


С получением фотографий все очень просто. После того, как мы нашли карту, достаточно присоединиться к карте на порт 5566.
Теперь, как только фотоаппарат сделает новый кадр, через 7-8 секунд к нам через открытый сокет придет информация о новых файлах, которые появились на карте, выглядит это так:
>/mnt/DCIM/101CANON/IMG_1754.JPG

Если сделали несколько фотографий, то в одном сообщении эти строки разделены нулевым байтом (0x00)

Хочу подчеркнуть — именно через 7-8 секунд. Почему так сделано, не совсем понятно, но повлиять на это мы не можем. Так же, приходит только информация о новых снимках в формате jpeg, причем ПО карты имеет возможность вытаскивать вшитую jpg превьюшку из RAW файла (об этом чуть ниже), но программисты предпочли лишить нас возможности снимать в jpg, заставляя снимать в RAW+jpg, либо писать RAW на одну карту, а jpg на другую. Так же, у меня не получалось копировать фотографии с кард-ридера, Shoot&View реагирует только на новые снимки, сделанные камерой.

Запрограммировать все это дело проще простого. Я пожалуй начну показывать отрывки кода, а полный код вы сможете найти в конце статьи:
	def listener_thread(self,callback):
		
		sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
		# коннектимся к карте памяти
		sock.connect((self.card_ip, 5566))
		while self.listen_flag:
			message=sock.recv(1024)
			# разделяем сообщение по нулевому байту (если пришло несколько фотографий)
			new_files=message.split('\00')
			for x in new_files:
				if x:
					# добавляем все файлы в список загрузок
					self.all_files.append(x[1:]) # x[1:] - опускаем символ ">", он нам не нужен
			# добавляем последний файл в очередь загрузок
			self.download_list.put(self.all_files[-1])

			if callback:callback(self.all_files[-1])


Загрузка фотографий с карты памяти


Теперь у нас есть список новых файлов, остался самый последний шаг — загрузка фотографий на компьютер. Сама по себе загрузка реализуется через встроенный веб сервер карты. Удивительно, но факт — все что мы делали раньше, а так же загрузка фотографий и некоторые действия, такие как получение списка файлов, получение превью и пр., совершенно НЕ ТРЕБУЮТ АВТОРИЗАЦИИ. То есть если карта настроена как точка доступа, и пользователь не сменил пароль WiFi, вы можете спокойно подключиться к ней, и скачать все что там есть. Надо будет как нибудь пройтись летом по туристическим местам и поискать WiFi сети среди туристов с фотоаппаратами.

Если заглянуть в папку cgi-bin, то мы найдем много чего интересного, что может понадобиться в других проектах. Заглянуть в нее легко, достаточно поднять на карте telnet, согласно простым инструкциям. А внутри у нас:


Например, бинарник wifi_filelist отдаст нас список файлов в директории (в формате XML), достаточно обратиться к нему так: CARD_IP/cgi-bin/wifi_filelist?fn=DIR, где CARD_IP — ip адрес карты памяти, который мы уже нашли, а DIR — директория (например, /mnt/DCIM). Бинарник thumbNail отдаст нам превьюшку фотографии, достаточно скормить ему таким же образом путь к файлу. Причем на стороне сервера не делается ресурсоемкий резайс фотографии, а выдергивается вшитая в jpg или в raw превьюшка.

Но нам интересна загрузка фотографии. Получение нужной фотографии реализуется простым GET запросом на адрес CARD_IP/cgi-bin/wifi_download?fn=IMAGE_PATH, где IMAGE_PATH путь к фотографий, который нам приходит по сокету, который мы создали выше. Для загрузки в python'e в данном случае подходит функция urlretrieve библиотеки urllib. Она позволяет сразу же сохранять результат запроса в файл, и самое главное — получать прогресс загрузки, что потом пригодится в GUI.
Функция загрузки выглядит так:
	def download_thread(self,download_callback,download_complete):
		while self.listen_flag:
			# если очередь на загрузку не пуста
			if not self.download_list.empty():
				# берем путь из очереди
				fl=self.download_list.get(block=0)
				# и загружаем его в папку с фотографиями
				urllib.urlretrieve('http://%s/cgi-bin/wifi_download?fn=%s'%(self.card_ip,fl),self.home_dir+fl.split('/')[-1],download_callback if download_callback else None)
				if download_complete:download_complete(self.download_now)
			time.sleep(0.1)


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

sdwificard.py
#coding:utf-8
"""
    Copyright (C) 2010 Igor zalomskij <igor.kaist@gmail.com>

    This program is free software; you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation; either version 2 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License along
    with this program; if not, write to the Free Software Foundation, Inc.,
    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
"""

import os
import socket
import thread
import time
import ping
import Queue
import urllib
import sys



class SDCard:
	def __init__(self,home_dir=''):
		self.home_dir=home_dir
		# выясняем ip адрес сетевого интерфейса компьютера
		self.ip=socket.gethostbyname(socket.gethostname())
		self.card_ip=None #переменная с ip адресом карты памяти
		self.all_files=[] # список всех файлов
		
		self.download_list=Queue.Queue() # очередь для загрузки фотографий
		self.in_queue=[] # что в очереди на загрузку, понадобится в GUI
		

		
		
	def find_card(self,callback=None):
		# стартуем новый поток с поиском карты
		thread.start_new_thread(self.find_card_thread,(callback,))

		
	def find_card_thread(self,callback=None):
		""" поток поиска карты памяти """
		while not self.card_ip:
			# создаем UDP сокет
			s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
			s.settimeout(5)
			s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
			s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)			
			try:s.bind((self.ip, 58255)) #биндим его на нужный порт
			except socket.error:
				s.close()
				time.sleep(1)
				continue


			# посылаем широковещательный запрос на порт 55777
			s.sendto('', ('<broadcast>', 55777))
			try:
				resp=s.recv(400)
				s.close()
				try:
					# пробуем распарсить ответ 
					self.card_ip=resp.split('ip=')[1].split('\n')[0]
				except IndexError:
					# иначе сообщаем о неудаче
					if callback:callback(None)
		
				if callback:callback(self.card_ip)
			
			except socket.timeout:
				callback(None)
			finally:
				time.sleep(2)
			
			
	def start_listen(self,callback=None,download_callback=None,download_complete=None):
		""" Запуск мониторинга новых фотографий. запускаем три потока """
		self.listen_flag=True
		# поток сокета, который будет слушать сокет с новыми фотографиями
		thread.start_new_thread(self.listener_thread,(callback,))
		
		# время от времени советую пинговать карту, чтоб она не отвелилась.
		thread.start_new_thread(self.ping_card,())
		
		# поток фоновой загрузки фотографий
		thread.start_new_thread(self.download_thread,(download_callback,download_complete))
		

		
	def ping_card(self):
		# пингуем карту с переодичность 20 секунд.
		while self.listen_flag:
			try:
				resp=ping.do_one(self.card_ip)
			except socket.error: # во время загрузки фотографий карта может не отвечать на пинги, это нормально
				
				pass
			time.sleep(20)
				
			
	def listener_thread(self,callback):
		# поток получения информации о новых фотографиях
		
		sock=socket.socket(socket.AF_INET, socket.SOCK_STREAM)
		# коннектимся к карте на порт 5566
		sock.connect((self.card_ip, 5566))
		while self.listen_flag:
			message=sock.recv(1024)
			new_files=message.split('\00') # разделяем сообщение по нулевому байту (если пришло несколько фотографий)
			for x in new_files:
				if x:
					# добавляем все файлы в список всех файлов ;)
					self.all_files.append(x[1:]) # x[1:] отсекаем первый символ ">", он нам не нужен
			
			self.download_list.put(self.all_files[-1]) # добавляем последний файл в очередь на загрузку
			self.in_queue.append(self.all_files[-1]) # добавляем так же в другой список, он нужен для GUI
			if callback:callback(self.all_files[-1]) 

			
	def download_thread(self,download_callback,download_complete):
		# поток загрузки фотографий
		while self.listen_flag:
			if not self.download_list.empty(): # если очередь не пуста
				fl=self.download_list.get(block=0)
				self.download_now=fl # что в данный момент загружается, нужно для GUI
				
				# загружаем 
				urllib.urlretrieve('http://%s/cgi-bin/wifi_download?fn=%s'%(self.card_ip,fl),self.home_dir+fl.split('/')[-1],download_callback if download_callback else None)
				if download_complete:download_complete(self.download_now)
			time.sleep(0.1)


def find_callback(ip):
	if not ip:return
	print 'Find card on ip:',ip
	# если определен IP адрес карты, стартуем мониторинг новых файлов
	sd.start_listen(download_complete=download_complete)
	

def download_complete(fname):
	print 'New image: %s'%(HOME_DIR+fname.split('/')[-1])
	
		
if __name__=='__main__':
	""" Для консольного клиента, парсим опции. Возможно пользователь
	захочет переопределить папку для загрузки изображений, либо ip адрес интерфейса компьютера
	"""
	from optparse import OptionParser
	parser = OptionParser()
	parser.add_option("-d", "--dir", dest="dir",default=None,help="directory for storing images")
	parser.add_option("-i", "--ip", dest="ip",default=None,help="ip address of the computer (default %s)"%(socket.gethostbyname(socket.gethostname())))
	(options, args) = parser.parse_args()
	# готовим папку для загрузки изображений по умолчанию.
	HOME_DIR=os.path.expanduser('~')
	if not os.path.exists(HOME_DIR+'/'+'ShootAndView'):
		os.mkdir(HOME_DIR+'/'+'ShootAndView')
	HOME_DIR=HOME_DIR+'/ShootAndView/'
	if options.dir:HOME_DIR=options.dir
		
	sd=SDCard(home_dir=HOME_DIR)	
		
	if options.ip:sd.ip=options.ip
	print 'Finding sd card...'
	# запускаем поиск карты памяти
	sd.find_card(callback=find_callback)
		
	while 1:
		time.sleep(1)




Я прошу не ругать меня за возможные отступления от pep-8, сейчас я практикую программирование достаточно редко, да и люблю про себя повторять: «В голове моей опилки не-бе-да, pep-8 не читал я, да-да-да».
Все исходные коды вы можете взять на github.com/kaist/shoot-and-view

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

GUI


Для GUI я использовал самое простое средство в питоне, это Tkinter. Он доступен «из коробки» в windows и MacOS, и к тому же занимает мало места, если собирать standalone приложение. Процесс написания GUI, пожалуй описывать не буду, ограничусь только небольшой инструкцией:

  1. Импортируйте Tkinter
    from Tkinter import * 

  2. Напишите GUI



Консольное приложение не требует дополнительных библиотек, а вот GUI версия хочет разных плюшек, таких как чтение exif, работа с изображениями и пр. Если вы хотите запустить ее из исходников (извините, на Linux я подготовил только такой вариант), то вам потребуется:
sudo apt-get install python-tk python-imagetk python-imaging libimage-exiftool-perl
А так же, установить вручную биндинг к exiftool (sudo python setup.py install)
В windows, кроме python 2.7 и биндинга к exiftool, требуется PIL и exiftool.
Так же установка exiftool и биндинга к нему требуется на MacOS, см. ссылки выше.

Приложение собирается при помощи py2exe на windows и py2app на MacOS, скрипты вы сможете так же найти среди исходников.

Итог


Как и обещал, для самых ленивых доступны готовые сборки для Windows и MacOS. Взять их можно на этой странице.
Кое что из возможностей:
  • История съемки — возможность клавишами влево-право просмотреть предыдущую или следующую отснятую фотографию.
  • Зум — при нажатии клавиши «проблем», фотография зуммируется до 100%
  • Автоматический поиск карты памяти
  • Ну и конечно же, все бесплатно для коммерческого и некоммерческого использования. Исходники доступны по лицензии GPL v2


Ну и напоследок пару скриншотов:







P.S. Я собирал приложение для MacOS впервые, прошу протестировать, работает ли, особенно не на машине python разработчиков )

Эта статья распространяется на условиях лицензии Creative Commons Attribution 3.0 Unported (CC BY 3.0)
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+55
Comments18

Articles