From d3da193e3972d7e1bb17f9fa8133f820078e7578 Mon Sep 17 00:00:00 2001 From: s-hadinger <49731213+s-hadinger@users.noreply.github.com> Date: Wed, 8 Jan 2025 22:53:31 +0100 Subject: [PATCH] WS2812 real-time Leds panel as Berry app (#22788) --- CHANGELOG.md | 1 + tasmota/berry/leds_panel/leds_panel.be | 964 ++++++++++++++++++ tasmota/berry/leds_panel/leds_panel.bec | Bin 0 -> 13805 bytes tasmota/my_user_config.h | 2 + .../xdrv_52_0_berry_struct.ino | 13 + .../tasmota_xdrv_driver/xdrv_52_9_berry.ino | 6 + 6 files changed, 986 insertions(+) create mode 100644 tasmota/berry/leds_panel/leds_panel.be create mode 100644 tasmota/berry/leds_panel/leds_panel.bec diff --git a/CHANGELOG.md b/CHANGELOG.md index 31eafdda3..8af4e0a94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ All notable changes to this project will be documented in this file. - ESP32 expand `Pixels` with reverse, height and alternate (#22755) - Berry add light_pixels values to `tasmota.settings` (#22762) - Berry add `bytes().appendhex()` (#22767) +- WS2812 real-time Leds panel as Berry app ### Breaking Changed diff --git a/tasmota/berry/leds_panel/leds_panel.be b/tasmota/berry/leds_panel/leds_panel.be new file mode 100644 index 000000000..f0e0b2aff --- /dev/null +++ b/tasmota/berry/leds_panel/leds_panel.be @@ -0,0 +1,964 @@ +# +# leds_panel.be - implements a real-time mirroring of the WS2812 leds on the main page +# +# Copyright (C) 2023 Stephan Hadinger & Theo Arends +# +# 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 3 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, see . +# + +# make sure we use `webserver_async` if it's already solidified +if !global.contains("webserver_async") || type(global.webserver_async) != 'class' + class webserver_async + ############################################################# + # class webserver_async_cnx + # + # This instance represents an active connection between + # the server and a client (TCP connection) + ############################################################# + static class webserver_async_cnx + var server # link to server object + var cnx # holds the tcpclientasync instance + var close_after_send # if true, close after we finished sending the out_buffer + var fastloop_cb # cb for fastloop + var buf_in # incoming buffer + var buf_in_offset + var buf_out + var phase # parsing phase: 0/ status line, 1/ headers, 2/ payload + # request + var req_verb # verb for request (we support only GET) + var req_uri # URI for request + var req_version # HTTP version for request + var header_host # 'Host' header - useful for redirections + # response + var resp_headers # (string) aggregate headers + var chunked # if true enable chunked encoding (default true) + # conversion + static var CODE_TO_STRING = { + # 100: "Continue", + 200: "OK", + # 204: "No Content", + 301: "Moved Permanently", + # 400: "Bad Request", + # 401: "Unauthorized", + # 403: "Payment Required", # not sure it's useful in Tasmota context + 404: "Not Found", + 500: "Internal Server Error", + # 501: "Not Implemented" + } + + ############################################################# + # init + # + # Called when a new connection is received from a client + # Arg: + # - server : main instance of `webserver_async` server + # - cnx : instance of `tcpclientasync` + # + # By default: + # version is HTTP/1.1 + # response is chunked-encoded + def init(server, cnx) + self.server = server + self.cnx = cnx + self.buf_in = '' + self.buf_in_offset = 0 + self.buf_out = bytes() + self.phase = 0 # 0 = status line + # util + self.close_after_send = false + # response + self.resp_headers = '' + self.chunked = true + # register cb + self.fastloop_cb = def () self.loop() end # the closure needs to be kept, to allow removal of fast_loop later + tasmota.add_fast_loop(self.fastloop_cb) + end + + ############################################################# + # set_chunked: sets whether the response is chunked encoded + # true by default + # + def set_chunked(chunked) + self.chunked = bool(chunked) + end + + ############################################################# + # connected: returns `true` if the connection is still open + # + def connected() + return self.cnx ? self.cnx.connected() : false + end + + ############################################################# + # buf_out_empty: returns `true` if out buffer is empty, + # i.e. all content was sent to client + # + def buf_out_empty() + return size(self.buf_out) == 0 + end + + ############################################################# + # _write: (internal method) write bytes + # + # Arg: + # v must be bytes() + # + def _write(v) + var sz_v = size(v) + if (sz_v == 0) return end # do nothing if empty content + + var buf_out = self.buf_out # keep a copy of reference in local variable (avoids multiple dereferencing) + var buf_out_sz = size(buf_out) + buf_out.resize(buf_out_sz + sz_v) + buf_out.setbytes(buf_out_sz, v) + + self._send() # try sending `self.buf_out` now + end + + ############################################################# + # close: close the connection to client + # + # Can be called multiple times + # Does nothing if connection is already closed + # + def close() + # log(f"WEB: closing cnx", 3) + if (self.cnx != nil) self.cnx.close() end + self.cnx = nil + end + + ############################################################# + # loop: called by fastloop every 5 ms + # + def loop() + if self.cnx == nil # if connection is closed, this instance is marked for deletion + tasmota.remove_fast_loop(self.fastloop_cb) # remove from fast_loop + self.fastloop_cb = nil # fastloop_cb can be garbage collected + return + end + + self._send() # try sending any pending output data + + var cnx = self.cnx # keep copy + if (cnx == nil) return end # it's possible that it was closed after _send() + + # any new incoming data received? + if cnx.available() > 0 + var buf_in_new = cnx.read() # read bytes() object + if (!self.buf_in) # use the same instance if none present + self.buf_in = buf_in_new + else # or append to current incoming buffer + self.buf_in += buf_in_new + end + end + + # parse incoming data if any + if (self.buf_in) + self.parse() + end + end + + ############################################################# + # _send: (internal method) try sending pendin data out + # + # the content is in `self.buf_out` + # + def _send() + # any data waiting to go out? + var cnx = self.cnx + if (cnx == nil) return end # abort if connection is closed + + var buf_out = self.buf_out # keep reference in local variable + if size(buf_out) > 0 + if cnx.listening() # is the client ready to receive? + var sent = cnx.write(buf_out) # send the buffer, `sent` contains the number of bytes actually sent + if sent > 0 # did we succeed in sending anything? + # we did sent something + if sent >= size(buf_out) # the entire buffer was sent, clear it + # all sent + self.buf_out.clear() + else # buffer was sent partially, remove what was sent from `out_buf` + # remove the first bytes already sent + self.buf_out.setbytes(0, buf_out, sent) # copy to index 0 (start of buffer), content from the same buffer starting at offset 'sent' + self.buf_out.resize(size(buf_out) - sent) # shrink buffer + end + end + end + else + # empty buffer, do the cleaning + # self.buf_out.clear() # TODO not needed? + # self.buf_in_offset = 0 # TODO really useful? + + if self.close_after_send # close connection if we have sent everything + self.close() + end + end + end + + ############################################################# + # parse incoming + # + # pre: self.buf_in is not empty + # post: self.buf_in has made progress (smaller or '') + def parse() + # log(f"WEB: incoming {bytes().fromstring(self.buf_in).tohex()}", 3) + if self.phase == 0 + self.parse_http_req_line() + elif self.phase == 1 + self.parse_http_headers() + elif self.phase == 2 + self.parse_http_payload() + end + end + + ############################################################# + # parse incoming request + # + # pre: self.buf_in is not empty + # post: self.buf_in has made progress (smaller or '') + def parse_http_req_line() + var m = global._re_http_srv.match2(self.buf_in, self.buf_in_offset) + # Ex: "GET / HTTP/1.1\r\n" + if m + var offset = m[0] + self.req_verb = m[1] # GET/POST... + self.req_uri = m[2] # / + self.req_version = m[3] # "1.0" or "1.1" + self.phase = 1 # proceed to parsing headers + self.buf_in = self.buf_in[offset .. ] # remove what we parsed + if tasmota.loglevel(4) + log(f"WEB: HTTP verb: {self.req_verb} URI: '{self.req_uri}' Version:{self.req_version}", 4) + end + self.parse_http_headers() + elif size(self.buf_in) > 100 # if no match and we still have 100 bytes, then it fails + log("WEB: error invalid request", 4) + self.close() + self.buf_in = '' + end + end + + ############################################################# + # parse incoming headers + def parse_http_headers() + while true + # print("parse_http_headers", "self.buf_in_offset=", self.buf_in_offset) + var m = global._re_http_srv_header.match2(self.buf_in, self.buf_in_offset) + # print("m=", m) + # Ex: [32, 'Content-Type', 'application/json'] + if m + self.event_http_header(m[1], m[2]) + self.buf_in_offset += m[0] + else # no more headers + var m2 = global._re_http_srv_body.match2(self.buf_in, self.buf_in_offset) + if m2 + # end of headers + # we keep \r\n which is used by pattern + self.buf_in = self.buf_in[self.buf_in_offset + m2[0] .. ] # truncate + self.buf_in_offset = 0 + + # self.event_http_headers_end() # no more headers + self.phase = 2 + self.parse_http_payload() # continue to parsing payload + end + if size(self.buf_in) > 1024 # we don't accept a single header larger than 1KB + log("WEB: error header is bigger than 1KB", 4) + self.close() + self.buf_in = '' + end + return + end + end + + end + + ############################################################# + # event_http_header: method called for each header received + # + # Default implementation only stores "Host" header + # and ignores all other headers + # + # Args: + # header_key: string + # header_value: string + # + def event_http_header(header_key, header_value) + # log(f"WEB: header key '{header_key}' = '{header_value}'") + + if (header_key == "Host") + self.header_host = header_value + end + end + + ############################################################# + # event_http_headers_end: called afte all headers are received + # + # By default does nothing + # + # def event_http_headers_end() + # end + + ############################################################# + # parse incoming payload (if any) + # + # Calls the server's dispatcher with 'verb' and 'uri' + # + # Payload is in `self.buf_in` + # + def parse_http_payload() + # log(f"WEB: parsing payload '{bytes().fromstring(self.buf_in).tohex()}'") + # dispatch request before parsing payload + self.server.dispatch(self, self.req_uri, self.req_verb) + end + + + ############################################################# + # Responses + ############################################################# + # send_header: add header to the response + # + # Args: + # name: key of header + # value: value of header + # first: if 'true' prepend, or append if 'false' + def send_header(name, value, first) + if first + self.resp_headers = f"{name}: {value}\r\n{self.resp_headers}" + else + self.resp_headers = f"{self.resp_headers}{name}: {value}\r\n" + end + end + + ############################################################# + # send: send response to client + # + # Args + # code: (int) http code (ex: 200) + # content_type: (string, opt) MIME type, "text/html" if not specified + # content: (bytes or string, opt) first content to send to client (you can send more later) + # + def send(code, content_type, content) + var response = f"HTTP/1.1 {code} {self.CODE_TO_STRING.find(code, 'UNKNOWN')}\r\n" + self.send_header("Content-Type", content_type ? content_type : "text/html", true) + + self.send_header("Accept-Ranges", "none") + # chunked encoding? + if self.chunked + self.send_header("Transfer-Encoding", "chunked") + end + # cors? + if self.server.cors + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "*") + self.send_header("Access-Control-Allow-Headers", "*") + end + # others + self.send_header("Connection", "close") + + response += self.resp_headers + response += "\r\n" + self.resp_headers = nil + + # send status-line and headers + self.write_raw(response) + + # send first part of content + if (content) self.write(content) end + end + + ############################################################# + # write: writes a bytes or string piece of content + # + # If chunked encoding is enabled, it is sent as a separate chunk + # + # If content is empty, it can be sent as an empty chunk + # which is an indicator of end-of-content + # + def write(v) + if type(v) == 'string' # if string, convert to bytes + v = bytes().fromstring(v) + end + + # use chunk encoding + if self.chunked + var p1 = self.server.p1 + p1.clear() + p1.append(f"{size(v):X}\r\n") + p1.append(v) + p1.append("\r\n") + + # log(f"WEB: sending chunk '{p1.tohex()}'") + self._write(p1) + else + self._write(v) + end + end + + ############################################################# + # write_raw: low-level write of string or bytes (without chunk encoding) + # + # If content is empty, nothing is sent + # + def write_raw(v) + if (size(v) == 0) return end + + if type(v) == 'string' # if string, convert to bytes + v = bytes().fromstring(v) + end + + self._write(v) + end + + ############################################################# + # content_stop: signal that the response is finished + # + def content_stop() + self.write('') # send 'end-of-content' for chunked encoding + self.close_after_send = true # close connection when everything was sent to client + end + end + + ####################################################################### + # class webserver_async_dispatcher + # + # Pre-register callbacks triggered when a certain URL is accessed + # + # You can register either a pure function or a method and an instance + # + # Pure function: + # webserver_async_dispatcher(uri_prefix, nil, func, verb) + # will call: + # func(cnx, uri, verb) + # + # Instance and method: + # webserver_async_dispatcher(uri_prefix, instance, method, verb) + # will call: + # insatnce.method(cnx, uri, verb) + # + # Args in: + # uri_prefix: prefix string for matchin URI, must start with '/' + # cb_obj: 'nil' for pure function or instance from which we call a method + # cb_mth: pure function or method to call + # verb: verb to match, only supported: 'GET' or 'nil' for any + # + # Args of callback: + # cnx: instance of 'webserver_async_cnx' for the current connection + # uri: full uri of request + # verb: verb received (currently only GET is supported) + ####################################################################### + static class webserver_async_dispatcher + var uri_prefix # prefix string, must start with '/' + var verb # verb to match, or nil for ANY + var cb_obj # callback object (sent as first argument if not 'nil') + var cb_mth # callback function + + def init(uri_prefix, cb_obj, cb_mth, verb) + self.uri_prefix = uri_prefix + self.cb_obj = cb_obj + self.cb_mth = cb_mth + self.verb = verb + end + + # return true if matched + def dispatch(cnx, uri, verb) + import string + if string.find(uri, self.uri_prefix) == 0 + var match = false + if (self.verb == nil) || (self.verb == verb) + # method is valid + var cb_obj = self.cb_obj + if (cb_obj != nil) + self.cb_mth(self.cb_obj, cnx, uri, verb) + else + self.cb_mth(cnx, uri, verb) + end + return true + end + end + return false + end + end + + ############################################################# + # class webserver_async + # + # This is the main class to call + ############################################################# + var local_port # listening port, 80 is already used by Tasmota + var server # instance of `tcpserver` + var fastloop_cb # closure used by fastloop + # var timeout # default timeout for tcp connection + var connections # list of active connections + # var auth # web authentication string (Basic Auth) or `nil`, in format `user:password` as bade64 + # var cmd # GET url command + var dispatchers + # copied in each connection + var chunked # if true enable chunked encoding (default true) + var cors # if true send CORS headers (default false) + # + var p1 # temporary object bytes() to avoid reallocation + + # static var TIMEOUT = 1000 # default timeout: 1000ms + # static var HTTP_REQ = "^(\\w+) (\\S+) HTTP\\/(\\d\\.\\d)\r\n" + # static var HTTP_HEADER_REGEX = "([A-Za-z0-9-]+): (.*?)\r\n" # extract a header with its 2 parts + # static var HTTP_BODY_REGEX = "\r\n" # end of headers + + ############################################################# + # init + def init(port, timeout) + # if (timeout == nil) timeout = self.TIMEOUT end + # if (timeout == nil) timeout = 1000 end + self.connections = [] + self.dispatchers = [] + self.server = tcpserver(port) # throws an exception if port is not available + self.chunked = true + self.cors = false + self.p1 = bytes(100) # reserve 100 bytes by default + # TODO what about max_clients ? + self.compile_re() + # register cb + tasmota.add_driver(self) + self.fastloop_cb = def () self.loop() end + tasmota.add_fast_loop(self.fastloop_cb) + end + + ############################################################# + # compile once for all the regex + def compile_re() + import re + if !global.contains("_re_http_srv") + # global._re_http_srv = re.compile(self.HTTP_REQ) + # global._re_http_srv_header = re.compile(self.HTTP_HEADER_REGEX) + # global._re_http_srv_body = re.compile(self.HTTP_BODY_REGEX) + global._re_http_srv = re.compile("^(\\w+) (\\S+) HTTP\\/(\\d\\.\\d)\r\n") + global._re_http_srv_header = re.compile("([A-Za-z0-9-]+): (.*?)\r\n") + global._re_http_srv_body = re.compile("\r\n") + end + end + + ############################################################# + # enable or disable chunked mode (enabled by default) + def set_chunked(chunked) + self.chunked = bool(chunked) + end + + ############################################################# + # enable or disable CORS mode (enabled by default) + def set_cors(cors) + self.cors = bool(cors) + end + + ############################################################# + # Helper function to encode integer as hex (uppercase) + static def bytes_format_hex(b, i, default) + b.clear() + if (i == nil) b .. default return end + # sanity check + if (i < 0) i = -i end + if (i < 0) return end # special case for MININT + if (i == 0) b.resize(1) b[0] = 0x30 return end # return bytes("30") + + b.resize(8) + var len = 0 + while i > 0 + var digit = i & 0x0F + if (digit < 10) + b[len] = 0x30 + digit + else + b[len] = 0x37 + digit # 0x37 = 0x41 ('A') - 10 + end + len += 1 + i = (i >> 4) + end + # reverse order + b.resize(len) + b.reverse() + end + + ############################################################# + # Helper function to encode integer as int + static def bytes_append_int(b, i, default) + var sz = size(b) + if (i == 0) # just append '0' + b.resize(sz + 1) + b[sz] = 0x30 + elif (i != nil) # we have a non-zero value + var negative = false + # sanity check + if (i < 0) i = -i negative = true end + if (i < 0) return b end # special case for MININT + + if negative + b.resize(sz + 1) + b[sz] = 0x2D + sz += 1 + end + + var start = sz + while i > 0 + var digit = i % 10 + b.resize(sz + 1) + b[sz] = 0x30 + digit + sz += 1 + i = (i / 10) + end + # reverse order starting where the integer is + b.reverse(start) + + else # i is `nil`, append default + b.append(default) + end + return b + end + + ############################################################# + # closing web server + def close() + tasmota.remove_driver(self) + tasmota.remove_fast_loop(self.fastloop_cb) + self.fastloop_cb = nil + self.server.close() + + # close all active connections + for cnx: self.connections + cnx.close() + end + self.connections = nil # and free memory + end + + ############################################################# + # clean connections + # + # Remove any connections that is closed or in error + def clean_connections() + var idx = 0 + while idx < size(self.connections) + var cnx = self.connections[idx] + # remove if not connected + if !cnx.connected() + # log("WEB: does not appear to be connected") + cnx.close() + self.connections.remove(idx) + else + idx += 1 + end + end + end + + ############################################################# + # called by fastloop + def loop() + self.clean_connections() + # check if any incoming connection + while self.server.hasclient() + # retrieve new client + var cnx = self.webserver_async_cnx(self, self.server.acceptasync()) + cnx.set_chunked(self.chunked) + self.connections.push(cnx) + end + end + + ############################################################# + # add to dispatcher + def on(prefix, obj, mth, verb) + var dispatcher = self.webserver_async_dispatcher(prefix, obj, mth, verb) + self.dispatchers.push(dispatcher) + end + + ############################################################# + # add to dispatcher + def dispatch(cnx, uri, verb) + var idx = 0 + while idx < size(self.dispatchers) + if (self.dispatchers[idx].dispatch(cnx, uri, verb)) + return + end + idx += 1 + end + # fallback unsupported request + cnx.send(500, "text/plain") + cnx.write("Unsupported") + cnx.content_stop() + end + + end + + # assign the class to a global + global.webserver_async = webserver_async +end + +class leds_panel + var port + var web + var sampling_interval + var p1, p_leds + var strip # strip object + var h, w, cell_size, cell_space + + static var SAMPLING = 100 + static var PORT = 8887 # default port 8886 + static var HTML_HEAD1 = + "" + static var HTML_URL_F = + "" + static var HTML_HEAD2 = + '' + '' + '' + '' + static var HTML_CONTENT = + '' + '' + '' + '' + '
' + '' + '
' + static var HTML_END = + '' + '' + + def init(port) + import gpio + if !gpio.pin_used(gpio.WS2812, 0) + log("LED: no leds configured, can't start leds_panel", 3) + return + end + if (port == nil) port = 8886 end + self.port = port + self.web = global.webserver_async(port) + self.sampling_interval = self.SAMPLING + + self.strip = Leds() + self.p1 = bytes(100) + self.p_leds = self.strip.pixels_buffer() + + self.web.set_chunked(true) + self.web.set_cors(true) + self.web.on("/leds_feed", self, self.send_info_feed) # feed with leds values + self.web.on("/leds", self, self.send_info_page) # display leds page + + tasmota.add_driver(self) + end + + def close() + tasmota.remove_driver(self) + self.web.close() + end + + def update() + self.p_leds = self.strip.pixels_buffer(self.p_leds) # update buffer + self.h = tasmota.settings.light_pixels_height_1 + 1 + self.w = self.strip.pixel_count() / (tasmota.settings.light_pixels_height_1 + 1) + self.cell_size = tasmota.int(330 / self.w, 4, 25) + self.cell_space = (self.cell_size <= 6) ? 1 : 2 + end + + def send_info_page(cnx, uri, verb) + import string + + self.update() + var host = cnx.header_host + var host_split = string.split(host, ':') # need to make it stronger + var ip = host_split[0] + var port = 80 + if size(host_split) > 1 + port = int(host_split[1]) + end + + cnx.send(200, "text/html") + cnx.write(self.HTML_HEAD1) + cnx.write(format(self.HTML_URL_F, ip, port)) + cnx.write(self.HTML_HEAD2) + cnx.write(self.HTML_CONTENT) + cnx.write(self.HTML_END) + + cnx.content_stop() + end + + static class feeder + var app # overarching app (debug_panel) + var cnx # connection object + + def init(app, cnx) + self.app = app + self.cnx = cnx + tasmota.add_driver(self) + end + + def close() + tasmota.remove_driver(self) + end + + def every_100ms() + self.send_feed() + end + + def send_feed() + var cnx = self.cnx + if !cnx.connected() + self.close() + return + end + + var server = self.cnx.server + if cnx.buf_out_empty() + # if out buffer is not empty, do not send any new information + var app = self.app + app.update() + var payload1 = app.p1 + var p_leds = app.p_leds + + payload1.clear() + payload1 .. "id:" + server.bytes_append_int(payload1, tasmota.millis()) + payload1 .. "\r\nevent:leds\r\ndata:" + + payload1 .. '{"w":' + server.bytes_append_int(payload1, app.w) + payload1 .. ',"h":' + server.bytes_append_int(payload1, app.h) + payload1 .. ',"clsz":' + server.bytes_append_int(payload1, app.cell_size) + payload1 .. ',"clsp":' + server.bytes_append_int(payload1, app.cell_space) + payload1 .. ',"rev":' + server.bytes_append_int(payload1, tasmota.settings.light_pixels_reverse) + payload1 .. ',"alt":' + server.bytes_append_int(payload1, tasmota.settings.light_pixels_alternate) + payload1 .. ',"hex":"' + payload1.appendhex(p_leds) + payload1 .. '"}\r\n\r\n' + cnx.write(payload1) + end + end + + end + + def send_info_feed(cnx, uri, verb) + cnx.set_chunked(false) # no chunking since we use EventSource + cnx.send(200, "text/event-stream") + # + var feed = feeder(self, cnx) + feed.send_feed() # send first values immediately + end + + def web_add_main_button() + self.send_iframe_code() + end + + def send_iframe_code() + import webserver + self.update() + var ip = tasmota.wifi().find('ip') + if (ip == nil) + ip = tasmota.eth().find('ip') + end + if (ip != nil) + var height = self.h * self.cell_size + 10 + webserver.content_send( + f'' + '' + '
 Leds mirroring ' + '' + '
' + '' + '' + '
' + ) + end + end + +end + +return leds_panel() diff --git a/tasmota/berry/leds_panel/leds_panel.bec b/tasmota/berry/leds_panel/leds_panel.bec new file mode 100644 index 0000000000000000000000000000000000000000..861a83c7f67ec77cf4379de26e1fb0e8af8af96b GIT binary patch literal 13805 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