diff --git a/tasmota/berry/drivers/ftp.be b/tasmota/berry/drivers/ftp.be new file mode 100644 index 000000000..7ca279d46 --- /dev/null +++ b/tasmota/berry/drivers/ftp.be @@ -0,0 +1,564 @@ +#- Simple FTP server in Berry by Christian Baars +# supports active and passive mode - but passive is preferred! +# only light error handling +-# + +class PATH # helper class to hold the current directory + var p # path components in a list + + def init() + import string + self.p = [] + end + + def set(p) + import string + import path + + if path.isdir(p) != true + return false + end + + var new = string.split(p,"/") + self.p = [] + for c:new + if c != "" + self.p.push(c) + end + end + return true + end + + def dir_up() + if size(self.p) > 0 + self.p.pop() + end + end + + def get_url() + var url = "/" + for c:self.p + if c != "" + url += f"{c}/" + end + end + return url + end +end + +class FTP : Driver + + var connection, server, client, data_server, data_client, data_ip + var dir, dir_list, dir_pos + var file, file_size, file_rename, retries, chunk_size + var binary_mode, active_ip, active_port, user_input + var data_buf, data_ptr, fast_loop, data_op + static port = 21 + static data_port = 20 # data connection in passive mode + static allow_anonymous = true # allow everything .. + static user = "user" + static password = "pass" + + def init() + self.server = tcpserver(self.port) # connection for control data + self.connection = false + self.data_ip = tasmota.wifi()['ip'] + self.dir = PATH() + self.readDir() + self.data_ptr = 0 + self.active_port = nil + tasmota.add_driver(self) + log(f"FTP: init server on port {self.port}",1) + end + + def deinit() + self.server.deinit() + self.data_server.deinit() + tasmota.remove_driver(self) + end + + def every_50ms() + if self.connection == true + self.loop() + elif self.server.hasclient() + self.client = self.server.acceptasync() + self.sendResponse("220 Welcome") + self.connection = true + self.pubClientInfo() + else + self.connection = false + end + end + + def every_second() + if self.client && self.connection != false + if self.client.connected() == false + self.pubClientInfo() + self.connection = false + self.abortDataOp() + end + end + end + + def pubClientInfo() + import mqtt + var payload = self.client.info().tostring() + mqtt.publish("FTP",format("{'server':%s}", payload)) + end + + def loop() + if self.connection == true + self.handleConnection() + end + end + + def abortDataOp() + if self.data_op == "d" + self.finishDownload(true) + elif self.data_op == "u" + self.finishUpload(true) + elif self.data_op == "dir" + self.finishUpload(false) + end + end + + def download() # ESP -> client + self.data_buf..self.file.readbytes(self.chunk_size) + if size(self.data_buf) == 0 + self.retries -= 1 + if self.retries > 0 + return + end + else + var written = self.data_client.write(self.data_buf) + self.data_buf.clear() + self.data_ptr += written + if self.data_ptr < self.file_size + self.file.seek(self.data_ptr) + if self.retries > 0 + return + end + end + end + self.finishDownload() + end + + def finishDownload(error) + self.data_client.close() + tasmota.remove_fast_loop(self.fast_loop) + self.file.close() + if error + self.sendResponse(f"426 Connection closed; transfer aborted after {self.data_ptr} bytes.") + else + self.sendResponse(f"250 download done with {self.data_ptr} bytes.") + end + self.data_op = nil + tasmota.gc() + end + + def upload() # client -> ESP + self.data_buf..self.data_client.readbytes() + + if size(self.data_buf) > 0 + self.file.write(self.data_buf) + self.data_ptr += size(self.data_buf) + self.data_buf.clear() + else + log(f"FTP: {self.retries} retries",4) + self.retries -= 1 + if self.retries > 0 + return + end + self.finishUpload() + end + end + + def finishUpload(error) + self.data_client.close() + tasmota.remove_fast_loop(self.fast_loop) + self.file.close() + if error + self.sendResponse(f"426 Connection closed; transfer after {self.data_ptr} bytes") + else + self.sendResponse(f"250 upload done with {self.data_ptr} bytes") + end + self.data_op = nil + tasmota.gc() + end + + def transferDir(mode) + import path + var sz, date, isdir + var i = self.dir_list[self.dir_pos] + var url = f"{self.dir.get_url()}{i}" + isdir = path.isdir(url) + if isdir == false + var f = open(url,"r") + sz = f.size() + f.close() + date = path.last_modified(url) + end + if self.data_client.connected() + var dir = "" + if mode == "MLSD" + if isdir + dir = "Type=dir;Perm=edlmp; " + else + date = tasmota.time_dump(date) + var y = str(date['year']) + var m = f"{date['month']:02s}" + var d = f"{date['day']:02s}" + var h = f"{date['hour']:02s}" + var min = f"{date['min']:02s}" + var sec = f"{date['sec']:02s}" + var modif =f"{y}{m}{d}{h}{min}{sec}" + dir = f"Type=file;Perm=rwd;Modify={modif};Size={sz}; " + end + elif mode == "LIST" + var d = "-" + if isdir + d = "d" + date = "" + sz = "" + else + date = tasmota.strftime("%b %d %H:%M", date) + end + dir = f"{d}rw------- 1 all all{sz:14s} {date} " + + elif mode == "NLST" + dir=self.dir.get_url() + end + var entry = f"{dir}{i}" + log(entry,4) + self.data_client.write(entry + "\r\n") + self.dir_pos += 1 + else + self.finishTransferDir(false) + end + if self.dir_pos < size(self.dir_list) + return + end + self.finishTransferDir(true) + end + + def finishTransferDir(success) + self.data_client.close() + if success + var n = size(self.dir_list) + self.sendResponse(f"226 {n} files in {self.dir.get_url()}") + else + self.sendResponse("426 Transfer aborted") + end + self.data_op = nil + tasmota.remove_fast_loop(self.fast_loop) + tasmota.gc() + end + + def readDir() + import path + self.dir_list = path.listdir(self.dir.get_url()) + end + + def openFile(name,mode) + import path + var url = f"{self.dir.get_url()}{name}" + if path.isdir(url) == true + log(f"FTP: {url} is a folder",2) + return false + end + if mode == "r" + if path.exists(url) != true + log(f"FTP: {url} not found",2) + return false + end + end + log(f"FTP: Open file {url} in {mode} mode",3) + self.file = open(f"{url}",mode) + if mode == "a" + if self.data_ptr != 0 + log(f"FTP: Appending file {url} at position {self.data_ptr}",3) + if self.data_ptr != self.file.size() + log(f"FTP: !!! resume position of {self.data_ptr} != file size of {self.file.size()} !!!",2) + end + end + end + return true + end + + def close() + self.sendResponse("221 Closing connection") + self.connection = false + end + + + def deinitConnectServer() + if self.data_server != nil + self.data_server.close() + self.data_server.deinit() + self.data_server = nil + log("FTP: Delete server for passive data connection",2) + end + end + + def initConnectServer() + if self.data_server == nil + self.data_server = tcpserver(self.data_port) + log("FTP: Start server for passive data connection",2) + end + end + + def connectActive() + self.data_client = tcpclientasync() + if self.data_client.connect(self.active_ip,self.active_port) != false + log(f"FTP: Try to connect to {self.active_ip}:{self.active_port}",3) + end + end + + def connectPassive() + if self.data_server.hasclient() + self.data_client = self.data_server.acceptasync() + end + end + + def dataconnect() + if self.data_client != nil + self.data_client.close() + self.data_client.deinit() + end + if self.active_port != nil + self.connectActive() + else + self.connectPassive() + end + if self.data_client == nil + self.sendResponse("425 Data connection failed") + return false + end + self.data_buf = bytes() + self.retries = 10 + self.chunk_size = 5760 + self.sendResponse("150 Ready for data transfer") + return true + end + + def sendResponse(resp) + self.client.write(f"{resp}\r\n") + log(f"FTP: Response: {resp}",3) + end + + def handleConnection() # main loop for incoming commands + import string + import mqtt + import path + var d = self.client.read() + if size(d) == 0 return end + var items = string.split(d," ") + var cmd = items[0] + var arg = "" + var response = "" + if size(items) > 1 + arg = string.split(items[1],'\r\n')[0] + else + cmd = string.split(cmd,'\r\n')[0] + end + + log(f"FTP: Received: {cmd} {arg}",3) + + # connect + if cmd == "USER" + if self.allow_anonymous + response = "230 accept any/anonymous user" + else + self.user_input = arg + response = "331 Password required" + end + elif cmd == "PASS" + if self.user_input == self.user && arg == self.password + response = "230 User accepted" + else + response = "530 Wrong login credentials" + mqtt.publish("FTP","{'login':'wrong credentials'}") + end + elif cmd == "AUTH" + response = f"500 Server does not support {arg}" + elif cmd == "ABOR" + self.abortDataOp() + response = f"200 Aborting" + elif cmd == "QUIT" + self.close() + #options + elif cmd == "FEAT" + self.sendResponse("211-Extensions supported:") + self.sendResponse(" MLSD") + self.sendResponse(" EPSV") + self.sendResponse(" SIZE") + # self.sendResponse(" MDTM") + self.sendResponse(" REST STREAM") + response = "211 End" + elif cmd == "OPTS" + if arg == "UTF8" + response = "200 UTF Ok" + else + response = f"500 Server does not support {arg}" + end + elif cmd == "STRU" + if arg == "F" + response = "200 F Ok" + else + response = "504 Only F (ile) is supported" + end + elif cmd == "SYST" + response = "215 UNIX" + elif cmd == "LPRT" + response = f"501 active connection with long address not supported" + elif cmd == "PORT" + var el = string.split(arg,",") + self.active_ip = f"{el[0]}.{el[1]}.{el[2]}.{el[3]}" + self.active_port = int(el[4])*256 + int(el[5]) + response = f"200 port received {self.active_ip}:{self.active_port}" + self.deinitConnectServer() + # response = f"501 active connection not supported" + elif cmd == "EPRT" + var el = string.split(arg,"|") # |1|192.168.1.54|65519| -> 1 IPV4, 2 IPV6 + self.active_ip = el[2] + self.active_port = int(el[3]) + self.deinitConnectServer() + response = f"200 extended port received {self.active_ip}:{self.active_port}" + elif cmd == "TYPE" + if arg == "I" + response = "200 binary mode" + self.binary_mode = true + elif arg == "A" + response = "200 ascii mode" + self.binary_mode = false + end + elif cmd == "EPSV" + self.active_port = nil + self.initConnectServer() + response = f"229 Entering Extended Passive Mode (|||{self.data_port}|)" + elif cmd == "PASV" + self.active_port = nil + var el = string.split(self.data_ip,".") + var hi = self.data_port >> 8 + var lo = self.data_port & 0xff + self.initConnectServer() + response = f"227 Entering passive mode ({el[0]},{el[1]},{el[2]},{el[3]},{hi},{lo})" + elif cmd == "DELE" + if path.remove(f"{self.dir.get_url()}{arg}") + response = f"250 {self.dir.get_url()}{arg} deleted" + else + response = f"550 Could not delete file {self.dir.get_url()}{arg}" + end + elif cmd == "RMD" + var url = arg + if arg[0] != "/" + url = f"{self.dir.get_url()}{arg}" + end + if path.rmdir(url) + response = f"250 {url} deleted" + else + response = f"550 Could not delete folder {url}" + end + elif cmd == "STOR" + self.dataconnect() + if self.data_client != nil + response = "" + var mode = "w" + if self.data_ptr > 0 + mode = "a" + end + if self.openFile(arg,mode) + self.data_op = "u" + self.fast_loop = /->self.upload() + tasmota.add_fast_loop(self.fast_loop) + else + response = f"550 Could not open file" + end + else + response = f"501 Could not init data connection" + end + elif cmd == "REST" + self.data_ptr = int(arg) + response = f"350 {self.data_ptr}" + elif cmd == "RNFR" + if self.openFile(arg,"r") + self.file_rename = f"{self.dir.get_url()}{arg}" + response = f"350 {arg}" + self.file.close() + else + self.file_rename = nil + response = f"550 Could not open file" + end + elif cmd == "RNTO" + if self.file_rename != nil + tasmota.cmd(f"UfsRename {self.file_rename},{self.dir.get_url()}{arg}") + response = f"250 Renamed {self.file_rename} -> {arg}" + else + response = f"550 Could not rename file" + end + self.file_rename = nil + elif cmd == "SIZE" + if self.openFile(arg,"r") + response = f"213 {self.file.size()}" + self.file.close() + else + response = f"550 Could not open file" + end + elif cmd == "RETR" + self.dataconnect() + if self.data_client != nil + if self.openFile(arg,"r") + self.file_size = self.file.size() + self.data_op = "d" + self.fast_loop = /->self.download() + tasmota.add_fast_loop(self.fast_loop) + else + response = f"550 Could not open file" + end + else + response = f"501 Could not init data connection" + end + # folder + elif cmd == "CDUP" + self.dir.dir_up() + response = "250 okay" + elif cmd == "CWD" + if self.dir.set(arg) + response = "250 okay" + else + response = "550 Failed to change directory." + end + elif cmd == "PWD" + self.readDir() + response = f"250 {self.dir.get_url()}" + elif cmd == "MKD" + path.mkdir(f"{self.dir.get_url()}{arg}") + response = f"250 {self.dir.get_url()}{arg} created" + elif cmd == "LIST" || cmd == "MLSD" || cmd == "NLST" + if arg != "" + self.dir.set(arg) + end + self.readDir() + if self.dataconnect() + if size(self.dir_list) > 0 + self.data_op = "dir" + self.dir_pos = 0 + self.fast_loop = /->self.transferDir(cmd) + tasmota.add_fast_loop(self.fast_loop) + else + self.finishTransferDir(true) + end + else + response = f"501 Could not init data connection" + end + else # any unknown command + response = "202 Command not implemented in Berry FTP" + end + + if response != "" + self.sendResponse(response) + end + end +end + +var ftp = FTP()