pdd2.py

#!/usr/bin/python


# pdd2.py --- A collection of handy python functions for dealing with

# a Tandy Portable Disk Drive 2.


# 2013-10-25

# Bee Nine <hackerb9@gmail.com>

# Explicitly released into the Public Domain.


# Note: You'll need to be in either group dialout (check /dev/tty*) or

# run this as root. I suggest the former as the latter is overkill.



# REFERENCES:

# ftp://ftp.whtech.com/club100/ref/comand.tdd

# http://www.club100.org/library/doc/testtdd.html

# http://bitchin100.com/wiki/index.php?title=TPDD_Base_Protocol


# But note, reference documentation is buggy in many places!


# bitchin100 perhaps has the most complete information, but the last

# two sections ("Calculating Checksum" and "Using this Information")

# are just plain wrong.

# * The checksum is NOT the "number of bytes" but the sum all the bytes.

# * Space$(24) would give a different checksum than the examples show; so

# the characters to pad filenames with are possibly NULLs (chr(0)).

# * There is a suspicious chr$(13) at the end after the checksum for

# Status and Format. I notice that both of those sections are written

# in the first person, perhaps by someone other than the original author?

# * The file suggests that PDD2 units can autobaud, but mine certainly

# can't. It seems to only talk at 19200. I notice there are solder pads

# where a switch could be put in. Probably Radio Shack didn't want

# to waste the money on a feature (slower xfr rate) nobody wanted.


# The "command.tdd" file has some additional information that ought to

# be in the bitchin100 file:

# * The drive uses RTS/CTS hardware handshaking

# * The filename should be padded to the left with spaces to fill the

# first six characters. The seventh character must be a period. The

# next three characters are the optional extension. After that,

# there should be enough spaces to make the entire string 26

# characters long.

# * "File" opcode works on files in the directory list, not "blocks".

# And some possible misinformation:

# * This file mentions that the checksum is actually a sum (good) but

# does not metion that the result is then complemented (all bits

# inverted using XOR 255).

# * This file does not mention the opcodes for 'condition' or 'rename'

# * The file has weird stubs for an "unknown" opcode 8, and a mode

# type for the "Close" and "Delete" opcodes which probably should be

# removed if they don't actually exist.

# * The "Find" opcode doesn't mention the modes for "previousblock" or "end",

# and completely forgets the mandatory "F" parameter before the checksum.


# The testtdd.html file is the only reference that mentions the FLOPPY

# test, which is actually a very strange invocation of the STATUS

# opcode (7). Length is 7, data is "^K^LFLOPP", and checksum is "Y".

# (Which is, bizarrely, correct!) I think that FLOPPY is perhaps not a

# special command, but merely a string someone made up so that a valid

# STATUS + checksum could be typed from TELCOM. The results mentioned

# are "q{" for PDD1 and "p|" for PDD2, when no disk is in the drive.

# That fits with the error codes from a normal STATUS return: q (0x71)

# is "disk change error" and p (0x70) is "no disk". Why the errors

# differ for the two drives, who knows?




### Global hash table of opcode names to numbers

# (Add 0x40 to access BANK 1 instead of BANK 0)

optable={'find' :0x00,

'open' :0x01,

'close' :0x02,

'read' :0x03,

'write' :0x04,

'delete' :0x05,

'format' :0x06,

'status' :0x07,

'condition' :0x0c,

'rename' :0x0d,

}


### Global hash table of mode types to mode number

modetable={

# For FIND opcode

'reference' : 0x00,

'first' : 0x01,

'next' : 0x02,

'previous' : 0x03,

'end' : 0x04,


# For OPEN opcode

'write' : 0x01,

'append' : 0x02,

'read' : 0x03

}


### Global hash table of return opcodes FROM the drive

returntable={

0x10 :'read', # [0x0a, len, data, cksum] (if len==0x80, read again)

0x11 :'find', # [0x11, 0x1c, filename, size, free, cksum]

0x12 :'normal', # [0x0c, 0x01, errorcode, cksum]

0x15 :'condition', # [0x0f, 0x01, condition, cksum]


# This is not documented, but is an error code my PDD2 returned

# whenever I sent it a 'find' with an invalid length of data.

# (Filename padded to less than 24 bytes).

# It appears to be a variant of the 'normal' return type.

0x38 :'error' # [0x38, 0x01, errorcode, cksum]


}


### Global hash table mapping error codes from drive to error messages

errortable={0x00: 'Normal (no error)',

0x10: 'File does not exist',

0x11: 'File exists',

0x30: 'No filename',

0x31: 'Dir search error',

0x35: 'Bank error',

0x36: 'Parameter error',

0x37: 'Open format mismatch',

0x3f: 'End of file',

0x40: 'No start mark',

0x41: 'CRC check error in ID',

0x42: 'Sector length error',

0x44: 'Format verify error',

0x46: 'Format interruption',

0x47: 'Erase offset error',

0x49: 'CRC check error in data',

0x4a: 'Sector number error',

0x4b: 'Read data timeout',

0x4d: 'Sector number error', # Again?

0x50: 'Disk write protect',

0x5e: 'Un-initialized disk',

0x60: 'Directory full',

0x61: 'Disk full',

0x6e: 'File too long',

0x70: 'No disk',

0x71: 'Disk change error',


# The following errors I've received from my PDD2, but

# didn't see in any documentation


0x80: 'Hardware failure', # (Belt got stuck in motor in my case)

0xd0: 'Invalid filename' # (Sent FIND an invalid filename request)

}


### Mapping of bits in 'condition' byte from drive to messages

conditiontable={

0b1000: {0: "Power is normal", 8: "Low batteries"},

0b0100: {0: "Disk is writable", 4: "Write protected"},

0b0010: {0: "Disk in drive", 2: "No disk in drive"},

0b0001: {0: "Disk not changed", 1: "Disk changed"}

}



import serial

from serial.tools.list_ports import comports


def pickaport():

portlist=[]

for i,(port,desc,hwid) in enumerate(sorted(comports())):

print "%d) %s (%s, %s)" % (i,port, desc, hwid)

portlist.append(port)


reply=raw_input("Type the # which the PDD is attached to or hit ENTER for " + portlist[-1] +": ")

try:

r=int(reply)

port=portlist[r]

except ValueError:

if reply=="":

port=portlist[-1]

elif reply.startswith('/'):

port=reply

elif reply.startswith('tty'):

port="/dev/"+reply

else:

port="/dev/tty"+reply


print "Using '%s' as the port name to open." % (port)

return port



def space(x):

if type(x)==int:

return ' '*x

else:

raise TypeError


def checkdsr():

global ser # In case we have to reopen it when DSR is down but CTS is up.

if not ser.getDSR():

print "DSR is false, Please connect the Portable Disk Drive 2 and turn it on"

if ser.getCTS():

print "(By the way, oddly enough CTS is true so *something* is hooked up...)"

print "Trying to reset the circuit by reinitializing the serial port"

ser.close()

ser = serial.Serial(port, baud, timeout=timeout, rtscts=True,dsrdtr=True)

while not ser.getDSR():

pass

print "Okay, got DSR. Here we go!"


def checkcts():

if not ser.getCTS():

print "CTS is False... waiting for PDD to catch up"

while not ser.getCTS():

pass

print "Okay, got CTS."




def write(s):

checkdsr()

checkcts()

purgereadbuffer()


print "SENDING: ", [ x if x.isalnum() else hex(ord(x)) for x in s ]

ser.write(s)


def read(timeout=5):

'''Read and interpret response from the drive. Optional timeout

specifies how many seconds to wait for the first byte to be read.

A timeout of -1 means "wait forever for the first byte" and might

be useful for a long running task such as "format". After the

first byte, the timeout is not used, but the serial module has its

own read timeout (which we don't adjust here since it would slow

down every read, not just the first).

'''


checkdsr() # XXX Should I be doing this? Why does the drive sometimes put DSR low but CTS high? USB<->Serial converter problem?



print "RECEIVING"

msg="" # Slowly accumulate data into "msg"


# The drive may take some time before the first byte arrives, so

# keep trying up to 'timeout' seconds.

from time import time

start=time()

x=ser.read()

while (x=="" and (time()-start<timeout or timeout==-1)):

x=ser.read()


if x=="": return # Drive has nothing to say...


# First byte, "rt", specifies the "return type", how the following

# data should be interpreted. In short:

# READ FILE: (0x0a, len, data, cksum) # (if len==0x80, read again)

# FIND REFERENCE: (0x11, 0x1c, filename, size, free, cksum)

# NORMAL RETURN: (0x0c, 0x01, errorcode, cksum)

# DRIVE CONDITION: (0x0f, 0x01, condition, cksum)

# (Bonus return type: 0x38, possibly for incorrectly formatted requests?)

rt=ord(x)

msg+=x

if rt in returntable:

print "Return type:", returntable[rt]

else:

print "Error! Unknown return type: ", rt

purgereadbuffer()

return

# Second byte, "length", specifies number of bytes of data that

# follow, not including the one byte checksum.

x=ser.read()

if x=="":

print "Error! Only one byte received from drive."

return

length=ord(x)

msg+=x


# Now that we have the length, read that many bytes into "data"

data=ser.read(length)

msg+=data

print " length: ", length

print " data: ", [ x if x.isalnum() else hex(ord(x)) for x in data ]


# Finally, read the one byte checksum

x=ser.read()

if x=="":

print "Error! Ran out of data reading from drive."

return

else:

cksum=ord(x)



# DONE WITH READING, NOW COMES INTERPRETATION...


valid=iscksumvalid(msg, cksum)

print " checksum:", cksum, "(valid)" if valid else "(INVALID)"


# Handle different return types differently


if returntable[rt] == 'read':

'''

A chunk of the file is returned in "data", but it might be

incomplete. If "length" is 0x80 (128 bytes), another READ

command must be sent to drive to get the next chunk. (Which

could be empty.)

'''

# Already printed data above, so not much to do here.

if length==0x80:

print "There is more data to read. Send another read command."


elif returntable[rt] == 'find':

'''

Data is 28 bytes:

24 bytes filename,

1 byte attribute,

2 bytes size, and

1 byte free sectors.


Filename is "123456.89" padded with spaces to 24 characters.

The period is always in the seventh position. If the filename

is shorter than six characters, it is left-padded with spaces.


The attribute byte is 'F' if a valid file response is being

returned. If the directory is empty or no more files are to be

found, the attribute is '\0'.


The size is two bytes BIG endian. (MSB LSB)

The number of free sectors should be multiplied by 1280 for bytes.

'''

attribute=data[24]

if attribute=='F':

filename=strip(data[0:24])

size=256*ord(data[25])+ord(data[26])

free=ord(data[27])

print "filename:", filename

print "size:", size, "bytes"

print "free sectors:", free, "(", free*1280, "bytes )"

elif attribute=='\0':

print "No more files in the directory."

else:

print "Error! Unknown attribute:", hex(ord(attribute))


elif returntable[rt] == 'normal':

'''

I don't know why some documentation called this "normal" as

only if the data is 0x00 was the condition normal. All other

results specify error codes (as a single byte, easily looked

up in a hash table). Any of the drive commands (except

CONDITION) could potentially return this type of result.

'''

d=ord(data)

if d==0:

print "Success"

else:

print " Error code:", errortable[d] if d in errortable else hex(d)


elif returntable[rt] == 'error':

'''

Undocumented return type. 0x38 is the return type my PDD2

replied with whenever I sent it a 'find' with an invalid

length of data. (E.g., filename not padded to 24 bytes). It

appears to be a variant of the 'normal' return type, as the

length is 1 and the errorcode returned is "parameter error"

(0x36) which makes sense.

'''

d=ord(data)

if d==0:

print "Success"

else:

print " Error code:", errortable[d] if d in errortable else hex(d)


elif returntable[rt] == 'condition':

'''

The "condition" return type is a single byte bitmap of four

flags in the low order bits. Assuming 1 means true, they are:

0b1000 Low battery?

0b0100 Write protected?

0b0010 No disk in drive?

0b0001 Disk changed?

'''

d=ord(data)

i=1<<3

while i:

print "Drive condition:", conditiontable[i][d&i]

i>>=1

else:

print "This should never happen. Unhandled return type:",returntable[rt]


print




def purgereadbuffer():

'''Before writing, we should make sure the drive isn't still

trying to send bytes or our return results will get all

higgledy-piggledy. This can happen if the program miscalculates

the data length or if there is line noise.


Likewise, if we notice something going wrong while reading, we should

clear out the read buffer.'''

oldt=ser.getTimeout()

ser.setTimeout(1)

x=ser.read()

if x!="":

print "Purging excess data from drive: ",

while x != "":

print x, ord(x)

x=ser.read()

ser.setTimeout(oldt)


def checksum(s):

'''Calculate the Tandy Portable Disk Drive checksum. Given the

command string (without the leading "ZZ"), this routine returns

the checksum as a single byte string.'''


return chr((sum(map(ord,s)) % 256) ^ 255)


def iscksumvalid(s, c):

'''Given a string and a checksum as an integer, returns

True if the checksum is valid or False otherwise.'''

return checksum(s)==chr(c)


def makecommand(opcode, data="", mode=None, bank=0):

'''Given an opcode as an integer (and optionally a string of data

and an integer for mode), returns a string ready to be sent over

the serial port to the drive as a message. It does this by

concatenating the preamble ("ZZ"), the opcode, the data length,

the data, the optional mode, and finally the checksum.


Optionally, opcode can be specified as a string, such as "open",

which will be automatically converted to the proper integer.'''


if len(data)>255:

print "Tandy's drive protocol does not handle data longer than 255 bytes."

if type(opcode)==str:

opcode=optable[opcode]


if bank==1: # Second bank available on PDD2

opcode=opcode|0x40



if mode==None:

mode=""

elif type(mode)==str:

mode=chr(modetable[mode])

elif type(mode)==int:

mode=chr(mode)


if (opcode==optable['find'] or opcode==optable['delete']):

attribute="F"

else:

attribute=""


if (opcode==optable['find']):

data=alignfilename(data) # Align to 24chars wide, period at 7.

data=data+attribute+mode

message=chr(opcode)+chr(len(data))+data

preamble="ZZ"

return preamble+message+checksum(message)



def alignfilename(s):

'''Given a filename as a string, do some sanity checks and return

the filename aligned with the period at the 7th position and

padded with spaces to become 24 characters long. As a special case,

if the input is the empty string, a string of 24 spaces is returned.'''


if len(s)>24:

print "Warning: Requested filename is longer than the Portable Disk Drive can handle."


if len(s)>0:

if s.count('.')==1:

# Shift filename over so period is at position 7

s="%6s.%s" % tuple(s.split('.'))

if s.find('.')>6:

print "Warning: Requested filename is longer than Model T's expect."

if len(s)-1-s.find('.')>2:

print "Warning: Requested extension is longer than Model T's expect."

elif s.count('.')==0:

# No period in filename. Is this legal?

print "??? Will find on a filename without a dot work?"

print "??? Or should this program add one for you? FIXME."

else:

print "Warning: Requested filename has more than one period."


# Pad filename to 24 characters

s="%-24s" % s


return s



### MAIN


import argparse


parser=argparse.ArgumentParser(description='Control a Tandy Portable Disk Drive 2 from GNU/Linux')

parser.add_argument('-p', '--port', help='Port to connect to. Examples: /dev/ttyUSB0, ttyS0, S1. Default is last listed.')

parser.add_argument('-b', '--baud', help='Baudrate. Examples: 19200, 9600. Default is 19200.')

parser.add_argument('-t', '--timeout', help='Read timeout in seconds. E.g, 0, 10. (Default is 1).', type=int) # Probably should get rid of this as it's not actually useful.

args=parser.parse_args()



if args.port==None:

port=pickaport()

else:

if args.port=="":

port=pickaport()

elif args.port.startswith('/'):

port=args.port

elif args.port.startswith('tty'):

port="/dev/"+args.port

else:

port="/dev/tty"+args.port


# Should probably check here if port even exists. Ah well.


baud=args.baud if args.baud else 19200

timeout=int(args.timeout) if args.timeout!=None else 1


try:

ser = serial.Serial(port, baud, timeout=timeout, rtscts=True,dsrdtr=True)

print ser


print "Carrier Detect "+str(ser.getCD())

print "Clear to Send "+str(ser.getCTS())

print "Data Set Ready "+str(ser.getDSR())

print "Ring Indicator "+str(ser.getRI())

print "RTS/CTS flow is set to "+str(ser.getRtsCts())

print "DSR/DTR flow is set to "+str(ser.getDsrDtr())



floppytest="ZZ"+chr(7)+chr(7)+chr(11)+chr(12)+"FLOPPY"

# Valid results for FLOPPY test when no disk is in the drive:

# q{ for the one-bank Portable Disk Drive (PDD).

# p| for the two-bank Portable Disk Drive (PDD2).

# Note floppytest is equivalent to makecommand('status',data=chr(11)+chr(12)+"FLOPP")


condition=makecommand('condition')


# Some sample commands from the "PDD Command Reference"

status="ZZ"+chr(7)+chr(0)+chr(248)+chr(13)

seek1="ZZ"+chr(1)+chr(1)+chr(1)+chr(252)

seek2="ZZ"+chr(1)+chr(1)+chr(2)+chr(251)

seek3="ZZ"+chr(1)+chr(1)+chr(3)+chr(250)

dir1="ZZ"+chr(0)+chr(26)+space(24)+"F"+chr(1)+chr(158)

dir2="ZZ"+chr(0)+chr(26)+space(24)+"F"+chr(2)+chr(157)




# Just a bunch of commands to test out the drive.

write(makecommand('condition'))

read()

print "status"

write(makecommand('status'))

read()

# print "format"

# write(makecommand('format'))

# read(timeout=-1)

print "find reference"

write(makecommand('find',data='MISTER.DO',mode='reference'))

read()

print "open for write"

write(makecommand('open',mode='write'))

read()

print "write"

write(makecommand('write',data='Lorem ipsum delorem lemur est.'))

read()

print "close"

write(makecommand('close'))

read()

print "find first file"

write(dir1)

read()

print "open for read"

write(makecommand('open',mode='read'))

read()

print "read"

write(makecommand('read'))

read()

print "find first file"

write(makecommand('find',data='',mode='first'))

read()

print "find next file"

write(makecommand('find',data='',mode='next'))

read()

print "find next file"

write(makecommand('find',data='',mode='next'))

read()


ser.close()


except serial.SerialException as e:

print "pdd2.py: Failed to open" + port

print e