From 47c68c8aef9d657564fee40298d418e5a5f76d5f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 20 Aug 2024 12:56:45 +1200 Subject: [PATCH] Add ``file`` component --- CODEOWNERS | 1 + esphome/components/file/__init__.py | 148 ++++++++++++++++++++++ tests/components/file/bloop.wav | Bin 0 -> 3854 bytes tests/components/file/test.esp32-idf.yaml | 7 + 4 files changed, 156 insertions(+) create mode 100644 esphome/components/file/__init__.py create mode 100644 tests/components/file/bloop.wav create mode 100644 tests/components/file/test.esp32-idf.yaml diff --git a/CODEOWNERS b/CODEOWNERS index 9159f5f843..a06f0620c6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -139,6 +139,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/fastled_base/* @OttoWinter esphome/components/feedback/* @ianchi +esphome/components/file/* @jesserockz esphome/components/fingerprint_grow/* @OnFreund @alexborro @loongyh esphome/components/font/* @clydebarrow @esphome/core esphome/components/fs3000/* @kahrendt diff --git a/esphome/components/file/__init__.py b/esphome/components/file/__init__.py new file mode 100644 index 0000000000..b31fa94a2f --- /dev/null +++ b/esphome/components/file/__init__.py @@ -0,0 +1,148 @@ +import hashlib +import logging +from pathlib import Path + +from magic import Magic + +from esphome import external_files +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import ( + CONF_FILE, + CONF_FORMAT, + CONF_ID, + CONF_PATH, + CONF_TYPE, + CONF_URL, +) +from esphome.core import CORE, HexInt +from esphome.external_files import download_content + +_LOGGER = logging.getLogger(__name__) + + +CODEOWNERS = ["@jesserockz"] +DOMAIN = "file" +MULTI_CONF = True + +TYPE_LOCAL = "local" +TYPE_WEB = "web" + +FORMAT_RAW = "raw" +FORMAT_WAV = "wav" + +FORMATS = [FORMAT_RAW, FORMAT_WAV] + + +def _compute_local_file_path(value: dict) -> Path: + url = value[CONF_URL] + h = hashlib.new("sha256") + h.update(url.encode()) + key = h.hexdigest()[:8] + base_dir = external_files.compute_local_file_dir(DOMAIN) + _LOGGER.debug("_compute_local_file_path: base_dir=%s", base_dir / key) + return base_dir / key + + +def _download_web_file(value: dict) -> dict: + url = value[CONF_URL] + path = _compute_local_file_path(value) + + download_content(url, path) + _LOGGER.debug("download_web_file: path=%s", path) + return value + + +LOCAL_SCHEMA = cv.Schema( + { + cv.Required(CONF_PATH): cv.file_, + } +) + +WEB_SCHEMA = cv.All( + { + cv.Required(CONF_URL): cv.url, + }, + _download_web_file, +) + + +def _validate_file_shorthand(value): + value = cv.string_strict(value) + if value.startswith("http://") or value.startswith("https://"): + return _file_schema( + { + CONF_TYPE: TYPE_WEB, + CONF_URL: value, + } + ) + return _file_schema( + { + CONF_TYPE: TYPE_LOCAL, + CONF_PATH: value, + } + ) + + +TYPED_FILE_SCHEMA = cv.typed_schema( + { + TYPE_LOCAL: LOCAL_SCHEMA, + TYPE_WEB: WEB_SCHEMA, + }, +) + + +def _file_schema(value): + if isinstance(value, str): + return _validate_file_shorthand(value) + return TYPED_FILE_SCHEMA(value) + + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.declare_id(cg.uint8), + cv.Required(CONF_FILE): _file_schema, + cv.Optional(CONF_FORMAT): cv.one_of(*FORMATS, lower=True), + } +) + + +def _trim_wav_file(data: bytes) -> bytes: + header = [] + index = 0 + length = len(data) + while index < length: + byte = data[index : index + 1] + if byte == b"": + raise ValueError("Could not find data in wav file") + header.append(byte) + index += 1 + if header[-4:] == [b"d", b"a", b"t", b"a"] or index > 100: + break + index += 2 + return data[index:] + + +async def to_code(config: dict) -> None: + conf_file: dict = config[CONF_FILE] + file_source = conf_file[CONF_TYPE] + if file_source == TYPE_LOCAL: + path = CORE.relative_config_path(conf_file[CONF_PATH]) + elif file_source == TYPE_WEB: + path = _compute_local_file_path(conf_file) + + with open(path, "rb") as f: + data = f.read() + + # Get format from config or fallback to magic + if (format := config.get(CONF_FORMAT)) is None: + magic = Magic(mime=True) + file_type = magic.from_buffer(data) + if "wav" in file_type: + format = FORMAT_WAV + + if format == FORMAT_WAV: + data = _trim_wav_file(data) + + rhs = [HexInt(x) for x in data] + cg.progmem_array(config[CONF_ID], rhs) diff --git a/tests/components/file/bloop.wav b/tests/components/file/bloop.wav new file mode 100644 index 0000000000000000000000000000000000000000..85bdb2f783d43b89c804b2f57859c4b23748d484 GIT binary patch literal 3854 zcmXw62~ZSSnr0$qCuUf7)cT)p^_#j zCg$1)_2q5uS7M!rnVvI^4)#>#H#9+|rv^t#*K?%Z4TdZJ+2bZ0^&1SJcFqmM6 zz%jtW35+0V*5+{f0`+wPkDVcmI*m%DRI0TG07FrXG_$P3;p7|+9#A+NZJ|v@#Go@E zxDf$EDuoImt@i4g`o_lkYCA=sMikZSb!wGTqtP2M!bF*^HmAq$_xn6f)?zkO@D{)> zff=+Kr9vW8AtsB<7p$$X_wx*eV<;>JqKBI*wN7V1Nt1=+c{^vNO|%uFv6)G@tw(Sg zUIC_4X$)qYx3;m~Yo~A&$BlY}L64v~7=TScP*$7W?sU00n}tA8A$VLTlWI`XSsyg( zwX!0ytFyhvZ6#4eEfY!PIuk=fTxt#FYiVx^utv4oXanClLup>Qk`8t?Qj(JF)T+SQ zEmXz>NN(fT%j`HR+|OYAOyuYc?LJz zEI0zM5hYBFySdYkiWE8solvRuIK@yVOeIk>oWs|^$~Dev3vCn?DIK9kjgxjVo7IR)wE6YzR3f{Qd-D7^#{IS+Mrz~X= zi>e;Jy=be-EYV>EX42;#I%a5nFpfrlo>23{EBE1*>zE&3@lxE3tI9-dP;=;Dap38c za>JU;f&aK6jSD|=_WNGc(co0(7TUXAsXKB*-&`wN5rxkDwK-=)B=z(r$Fk$CU7 zrElMPjLIDBym#Gu$EKdjK3L4Yn=Sm856wS4Gge@Hk3Vif%B49^_%hA&9~{g6bbR)| zTXOZiBQ=NCWcS{wj@_u4dRf2i|E7_cgVo6L;WYPYeB)dCz(19JksGQbtAjpKmMf%$>mEyAuAMl@}Pc?>kP zKoGbf2SBPgj*%pgEN%q$(gKg_wHmcnqfy8uVzC%lN~u&Rl`6FwK1V$Q`ipHbgR|LKmIhOxw+I3& zsg#RlN;O!MLTu&b$15sIOG`@fa*IStg;cCmtE5tyTrQQXj0AyVKpPei4TsZP7Ya3a zYwBCTN~qcEX*zfLY6z*fDZ6K$jRlGJamO+5H=|Xm@td-* zswb`wwCZ&o{x1_wj1=#p&x<3APkIrmXlouH`@VQ6^ABm=ng5y;(q~eIi@vNcUS%4s zSZuyh6z;vwDEdq(j+=(`k=Np!C-)C){^zmx%&tN=|CzAZ9w;hfqNolnu zsUlRlRpQtj-5f|MP{nR5&smXb-?Jc3zWU2HHnBpRnr@Wk@l9gcNmo_qNh{XcM%A1T z_@+mg)~{{0SFhSTue5u5yKId;7}11QHJwDHxF)SYpO%ScMdqZh%k>}Mx-TmxGCymN zR1%x3`bysCD^|dg1q1q~K^=F2uKwF>b?v#X;K!5m)WZ6~8;^VmMwy+wu#Q zhjQx4q=dZG*yMv-GV@nNCP!!Z6;X%E3yXDC5*H(;P5S=3epPLA!`Z>$@Walop_;y* zp0@N1h6ejwOpeJO`8+*!cDHZGncq{*d2hp>VBQt1M24Ju0n8id?@nI%Y@QfgKT% zhe~B7Ir$Y5I7C=!Gk4?FItxo6<)z`1J=zI*$8YJZFNn+jmxAm zX-UqJy)lW!*;|*ci;juev1{jnEUB!pREc4f-Q{V#GCh9g^4r>r(2qa-~Rp1$m2IpCi>gD zM=rGo{9db}AWiB-zuLYdcKwp&>to`!ZHkFcO-s)%P%|GYPK|K@Pd zg(qLP)zo_p5^I||IWd37hifaO%fi-gTe2lvnYJspB2QA?A3)m&J4XKV=Lc7xOrE_y zbF()T;u+pci7IjqCKVowTD@-T?$qqK=zVD=s8wB}t3EL?cQw#^=ial|W6d3dJw2!V zI>dySu#`qnh0C@ znuU9F)rbH1eoU556djXm>Kjm{A(#I7^CXXV|NH+w?c<2h#Kj<*6JJb764!nF=Z*Ul z;`Z-P$S6^utc&;e+?jp(?EcO1+Y=)L-EFlVt5IB#vTyswwQJUI+_GbD!jarEC2sSD z&R(CqcXx7nYV6v@GbdXE4oX#eG(LL6>J^`@-VnJf?nqXN9Or{4FW#DY_T<6j#OUz3 z?ogeF(G(`_+_+}h;>9c1N5#gc9;?*aYP&Dre*EIqvqzH?S1gQMll*u$4^zL|S)XY9&gXV9uE&)glpbit>~)<(p{rQ}r^tc?R>)3dMU z<{nMn7&_hRGb;;|A{KqR@U!(>w#TPtl%n3y;Fa+g|9JQ4=E(4sGquLbyu7#t|MtfqU_cmJ51p1d`Bq1kQLiw~~+WJ!3`#?5<@%cOEFFf{l0_VlwCQ$xKS zjc!TO&h62w7OaRmkdmCEaCY`xn0oX5-0iWEa~+(vEMwR5KQ9iC*|q0LF~~qDijN2p&>t;PrE3PL=a}v zye7jr8HJPvMIb8x34&uVq!+>!sBkJqF%=iJrVAs`X22Da zK`7)eDWj0AL508hlyUwBNkE