├── LICENSE ├── README.md ├── sample-settings.yaml ├── scanman ├── __init__.py ├── main.py ├── scanman.kv └── scanner.py ├── setup.cfg └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Olivier Poitrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ScanMan 2 | 3 | ScanMan is a [ScanSnap iX500](http://scanners.fcpa.fujitsu.com/scansnap11/features_iX500.html) manager optimized [Raspberry Pi](https://www.raspberrypi.org) connected to a [Raspberry 7" touch display](https://www.raspberrypi.org/blog/the-eagerly-awaited-raspberry-pi-display/). 4 | 5 | This project use [Python SANE](https://github.com/python-pillow/Sane) to connect to the scanner so it can be made compatible with any supported scanner with proper options set. Feel free to send pull requests to add support for more scanners. 6 | 7 | The GUI is created using [Python Kivy](https://kivy.org/#home), so it should work with any platform / touch screen supported by this framework (with [SANE]( support). Again, feel free to send pull request for screen size / ratio or pixel density adaptations. 8 | 9 | ## Features 10 | 11 | - Handle ultra-fast Automatic Document Feeder (ADF) scanners like [ScanSnap iX500](http://scanners.fcpa.fujitsu.com/scansnap11/features_iX500.html) 12 | - Generates an optimized multi-page / multi-size PDF 13 | - Send PDFs by email 14 | - Multi profiles selector 15 | - Touchscreen UI 16 | - On screen preview of scanned documents 17 | - Works on Linux and any platform supported by both [Python Kivy](https://kivy.org/#home) and [SANE]( support) 18 | 19 | ## Install 20 | 21 | This project has been developed and tested on [Raspberry Pi](https://www.raspberrypi.org) attached to a [Raspberry 7" touchscreen](https://www.raspberrypi.org/blog/the-eagerly-awaited-raspberry-pi-display/) using [Raspbian](https://www.raspberrypi.org/downloads/raspbian/). 22 | 23 | This document assumes you have a Raspberry Pi already attached to a touch display with Raspbian Jessi installed: 24 | 25 | - Follow [Kivy installation instruction](https://kivy.org/docs/installation/installation-rpi.html) 26 | - Install `libksane-dev` package 27 | - Install `scanman` with its dependencies with `sudo pip install scanman` 28 | - Customize the `sample-settings.yaml` file with your settings 29 | 30 | You can launch `scanman` using the pi user or as root as follow: 31 | 32 | scanman /path/to/settings.yaml 33 | 34 | To add scanman as a systemd service on Rasbian, create the following file in `/etc/systemd/system/scanman.service`: 35 | 36 | [Unit] 37 | Description=ScanMan 38 | 39 | [Service] 40 | User=pi 41 | Group=pi 42 | Restart=on-failure 43 | ExecStart=/usr/local/bin/scanman /etc/scanman.yaml 44 | 45 | [Install] 46 | WantedBy=multi-user.target 47 | 48 | ## Licenses 49 | 50 | All source code is licensed under the [MIT License](https://raw.github.com/rs/scanman/master/LICENSE). 51 | -------------------------------------------------------------------------------- /sample-settings.yaml: -------------------------------------------------------------------------------- 1 | # SMTP server to use by default for all profiles 2 | smtp_server: 'smtp.gmail.com:587' 3 | smtp_tls: true 4 | smtp_credentials: ['username@gmail.com', 'secret'] 5 | 6 | # Filename pattern 7 | filename: 'scan-%Y%m%d-%H%M%S' 8 | 9 | profiles: 10 | - 11 | # Send scans to evernote using email to evernote feature 12 | name: evernote 13 | icon: /path/to/evernote.png 14 | email_from: username@gmail.com 15 | email_to: username.37a2f@m.evernote.com 16 | - 17 | # Use IFTTT to send scans to Dropbox or any other IFTTT channel 18 | name: dropbox-thru-ifttt 19 | icon: /path/to/dropbox.png 20 | email_from: john@icloud.com 21 | email_to: trigger@recipe.ifttt.com 22 | # Profile specific smtp account 23 | smtp_server: 'smtp.mail.me.com:587' 24 | smtp_tls: true 25 | smtp_credentials: ['john@icloud.com', 'icloudsecret'] 26 | -------------------------------------------------------------------------------- /scanman/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rs/scanman/7dd61360c36e8378219abc80152d996c91b0a85a/scanman/__init__.py -------------------------------------------------------------------------------- /scanman/main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from kivy.app import App 4 | from kivy.uix.boxlayout import BoxLayout 5 | from kivy.uix.image import Image 6 | from kivy.core.image import ImageData 7 | from kivy.graphics.texture import Texture 8 | from kivy.clock import mainthread, Clock 9 | from kivy.properties import NumericProperty, ObjectProperty, BooleanProperty, StringProperty 10 | from kivy.uix.behaviors import ToggleButtonBehavior 11 | from kivy.graphics import Line, Color 12 | from kivy.logger import Logger 13 | from img2pdf import pdfdoc 14 | from datetime import datetime 15 | import smtplib 16 | from email.mime.application import MIMEApplication 17 | from email.mime.multipart import MIMEMultipart 18 | import sys 19 | import yaml 20 | 21 | from .scanner import Scanner 22 | 23 | try: 24 | from StringIO import StringIO as BytesIO 25 | except: 26 | from io import BytesIO 27 | 28 | 29 | class ScanMan(BoxLayout): 30 | scan_button = ObjectProperty(None) 31 | default_scan_button_background = None 32 | status_label = ObjectProperty(None) 33 | preview_image = ObjectProperty(None) 34 | profiles_container = ObjectProperty(None) 35 | 36 | def on_scan_button(self, instance, value): 37 | self.default_scan_button_background = self.scan_button.background_color 38 | 39 | def active_profile(self): 40 | # Get the active profile 41 | for i, w in enumerate(ToggleButtonBehavior.get_widgets('profile')): 42 | if w.state == 'down': 43 | return i 44 | 45 | 46 | class ImageButton(ToggleButtonBehavior, Image): 47 | border_width = NumericProperty(2) 48 | border_color = ObjectProperty((.196, .647, .812)) 49 | 50 | def on_border_color(self, instance, value): 51 | self.update_state() 52 | 53 | def on_border_size(self, instance, value): 54 | self.update_state() 55 | 56 | def on_pos(self, instance, value): 57 | self.update_state() 58 | 59 | def on_state(self, instance, value): 60 | self.update_state() 61 | 62 | def update_state(self): 63 | self.canvas.after.clear() 64 | if self.state == 'down': 65 | self.draw_border() 66 | 67 | def draw_border(self): 68 | with self.canvas.after: 69 | Color(*self.border_color) 70 | w = self.border_width 71 | Line(width=w, rectangle=(self.x+w, self.y+w, self.width-w, self.height-w)) 72 | 73 | 74 | class ScanManApp(App): 75 | scanning = BooleanProperty(False) 76 | ready = BooleanProperty(False) 77 | connected = BooleanProperty(False) 78 | custom_status_text = StringProperty("") 79 | 80 | def __init__(self, settings, **kwargs): 81 | self.settings = settings 82 | super(ScanManApp, self).__init__(**kwargs) 83 | 84 | def build(self): 85 | self.ui = ScanMan() 86 | self.init_profiles() 87 | self.scanner = Scanner() 88 | return self.ui 89 | 90 | def init_profiles(self): 91 | state = 'down' 92 | for profile in self.settings.get('profiles', []): 93 | b = ImageButton() 94 | b.name = profile 95 | b.source = profile['icon'] 96 | b.size_hint = (None, None) 97 | b.width = 120 98 | b.height = 120 99 | b.allow_stretch = True 100 | b.group = 'profile' 101 | b.state = state 102 | b.allow_no_selection = False 103 | self.ui.profiles_container.add_widget(b) 104 | state = 'normal' 105 | 106 | def on_start(self): 107 | # The UI is init 108 | self.scanner.scan_button(self.scan) 109 | self.ui.scan_button.on_release = self.scan 110 | 111 | def set_ready(state): 112 | self.ready = state 113 | self.scanner.page_loaded(set_ready) 114 | 115 | def set_connected(state): 116 | self.connected = state 117 | self.scanner.connected(set_connected) 118 | 119 | def on_connected(self, instance, value): 120 | Logger.info('Scanman: connected: %s', value) 121 | self.reset_custom_status() 122 | self._update_status() 123 | 124 | def on_ready(self, instance, value): 125 | Logger.info('Scanman: ready: %s', value) 126 | if not self.scanning: 127 | self.reset_custom_status() 128 | self._update_status() 129 | 130 | def on_scanning(self, instance, value): 131 | Logger.info('Scanman: scanning: %s', value) 132 | self._update_status() 133 | 134 | def on_custom_status_text(self, instance, value): 135 | self._update_status() 136 | 137 | def reset_custom_status(self, *args): 138 | self.custom_status_text = "" 139 | 140 | @mainthread 141 | def _update_status(self): 142 | self.ui.scan_button.text = "Scan" 143 | self.ui.scan_button.disabled = False 144 | self.ui.scan_button.background_color = self.ui.default_scan_button_background 145 | if not self.connected: 146 | self.ui.status_label.text = 'Scanner is not connected.' 147 | self.ui.scan_button.disabled = True 148 | elif self.scanning: 149 | self.ui.status_label.text = 'Scanning...' 150 | self.ui.scan_button.background_color = (1, 0, 0, 1) 151 | self.ui.scan_button.text = "Cancel" 152 | elif self.ready: 153 | self.ui.status_label.text = 'Ready.' 154 | else: 155 | self.ui.status_label.text = 'No scan in progress.' 156 | self.ui.scan_button.disabled = True 157 | if self.custom_status_text: 158 | self.ui.status_label.text = self.custom_status_text 159 | 160 | @mainthread 161 | def _update_preview(self, image): 162 | self.ui.preview_image.texture = Texture.create_from_data(ImageData(image.size[0], image.size[1], image.mode.lower(), image.tobytes())) 163 | 164 | def scan(self): 165 | if not self.ready: 166 | return 167 | if self.scanning: 168 | self.cancel() 169 | self.reset_custom_status() 170 | self.scanning = True 171 | profile = self.settings['profiles'][self.ui.active_profile()] 172 | Logger.info('Scanman: scanning with profile: %s', profile.get('name')) 173 | 174 | pdf = pdfdoc() 175 | dpi = self.scanner.dev.resolution 176 | 177 | def scan_processor(page_index, image): 178 | Logger.info('Scanman: processing page %d', page_index+1) 179 | self.custom_status_text = 'Processing page {}'.format(page_index+1) 180 | self._update_preview(image) 181 | buf = BytesIO() 182 | image.save(buf, format='JPEG', quality=75, optimize=True) 183 | width, height = image.size 184 | pdf_x, pdf_y = 72.0*width/float(dpi), 72.0*height/float(dpi) 185 | pdf.addimage(image.mode, width, height, 'JPEG', buf.getvalue(), pdf_x, pdf_y) 186 | buf.close() 187 | 188 | def done(): 189 | Logger.info('Scanman: processing document') 190 | self.custom_status_text = 'Processing document…' 191 | self._update_status() 192 | filename = datetime.now().strftime(self.settings.get('filename', '%Y%m%d-%H%M%S')) 193 | msg = MIMEMultipart() 194 | msg['Subject'] = filename 195 | msg['From'] = profile['email_from'] 196 | msg['To'] = profile['email_to'] 197 | att = MIMEApplication(pdf.tostring(), _subtype='pdf') 198 | att.add_header('content-disposition', 'attachment', filename=('utf-8', '', filename + '.pdf')) 199 | msg.attach(att) 200 | Logger.info('Scanman: sending email: %s', profile) 201 | self.custom_status_text = 'Sending email…' 202 | s = smtplib.SMTP(profile.get('smtp_server', self.settings.get('smtp_server', 'localhost'))) 203 | if profile.get('smtp_tls', self.settings.get('smtp_tls', False)): 204 | s.starttls() 205 | cred = profile.get('smtp_credentials', self.settings.get('smtp_credentials')) 206 | if cred: 207 | s.login(*cred) 208 | s.sendmail(profile['email_from'], [profile['email_to']], msg.as_string()) 209 | s.quit() 210 | self.custom_status_text = 'Done.' 211 | Clock.schedule_once(self.reset_custom_status, 5) 212 | self.scanning = False 213 | 214 | def cancelled(): 215 | self.ui.status_label.text = 'Cancelled.' 216 | Clock.schedule_once(self.reset_custom_status, 5) 217 | self.scanning = False 218 | 219 | self.scanner.scan(scan_processor, done, cancelled) 220 | 221 | def cancel(self): 222 | Logger.info('Scanman: cancelling') 223 | self.scanning = False 224 | self.scanner.cancel() 225 | 226 | 227 | def main(): 228 | try: 229 | settings = yaml.safe_load(open(sys.argv[1])) 230 | except: 231 | print('Syntax: scanman ') 232 | exit(1) 233 | ScanManApp(settings).run() 234 | 235 | if __name__ == '__main__': 236 | main() 237 | -------------------------------------------------------------------------------- /scanman/scanman.kv: -------------------------------------------------------------------------------- 1 | #:kivy 1.4 2 | 3 | : 4 | orientation: "horizontal" 5 | scan_button: scan 6 | status_label: status 7 | preview_image: preview 8 | profiles_container: profiles 9 | 10 | BoxLayout: 11 | size_hint: (.55, 1) 12 | orientation: "vertical" 13 | padding: 10 14 | 15 | Image: 16 | id: preview 17 | size_hint: (1, 1) 18 | allow_stretch: True 19 | source: "" 20 | canvas.after: 21 | Line: 22 | rectangle: self.x+1,self.y+1,self.width-1,self.height-1 23 | dash_offset: 5 24 | dash_length: 3 25 | 26 | Label: 27 | id: status 28 | size_hint: (1, .2) 29 | text: "Scanner is not connected." 30 | 31 | BoxLayout: 32 | size_hint: (.45, 1) 33 | orientation: "vertical" 34 | padding: 10 35 | 36 | StackLayout: 37 | id: profiles 38 | spacing: 5 39 | 40 | Button: 41 | id: scan 42 | size_hint: (1, None) 43 | height: 200 44 | text: "Scan" 45 | disabled: True 46 | -------------------------------------------------------------------------------- /scanman/scanner.py: -------------------------------------------------------------------------------- 1 | import sane 2 | import threading 3 | import time 4 | 5 | try: 6 | from Queue import Queue, Empty 7 | except: 8 | from queue import Queue, Empty 9 | 10 | 11 | class Scanner(object): 12 | PAGE_LOADED = 82 13 | COVER_OPEN = 84 14 | SCAN_BUTTON_PRESSED = 88 15 | 16 | def __init__(self): 17 | self.version = sane.init() 18 | self.scanning = False 19 | self.cancelled = False 20 | self.dev = None 21 | self._open_first_device() 22 | 23 | def _open_first_device(self): 24 | devices = sane.get_devices() 25 | try: 26 | self.dev = sane.open(devices[0][0]) 27 | except: 28 | self.dev = None 29 | return 30 | # Selects the scan source (ADF Front/ADF Back/ADF Duplex). 31 | self.dev.source = 'ADF Duplex' 32 | # Specifies the height of the media. 33 | self._set_option(7, 320.0) 34 | self._set_option(11, 320.0) 35 | # Selects the scan mode (e.g., lineart, monochrome, or color). 36 | self.dev.mode = 'color' 37 | # Sets the resolution of the scanned image (50..600dpi in steps of 1)). 38 | self.dev.resolution = 192 39 | # Controls the brightness of the acquired image. -127..127 (in steps of 1) [0] 40 | self.dev.brightness = 15 41 | # Controls the contrast of the acquired image. 0..255 (in steps of 1) [0] 42 | self.dev.contrast = 20 43 | # Set SDTC variance rate (sensitivity), 0 equals 127. 0..255 (in steps of 1) [0] 44 | self.dev.variance = 0 45 | # Collect a few mm of background on top side of scan, before paper 46 | # enters ADF, and increase maximum scan area beyond paper size, to allow 47 | # collection on remaining sides. May conflict with bgcolor option 48 | self.dev.overscan = 'Off' 49 | # Scanner detects paper lower edge. May confuse some frontends (bool). 50 | self.dev.ald = True 51 | # Request scanner to read pages quickly from ADF into internal memory (On/Off/Default). 52 | self.dev.buffermode = 'On' 53 | # Request scanner to grab next page from ADF (On/Off/Default). 54 | self.dev.prepick = 'On' 55 | # Request driver to rotate skewed pages digitally. 56 | self.dev.swdeskew = False 57 | # Maximum diameter of lone dots to remove from scan ([0..9] in steps of 1). 58 | self.dev.swdespeck = 0 59 | # Request driver to remove border from pages digitally. 60 | self.dev.swcrop = True 61 | # Request driver to discard pages with low percentage of dark pixels 62 | self.dev.swskip = 5 63 | 64 | def _get_option(self, code): 65 | try: 66 | return self.dev.__dict__['dev'].get_option(code) 67 | except AttributeError: 68 | return None 69 | except Exception as e: 70 | if str(e) == 'Error during device I/O': 71 | self._open_first_device() 72 | else: 73 | raise 74 | 75 | def _set_option(self, code, value): 76 | try: 77 | return self.dev.__dict__['dev'].set_option(code, value) 78 | except AttributeError: 79 | return None 80 | except Exception as e: 81 | if str(e) == 'Error during device I/O': 82 | self._open_first_device() 83 | else: 84 | raise 85 | 86 | def _is_scan_button_pressed(self): 87 | """ 88 | Returns true if the scan button on the scanner has been pressed. 89 | """ 90 | return self._get_option(self.SCAN_BUTTON_PRESSED) == 1 91 | 92 | def _is_cover_open(self): 93 | """ 94 | Returns true if the scan cover is opened. 95 | """ 96 | return self._get_option(self.COVER_OPENED) == 1 97 | 98 | def _is_page_loaded(self): 99 | """ 100 | Returns true if a page is loaded in the automatic document feeder 101 | """ 102 | return self._get_option(self.PAGE_LOADED) == 1 103 | 104 | def scan(self, processor, done, cancelled): 105 | if not self.dev or self.scanning: 106 | done() 107 | return 108 | self.scanning = True 109 | self.cancelled = False 110 | self.iterator = self.dev.multi_scan() 111 | q = Queue() 112 | 113 | def scan(): 114 | i = 0 115 | while True: 116 | try: 117 | self.dev.start() 118 | except Exception as e: 119 | if str(e) == 'Document feeder out of documents': 120 | q.join() 121 | done() 122 | else: 123 | cancelled() 124 | self.scanning = False 125 | return 126 | if self.cancelled: 127 | cancelled() 128 | self.scanning = False 129 | return 130 | image = self.dev.snap(True) 131 | q.put((i, image)) 132 | i += 1 133 | if self.cancelled: 134 | cancelled() 135 | self.scanning = False 136 | return 137 | 138 | def process_queue(): 139 | while True: 140 | try: 141 | processor(*q.get()) 142 | q.task_done() 143 | except Empty: 144 | return 145 | 146 | threading.Thread(target=scan).start() 147 | threading.Thread(target=process_queue).start() 148 | 149 | def cancel(self): 150 | self.cancelled = True 151 | self.dev.cancel() 152 | 153 | def connected(self, callback): 154 | def check_connection(): 155 | while True: 156 | if self.dev is None: 157 | # Try to reconnect 158 | self._open_first_device() 159 | callback(self.dev is not None) 160 | time.sleep(1) 161 | return threading.Thread(target=check_connection).start() 162 | 163 | def scan_button(self, callback): 164 | def monitor_scan_button(): 165 | while True: 166 | try: 167 | if not self.scanning and self._is_scan_button_pressed(): 168 | callback() 169 | except: 170 | pass 171 | time.sleep(1) 172 | return threading.Thread(target=monitor_scan_button).start() 173 | 174 | def page_loaded(self, callback): 175 | def monitor_page_loaded(): 176 | while True: 177 | if not self.scanning: 178 | try: 179 | state = self._is_page_loaded() 180 | if state is None: 181 | state = False 182 | callback(state) 183 | except: 184 | pass 185 | time.sleep(1) 186 | return threading.Thread(target=monitor_page_loaded).start() 187 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | setup( 3 | name='scanman', 4 | packages=['scanman'], 5 | version='0.8', 6 | description='A ScanSnap manager for Raspbery Pi', 7 | author='Olivier Poitrey', 8 | author_email='rs@rhapsodyk.net', 9 | url='https://github.com/rs/scanman', 10 | download_url='https://github.com/rs/scanman/tarball/0.8', 11 | install_requires=['kivy', 'python-sane', 'img2pdf', 'pyyaml'], 12 | package_data={'scanman': ['*.kv']}, 13 | entry_points={'console_scripts': [ 14 | 'scanman = scanman.main:main', 15 | ]}, 16 | keywords=['scan', 'scansnap', 'sane', 'raspberry', 'kivy', 'paperless', 'office'], 17 | classifiers=[ 18 | 'Development Status :: 3 - Alpha', 19 | 'Environment :: Console :: Framebuffer', 20 | 'Intended Audience :: End Users/Desktop', 21 | 'License :: OSI Approved :: MIT License', 22 | 'Operating System :: POSIX', 23 | 'Programming Language :: Python :: 2.7', 24 | 'Topic :: Office/Business', 25 | ] 26 | ) 27 | --------------------------------------------------------------------------------