├── .github ├── FUNDING.yml └── workflows │ └── publish.yml ├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── msrx.spec ├── msrx └── __init__.py ├── scripts └── msrx └── setup.py /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: oxplot 2 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Python 🐍 distributions 📦 to PyPI and TestPyPI 2 | on: push 3 | 4 | jobs: 5 | build-n-publish: 6 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 7 | runs-on: ubuntu-18.04 8 | steps: 9 | - uses: actions/checkout@v2 10 | - name: Set up Python 11 | uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.9 14 | - name: Install pypa/build 15 | run: >- 16 | python -m 17 | pip install 18 | build 19 | --user 20 | - name: Build a binary wheel and a source tarball 21 | run: >- 22 | python -m 23 | build 24 | --sdist 25 | --wheel 26 | --outdir dist/ 27 | . 28 | - name: Publish distribution 📦 to PyPI 29 | if: startsWith(github.ref, 'refs/tags') 30 | uses: pypa/gh-action-pypi-publish@master 31 | with: 32 | password: ${{ secrets.PYPI_API_TOKEN }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | msrx.egg-info 4 | MANIFEST 5 | *.swp 6 | *~ 7 | *.pyc 8 | __pycache__ 9 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2022, Mansour Behabadi 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include msrx.spec MANIFEST.in README.md LICENSE.txt 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Intro 2 | ===== 3 | 4 | MSR605 is a very well built and popular magnetic card reader/writer. 5 | msrx is a library and a command line utility that allows talking to this 6 | device. 7 | 8 | Features 9 | ======== 10 | 11 | * msrx python module compatible with python 2.7+ and python 3 12 | * Command line utility with read, write and erase functionality 13 | 14 | Installation 15 | ============ 16 | 17 | $ pip install msrx 18 | 19 | Usage 20 | ===== 21 | 22 | Ensure you've plugged a 9V supply to the power injector on the USB cable 23 | before continuing. 24 | 25 | To read a card's data, run the following and swipe a card: 26 | 27 | $ msrx read 28 | %PA1VSBUTT0 .8W11(BT003423342?|;943300000002342?:| 29 | 30 | The output is a pipe ('|') separated track data in ISO-7811 format. In 31 | the above example, only tracks 1 and 2 have data in them. 32 | 33 | To erase a card, run the following and swipe a card (**WARNING** this is 34 | non-reversible): 35 | 36 | $ msrx erase -t 1,3 37 | 38 | The above erases tracks 1 and 3. To erase all tracks, leave out `-t`. 39 | 40 | To write to a card, run the following and swipe a card: 41 | 42 | $ echo '%HAPPY?||;99?' | msrx write 43 | 44 | This writes to tracks 1 and 3 because we left track 2 data empty. Note 45 | that restrictions apply as to what set of characters and in what format 46 | may be stored in each track. Consult ISO-7811 parts 2 and 6 for more 47 | information. 48 | 49 | To see other options, run msrx with `-h` option. 50 | 51 | To use msrx as a library: 52 | 53 | import msrx 54 | mymsrx = msrx.MSRX('/dev/ttyUSB0') 55 | -------------------------------------------------------------------------------- /msrx.spec: -------------------------------------------------------------------------------- 1 | %define name msrx 2 | %define version 0.1 3 | %define release 1 4 | 5 | Name: %{name} 6 | Version: %{version} 7 | Release: %{release} 8 | Summary: MSR605 library and command line tools 9 | 10 | Group: Development/Libraries 11 | License: BSD 12 | Source0: %{name}-%{version}.tar.gz 13 | Vendor: Mansour Behabadi 14 | URL: https://github.com/oxplot/msrx 15 | 16 | BuildArch: noarch 17 | BuildRequires: python >= 2.7 18 | Requires: python >= 2.7 19 | 20 | %description 21 | Library and command line tools for talking with MSR605 magnetic card 22 | reader/writer. 23 | 24 | %prep 25 | %setup -n %{name}-%{version} 26 | 27 | %build 28 | %{__python} setup.py build 29 | 30 | %install 31 | %{__python} setup.py install -O1 --root=$RPM_BUILD_ROOT --record=INSTALLED_FILES 32 | 33 | %clean 34 | rm -rf $RPM_BUILD_ROOT 35 | 36 | %files -f INSTALLED_FILES 37 | %defattr(-,root,root) 38 | %doc README.md LICENSE.txt 39 | 40 | %changelog 41 | * Fri Aug 3 2014 Mansour Behabadi - 0.1-1 42 | - Initial release 43 | -------------------------------------------------------------------------------- /msrx/__init__.py: -------------------------------------------------------------------------------- 1 | # msrx.py - Library for talking with MSR605 magnetic card reader/writer 2 | # Copyright (C) 2022 Mansour Behabadi 3 | 4 | """Library for talking with MSR605 magnetic card reader/writer""" 5 | 6 | from __future__ import division 7 | from __future__ import print_function 8 | from __future__ import unicode_literals 9 | 10 | import argparse 11 | import codecs 12 | import os 13 | import re 14 | import sys 15 | 16 | try: 17 | unicode = unicode 18 | range = xrange 19 | to_byte = chr 20 | to_uni = unichr 21 | input = raw_input 22 | biter = lambda x: x 23 | except NameError: 24 | unicode = str 25 | to_byte = lambda c: bytes([c]) 26 | to_uni = chr 27 | biter = lambda x: (to_byte(i) for i in x) 28 | 29 | __author__ = 'Mansour Behabadi' 30 | __copyright__ = 'Copyright (C) 2022 Mansour Behabadi' 31 | __credits__ = ['Mansour Behabadi'] 32 | __email__ = 'mansour@oxplot.com' 33 | __license__ = 'BSD' 34 | __maintainer__ = 'Mansour Behabadi' 35 | __version__ = '0.3' 36 | __description__ = 'Library and command line utils to use MSR605 magnetic card reader/writer' 37 | __progname__ = 'msrx' 38 | __verinfo__ = """%s version %s""" % (__progname__, __version__) 39 | 40 | _TRACK_CNT = 3 41 | _DEF_DEV = '/dev/ttyUSB0' 42 | _DEV_ENV = 'MSRX_DEV' 43 | _DELIM = '|' 44 | _DEF_TYPE = 'iso' 45 | 46 | class ISO7811(object): 47 | 48 | _PARAM_MAP = {1: (0x20, 7), 2: (0x30, 5), 3: (0x30, 5)} 49 | _CODEC_NAMES = set(['iso7811_t%d' % i for i in _PARAM_MAP]) 50 | 51 | @classmethod 52 | def codec_search(cls, name): 53 | if name in cls._CODEC_NAMES: 54 | andlen = lambda x: (x, len(x)) 55 | params = cls._PARAM_MAP[int(name[-1])] 56 | return ( 57 | (lambda data: andlen(''.join(cls._enc(data, *params)))), 58 | (lambda data: andlen(b''.join(cls._dec(data, *params)))), 59 | None, 60 | None 61 | ) 62 | return None 63 | 64 | @classmethod 65 | def _dec(cls, data, low, bits): 66 | atbit, whole = 0, 0 67 | for d in data: 68 | part = (ord(d) - low) & ((1 << (bits - 1)) - 1) 69 | part |= (~(((part * 0x0101010101010101) 70 | & 0x8040201008040201) % 0x1FF) & 1) << (bits - 1) 71 | whole |= (part << atbit) & 255 72 | atbit += bits 73 | if atbit > 7: 74 | yield to_byte(whole) 75 | atbit = atbit % 8 76 | whole = part >> (bits - atbit) 77 | if atbit > 0: 78 | yield to_byte(whole) 79 | 80 | @classmethod 81 | def _enc(cls, data, low, bits): 82 | data = biter(iter(data)) 83 | try: 84 | atbit, whole = 0, ord(next(data)) 85 | while True: 86 | part = (whole >> atbit) & ((1 << bits) - 1) 87 | atbit += bits 88 | if atbit > 7: 89 | whole = ord(next(data)) 90 | atbit = atbit % 8 91 | part |= (whole & ((1 << atbit) - 1)) << (bits - atbit) 92 | if part == 0: 93 | return 94 | # TODO verify the parity bit before yielding 95 | yield to_uni((part & ((1 << (bits - 1)) - 1)) + low) 96 | except StopIteration: 97 | pass 98 | 99 | codecs.register(ISO7811.codec_search) 100 | 101 | class ProtocolError(Exception): 102 | pass 103 | 104 | class DeviceError(Exception): 105 | 106 | RW = 'read_write' 107 | CMD = 'command' 108 | SWP = 'swipe' 109 | ERASE = 'erase' 110 | 111 | def __init__(self, code): 112 | super(DeviceError, self).__init__('MSR605 %s error' % code) 113 | self.code = code 114 | 115 | class MSRX(object): 116 | 117 | _DEV_ERR = { 118 | b'1': DeviceError.RW, 119 | b'2': DeviceError.CMD, 120 | b'4': DeviceError.CMD, 121 | b'9': DeviceError.SWP, 122 | b'A': DeviceError.ERASE 123 | } 124 | 125 | def __init__(self, device): 126 | '''Open the serial device''' 127 | import serial 128 | self._dev = serial.Serial(device, 9600, 8, serial.PARITY_NONE) 129 | 130 | def _send(self, d): 131 | self._dev.write(d) 132 | self._dev.flush() 133 | 134 | def _expect(self, d): 135 | rd = self._dev.read(len(d)) 136 | if rd != d: 137 | raise ProtocolError('expected %s, got %s' % ( 138 | codecs.encode(rd, 'hex_codec'), codecs.encode(d, 'hex_encode') 139 | )) 140 | 141 | def reset(self): 142 | '''Reset device to initial state''' 143 | self._send(b'\x1ba') 144 | 145 | def erase(self, tracks=(True, True, True)): 146 | '''Erase tracks 147 | 148 | tracks: tuple of 3 bools - each indicating whether the corresponding 149 | track should be erased. 150 | ''' 151 | self._send(b'\x1bc' + to_byte( 152 | (1 if tracks[0] else 0) 153 | | (2 if tracks[1] else 0) 154 | | (4 if tracks[2] else 0) 155 | )) 156 | self._handle_status() 157 | 158 | def read(self): 159 | '''read() -> (t1, t2, t3) 160 | 161 | Read all tracks 162 | ''' 163 | tracks = [b''] * _TRACK_CNT 164 | self._send(b'\x1bm') 165 | self._expect(b'\x1bs') 166 | for t in range(_TRACK_CNT): 167 | self._expect(b'\x1b' + to_byte(t + 1)) 168 | tracks[t] = b''.join( 169 | # Some bit hackery to reverse the bits - we shouldn't need this 170 | # but the hardware works in mysterious ways. 171 | to_byte((((ord(c) * 0x80200802) 172 | & 0x0884422110) * 0x0101010101 >> 32) & 255) 173 | for c in biter(self._dev.read(ord(self._dev.read(1)))) 174 | ) 175 | self._expect(b'?\x1c') 176 | self._handle_status() 177 | return tracks 178 | 179 | def write(self, tracks): 180 | '''Write all tracks 181 | 182 | tracks: tuple of three byte strings, each data for the corresponding 183 | track. To preserve a track, pass empty byte string. 184 | ''' 185 | self._send(b'\x1bn\x1bs') 186 | for t, i in zip(tracks, range(_TRACK_CNT)): 187 | self._send(b'\x1b' + to_byte(i + 1) + to_byte(len(t)) + t) 188 | self._send(b'?\x1c') 189 | self._handle_status() 190 | 191 | def _handle_status(self): 192 | self._expect(b'\x1b') 193 | status = self._dev.read(1) 194 | if status == b'0': 195 | return 196 | elif status in self._DEV_ERR: 197 | raise DeviceError(self._DEV_ERR[status]) 198 | else: 199 | raise ProtocolError( 200 | "invalid status %s" % codec.encode(status, 'hex_codec') 201 | ) 202 | 203 | _DATA_CONV = { 204 | ('raw', 'hex'): 205 | (lambda d, _: codecs.encode(d, 'hex_codec')), 206 | ('hex', 'raw'): (lambda d, _: codecs.decode(d, 'hex_codec')), 207 | ('raw', 'iso'): 208 | (lambda d, t: codecs.encode(d, 'iso7811_t%d' % t)), 209 | ('iso', 'raw'): (lambda d, t: codecs.decode(d, 'iso7811_t%d' % t)) 210 | } 211 | 212 | _DTYPE_VFY = { 213 | 'hex': lambda d, _: bool(re.search(r'^[0-9a-fA-F]*$', d)), 214 | 'iso': lambda d, t: bool( 215 | re.search(r'^[ -_]*$' if t == 1 else r'^[0-?]*$', d) 216 | ) 217 | } 218 | 219 | def _do_read(args): 220 | 221 | print(_DELIM.join( 222 | _DATA_CONV[('raw', args.type)](d, t + 1) 223 | for d, t in zip(args.msrx.read(), range(_TRACK_CNT)) 224 | )) 225 | 226 | def _do_write(args): 227 | 228 | data = (args.data or input()).split(_DELIM) 229 | if len(data) != _TRACK_CNT: 230 | args.parser.error( 231 | "there must be exactly be %d '%s'" 232 | " in data separating the %d tracks" 233 | % (_TRACK_CNT - 1, _DELIM, _TRACK_CNT) 234 | ) 235 | if not all( 236 | _DTYPE_VFY[args.type](d, t + 1) 237 | for d, t in zip(data, range(_TRACK_CNT)) 238 | ): 239 | args.parser.error( 240 | "the data doesn't match the type given (%s)" % args.type 241 | ) 242 | 243 | data = [ 244 | _DATA_CONV[args.type, 'raw'](d, t + 1) 245 | for d, t in zip(data, range(_TRACK_CNT)) 246 | ] 247 | args.msrx.write(data) 248 | 249 | def _do_erase(args): 250 | 251 | args.msrx.erase(args.tracks) 252 | 253 | def main(): 254 | 255 | def track_sel_type(data): 256 | tracks = [False] * _TRACK_CNT 257 | try: 258 | for t in map(int, data.split(',')): 259 | if t > _TRACK_CNT or t < 1: 260 | raise argparse.ArgumentTypeError( 261 | 'track numbers must be between %d and %d' 262 | % (1, _TRACK_CNT) 263 | ) 264 | tracks[t - 1] = True 265 | except ValueError: 266 | raise argparse.ArgumentTypeError( 267 | 'provide track numbers separated with commas - e.g 1,3' 268 | ) 269 | return tracks 270 | 271 | def add_type_arg(parser): 272 | parser.add_argument( 273 | '-t', '--type', 274 | metavar='TYPE', 275 | default=_DEF_TYPE, 276 | choices=list(_DTYPE_VFY), 277 | type=unicode, 278 | help='data type: %s - defaults to %s' 279 | % (', '.join(_DTYPE_VFY), _DEF_TYPE) 280 | ) 281 | 282 | if any(a == '--version' for a in sys.argv[1:]): 283 | print(__verinfo__) 284 | exit(0) 285 | 286 | parser = argparse.ArgumentParser( 287 | formatter_class=argparse.RawDescriptionHelpFormatter, 288 | description=__description__ 289 | ) 290 | parser.add_argument( 291 | '-D', '--dev', 292 | metavar='DEV', 293 | default=os.environ.get(_DEV_ENV, _DEF_DEV), 294 | help='serial device to use - can be override by %s env' 295 | ' variable - defaults to %s' % (_DEV_ENV, _DEF_DEV) 296 | ) 297 | parser.add_argument( 298 | '-R', '--no-reset', 299 | action='store_true', 300 | default=False, 301 | help='do NOT issue reset before the main command' 302 | ) 303 | parser.add_argument( 304 | '--version', 305 | action='store_true', 306 | help='show license and version of ' + __progname__ 307 | ) 308 | 309 | subparsers = parser.add_subparsers( 310 | dest='cmd' 311 | ) 312 | 313 | parser_a = subparsers.add_parser( 314 | 'read', 315 | description='Read card and output data as' 316 | " '%s' delimited string to stdout" % _DELIM, 317 | help='read card' 318 | ) 319 | add_type_arg(parser_a) 320 | parser_a.set_defaults(func=_do_read) 321 | 322 | parser_a = subparsers.add_parser( 323 | 'write', 324 | description="Write to card from '%s' delimited data in stdin" 325 | " or from --data command line arg" % _DELIM, 326 | help='write card' 327 | ) 328 | parser_a.add_argument( 329 | '-d', '--data', 330 | metavar='DATA', 331 | default=None, 332 | type=unicode, 333 | help='data to write - overrides stdin' 334 | ) 335 | add_type_arg(parser_a) 336 | parser_a.set_defaults(func=_do_write) 337 | 338 | parser_a = subparsers.add_parser( 339 | 'erase', 340 | description='Erase all tracks', 341 | help='erase card' 342 | ) 343 | parser_a.add_argument( 344 | '-t', '--tracks', 345 | metavar='TRACKS', 346 | default=','.join(str(i + 1) for i in range(_TRACK_CNT)), 347 | type=track_sel_type, 348 | help='tracks to erase - default is ' 349 | + ','.join(str(i + 1) for i in range(_TRACK_CNT)) 350 | ) 351 | parser_a.set_defaults(func=_do_erase) 352 | 353 | args = parser.parse_args() 354 | args.parser = parser 355 | 356 | try: 357 | msrxinst = MSRX(args.dev) 358 | if not args.no_reset: 359 | msrxinst.reset() 360 | args.msrx = msrxinst 361 | args.func(args) 362 | except OSError as e: 363 | print( 364 | '%s: error: %s' % (__progname__, os.strerror(e.errno)), 365 | file=sys.stderr 366 | ) 367 | except (DeviceError, ProtocolError) as e: 368 | print('%s: error: %s' % (__progname__, e.args[0]), file=sys.stderr) 369 | exit(254) 370 | except KeyboardInterrupt: 371 | print('keyboard interrupt', file=sys.stderr) 372 | exit(255) 373 | -------------------------------------------------------------------------------- /scripts/msrx: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import msrx 4 | 5 | if __name__ == '__main__': 6 | msrx.main() 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | from subprocess import Popen, PIPE 5 | import errno 6 | import msrx 7 | 8 | # All of this so we don't have to maintain an RST version of README.md 9 | # for when submitting packages to PyPi - arghh 10 | 11 | readme_path = 'README.md' 12 | 13 | try: 14 | 15 | readme = Popen( 16 | ['pandoc', '-f', 'markdown', '-t', 'rst', readme_path, '-o', '-'], 17 | stdout=PIPE 18 | ).communicate()[0].decode('utf8') 19 | if not readme: 20 | raise Exception() 21 | 22 | except: 23 | 24 | print('warning: pandoc coud not be used to convert readme to RST') 25 | readme = open(readme_path, 'rb').read().decode('utf8') 26 | 27 | setup( 28 | name='msrx', 29 | version=msrx.__version__, 30 | packages=['msrx'], 31 | scripts=['scripts/msrx'], 32 | install_requires=['PySerial'], 33 | 34 | author=msrx.__author__, 35 | author_email=msrx.__email__, 36 | maintainer=msrx.__maintainer__, 37 | maintainer_email=msrx.__email__, 38 | description=msrx.__description__, 39 | long_description=readme, 40 | license=msrx.__license__, 41 | url='https://github.com/oxplot/msrx', 42 | 43 | classifiers=[ 44 | 'Development Status :: 4 - Beta', 45 | 'Environment :: Console', 46 | 'Intended Audience :: Developers', 47 | 'Intended Audience :: End Users/Desktop', 48 | 'License :: OSI Approved :: BSD License', 49 | 'Natural Language :: English', 50 | 'Programming Language :: Python :: 2.7', 51 | 'Programming Language :: Python :: 3.9', 52 | 'Operating System :: POSIX :: Linux', 53 | 'Topic :: Utilities' 54 | ] 55 | ) 56 | --------------------------------------------------------------------------------