mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
[Device] Add basic support for Bambook device.
This commit is contained in:
parent
c33593c43b
commit
ea727b2c5f
@ -479,6 +479,7 @@ from calibre.devices.misc import PALMPRE, AVANT, SWEEX, PDNOVEL, KOGAN, \
|
|||||||
GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, Q600
|
GEMEI, VELOCITYMICRO, PDNOVEL_KOBO, Q600
|
||||||
from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG
|
from calibre.devices.folder_device.driver import FOLDER_DEVICE_FOR_CONFIG
|
||||||
from calibre.devices.kobo.driver import KOBO
|
from calibre.devices.kobo.driver import KOBO
|
||||||
|
from calibre.devices.bambook.driver import BAMBOOK
|
||||||
|
|
||||||
from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon, \
|
from calibre.ebooks.metadata.fetch import GoogleBooks, ISBNDB, Amazon, \
|
||||||
LibraryThing
|
LibraryThing
|
||||||
@ -598,6 +599,7 @@ plugins += [
|
|||||||
VELOCITYMICRO,
|
VELOCITYMICRO,
|
||||||
PDNOVEL_KOBO,
|
PDNOVEL_KOBO,
|
||||||
ITUNES,
|
ITUNES,
|
||||||
|
BAMBOOK,
|
||||||
]
|
]
|
||||||
plugins += [x for x in list(locals().values()) if isinstance(x, type) and \
|
plugins += [x for x in list(locals().values()) if isinstance(x, type) and \
|
||||||
x.__name__.endswith('MetadataReader')]
|
x.__name__.endswith('MetadataReader')]
|
||||||
|
0
src/calibre/devices/bambook/__init__.py
Normal file
0
src/calibre/devices/bambook/__init__.py
Normal file
461
src/calibre/devices/bambook/driver.py
Normal file
461
src/calibre/devices/bambook/driver.py
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2010, Li Fanxi <lifanxi at freemindworld.com>'
|
||||||
|
__docformat__ = 'restructuredtext en'
|
||||||
|
|
||||||
|
'''
|
||||||
|
Device driver for Sanda's Bambook
|
||||||
|
'''
|
||||||
|
|
||||||
|
import time, os, hashlib
|
||||||
|
from itertools import cycle
|
||||||
|
from calibre.devices.interface import DevicePlugin
|
||||||
|
from calibre.devices.usbms.deviceconfig import DeviceConfig
|
||||||
|
from calibre.devices.bambook.libbambookcore import Bambook, text_encoding, CONN_CONNECTED
|
||||||
|
from calibre.devices.usbms.books import Book, BookList
|
||||||
|
from calibre.ebooks.metadata.book.json_codec import JsonCodec
|
||||||
|
from calibre.ptempfile import TemporaryDirectory, TemporaryFile
|
||||||
|
from calibre.constants import __appname__, __version__
|
||||||
|
|
||||||
|
class BAMBOOK(DeviceConfig, DevicePlugin):
|
||||||
|
name = 'Bambook Device Interface'
|
||||||
|
description = _('Communicate with the Sanda Bambook eBook reader.')
|
||||||
|
author = _('Li Fanxi')
|
||||||
|
supported_platforms = ['windows', 'linux']
|
||||||
|
log_packets = False
|
||||||
|
|
||||||
|
booklist_class = BookList
|
||||||
|
book_class = Book
|
||||||
|
|
||||||
|
FORMATS = [ "snb" ]
|
||||||
|
VENDOR_ID = 0x230b
|
||||||
|
PRODUCT_ID = 0x0001
|
||||||
|
BCD = None
|
||||||
|
CAN_SET_METADATA = False
|
||||||
|
THUMBNAIL_HEIGHT = 155
|
||||||
|
|
||||||
|
# path_sep = "/"
|
||||||
|
icon = I("devices/bambook.png")
|
||||||
|
# OPEN_FEEDBACK_MESSAGE = _(
|
||||||
|
# 'Connecting to Bambook device, please wait ...')
|
||||||
|
BACKLOADING_ERROR_MESSAGE = _(
|
||||||
|
'Unable to add book to library directly from Bambook. '
|
||||||
|
'Please save the book to disk and add the file to library from disk.')
|
||||||
|
|
||||||
|
METADATA_CACHE = '.calibre.bambook'
|
||||||
|
METADATA_FILE_GUID = 'calibremetadata.snb'
|
||||||
|
|
||||||
|
bambook = None
|
||||||
|
|
||||||
|
def reset(self, key='-1', log_packets=False, report_progress=None,
|
||||||
|
detected_device=None) :
|
||||||
|
self.open()
|
||||||
|
|
||||||
|
def open(self):
|
||||||
|
# Disconnect first if connected
|
||||||
|
self.eject()
|
||||||
|
# Connect
|
||||||
|
self.bambook = Bambook()
|
||||||
|
self.bambook.Connect()
|
||||||
|
if self.bambook.GetState() != CONN_CONNECTED:
|
||||||
|
self.bambook = None
|
||||||
|
raise Exception(_("Unable to connect to Bambook."))
|
||||||
|
|
||||||
|
def eject(self):
|
||||||
|
if self.bambook:
|
||||||
|
self.bambook.Disconnect()
|
||||||
|
self.bambook = None
|
||||||
|
|
||||||
|
def post_yank_cleanup(self):
|
||||||
|
self.eject()
|
||||||
|
|
||||||
|
def set_progress_reporter(self, report_progress):
|
||||||
|
'''
|
||||||
|
:param report_progress: Function that is called with a % progress
|
||||||
|
(number between 0 and 100) for various tasks
|
||||||
|
If it is called with -1 that means that the
|
||||||
|
task does not have any progress information
|
||||||
|
|
||||||
|
'''
|
||||||
|
self.report_progress = report_progress
|
||||||
|
|
||||||
|
def get_device_information(self, end_session=True):
|
||||||
|
"""
|
||||||
|
Ask device for device information. See L{DeviceInfoQuery}.
|
||||||
|
|
||||||
|
:return: (device name, device version, software version on device, mime type)
|
||||||
|
|
||||||
|
"""
|
||||||
|
if self.bambook:
|
||||||
|
deviceInfo = self.bambook.GetDeviceInfo()
|
||||||
|
return (_("Bambook"), "SD928", deviceInfo.firmwareVersion, "MimeType")
|
||||||
|
|
||||||
|
|
||||||
|
def card_prefix(self, end_session=True):
|
||||||
|
'''
|
||||||
|
Return a 2 element list of the prefix to paths on the cards.
|
||||||
|
If no card is present None is set for the card's prefix.
|
||||||
|
E.G.
|
||||||
|
('/place', '/place2')
|
||||||
|
(None, 'place2')
|
||||||
|
('place', None)
|
||||||
|
(None, None)
|
||||||
|
'''
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
def total_space(self, end_session=True):
|
||||||
|
"""
|
||||||
|
Get total space available on the mountpoints:
|
||||||
|
1. Main memory
|
||||||
|
2. Memory Card A
|
||||||
|
3. Memory Card B
|
||||||
|
|
||||||
|
:return: A 3 element list with total space in bytes of (1, 2, 3). If a
|
||||||
|
particular device doesn't have any of these locations it should return 0.
|
||||||
|
|
||||||
|
"""
|
||||||
|
deviceInfo = self.bambook.GetDeviceInfo()
|
||||||
|
return (deviceInfo.deviceVolume * 1024, 0, 0)
|
||||||
|
|
||||||
|
def free_space(self, end_session=True):
|
||||||
|
"""
|
||||||
|
Get free space available on the mountpoints:
|
||||||
|
1. Main memory
|
||||||
|
2. Card A
|
||||||
|
3. Card B
|
||||||
|
|
||||||
|
:return: A 3 element list with free space in bytes of (1, 2, 3). If a
|
||||||
|
particular device doesn't have any of these locations it should return -1.
|
||||||
|
|
||||||
|
"""
|
||||||
|
deviceInfo = self.bambook.GetDeviceInfo()
|
||||||
|
return (deviceInfo.spareVolume * 1024, -1, -1)
|
||||||
|
|
||||||
|
|
||||||
|
def books(self, oncard=None, end_session=True):
|
||||||
|
"""
|
||||||
|
Return a list of ebooks on the device.
|
||||||
|
|
||||||
|
:param oncard: If 'carda' or 'cardb' return a list of ebooks on the
|
||||||
|
specific storage card, otherwise return list of ebooks
|
||||||
|
in main memory of device. If a card is specified and no
|
||||||
|
books are on the card return empty list.
|
||||||
|
|
||||||
|
:return: A BookList.
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Bambook has no memroy card
|
||||||
|
if oncard:
|
||||||
|
return self.booklist_class(None, None, None)
|
||||||
|
|
||||||
|
prefix = ''
|
||||||
|
booklist = self.booklist_class(oncard, prefix, self.settings)
|
||||||
|
need_sync = self.parse_metadata_cache(booklist)
|
||||||
|
|
||||||
|
from calibre.ebooks.metadata.book.base import Metadata
|
||||||
|
devicebooks = self.bambook.GetBookList()
|
||||||
|
books = []
|
||||||
|
for book in devicebooks:
|
||||||
|
if book.bookGuid == self.METADATA_FILE_GUID:
|
||||||
|
continue
|
||||||
|
b = self.book_class('', book.bookGuid)
|
||||||
|
b.title = book.bookName.decode(text_encoding)
|
||||||
|
b.authors = [ book.bookAuthor.decode(text_encoding) ]
|
||||||
|
b.size = 0
|
||||||
|
b.datatime = time.gmtime()
|
||||||
|
# b.path = book.bookGuid
|
||||||
|
b.lpath = book.bookGuid
|
||||||
|
b.thumbnail = None
|
||||||
|
b.tags = None
|
||||||
|
b.comments = book.bookAbstract.decode(text_encoding)
|
||||||
|
books.append(b)
|
||||||
|
|
||||||
|
# make a dict cache of paths so the lookup in the loop below is faster.
|
||||||
|
bl_cache = {}
|
||||||
|
|
||||||
|
for idx, b in enumerate(booklist):
|
||||||
|
bl_cache[b.lpath] = idx
|
||||||
|
|
||||||
|
def update_booklist(book, prefix):
|
||||||
|
changed = False
|
||||||
|
try:
|
||||||
|
idx = bl_cache.get(book.path, None)
|
||||||
|
if idx is not None:
|
||||||
|
bl_cache[book.path] = None
|
||||||
|
if self.update_metadata_item(book, booklist[idx]):
|
||||||
|
changed = True
|
||||||
|
else:
|
||||||
|
if booklist.add_book(book,
|
||||||
|
replace_metadata=False):
|
||||||
|
changed = True
|
||||||
|
except: # Probably a filename encoding error
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
for i, book in enumerate(books):
|
||||||
|
self.report_progress(i/float(len(books)), _('Getting list of books on device...'))
|
||||||
|
changed = update_booklist(book, prefix)
|
||||||
|
if changed:
|
||||||
|
need_sync = True
|
||||||
|
|
||||||
|
# Remove books that are no longer in the filesystem. Cache contains
|
||||||
|
# indices into the booklist if book not in filesystem, None otherwise
|
||||||
|
# Do the operation in reverse order so indices remain valid
|
||||||
|
for idx in sorted(bl_cache.itervalues(), reverse=True):
|
||||||
|
if idx is not None:
|
||||||
|
need_sync = True
|
||||||
|
del booklist[idx]
|
||||||
|
|
||||||
|
if need_sync:
|
||||||
|
self.sync_booklists((booklist, None, None))
|
||||||
|
|
||||||
|
self.report_progress(1.0, _('Getting list of books on device...'))
|
||||||
|
return booklist
|
||||||
|
|
||||||
|
def upload_books(self, files, names, on_card=None, end_session=True,
|
||||||
|
metadata=None):
|
||||||
|
'''
|
||||||
|
Upload a list of books to the device. If a file already
|
||||||
|
exists on the device, it should be replaced.
|
||||||
|
This method should raise a :class:`FreeSpaceError` if there is not enough
|
||||||
|
free space on the device. The text of the FreeSpaceError must contain the
|
||||||
|
word "card" if ``on_card`` is not None otherwise it must contain the word "memory".
|
||||||
|
|
||||||
|
:param files: A list of paths and/or file-like objects. If they are paths and
|
||||||
|
the paths point to temporary files, they may have an additional
|
||||||
|
attribute, original_file_path pointing to the originals. They may have
|
||||||
|
another optional attribute, deleted_after_upload which if True means
|
||||||
|
that the file pointed to by original_file_path will be deleted after
|
||||||
|
being uploaded to the device.
|
||||||
|
:param names: A list of file names that the books should have
|
||||||
|
once uploaded to the device. len(names) == len(files)
|
||||||
|
:param metadata: If not None, it is a list of :class:`Metadata` objects.
|
||||||
|
The idea is to use the metadata to determine where on the device to
|
||||||
|
put the book. len(metadata) == len(files). Apart from the regular
|
||||||
|
cover (path to cover), there may also be a thumbnail attribute, which should
|
||||||
|
be used in preference. The thumbnail attribute is of the form
|
||||||
|
(width, height, cover_data as jpeg).
|
||||||
|
|
||||||
|
:return: A list of 3-element tuples. The list is meant to be passed
|
||||||
|
to :meth:`add_books_to_metadata`.
|
||||||
|
'''
|
||||||
|
self.report_progress(0, _('Transferring books to device...'))
|
||||||
|
booklist = []
|
||||||
|
paths = []
|
||||||
|
if self.bambook:
|
||||||
|
for (i, f) in enumerate(files):
|
||||||
|
self.report_progress((i+1) / float(len(files)), _('Transferring books to device...'))
|
||||||
|
if not hasattr(f, 'read'):
|
||||||
|
if self.bambook.VerifySNB(f):
|
||||||
|
guid = self.bambook.SendFile(f, self.get_guid(metadata[i].uuid))
|
||||||
|
if guid:
|
||||||
|
paths.append(guid)
|
||||||
|
else:
|
||||||
|
print "Send fail"
|
||||||
|
else:
|
||||||
|
print "book invalid"
|
||||||
|
ret = zip(paths, cycle([on_card]))
|
||||||
|
self.report_progress(1.0, _('Transferring books to device...'))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
def add_books_to_metadata(self, locations, metadata, booklists):
|
||||||
|
metadata = iter(metadata)
|
||||||
|
for i, location in enumerate(locations):
|
||||||
|
self.report_progress((i+1) / float(len(locations)), _('Adding books to device metadata listing...'))
|
||||||
|
info = metadata.next()
|
||||||
|
|
||||||
|
# Extract the correct prefix from the pathname. To do this correctly,
|
||||||
|
# we must ensure that both the prefix and the path are normalized
|
||||||
|
# so that the comparison will work. Book's __init__ will fix up
|
||||||
|
# lpath, so we don't need to worry about that here.
|
||||||
|
|
||||||
|
book = self.book_class('', location[0], other=info)
|
||||||
|
if book.size is None:
|
||||||
|
book.size = 0
|
||||||
|
b = booklists[0].add_book(book, replace_metadata=True)
|
||||||
|
if b:
|
||||||
|
b._new_book = True
|
||||||
|
self.report_progress(1.0, _('Adding books to device metadata listing...'))
|
||||||
|
|
||||||
|
def delete_books(self, paths, end_session=True):
|
||||||
|
'''
|
||||||
|
Delete books at paths on device.
|
||||||
|
'''
|
||||||
|
if self.bambook:
|
||||||
|
for i, path in enumerate(paths):
|
||||||
|
self.report_progress((i+1) / float(len(paths)), _('Removing books from device...'))
|
||||||
|
self.bambook.DeleteFile(path)
|
||||||
|
self.report_progress(1.0, _('Removing books from device...'))
|
||||||
|
|
||||||
|
def remove_books_from_metadata(self, paths, booklists):
|
||||||
|
'''
|
||||||
|
Remove books from the metadata list. This function must not communicate
|
||||||
|
with the device.
|
||||||
|
|
||||||
|
:param paths: paths to books on the device.
|
||||||
|
:param booklists: A tuple containing the result of calls to
|
||||||
|
(:meth:`books(oncard=None)`,
|
||||||
|
:meth:`books(oncard='carda')`,
|
||||||
|
:meth`books(oncard='cardb')`).
|
||||||
|
|
||||||
|
'''
|
||||||
|
for i, path in enumerate(paths):
|
||||||
|
self.report_progress((i+1) / float(len(paths)), _('Removing books from device metadata listing...'))
|
||||||
|
for bl in booklists:
|
||||||
|
for book in bl:
|
||||||
|
if book.lpath == path:
|
||||||
|
bl.remove_book(book)
|
||||||
|
self.report_progress(1.0, _('Removing books from device metadata listing...'))
|
||||||
|
|
||||||
|
def sync_booklists(self, booklists, end_session=True):
|
||||||
|
'''
|
||||||
|
Update metadata on device.
|
||||||
|
|
||||||
|
:param booklists: A tuple containing the result of calls to
|
||||||
|
(:meth:`books(oncard=None)`,
|
||||||
|
:meth:`books(oncard='carda')`,
|
||||||
|
:meth`books(oncard='cardb')`).
|
||||||
|
|
||||||
|
'''
|
||||||
|
if not self.bambook:
|
||||||
|
return
|
||||||
|
|
||||||
|
json_codec = JsonCodec()
|
||||||
|
|
||||||
|
# Create stub virtual book for sync info
|
||||||
|
with TemporaryDirectory() as tdir:
|
||||||
|
snbcdir = os.path.join(tdir, 'snbc')
|
||||||
|
snbfdir = os.path.join(tdir, 'snbf')
|
||||||
|
os.mkdir(snbcdir)
|
||||||
|
os.mkdir(snbfdir)
|
||||||
|
|
||||||
|
f = open(os.path.join(snbfdir, 'book.snbf'), 'wb')
|
||||||
|
f.write('''<book-snbf version="1.0">
|
||||||
|
<head>
|
||||||
|
<name>calibre同步信息</name>
|
||||||
|
<author>calibre</author>
|
||||||
|
<language>ZH-CN</language>
|
||||||
|
<rights/>
|
||||||
|
<publisher>calibre</publisher>
|
||||||
|
<generator>''' + __appname__ + ' ' + __version__ + '''</generator>
|
||||||
|
<created/>
|
||||||
|
<abstract></abstract>
|
||||||
|
<cover/>
|
||||||
|
</head>
|
||||||
|
</book-snbf>
|
||||||
|
''')
|
||||||
|
f.close()
|
||||||
|
f = open(os.path.join(snbfdir, 'toc.snbf'), 'wb')
|
||||||
|
f.write('''<toc-snbf>
|
||||||
|
<head>
|
||||||
|
<chapters>0</chapters>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
</body>
|
||||||
|
</toc-snbf>
|
||||||
|
''');
|
||||||
|
f.close()
|
||||||
|
cache_name = os.path.join(snbcdir, self.METADATA_CACHE)
|
||||||
|
with open(cache_name, 'wb') as f:
|
||||||
|
json_codec.encode_to_file(f, booklists[0])
|
||||||
|
|
||||||
|
with TemporaryFile('.snb') as f:
|
||||||
|
if self.bambook.PackageSNB(f, tdir):
|
||||||
|
t = open('/tmp/abcd.snb', 'wb')
|
||||||
|
t2 = open(f, 'rb')
|
||||||
|
t.write(t2.read())
|
||||||
|
t.close()
|
||||||
|
t2.close()
|
||||||
|
if not self.bambook.SendFile(f, self.METADATA_FILE_GUID):
|
||||||
|
print "Upload failed"
|
||||||
|
|
||||||
|
# Clear the _new_book indication, as we are supposed to be done with
|
||||||
|
# adding books at this point
|
||||||
|
for blist in booklists:
|
||||||
|
if blist is not None:
|
||||||
|
for book in blist:
|
||||||
|
book._new_book = False
|
||||||
|
|
||||||
|
self.report_progress(1.0, _('Sending metadata to device...'))
|
||||||
|
|
||||||
|
def get_file(self, path, outfile, end_session=True):
|
||||||
|
'''
|
||||||
|
Read the file at ``path`` on the device and write it to outfile.
|
||||||
|
|
||||||
|
:param outfile: file object like ``sys.stdout`` or the result of an
|
||||||
|
:func:`open` call.
|
||||||
|
|
||||||
|
'''
|
||||||
|
if self.bambook:
|
||||||
|
with TemporaryDirectory() as tdir:
|
||||||
|
self.bambook.GetFile(path, tdir)
|
||||||
|
filepath = os.path.join(tdir, path)
|
||||||
|
f = file(filepath, 'rb')
|
||||||
|
outfile.write(f.read())
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
# @classmethod
|
||||||
|
# def config_widget(cls):
|
||||||
|
# '''
|
||||||
|
# Should return a QWidget. The QWidget contains the settings for the device interface
|
||||||
|
# '''
|
||||||
|
# raise NotImplementedError()
|
||||||
|
|
||||||
|
# @classmethod
|
||||||
|
# def save_settings(cls, settings_widget):
|
||||||
|
# '''
|
||||||
|
# Should save settings to disk. Takes the widget created in
|
||||||
|
# :meth:`config_widget` and saves all settings to disk.
|
||||||
|
# '''
|
||||||
|
# raise NotImplementedError()
|
||||||
|
|
||||||
|
# @classmethod
|
||||||
|
# def settings(cls):
|
||||||
|
# '''
|
||||||
|
# Should return an opts object. The opts object should have at least one attribute
|
||||||
|
# `format_map` which is an ordered list of formats for the device.
|
||||||
|
# '''
|
||||||
|
# raise NotImplementedError()
|
||||||
|
|
||||||
|
def parse_metadata_cache(self, bl):
|
||||||
|
bl = []
|
||||||
|
need_sync = True
|
||||||
|
if not self.bambook:
|
||||||
|
return need_sync
|
||||||
|
|
||||||
|
# Get the metadata virtual book from Bambook
|
||||||
|
with TemporaryDirectory() as tdir:
|
||||||
|
if self.bambook.GetFile(self.METADATA_FILE_GUID, tdir):
|
||||||
|
cache_name = os.path.join(tdir, self.METADATA_CACHE)
|
||||||
|
if self.bambook.ExtractSNBContent(os.path.join(tdir, self.METADATA_FILE_GUID),
|
||||||
|
'snbc/' + self.METADATA_CACHE,
|
||||||
|
cache_name):
|
||||||
|
json_codec = JsonCodec()
|
||||||
|
if os.access(cache_name, os.R_OK):
|
||||||
|
try:
|
||||||
|
with open(cache_name, 'rb') as f:
|
||||||
|
json_codec.decode_from_file(f, bl, self.book_class, '')
|
||||||
|
need_sync = False
|
||||||
|
except:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
bl = []
|
||||||
|
return need_sync
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def update_metadata_item(cls, book, blb):
|
||||||
|
changed = False
|
||||||
|
if book.bookName.decode(text_encoding) != blb.title:
|
||||||
|
changed = True
|
||||||
|
if book.bookAuthor.decode(text_encoding) != blb.authors[0]:
|
||||||
|
changed = True
|
||||||
|
if book.bookAbstract.decode(text_encoding) != blb.comments:
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_guid(uuid):
|
||||||
|
guid = hashlib.md5(uuid).hexdigest()[0:15] + ".snb"
|
||||||
|
return guid
|
491
src/calibre/devices/bambook/libbambookcore.py
Normal file
491
src/calibre/devices/bambook/libbambookcore.py
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
__license__ = 'GPL v3'
|
||||||
|
__copyright__ = '2010, Li Fanxi <lifanxi at freemindworld.com>'
|
||||||
|
__docformat__ = 'restructuredtext en'
|
||||||
|
|
||||||
|
'''
|
||||||
|
Sanda library wrapper
|
||||||
|
'''
|
||||||
|
|
||||||
|
import ctypes, uuid, hashlib
|
||||||
|
from threading import Event, Thread, Lock
|
||||||
|
from calibre.constants import iswindows, islinux
|
||||||
|
|
||||||
|
try:
|
||||||
|
if iswindows:
|
||||||
|
text_encoding = 'mbcs'
|
||||||
|
lib_handle = ctypes.cdll.BambookCore
|
||||||
|
elif islinux:
|
||||||
|
text_encoding = 'utf-8'
|
||||||
|
lib_handle = ctypes.CDLL('libBambookCore.so')
|
||||||
|
except:
|
||||||
|
lib_handle = None
|
||||||
|
|
||||||
|
# Constant
|
||||||
|
DEFAULT_BAMBOOK_IP = '192.168.250.2'
|
||||||
|
BAMBOOK_SDK_VERSION = 0x00090000
|
||||||
|
BR_SUCC = 0 # 操作成功
|
||||||
|
BR_FAIL = 1001 # 操作失败
|
||||||
|
BR_NOT_IMPL = 1002 # 该功能还未实现
|
||||||
|
BR_DISCONNECTED = 1003 # 与设备的连接已断开
|
||||||
|
BR_PARAM_ERROR = 1004 # 调用函数传入的参数错误
|
||||||
|
BR_TIMEOUT = 1005 # 操作或通讯超时
|
||||||
|
BR_INVALID_HANDLE = 1006 # 传入的句柄无效
|
||||||
|
BR_INVALID_FILE = 1007 # 传入的文件不存在或格式无效
|
||||||
|
BR_INVALID_DIR = 1008 # 传入的目录不存在
|
||||||
|
BR_BUSY = 1010 # 设备忙,另一个操作还未完成
|
||||||
|
BR_EOF = 1011 # 文件或操作已结束
|
||||||
|
BR_IO_ERROR = 1012 # 文件读写失败
|
||||||
|
BR_FILE_NOT_INSIDE = 1013 # 指定的文件不在包里
|
||||||
|
|
||||||
|
# 当前连接状态
|
||||||
|
CONN_CONNECTED = 0 # 已连接
|
||||||
|
CONN_DISCONNECTED = 1 # 未连接或连接已断开
|
||||||
|
CONN_CONNECTING = 2 # 正在连接
|
||||||
|
CONN_WAIT_FOR_AUTH = 3 # 已连接,正在等待身份验证(暂未实现)
|
||||||
|
|
||||||
|
#传输状态
|
||||||
|
TRANS_STATUS_TRANS = 0 #正在传输
|
||||||
|
TRANS_STATUS_DONE = 1 #传输完成
|
||||||
|
TRANS_STATUS_ERR = 2 #传输出错
|
||||||
|
|
||||||
|
# Key Enums
|
||||||
|
BBKeyNum0 = 0
|
||||||
|
BBKeyNum1 = 1
|
||||||
|
BBKeyNum2 = 2
|
||||||
|
BBKeyNum3 = 3
|
||||||
|
BBKeyNum4 = 4
|
||||||
|
BBKeyNum5 = 5
|
||||||
|
BBKeyNum6 = 6
|
||||||
|
BBKeyNum7 = 7
|
||||||
|
BBKeyNum8 = 8
|
||||||
|
BBKeyNum9 = 9
|
||||||
|
BBKeyStar = 10
|
||||||
|
BBKeyCross = 11
|
||||||
|
BBKeyUp = 12
|
||||||
|
BBKeyDown = 13
|
||||||
|
BBKeyLeft = 14
|
||||||
|
BBKeyRight = 15
|
||||||
|
BBKeyPageUp = 16
|
||||||
|
BBKeyPageDown = 17
|
||||||
|
BBKeyOK = 18
|
||||||
|
BBKeyESC = 19
|
||||||
|
BBKeyBookshelf = 20
|
||||||
|
BBKeyStore = 21
|
||||||
|
BBKeyTTS = 22
|
||||||
|
BBKeyMenu = 23
|
||||||
|
BBKeyInteract =24
|
||||||
|
|
||||||
|
class DeviceInfo(ctypes.Structure):
|
||||||
|
_fields_ = [ ("cbSize", ctypes.c_int),
|
||||||
|
("sn", ctypes.c_char * 20),
|
||||||
|
("firmwareVersion", ctypes.c_char * 20),
|
||||||
|
("deviceVolume", ctypes.c_int),
|
||||||
|
("spareVolume", ctypes.c_int),
|
||||||
|
]
|
||||||
|
def __init__(self):
|
||||||
|
self.cbSize = ctypes.sizeof(self)
|
||||||
|
|
||||||
|
class PrivBookInfo(ctypes.Structure):
|
||||||
|
_fields_ = [ ("cbSize", ctypes.c_int),
|
||||||
|
("bookGuid", ctypes.c_char * 20),
|
||||||
|
("bookName", ctypes.c_char * 80),
|
||||||
|
("bookAuthor", ctypes.c_char * 40),
|
||||||
|
("bookAbstract", ctypes.c_char * 256),
|
||||||
|
]
|
||||||
|
def Clone(self):
|
||||||
|
bookInfo = PrivBookInfo()
|
||||||
|
bookInfo.cbSize = self.cbSize
|
||||||
|
bookInfo.bookGuid = self.bookGuid
|
||||||
|
bookInfo.bookName = self.bookName
|
||||||
|
bookInfo.bookAuthor = self.bookAuthor
|
||||||
|
bookInfo.bookAbstract = self.bookAbstract
|
||||||
|
return bookInfo
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cbSize = ctypes.sizeof(self)
|
||||||
|
|
||||||
|
# extern "C"_declspec(dllexport) BB_RESULT BambookConnect(const char* lpszIP, int timeOut, BB_HANDLE* hConn);
|
||||||
|
def BambookConnect(ip = DEFAULT_BAMBOOK_IP, timeout = 0):
|
||||||
|
handle = ctypes.c_int(0)
|
||||||
|
if lib_handle == None:
|
||||||
|
raise Exception(_('Bambook SDK has not been installed.'))
|
||||||
|
ret = lib_handle.BambookConnect(ip, timeout, ctypes.byref(handle))
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return handle
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# extern "C" _declspec(dllexport) BB_RESULT BambookGetConnectStatus(BB_HANDLE hConn, int* status);
|
||||||
|
def BambookGetConnectStatus(handle):
|
||||||
|
status = ctypes.c_int(0)
|
||||||
|
ret = lib_handle.BambookGetConnectStatus(handle, ctypes.byref(status))
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return status.value
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# extern "C" _declspec(dllexport) BB_RESULT BambookDisconnect(BB_HANDLE hConn);
|
||||||
|
def BambookDisconnect(handle):
|
||||||
|
ret = lib_handle.BambookDisconnect(handle)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" const char * BambookGetErrorString(BB_RESULT nCode)
|
||||||
|
def BambookGetErrorString(code):
|
||||||
|
func = lib_handle.BambookGetErrorString
|
||||||
|
func.restype = c_char_p
|
||||||
|
return func(code)
|
||||||
|
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookGetSDKVersion(uint32_t * version);
|
||||||
|
def BambookGetSDKVersion():
|
||||||
|
version = ctypes.c_int(0)
|
||||||
|
lib_handle.BambookGetSDKVersion(ctypes.byref(version))
|
||||||
|
return version.value
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookGetDeviceInfo(BB_HANDLE hConn, DeviceInfo* pInfo);
|
||||||
|
def BambookGetDeviceInfo(handle):
|
||||||
|
deviceInfo = DeviceInfo()
|
||||||
|
ret = lib_handle.BambookGetDeviceInfo(handle, ctypes.byref(deviceInfo))
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return deviceInfo
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookKeyPress(BB_HANDLE hConn, BambookKey key);
|
||||||
|
def BambookKeyPress(handle, key):
|
||||||
|
ret = lib_handle.BambookKeyPress(handle, key)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookGetFirstPrivBookInfo(BB_HANDLE hConn, PrivBookInfo * pInfo);
|
||||||
|
def BambookGetFirstPrivBookInfo(handle, bookInfo):
|
||||||
|
bookInfo.contents.cbSize = ctypes.sizeof(bookInfo.contents)
|
||||||
|
ret = lib_handle.BambookGetFirstPrivBookInfo(handle, bookInfo)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookGetNextPrivBookInfo(BB_HANDLE hConn, PrivBookInfo * pInfo);
|
||||||
|
def BambookGetNextPrivBookInfo(handle, bookInfo):
|
||||||
|
bookInfo.contents.cbSize = ctypes.sizeof(bookInfo.contents)
|
||||||
|
ret = lib_handle.BambookGetNextPrivBookInfo(handle, bookInfo)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
elif ret == BR_EOF:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookDeletePrivBook(BB_HANDLE hConn, const char * lpszBookID);
|
||||||
|
def BambookDeletePrivBook(handle, guid):
|
||||||
|
ret = lib_handle.BambookDeletePrivBook(handle, guid)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
class JobQueue:
|
||||||
|
jobs = {}
|
||||||
|
maxID = 0
|
||||||
|
lock = Lock()
|
||||||
|
def __init__(self):
|
||||||
|
self.maxID = 0
|
||||||
|
|
||||||
|
def NewJob(self):
|
||||||
|
self.lock.acquire()
|
||||||
|
self.maxID = self.maxID + 1
|
||||||
|
maxid = self.maxID
|
||||||
|
self.lock.release()
|
||||||
|
event = Event()
|
||||||
|
self.jobs[maxid] = (event, TRANS_STATUS_TRANS)
|
||||||
|
return maxid
|
||||||
|
|
||||||
|
def FinishJob(self, jobID, status):
|
||||||
|
self.jobs[jobID][0].set()
|
||||||
|
self.jobs[jobID] = (self.jobs[jobID][0], status)
|
||||||
|
|
||||||
|
def WaitJob(self, jobID):
|
||||||
|
self.jobs[jobID][0].wait()
|
||||||
|
return (self.jobs[jobID][1] == TRANS_STATUS_DONE)
|
||||||
|
|
||||||
|
def DeleteJob(self, jobID):
|
||||||
|
del self.jobs[jobID]
|
||||||
|
|
||||||
|
job = JobQueue()
|
||||||
|
|
||||||
|
def BambookTransferCallback(status, progress, userData):
|
||||||
|
if status == TRANS_STATUS_DONE and progress == 100:
|
||||||
|
job.FinishJob(userData, status)
|
||||||
|
elif status == TRANS_STATUS_ERR:
|
||||||
|
job.FinishJob(userData, status)
|
||||||
|
|
||||||
|
TransCallback = ctypes.CFUNCTYPE(None, ctypes.c_int, ctypes.c_int, ctypes.c_int)
|
||||||
|
bambookTransferCallback = TransCallback(BambookTransferCallback)
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookAddPrivBook(BB_HANDLE hConn, const char * pszSnbFile,
|
||||||
|
# TransCallback pCallbackFunc, intptr_t userData);
|
||||||
|
def BambookAddPrivBook(handle, filename, callback, userData):
|
||||||
|
if isinstance(filename, unicode):
|
||||||
|
filename = filename.encode('ascii')
|
||||||
|
ret = lib_handle.BambookAddPrivBook(handle, filename, callback, userData)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookReplacePrivBook(BB_HANDLE hConn, const char *
|
||||||
|
# pszSnbFile, const char * lpszBookID, TransCallback pCallbackFunc, intptr_t userData);
|
||||||
|
def BambookReplacePrivBook(handle, filename, bookID, callback, userData):
|
||||||
|
if isinstance(filename, unicode):
|
||||||
|
filename = filename.encode('ascii')
|
||||||
|
ret = lib_handle.BambookReplacePrivBook(handle, filename, bookID, callback, userData)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookFetchPrivBook(BB_HANDLE hConn, const char *
|
||||||
|
# lpszBookID, const char * lpszFilePath, TransCallback pCallbackFunc, intptr_t userData);
|
||||||
|
def BambookFetchPrivBook(handle, bookID, filename, callback, userData):
|
||||||
|
if isinstance(filename, unicode):
|
||||||
|
filename = filename.encode('ascii')
|
||||||
|
ret = lib_handle.BambookFetchPrivBook(handle, bookID, filename, bambookTransferCallback, userData)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# extern "C" BB_RESULT BambookVerifySnbFile(const char * snbName)
|
||||||
|
def BambookVerifySnbFile(filename):
|
||||||
|
if isinstance(filename, unicode):
|
||||||
|
filename = filename.encode('ascii')
|
||||||
|
if lib_handle.BambookVerifySnbFile(filename) == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# BB_RESULT BambookPackSnbFromDir ( const char * snbName,, const char * rootDir );
|
||||||
|
def BambookPackSnbFromDir(snbFileName, rootDir):
|
||||||
|
ret = lib_handle.BambookPackSnbFromDir(snbFileName, rootDir)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# BB_RESULT BambookUnpackFileFromSnb ( const char * snbName,, const char * relativePath, const char * outfname );
|
||||||
|
def BambookUnpackFileFromSnb(snbFileName, relPath, outFileName):
|
||||||
|
ret = lib_handle.BambookUnpackFileFromSnb(snbFileName, relPath, outFileName)
|
||||||
|
if ret == BR_SUCC:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
class Bambook:
|
||||||
|
def __init__(self):
|
||||||
|
self.handle = None
|
||||||
|
|
||||||
|
def Connect(self, ip = DEFAULT_BAMBOOK_IP, timeout = 10000):
|
||||||
|
self.handle = BambookConnect(ip, timeout)
|
||||||
|
if self.handle and self.handle != 0:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def Disconnect(self):
|
||||||
|
if self.handle:
|
||||||
|
return BambookDisconnect(self.handle)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def GetState(self):
|
||||||
|
if self.handle:
|
||||||
|
return BambookGetConnectStatus(self.handle)
|
||||||
|
return CONN_DISCONNECTED
|
||||||
|
|
||||||
|
def GetDeviceInfo(self):
|
||||||
|
if self.handle:
|
||||||
|
return BambookGetDeviceInfo(self.handle)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def SendFile(self, fileName, guid = None):
|
||||||
|
if self.handle:
|
||||||
|
taskID = job.NewJob()
|
||||||
|
if guid:
|
||||||
|
if BambookReplacePrivBook(self.handle, fileName, guid,
|
||||||
|
bambookTransferCallback, taskID):
|
||||||
|
if(job.WaitJob(taskID)):
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return guid
|
||||||
|
else:
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
guid = hashlib.md5(str(uuid.uuid4())).hexdigest()[0:15] + ".snb"
|
||||||
|
if BambookReplacePrivBook(self.handle, fileName, guid,
|
||||||
|
bambookTransferCallback, taskID):
|
||||||
|
if job.WaitJob(taskID):
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return guid
|
||||||
|
else:
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def GetFile(self, guid, fileName):
|
||||||
|
if self.handle:
|
||||||
|
taskID = job.NewJob()
|
||||||
|
ret = BambookFetchPrivBook(self.handle, guid, fileName, bambookTransferCallback, taskID)
|
||||||
|
if ret:
|
||||||
|
ret = job.WaitJob(taskID)
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return ret
|
||||||
|
else:
|
||||||
|
job.DeleteJob(taskID)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def DeleteFile(self, guid):
|
||||||
|
if self.handle:
|
||||||
|
ret = BambookDeletePrivBook(self.handle, guid)
|
||||||
|
return ret
|
||||||
|
return False
|
||||||
|
|
||||||
|
def GetBookList(self):
|
||||||
|
if self.handle:
|
||||||
|
books = []
|
||||||
|
bookInfo = PrivBookInfo()
|
||||||
|
bi = ctypes.pointer(bookInfo)
|
||||||
|
|
||||||
|
ret = BambookGetFirstPrivBookInfo(self.handle, bi)
|
||||||
|
while ret:
|
||||||
|
books.append(bi.contents.Clone())
|
||||||
|
ret = BambookGetNextPrivBookInfo(self.handle, bi)
|
||||||
|
return books
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def GetSDKVersion():
|
||||||
|
return BambookGetSDKVersion()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def VerifySNB(fileName):
|
||||||
|
return BambookVerifySnbFile(fileName);
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ExtractSNBContent(fileName, relPath, path):
|
||||||
|
return BambookUnpackFileFromSnb(fileName, relPath, path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def ExtractSNB(fileName, path):
|
||||||
|
ret = BambookUnpackFileFromSnb(fileName, 'snbf/book.snbf', path + '/snbf/book.snbf')
|
||||||
|
if not ret:
|
||||||
|
return False
|
||||||
|
ret = BambookUnpackFileFromSnb(fileName, 'snbf/toc.snbf', path + '/snbf/toc.snbf')
|
||||||
|
if not ret:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def PackageSNB(fileName, path):
|
||||||
|
return BambookPackSnbFromDir(fileName, path)
|
||||||
|
|
||||||
|
def passed():
|
||||||
|
print "> Pass"
|
||||||
|
|
||||||
|
def failed():
|
||||||
|
print "> Failed"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
|
||||||
|
print "Bambook SDK Unit Test"
|
||||||
|
bb = Bambook()
|
||||||
|
|
||||||
|
print "Disconnect State"
|
||||||
|
if bb.GetState() == CONN_DISCONNECTED:
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Get SDK Version"
|
||||||
|
if bb.GetSDKVersion() == BAMBOOK_SDK_VERSION:
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Verify SNB File"
|
||||||
|
if bb.VerifySNB(u'/tmp/f2pioq3qf68h475.snb'):
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
if not bb.VerifySNB('./libwrapper.py'):
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Extract SNB File"
|
||||||
|
if bb.ExtractSNB('./test.snb', '/tmp'):
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Packet SNB File"
|
||||||
|
if bb.PackageSNB('/tmp/tmp.snb', '/tmp/test') and bb.VerifySNB('/tmp/tmp.snb'):
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Connect to Bambook"
|
||||||
|
if bb.Connect('192.168.250.2', 10000) and bb.GetState() == CONN_CONNECTED:
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Get Bambook Info"
|
||||||
|
devInfo = bb.GetDeviceInfo()
|
||||||
|
if devInfo:
|
||||||
|
# print "Info Size: ", devInfo.cbSize
|
||||||
|
# print "SN: ", devInfo.sn
|
||||||
|
# print "Firmware: ", devInfo.firmwareVersion
|
||||||
|
# print "Capacity: ", devInfo.deviceVolume
|
||||||
|
# print "Free: ", devInfo.spareVolume
|
||||||
|
if devInfo.cbSize == 52 and devInfo.deviceVolume == 1714232:
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Send file"
|
||||||
|
bb.SendFile('./test.snb')
|
||||||
|
|
||||||
|
print "Get book list"
|
||||||
|
books = bb.GetBookList()
|
||||||
|
if len(books) > 10:
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Get book"
|
||||||
|
if bb.GetFile('f2pioq3qf68h475.snb', '/tmp') and bb.VerifySNB('/tmp/f2pioq3qf68h475.snb'):
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
||||||
|
|
||||||
|
print "Disconnect"
|
||||||
|
if bb.Disconnect():
|
||||||
|
passed()
|
||||||
|
else:
|
||||||
|
failed()
|
BIN
src/calibre/devices/bambook/test.snb
Normal file
BIN
src/calibre/devices/bambook/test.snb
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user