summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--AEpy/AECmds.py321
-rw-r--r--AEpy/AEFuse.py459
-rw-r--r--AEpy/AELink.py36
-rw-r--r--AEpy/AEMultipart.py94
-rw-r--r--AEpy/AESession.py96
-rw-r--r--AEpy/FuseCache.py66
-rw-r--r--AEpy/FusePatches.py53
-rw-r--r--AEpy/__init__.py0
-rw-r--r--COPYING339
-rw-r--r--README.md91
-rw-r--r--ae-protocol.md206
-rwxr-xr-xfuse-aexplorer.py42
13 files changed, 1804 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..15584ca
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+.pyc
diff --git a/AEpy/AECmds.py b/AEpy/AECmds.py
new file mode 100644
index 0000000..a9ccd99
--- /dev/null
+++ b/AEpy/AECmds.py
@@ -0,0 +1,321 @@
+import binascii
+import serial
+from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IXGRP, S_IROTH, S_IXOTH, S_IFDIR, S_IFREG
+import struct
+
+from AESession import AESession
+import AEMultipart
+
+
+###################################
+#
+# All paths are without leading /
+# All paths are in Amiga charset.
+#
+###################################
+
+
+# Command 0x01
+# Cancel a running operation.
+#
+def cancel(session):
+ session.sendMsg(1, b'')
+
+
+
+# Command 0x64
+# List a directory.
+#
+def dir_list(session, path):
+ print("AECmds.dir_list: " + path)
+
+ session.sendMsg(0x64, path + b'\x00\x01')
+
+ dirlist = AEMultipart.recv(session)
+ # TODO: Check for None returned, meaning that the path doesn't exist.
+ # Actually, what can we return then?
+ # None is not iterable, and [] indicates an empty directory.
+ close(session)
+
+
+ # Parse dirlist
+
+ (numents,) = struct.unpack('!L', dirlist[0:4])
+ dirlist = dirlist[4:]
+
+ kids = {}
+ #kids['.'] = (dict(st_mode=(S_IFDIR | 0o755), st_nlink=2), {})
+ #kids['..'] = (dict(st_mode=(S_IFDIR | 0o755), st_nlink=2), {})
+
+ while numents > 0:
+ assert len(dirlist) > 29
+ (entlen, size, used, type, attrs, mdays, mmins, mticks, type2) = struct.unpack('!LLLHHLLLB', dirlist[0:29])
+ # entlen - Size of this entry in the list, in bytes
+ # size - Size of file.
+ # For drives, total space.
+ # used - 0 for files and folders.
+ # For drives, space used.
+ # type - ?
+ # attrs - Amiga attributes
+ # mdays - Last modification in days since 1978-01-01
+ # mmins - Last modification in minutes since mdays
+ # mticks - Last modification in ticks (1/50 sec) since mmins
+ # type2 - 0: Volume
+ # - 1: Volume
+ # - 2: Directory
+ # - 3: File
+ # - 4: ROM
+ # - 5: ADF
+ # - 6: HDF
+ assert entlen <= len(dirlist)
+
+ name = dirlist[29:entlen].split(b'\x00')[0].decode(encoding="iso8859-1")
+
+ dirlist = dirlist[entlen:]
+ numents -= 1
+
+ st = {}
+ st = {}
+ st['st_mode'] = 0
+ #if attrs & 0x40: st['st_mode'] |= Script
+ #if attrs & 0x20: st['st_mode'] |= Pure
+ #if attrs & 0x10: st['st_mode'] |= Archive
+ if not attrs & 0x08: st['st_mode'] |= S_IRUSR | S_IRGRP | S_IROTH # Read
+ if not attrs & 0x04: st['st_mode'] |= S_IWUSR # Write
+ if not attrs & 0x02: st['st_mode'] |= S_IXUSR | S_IXGRP | S_IXOTH # Execute
+ #if not attrs & 0x01: st['st_mode'] |= Delete
+
+ # Check for directory
+ if type & 0x8000:
+ st['st_mode'] |= S_IFDIR
+ else:
+ st['st_mode'] |= S_IFREG
+ st['st_nlink'] = 1
+
+ st['st_size'] = size
+
+ st['st_mtime'] = (2922*24*60*60) # Amiga epoch starts 1978-01-01
+ st['st_mtime'] += (mdays*24*60*60) # Days since 1978-01-01
+ st['st_mtime'] += (mmins*60) # Minutes
+ st['st_mtime'] += (mticks/50) # Ticks of 1/50 sec
+
+ st['st_blksize'] = session.packetsize - 4
+
+ # TODO: Convert time zone.
+ # Amiga time seems to be local time, we currently treat it as UTC.
+
+ kids[name] = (st, None)
+
+ return kids
+
+
+
+# Command 0x65
+# Read a file.
+#
+def file_read_start(session, path):
+ print("AECmds.file_read_start: " + path)
+
+ # Request file
+ session.sendMsg(0x65, path + b'\x00')
+
+ # Get file response header
+ (filelen, pktsize) = AEMultipart.recvHeader(session)
+
+ # TODO: Check for None returned, meaning that the path doesn't exist.
+ # This may not be necessary in case FUSE always issues a getattr()
+ # beforehand.
+
+ return filelen
+
+
+def file_read_next(session):
+ (blockOffset, blockData) = AEMultipart.recvBlock(session)
+
+ if (blockData == None):
+ blen = None
+ else:
+ blen = len(blockData)
+
+ print("AECmds.file_read_next: " + str(blen) + ' @ ' + str(blockOffset))
+
+ return (blockOffset, blockData)
+
+
+
+# Command 0x66
+# Write a complete file and set its attributes and time.
+#
+def file_write(session, path, amigaattrs, unixtime, filebody):
+ print('AECmds.file_write: ' + path + ' -- ' + str(len(filebody)))
+
+ filelen = len(filebody)
+
+ _utime = unixtime - (2922*24*60*60) # Amiga epoch starts 1978-01-01
+ mdays = int(_utime / (24*60*60)) # Days since 1978-01-01
+ _utime -= mdays * (24*60*60)
+ mmins = int(_utime / 60) # Minutes
+ _utime -= mmins * 60
+ mticks = _utime * 50 # Ticks of 1/50 sec
+
+ filetype = 3 # Regular file
+
+ data = struct.pack('!LLLLLLLB',
+ 29 + len(path) + 6, # Length of this message, really
+ filelen,
+ 0,
+ amigaattrs,
+ mdays,
+ mmins,
+ mticks,
+ filetype)
+ data += path
+ data += b'\0\0\0\0\0\0' # No idea what this is for
+ session.sendMsg(0x66, data)
+
+ # TODO: Handle target FS full
+ # TODO: Handle permission denied on target FS
+
+ AEMultipart.send(session, filebody)
+ close(session)
+
+
+
+# Command 0x67
+# Delete a path.
+# Can be a file or folder, and will be deleted recursively.
+#
+def delete(session, path):
+ print("AECmds.delete: " + path)
+
+ session.sendMsg(0x67, path + b'\x00')
+
+ (type, _) = session.recvMsg()
+
+ close(session)
+
+ # type == 0 means the file was deleted successfully
+ return (type == 0)
+
+
+
+# Command 0x68
+# Rename a file, leaving it in the same folder.
+#
+def rename(session, path, new_name):
+ print("AECmds.rename: " + path + ' - ' + new_name)
+ session.sendMsg(0x68, path + b'\x00' + new_name + b'\x00')
+ (type, _) = session.recvMsg()
+
+ if type != 0:
+ print("AECmds.rename: Response type " + str(type))
+
+ close(session)
+
+ # Assume that type == 0 means the file was renamed successfully
+ return (type == 0)
+
+
+
+# Command 0x69
+# Move a file from a folder to a different one, keeping its name.
+#
+def move(session, path, new_parent):
+ print("AECmds.move: " + path + ' - ' + new_parent)
+ session.sendMsg(0x69, path + b'\x00' + new_parent + b'\x00\xc9')
+ (type, _) = session.recvMsg()
+
+ if type != 0:
+ print("AECmds.move: Response type " + str(type))
+
+ close(session)
+
+ # Assume that type == 0 means the file was moved successfully
+ return (type == 0)
+
+
+
+# Command 0x6a
+# Copy a file.
+#
+def copy(session, path, new_parent):
+ print("AECmds.copy: " + path + ' - ' + new_parent)
+ session.sendMsg(0x6a, path + b'\x00' + new_parent + b'\x00\xc9')
+ (type, _) = session.recvMsg()
+
+ if type != 0:
+ print("AECmds.copy: Response type " + str(type))
+
+ close(session)
+
+ # Assume that type == 0 means the file was copied successfully
+ return (type == 0)
+
+
+
+# Command 0x6b
+# Set attributes and comment.
+#
+def setattr(session, amigaattrs, path, comment):
+ print("AECmds.setattr: " + str(amigaattrs) + ' - ' + path + ' - ' + comment)
+
+ data = struct.pack('!L', amigaattrs)
+ data += path + b'\x00'
+ data += comment + b'\x00'
+ data += struct.pack('!L', 0)
+
+ session.sendMsg(0x6b, data)
+ (type, _) = session.recvMsg()
+
+ if type != 0:
+ print("AECmds.setattr: Response type " + str(type))
+
+ close(session)
+
+ # Assume that type == 0 means the file was copied successfully
+ return (type == 0)
+
+
+
+# Command 0x6c
+# Request (?)
+#
+
+
+
+# Command 0x6d
+# Finish a running operation.
+#
+def close(session):
+ print('AECmds.close')
+
+ session.sendMsg(0x6d, b'')
+
+ (type, data) = session.recvMsg()
+ assert type == 0x0a
+ if data != b'\0\0\0\0\0':
+ print('AECmds.close: data == ' + binascii.hexlify(data))
+ # Format of data returned:
+ # Byte 0: ?
+ # Byte 1: ?
+ # Byte 2: ?
+ # Byte 3: 00 - No error
+ # 06 - File no longer exists
+ # 1c - Timed out waiting for host to request next read block
+ # Byte 4: Path in case of error 06.
+ # Empty (null-terminated) otherwise?
+ # Null-terminated string.
+ #assert data == b'\0\0\0\0\0'
+
+
+
+# Command 0x6e
+# Format a disk.
+#
+
+
+
+# Command 0x6f
+# Create a new folder and matching .info file.
+# This folder will be named "Unnamed1".
+#
diff --git a/AEpy/AEFuse.py b/AEpy/AEFuse.py
new file mode 100644
index 0000000..fea6037
--- /dev/null
+++ b/AEpy/AEFuse.py
@@ -0,0 +1,459 @@
+import binascii
+from errno import ENOENT, EINVAL, EIO, EROFS
+from fusepy import FUSE, FuseOSError, Operations
+from stat import S_IRUSR, S_IWUSR, S_IXUSR, S_IRGRP, S_IROTH, S_IXGRP, S_IXOTH, S_IFDIR, S_IFREG
+import struct
+import sys
+from time import time
+
+from AELink import AELinkSerial
+from AESession import AESession
+import AEMultipart
+import AECmds
+from FuseCache import FuseCache
+import FusePatches
+
+
+amiga_charset = 'iso8859-1'
+
+
+
+
+
+
+
+
+class AEFuse(Operations):
+ def __init__(self, session):
+ self.session = session
+
+ self.cache = FuseCache()
+
+ self.readpath = None
+ self.readbuf = None
+ self.readmax = None # Total size of the file being read
+
+ self.writepath = None
+ self.writebuf = None
+ self.writemtime = None
+ self.writeattrs = None
+
+
+ def getattr(self, path, fh=None):
+ print('AEFuse.getattr: ' + path)
+
+ dirpath = path[0:path.rfind('/')] # Without the trailing slash
+ name = path[path.rfind('/') + 1:] # Without a leading slash
+
+ st = self.cache.getattr(path)
+
+ if not st:
+ # If we have a cache miss, see if we've cached its parent
+ # directory.
+ #
+ # If not, then try to read the parent directory.
+ #
+ # Note that accessing a deep path will cause Linux to stat its
+ # parents recursively, which in turn will cause self.getattr()
+ # to call self.readdir() on them, filling all cache levels in
+ # between.
+ #
+ # https://sourceforge.net/p/fuse/mailman/message/19717106/
+ #
+ if self.cache.getkids(dirpath) == None:
+ # We haven't read the directory yet
+ if name in self.readdir(dirpath, fh):
+ # If the dirlist succeeded, try again.
+ #
+ # Note that we're never asking the host for a deep path
+ # that doesn't exist, since Linux will stat all parents,
+ # thus listing directories at every level until the first
+ # one that doesn't exist (see above).
+ #
+ # As a bonus, this automatically updates the cache if the
+ # path now exists.
+ return self.getattr(path, fh)
+
+ # Okay, the path doesn't exist.
+ # That means our file can't exist.
+ print('getaddr ENOENT: ' + path)
+ raise FuseOSError(ENOENT)
+
+ return st
+
+
+ def readdir(self, path, fh):
+ print("AEFuse.readdir: " + path)
+
+ self._cancel_read()
+ self._flush_write()
+
+ # Request directory listing
+ kids = AECmds.dir_list(self.session, path[1:].encode(amiga_charset))
+
+ # Update cache
+ self.cache.setkids(path, kids)
+
+ #return [(name, self.cache[name], 0) for name in self.cache.keys()]
+ return kids.keys()
+
+
+ def read(self, path, size, offset, fh):
+ print("AEFuse.read: " + path + ' -- ' + str(size) + ' @ ' + str(offset))
+
+ # Avoid inconsistency.
+ # If we don't do this, then reading the file while it's open
+ # returns something different from what is in the write buffer.
+ self._flush_write()
+
+ if self.readpath != path:
+ if self.readpath != None:
+ self._cancel_read()
+
+ self.readpath = path
+ self.readbuf = b''
+
+ # Request file
+ self.readmax = AECmds.file_read_start(self.session, path[1:].encode(amiga_charset))
+
+ # TODO: Check for None returned, meaning that the path doesn't exist.
+
+ if (offset + size > len(self.readbuf)) \
+ and (offset < self.readmax) \
+ and (len(self.readbuf) < self.readmax):
+ # Get further file contents if we need it.
+ while len(self.readbuf) < min(offset + size, self.readmax):
+ (blockOffset, blockData) = AECmds.file_read_next(self.session)
+
+ if blockData == None:
+ # Since we only enter this loop if there should be data
+ # left to read, a None returned means an unexpected EOF
+ # or more probably, the connection timed out.
+ #
+ # We operate under the illusion that we can keep the last
+ # accessed file open wherever we were at, however after a
+ # few seconds the host will close the file without us
+ # knowing. In case that happens, let's reload the file.
+ print('Unexpected error while reading next file block.')
+ print('Re-requesting file.')
+
+ # If we cancel the transfer ourselves, then the reply to
+ # sendClose() will be 0x00 0x00 0x00 0x1c 0x00.
+ #
+ # Strange: If we cancel in the same situation, but without
+ # having received the type 1 cancel message from the host,
+ # the reply to sendClose() is normal zeroes.
+ self._cancel_read()
+ return self.read(path, size, offset, fh)
+
+ assert blockOffset == len(self.readbuf)
+ self.readbuf += blockData
+
+ # When done transferring a whole file, the Amiga side insists on
+ # sending its end-of-file type 4 message. So let's request it.
+ if len(self.readbuf) == self.readmax:
+ (blockOffset, blockData) = AECmds.file_read_next(self.session)
+ assert blockData == None
+
+ print("read: " + path + ' -- finished with len(self.readbuf) == ' + str(len(self.readbuf)))
+ return self.readbuf[offset:offset + size]
+
+
+ def _cancel_read(self):
+ # Cancel current read operation
+ if self.readpath != None:
+ if (len(self.readbuf) < self.readmax):
+ print('_cancel_read: Cancelling.')
+ AECmds.cancel(self.session)
+
+ AECmds.close(self.session)
+
+ self.readbuf = None
+ self.readpath = None
+
+
+ def open(self, path, flags):
+ print("AEFuse.open: " + path)
+
+ # Dummy function to allow file access.
+
+ return 0
+
+
+ def _prep_write(self, path, trunclen):
+ # We can't write in the virtual top level directory
+ if len(path) < 4:
+ raise FuseOSError(ENOENT)
+
+ self._cancel_read()
+
+ if self.writepath != path:
+ self._flush_write()
+
+ # If the file already exists, read its previous contents.
+ # Check cache, since FUSE/VFS will have filled it.
+ st = self.cache.getattr(path)
+ if st != None and st['st_size'] > 0:
+ if trunclen != None:
+ if trunclen > 0:
+ self.writebuf = self.read(path, trunclen, 0, None)
+ else:
+ self.writebuf = self.read(path, st['st_size'], 0, None)
+ self._cancel_read()
+
+ self.writepath = path
+ self.writeattrs = 0
+ if self.writebuf == None:
+ self.writebuf = b''
+
+
+ def create(self, path, mode, fi=None):
+ print("AEFuse.create: " + path)
+
+ # We can't write in the virtual top level directory
+ if len(path) < 4:
+ raise FuseOSError(ENOENT)
+
+ # We can't create a file over an existing one.
+ # By now, the system will have primed our cache, so we won't ask
+ # the big fuseop self.getattr(). That wouldn't work anyway, since
+ # it raises a FuseOSError rather than returning None.
+ assert self.cache.getattr(path) == None
+ if self.writepath != None:
+ assert path != self.writepath
+
+ # Create a dummy file, so the path exists
+ AECmds.file_write(self.session, path[1:].encode(amiga_charset), 0, time(), b'')
+
+ # Refresh cache so a subsequent getattr() succeeds
+ dirpath = path[0:path.rfind('/')]
+ self.readdir(dirpath, None)
+
+ return 0
+
+
+ def truncate(self, path, length, fh=None):
+ print("AEFuse.truncate: " + path + ' -- ' + str(length))
+
+ self._prep_write(path, length)
+
+ self.writebuf = self.writebuf[:length]
+ if length > len(self.writebuf):
+ # Fill up
+ self.writebuf = self.writebuf[:len(self.writebuf)] + (b'\0' * (length - len(self.writebuf)))
+
+ self.writemtime = time()
+
+ return 0
+
+
+
+ # WARNING: Due to buffering, we will NOT be able to report
+ # a full disk as a response to the write call!
+ def write(self, path, data, offset, fh):
+ print("AEFuse.write: " + path + ' -- ' + str(len(data)) + ' @ ' + str(offset))
+
+ self._prep_write(path, None)
+
+ if offset > len(self.writebuf):
+ # Fill up
+ self.writebuf = self.writebuf[:len(self.writebuf)] + (b'\0' * (offset - len(self.writebuf)))
+
+ self.writebuf = self.writebuf[:offset] + data + self.writebuf[offset+len(data):]
+ self.writemtime = time()
+
+ # WARNING!
+ # We cannot check for a full disk here, so we're
+ # just assuming that buffering is fine.
+ return len(data)
+
+
+ def utimens(self, path, (atime, mtime)):
+ print("AEFuse.utimens: " + path + ' -- ' + str(mtime))
+
+ # We can only set the time when sending a complete file.
+ if self.writepath == path:
+ self.writemtime = mtime
+ else:
+ raise FuseOSError(EROFS)
+
+
+ def _flush_write(self):
+ if self.writepath == None:
+ return
+
+ # If this fails, there is nothing we can do.
+ # Except returning an error on close().
+ # But honestly, who checks for that?
+ AECmds.file_write(self.session,
+ self.writepath[1:].encode(amiga_charset), self.writeattrs,
+ self.writemtime, self.writebuf)
+
+ # Extract dirpath before we throw away self.writepath
+ dirpath = self.writepath[0:self.writepath.rfind('/')]
+
+ # Throw away the write buffer once done, so we don't cache full files.
+ self.writepath = None
+ self.writebuf = None
+ self.writemtime = None
+ self.writeattrs = None
+
+ # Refresh cache so subsequent accesses are correct
+ self.readdir(dirpath, None)
+
+
+ # Called on close()
+ def flush(self, path, fh):
+ print("AEFuse.flush: " + path)
+
+ # Flush any remaining write buffer
+ self._flush_write()
+
+ print("AEFuse.flush: finished: " + path)
+
+
+ def unlink(self, path):
+ print("AEFuse.unlink: " + path)
+
+ # TODO: Handle file that is currently open for reading/writing
+
+ self.session.sendMsg(0x67, path[1:].encode(amiga_charset) + b'\x00')
+
+ (type, _) = self.session.recvMsg()
+
+ self.session.sendClose()
+
+ # Refresh cache so a subsequent getattr() is up to date.
+ # We refresh even in case the delete failed, because it
+ # indicates our cache being out of date.
+ dirpath = path[0:path.rfind('/')]
+ self.readdir(dirpath, None)
+
+ if type == 0:
+ return 0 # File deleted successfully
+ else:
+ raise FuseOSError(EIO)
+
+
+ def rmdir(self, path):
+ # TODO: Refuse if the directory is not empty
+ return self.unlink(path)
+
+
+ def ae_mkdir(self, path):
+ self._cancel_read()
+
+ amigaattrs = 0
+
+ _utime = time() - (2922*24*60*60) # Amiga epoch starts 1978-01-01
+ mdays = int(_utime / (24*60*60)) # Days since 1978-01-01
+ _utime -= mdays * (24*60*60)
+ mmins = int(_utime / 60) # Minutes
+ _utime -= mmins * 60
+ mticks = _utime * 50 # Ticks of 1/50 sec
+
+ filetype = 2 # Folder
+
+ data = struct.pack('!LLLLLLLB',
+ 29 + len(path) + 6, # Length of this message, really
+ 0,
+ 0,
+ amigaattrs,
+ mdays,
+ mmins,
+ mticks,
+ filetype)
+ data += path.encode(amiga_charset)
+ data += b'\0\0\0\0\0\0' # No idea what this is for
+ self.session.sendMsg(0x66, data)
+
+ # TODO: Handle target FS full
+ # TODO: Handle permission denied on target FS
+
+ self.session.sendClose()
+
+
+ def mkdir(self, path, mode):
+ print('AEFuse.mkdir: ' + path)
+
+ self.ae_mkdir(path[1:].encode(amiga_charset))
+
+ # Refresh cache so a subsequent getattr() succeeds
+ dirpath = path[0:path.rfind('/')]
+ self.readdir(dirpath, None)
+
+
+ def rename(self, old, new):
+ print('AEFuse.rename: ' + old + ' - ' + new)
+ dirpath_old = old[0:old.rfind('/')]
+ dirpath_new = new[0:new.rfind('/')]
+ filename_old = old[old.rfind('/') + 1:]
+ filename_new = new[new.rfind('/') + 1:]
+
+ # If the file already exists, delete it.
+ # Check cache, since FUSE/VFS will have filled it.
+ st = self.cache.getattr(new)
+ if st != None:
+ self.unlink(new)
+
+ if dirpath_new == dirpath_old:
+ AECmds.rename(self.session,
+ old[1:].encode(amiga_charset),
+ filename_new.encode(amiga_charset))
+ else:
+ # Move in 3 steps:
+ # 1. Rename file to a temporary name
+ # (NOTE: We hope it doesn't exist!)
+ # 2. Move the file to the new folder
+ # 3. Rename the file to the target file name
+ # The reason for this is that the old file name may exist in
+ # the new directory, and the new file name may exist in the
+ # old directory. Thus, if we were to do only one renaming,
+ # either order of rename+move or move+rename could be
+ # problematic.
+
+ assert (self.cache.getattr(dirpath_old + '/_fatemp_') == None)
+ assert (self.cache.getattr(dirpath_new + '/_fatemp_') == None)
+
+ AECmds.rename(self.session,
+ old[1:].encode(amiga_charset),
+ '_fatemp_'.encode(amiga_charset))
+ AECmds.move(self.session,
+ (dirpath_old + '/_fatemp_')[1:].encode(amiga_charset),
+ dirpath_new[1:].encode(amiga_charset))
+ AECmds.rename(self.session,
+ (dirpath_new + '/_fatemp_')[1:].encode(amiga_charset),
+ filename_new.encode(amiga_charset))
+
+ # Refresh cache so a subsequent getattr() succeeds
+ self.readdir(dirpath_old, None)
+ self.readdir(dirpath_new, None)
+
+
+ def chmod(self, path, mode):
+ print("AEFuse.chmod: " + path + ' -- ' + str(mode))
+
+ amigaattrs = 0
+ # Apparently we don't have to worry about directory flags
+ if not mode & S_IRUSR: amigaattrs |= 0x08
+ if not mode & S_IWUSR: amigaattrs |= 0x04
+ if not mode & S_IXUSR: amigaattrs |= 0x02
+
+ AECmds.setattr(self.session,
+ amigaattrs,
+ path[1:].encode(amiga_charset),
+ '')
+
+
+ def chown(self, path, uid, gid):
+ # AmigaOS does not know users/groups.
+ raise FuseOSError(EROFS)
+
+
+ # Called on umount
+ def destroy(self, path):
+ print('AEFuse.destroy: ' + path)
+
+ # Flush any remaining write buffer
+ self._cancel_read()
+ self._flush_write()
diff --git a/AEpy/AELink.py b/AEpy/AELink.py
new file mode 100644
index 0000000..e75f3fb
--- /dev/null
+++ b/AEpy/AELink.py
@@ -0,0 +1,36 @@
+import binascii
+import serial
+import struct
+import traceback
+
+
+class AELink:
+ def __init__(self):
+ return
+
+
+
+
+
+class AELinkSerial(AELink):
+ def __init__(self, path, speed):
+ self.ser = serial.Serial(path, speed, timeout=5, rtscts=1)
+ assert self.ser.name == path
+
+ AELink.__init__(self)
+
+ def recv(self, rxlen):
+ assert self.ser.is_open
+ buf = self.ser.read(rxlen)
+ #print("Received: " + binascii.hexlify(buf))
+ return buf
+
+ def send(self, data):
+ assert self.ser.is_open
+ #print("Sending: " + binascii.hexlify(data))
+ #traceback.print_stack()
+ return self.ser.write(data)
+
+ def close(self):
+ assert self.ser.is_open
+ self.ser.close()
diff --git a/AEpy/AEMultipart.py b/AEpy/AEMultipart.py
new file mode 100644
index 0000000..a542bfa
--- /dev/null
+++ b/AEpy/AEMultipart.py
@@ -0,0 +1,94 @@
+import struct
+
+
+
+def recvHeader(session):
+ (type, data) = session.recvMsg()
+ # TODO: Check for type 1, as that means an error.
+ # For example, a directory can't be listed because it doesn't exist.
+ assert type == 3
+ assert len(data) == 8
+
+ (datalen, pktsize) = struct.unpack('!LL', data)
+
+ return (datalen, pktsize)
+
+
+def recvBlock(session):
+ # Send empty type 0 message to request next block
+ session.sendMsg(0, b'')
+
+ (type, data) = session.recvMsg()
+
+ if type == 1:
+ print('recvMultipartBlock: Host cancelled transfer.')
+ # Maybe TODO: Raise an exception
+ return (None, None)
+
+ if type == 4:
+ # Last block
+ assert len(data) == 0
+ return (None, None)
+
+ # Expect a data block
+ assert type == 5
+
+ # Any data block has the offset prepended,
+ # followed by up to PACKETSIZE-4 bytes of payload.
+ assert len(data) >= 4
+
+ (offset,) = struct.unpack('!L', data[0:4])
+
+ return (offset, data[4:])
+
+
+def recv(session):
+ fullData = b''
+
+ (datalen, pktsize) = recvHeader(session)
+
+ offset = 0
+ data = b''
+ while data != None:
+ assert offset == len(fullData)
+ fullData += data
+
+ (offset, data) = recvBlock(session)
+
+ return fullData
+
+
+def send(session, fullData):
+ # Wait for Amiga to request first block with type 0
+ (type, _) = session.recvMsg()
+
+ # TODO: Handle transfer abort (type 1)
+ # TODO: What happens when sending an ADF != 901120 bytes?
+ assert (type == 0) or (type == 8)
+
+ if type == 8:
+ # Does this confirm that we want to overwrite?
+ session.sendMsg(0, b'')
+
+ # Send length and block size
+ session.sendMsg(3, struct.pack('!LL', len(fullData), session.packetsize))
+
+ bytesSent = 0
+ while bytesSent < len(fullData):
+ # Wait for Amiga to request block with type 0
+ (type, _) = session.recvMsg()
+ assert type == 0
+
+ print("AEMultipart.send: " + str(bytesSent))
+
+ txlen = min(session.packetsize - 4, len(fullData) - bytesSent)
+ session.sendMsg(5, struct.pack('!L', bytesSent) + fullData[bytesSent : bytesSent+txlen])
+
+ bytesSent += txlen
+
+ # Wait for Amiga's type 0
+ (type, _) = session.recvMsg()
+ assert type == 0
+
+ # Finish transfer
+ session.sendMsg(4, b'')
diff --git a/AEpy/AESession.py b/AEpy/AESession.py
new file mode 100644
index 0000000..145fff7
--- /dev/null
+++ b/AEpy/AESession.py
@@ -0,0 +1,96 @@
+import binascii
+import serial
+import struct
+
+
+class AESession:
+ def __init__(self, link):
+ self.link = link
+ self.seq = 1
+ self.sendInit()
+ self.packetsize = 512
+
+
+ def recvMsg(self):
+ rawHeader = self.link.recv(12)
+ if len(rawHeader) != 12:
+ print('recvMsg: len(rawHeader) == ' + str(len(rawHeader)) + ' data: ' + binascii.hexlify(rawHeader))
+ #assert len(rawHeader) == 12
+
+ (type, datalen, seq, crc) = struct.unpack('!HHLL', rawHeader)
+ assert crc == (binascii.crc32(rawHeader[0:8]) & 0xffffffff)
+ # TODO: Send PkRs if CRC mismatches
+
+ data = b''
+
+ if datalen > 0:
+ data = self.link.recv(datalen)
+ assert len(data) == datalen
+
+ # Note: Python calculates signed CRCs.
+ # Thus, we parse the incoming CRC as signed.
+ datacrc = self.link.recv(4)
+ assert len(datacrc) == 4
+ (datacrc,) = struct.unpack('!L', datacrc)
+
+ assert datacrc == (binascii.crc32(data) & 0xffffffff)
+ # TODO: Send PkRs if CRC mismatches
+
+ self.link.send(b'PkOk')
+
+ # seq is currently ignored when receiving
+ return (type, data)
+
+
+ def sendMsg(self, type, data):
+ stream = struct.pack('!HHL', type, len(data), self.seq)
+ stream += struct.pack('!L', binascii.crc32(stream) & 0xffffffff)
+
+ if len(data) > 0:
+ stream += data
+ stream += struct.pack('!L', binascii.crc32(data) & 0xffffffff)
+
+ self.link.send(stream)
+
+ # TODO: Re-send on 'PkRs'
+ assert self.link.recv(4) == b'PkOk'
+
+ self.seq += 1;
+
+
+
+ def sendInit(self):
+ self.sendMsg(2, b'Cloanto(r)')
+ # TBD: Password support.
+ # It is CRC32'd and the CRC is appended to the string above.
+
+ # TODO: Recover from desynced state
+ #if not self.isAcked():
+ # self.sendAck()
+ # return self.sendInit()
+
+ (type, data) = self.recvMsg()
+ assert type == 2
+ assert data.startswith(b'Cloanto')
+
+
+ def sendClose(self):
+ print('sendClose')
+
+ self.sendMsg(0x6d, b'')
+
+ (type, data) = self.recvMsg()
+ assert type == 0x0a
+ if data != b'\0\0\0\0\0':
+ print('sendClose: data == ' + binascii.hexlify(data))
+ # Format of data returned:
+ # Byte 0: ?
+ # Byte 1: ?
+ # Byte 2: ?
+ # Byte 3: 00 - No error
+ # 06 - File no longer exists
+ # 1c - Timed out waiting for host to request next read block
+ # Byte 4: Path in case of error 06.
+ # Empty (null-terminated) otherwise?
+ # Null-terminated string.
+ #assert data == b'\0\0\0\0\0'
diff --git a/AEpy/FuseCache.py b/AEpy/FuseCache.py
new file mode 100644
index 0000000..420b992
--- /dev/null
+++ b/AEpy/FuseCache.py
@@ -0,0 +1,66 @@
+from stat import S_IFDIR
+
+
+class FuseCache():
+ def __init__(self):
+ # Initialize virtual root
+ self.cache = {}
+
+ # Cache tuple:
+ # (dict of attrs, dict of children in folder)
+
+
+ def getattr(self, path):
+ paths = [p for p in path.split('/') if len(p) > 0]
+ #print(paths)
+
+ # Virtual root
+ st = dict(st_mode=(S_IFDIR | 0o755), st_nlink=2)
+
+ c = self.cache
+ for p in paths:
+ if c == None:
+ return None
+
+ if p not in c:
+ return None
+
+ (st, c) = c[p]
+
+ return st
+
+
+ def getkids(self, path):
+ paths = [p for p in path.split('/') if len(p) > 0]
+
+ c = self.cache
+ for p in paths:
+ if c == None:
+ return None
+
+ if p not in c:
+ return None
+
+ (_, c) = c[p]
+
+ return c
+
+
+ # Set a cache node's children and their attributes.
+ # This implicitly purges any prvious children from the cache.
+ # Thus, those directories will be re-scanned next time.
+ def setkids(self, path, children):
+ paths = [p for p in path.split('/') if len(p) > 0]
+
+ c = self.cache
+ for p in paths:
+ # We expect to be able to walk the path, because we can't get
+ # there without Linux' VFS stat'ing all directories leading up.
+ parent = c
+ (_, c) = c[p]
+
+ if (c == self.cache):
+ self.cache = children
+ else:
+ (st, _) = parent[p]
+ parent[p] = (st, children)
diff --git a/AEpy/FusePatches.py b/AEpy/FusePatches.py
new file mode 100644
index 0000000..f02ddd2
--- /dev/null
+++ b/AEpy/FusePatches.py
@@ -0,0 +1,53 @@
+# Ugly patches for fusepy to accommodate inflexible userspace applications.
+#
+
+from ctypes import c_uint, cast, POINTER, Structure
+from fusepy import FUSE
+
+
+class fuse_conn_info(Structure):
+ _fields_ = [
+ ('proto_major', c_uint),
+ ('proto_minor', c_uint),
+ ('async_read', c_uint),
+ ('max_write', c_uint),
+ ('max_readahead', c_uint),
+ ('capable', c_uint),
+ ('want', c_uint),
+ ('max_background', c_uint),
+ ('congestion_threshold', c_uint)
+ ]
+
+
+# Ugly patch for fusepy to allow changing max_readahead.
+#
+# This is necessary for FUSE filesystems which need to avoid unnecessary
+# data transfers.
+#
+# Example:
+# If a GIO (as in GLib/GNOME's GIO) based file manager (e.g. XFCE's Thunar)
+# tries to determine each file's type by inspecting its header (GLib 2.50
+# reads up to 4 KB of each file), this results in a lot of traffic. If the
+# kernel increases this to 16 KB as part of its buffered I/O's readahead,
+# things become very slow on exotically slow links or host media.
+# In fact, this patch has been written with a 2 KByte/s link in mind.
+#
+# Why not turn off this header-sniffing feature in userspace?
+# Because... Neither Thunar nor GLib/GIO have this option, and exotic FUSE
+# filesystems still need to work on them until they have a mechanism to turn
+# this off. Preferably with a hint in stat() or statfs().
+#
+# NOTE:
+# The kernel will impose a lower bound on max_readahead.
+# As of 4.9 on x86-64, this is 4096 bytes.
+# (still a lot on a 2 KB/s link, but Thunar won't be *completely* useless)
+#
+def patch_max_readahead(max_readahead):
+ old_init = FUSE.init
+
+ def _new_fuse_init(self, conn):
+ conn2 = cast(conn, POINTER(fuse_conn_info))
+ conn2.contents.max_readahead = max_readahead
+ return old_init(self, conn)
+
+ FUSE.init = _new_fuse_init
diff --git a/AEpy/__init__.py b/AEpy/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/AEpy/__init__.py
diff --git a/COPYING b/COPYING
new file mode 100644
index 0000000..d159169
--- /dev/null
+++ b/COPYING
@@ -0,0 +1,339 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, write to the Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ <signature of Ty Coon>, 1 April 1989
+ Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d6775bd
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+fuse-aexplorer
+===============
+
+A crude re-implementation of the Amiga Explorer protocol, for Linux/FUSE.
+
+All knowledge of the protocol is either based on Mark Street's lxamiga or
+[lxamiga.pl](https://github.com/marksmanuk/lxamiga) or gained by
+monitoring communication between the original Windows version of
+Amiga Explorer and the Amiga side.
+
+This software is highly experimental and use is fully AT YOUR OWN RISK.
+Please don't blame me or Cloanto or anyone if anything breaks.
+
+If you experience any issues (and that's almost guaranteed), please use
+[Cloanto's official Windows software](https://www.amigaforever.com/ae/)
+instead. It's a great, stable software package.
+
+
+Usage:
+-------
+
+./fuse-aexplorer /mnt/amiga /dev/ttyUSB0 19200
+
+cp -a myfile "/mnt/amiga/RAM\:Ram\ Disk/"
+
+
+
+
+DATA LOSS WARNING
+==================
+
+Comments and attributes without an equivalence in Linux are lost
+whenever a write operation (including changing attributes) is
+performed on a file.
+
+I'm sure there's more, but I've forgotten about it.
+
+
+
+Things that are broken
+=======================
+
+A lot. The Amiga Explorer protocol really isn't meant to be used as
+a "network file system".
+
+Examples:
+
+ - Reading only a part of a file isn't possible.
+ There is a hack to only read the start of it, thus making Thunar
+ and other GIO programs not get stuck completely.
+
+ - Changing a part of a file or appending data isn't possible - it
+ has to be rewritten from scratch.
+ We're buffering a file and send it when flush() is called.
+
+ - Setting a file's modification date can only be done when
+ writing it from scratch.
+ Use 'cp -a' to copy your files.
+
+ - Files and folders being deleted on the Amiga while we know that they
+ exist (i.e. their attributes are cached) causes undefined behavior.
+
+ - And many more hacks!
+ Really, go use the original software instead!
+
+
+
+TODO
+=====
+
+Search the code for TODO and NOTE.
+
+:-)
+
+
+
+Thanks
+=======
+
+Cloanto for Amiga Explorer. Great stuff and a life saver.
+
+Mark Street for lxamiga and
+[lxamiga.pl](https://github.com/marksmanuk/lxamiga) - this gave me a
+head start in understanding the protocol.
+
+
+
+License
+========
+
+GNU General Public License v2 only.
diff --git a/ae-protocol.md b/ae-protocol.md
new file mode 100644
index 0000000..91bd860
--- /dev/null
+++ b/ae-protocol.md
@@ -0,0 +1,206 @@
+Message types
+==============
+
+ Msg ID | Sending side | Description
+ --------|--------------|----------------------------------
+ 00 | Ami/PC | Ask for next block
+ 01 | Ami/PC | Transfer cancelled
+ 02 | Ami/PC | Initialisation / Init response
+ 03 | Ami/? | Multipart header
+ 04 | Amiga | EOF (no payload)
+ 05 | Amiga | Next data block
+
+ 08 | Amiga | File already exists (when trying to write with 0x66)
+ 09 | Amiga | Size ? (response to 0x6c)
+ 0a | Amiga | Close response
+ 0b | Amiga | Format response?
+
+ 64 | PC | List directory
+ 65 | PC | File read
+ 66 | PC | File/folder write
+ 67 | PC | File/folder delete (recursively)
+ 68 | PC | File rename (name changes) (works on drives, too?)
+ 69 | PC | File move (path changes)
+ 6a | PC | File copy
+ 6b | PC | Set attributes and comment
+ 6c | PC | Request size on disk (?)
+ 6d | PC | Close file
+ 6e | PC | Format disk (needs Kickstart 2.0 or newer)
+ 6f | PC | New folder
+
+
+
+
+Request details
+================
+
+64 - List a directory
+----------------------
+
+### TODO ###
+
+
+65 - Read a file
+-----------------
+
+### TODO ###
+
+
+66 - Write a file
+------------------
+
+### TODO ###
+
+
+67 - Delete file/folder
+------------------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ n | Path
+ 1 | 0x00
+
+Then, read type 0 for confirmation.
+Then, sendClose() (0xa response: 5x 00).
+
+If Path is a folder, it will be deleted together with its contents.
+
+
+68 - Rename file/folder
+------------------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ n | Path (including old file name)
+ 1 | 0x00
+ n | New file name (without path)
+ 1 | 0x00
+
+Then, read type 0 for confirmation.
+Then, sendClose() (0xa response: 5x 00).
+
+
+69 - Move file/folder
+----------------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ n | Path (including old file name)
+ 1 | 0x00
+ n | New path to contain file (folder without trailing slash or file name)
+ 1 | 0x00
+ 1 | 0xc9 (?)
+
+Then, read type 0 for confirmation.
+Then, sendClose() (0xa response: 5x 00).
+
+If Path is a folder, it will be moved together with its contents.
+This command appears to work across devices.
+
+
+6a - Copy file/folder
+----------------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ n | Path (including old file name)
+ 1 | 0x00
+ n | New path to contain file (folder without trailing slash or file name)
+ 1 | 0x00
+ 1 | 0xc9 (?)
+
+Then, read type 0 for confirmation.
+Then, sendClose() (0xa response: 5x 00).
+
+If Path is a folder, it will be moved together with its contents.
+This command appears to work across devices.
+
+
+6b - Set attributes and comment
+--------------------------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ 4 | Attributes
+ n | Path
+ 1 | 0x00
+ n | Comment
+ 1 | 0x00
+ 4 | Checksum? (seems to be 0x00000000 if comment empty)
+
+Then, read type 0 for confirmation.
+Then, sendClose() (0xa response: 5x 00).
+
+
+6c - Request size on disk (?)
+------------------------------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ n | Path
+ 1 | 0x00
+
+Then, read type 0 for confirmation.
+Then, read type 9 for 12 bytes of payload (### TODO ###).
+Then, send type 0 to request more data (no payload).
+Then, read type 9 for 12 bytes of payload (### TODO ###).
+Then, send type 0 to request more data (no payload).
+Then, read type 0 signaling end of attributes (no payload).
+Then, sendClose() (0xa response: 5x 00).
+
+OR
+
+Then, read type 0 for confirmation.
+Then, read type 0 signaling end of attributes (no payload).
+Then, sendClose() (0xa response: 5x 00).
+
+
+
+6d - Close file
+----------------
+
+No payload.
+
+Then, read type 0x0a for confirmation (typical payload: 5 bytes of 0x00).
+
+This is used to finish an operation, such as a directory listing
+or renaming a file.
+
+
+6e - Format disk
+-----------------
+
+### TODO ###
+
+
+6f - New folder
+----------------
+
+Payload:
+
+ Bytes | Content
+ -------|--------------------
+ n | Parent path
+ 1 | 0x00
+
+Then, read type 0 for confirmation.
+Then, sendClose() (0xa response: 5x 00).
+
+The host will create a new folder in the given path, together with a
+matching .info file.
+The folder name cannot be chosen, and will be something like "Unnamed1".
+
+To create a folder with a specific name, use 0x66.
+Note that 0x66'ing a folder does not seem to set its time.
diff --git a/fuse-aexplorer.py b/fuse-aexplorer.py
new file mode 100755
index 0000000..41c8593
--- /dev/null
+++ b/fuse-aexplorer.py
@@ -0,0 +1,42 @@
+#!/usr/bin/env python
+#
+# A FUSE filesystem to access an Amiga's files via Cloanto's Amiga Explorer.
+#
+# Known limitations:
+# See README.
+#
+# SPDX-License-Identifier: GPL-2.0
+#
+
+
+from fusepy import FUSE
+import sys
+
+from AEpy.AELink import AELinkSerial
+from AEpy.AESession import AESession
+from AEpy.AEFuse import AEFuse
+from AEpy import FusePatches
+
+
+
+
+
+if __name__ == '__main__':
+ # Monkey patch fusepy
+ FusePatches.patch_max_readahead(512)
+
+ serspeed = 19200
+
+ if len(sys.argv) - 1 < 2:
+ print('Only ' + str(len(sys.argv) - 1) + ' arguments given.')
+ print('Usage: ' + sys.argv[0] + ' <mount point> <serial device> [serial speed]')
+
+ sys.exit(-1)
+
+ if len(sys.argv) - 1 > 2:
+ serspeed = int(sys.argv[3])
+
+ link = AELinkSerial(sys.argv[2], serspeed)
+ session = AESession(link)
+ fuse = AEFuse(session)
+ FUSE(fuse, sys.argv[1], nothreads=True, foreground=True)