@camstack/addon-provider-amcrest 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/assets/icon.svg CHANGED
@@ -1,6 +1,3 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
2
- <rect width="48" height="48" rx="10" fill="#16a34a"/>
3
- <path d="M14 18h13a3 3 0 0 1 3 3v9a3 3 0 0 1-3 3H14a3 3 0 0 1-3-3v-9a3 3 0 0 1 3-3Z" fill="#fff"/>
4
- <path d="M30 22l6-3v11l-6-3v-5Z" fill="#fff"/>
5
- <circle cx="20.5" cy="25.5" r="3.5" fill="#16a34a"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
2
+ <image href="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBwgHBgkIBwgKCgkLDRYPDQwMDRsUFRAWIB0iIiAdHx8kKDQsJCYxJx8fLT0tMTU3Ojo6Iys/RD84QzQ5OjcBCgoKDQwNGg8PGjclHyU3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3N//AABEIAb8BvwMBEQACEQEDEQH/xAAbAAEAAgMBAQAAAAAAAAAAAAAAAQYDBAUHAv/EADUQAQABAwIDBgMIAwEAAwAAAAABAgMEBREGITESEzVBcXNRgbEiMjRCUnLB0SNTYeEVg5H/xAAaAQEAAgMBAAAAAAAAAAAAAAAABAUCAwYB/8QAJxEBAAICAgICAgIDAQEAAAAAAAEDAgQRIQUzIjEycRJhQVGRQhP/2gAMAwEAAhEDEQA/APcQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfNVcUxvVMREdZmSO+oeTMRHMot3KLtMVW6qaqZ6TE7vZiYniSMomOYfbx6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5Wq67h6bE0119u95W6Oc/P4JNOrZb9R0iX7lVP3PMqZqmt5mpTNNyru7Plbp6fP4rinUrq+u5UOxu23T31DBp2qZem3Iqxrm1G/2rc/dlldr12x8oYUbNtM/GVy0niXFzuzbvTFi/P5ap5T6SqL9KyvuO4Xmt5Cu3rLqXbpndDWD6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABANXNz8bBt95lXaaI8omec+kNldWdk8YQ1W3YVRzlKoatxTkZPatYUTZtfr/ADVR/C1o0Mce8+5Umz5LPP419Qr8zMzMzMzM85mfNYRER1CrmZmeZQ9B6EvB2NK4hzMCaaK5m/Y/TVPOPSUO7Srs7jqU7X37Kep7hctM1fE1GiJsXIivzt1TtVHyVFuvZVPyhe07dd0fGXR3aEkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnkD4uXKLdE111U00x1mqdogiJmeIeTMRHMqvq3FdFve3p1MXKo5d5V0j0WVHj5y7z6VOz5OMeq+5VTJyL2Vd7zIu1XK/jUtcK8MI4xhS2WZWTznPLEzYAAAAAPq3XVbriuiqaao6TTO0vMsYyjiXuOU4zzE8LJpPFd21ta1GnvKOne09Y9YVt/j4n5VrXX8nlj8bO/wC1txMuxl2ou412m5RPnTKrzwywnjKOF1XbhZHOMs+7BsSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACNwcbVuIsTA7Vuie+vR+SmeUespdGnZb39Qg7G/XT1HcqZqWq5eo175Fzajflbp6Qt6dauqOo7UN+1ZdPynpopCOAAAAAAAAAz4mXfw7sXca7VRV/yevq12VYWRxlDbVdnVPOMrbpHFVm92bWfEWa+kXIn7M/0qr9DLD5YdwudbyeOfxs6WSi5TXTFVExVTPSYnqrp6+1rGUTHMPsegAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIkGlqOqYunW+1kXYifKiOcz8m2qnO2eMYaLtmunHnKVO1biXKzt7djexZnltE86vmt6NLCvvLuVFs+Qst6x6hw/JO4V4AAAAAAAAAAABscDoaZrGXptX+CvtW/O3V0/wDEe7Wrt+/tJo27KZ6+ly0niHE1Da3NXc3v0Vz19JU92pZV39wvtferu6+pdiJRU1IAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMWRft49qq5euU0UR1qqnZljjllPGMcsc88cI5ynhVNX4smre1ptP/O+rj6Qs6PH/wCbFPs+T/8ANSr3btd65Ny7XVXXV1qq6rPHGMY4xhT55ZZzzlPL4ZMQAAAAAAAAAAAAAAB5JE8O5pPEuVhdm3kb37Mcuc/aj5oV+jhZ3j1Kx1/I2VdZdwuOm6pi6hRFWPdiZ86J5THyVFtGdU8ZQvKdmu6OcZb27U3gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAImqIiZmeUDzmPtwNY4nxsPtWsba/fjyj7sesptGlnZ3l1Cu2PI119Ydyp2fqGVqF3t5N2ao35UR0p+S3qowqjjGFHdsWWzzlLVboaQAAAAAAGSzZu37sWrNuquufyxDHPLHGOcmeGGWc8YrTpHCe3Zu6jVvPXuaZ5fOVVf5CZ+Na41vFx+VrY1bhSzfibmBPc3P0fln+mFG/nj1Z3DZseMwz7r6lUs3DyMG7NrJtzRV5b9J9JWtduFkc4ypbac6p4yhrtrUAAAAAAA+7N25Zri5Zrqt1xO/apnaWOWOOUcZQywzywnnGVn0niyqns29Sp3jp31P8wrL/H/AOa1vr+U/wDNsLXjZFnItRcs3Ka6J84lWZY5YzxkuMLMc45xll3Ys0gAAAAAAAAAAAAAAAAAAAAAAAAAAjcHO1XWcTTaP81faueVunnVLfTrWWz8Y6Rr9uumPlPamarr+ZqEzRFU2bP6KZ6+srijTwq7nuVDsb1l3UdQ5Pp0S0IAAAAAAAD+nd0jhrKzZi5lf4LP/Y+1V8kG/eww6x7lY63jrLO8+oXHT9NxdPt9jGtU0/GrrM/NUW3Z2zzlK8p166ceMYbkNTekGDKxLGXZqtZFum5RPlVDLDPLCecZa7K8bI4zjlUtW4UuW97unVdujrNqrrHpK0o8hE9WKbZ8ZMT/ACqVq5brt1zRcoqorjrTVG0ws8coyjmFTljOM8S+XrwAAAAAABs4Ofk4F3vMW7VR8afKfWGq2nC2OMobqb86cucJW/SeKMfJmm1lxFi9PSfy1Km/Ryw7x7hd6/ksLPjn1Kw01RVtMc4nzhAWUTz2+h6AAAAAAAAAAAAAAAAAAAAAAAA18vMsYdubmTcpt0R5zLPDDLOeMYa7LcK45ylUtW4qu3t7enxNujpNyr70+i0o0Ijuz/il2fJzn8alarqqrqmuuqaqp6zM7zKy4iI4hVTMzPMoew8AAAAAAAb2naTmajXtj29qPO5Vyphou2cKo7ntIo1bbp6jpc9J4exNP2uVUxev7ffqjp6Qpr9vO3qOoX2voV1dz3Ls7QipyQAAARsDQ1PScTUaNr9qO1tyuU8qobqr7Kp5xlHv1q7o4yhTtV4czMHeu3Hf2I/NT1j1hb0buFnU9SotjQsq7juHFTUD+gAAAAAAAHT0vXMzTZimiublrzt1c4+XwRbtWu376lL1tyyn/PMLnpWu4eoxtRX3d3zt19fkqLtWyme+4Xuvu13R1PEupE7oyYkAAAAAAAAAAAAAAAAAAAAHzXVTTG9UxER1mTjn6eTMRHMq3q3FVmxvawI7650mufu0/wBrCjQyy7z+lXs+Txw6r7lUczMyc273mTdquVT8Z5R6R5LaurCuOMYUtt1ls85ywNjUAAAAAAAy4+PeybkWse3VcrnltRHRhnnjXHOUs8K8rJ4wha9J4TooiLmpTFdXXuo+78/iqr9+Z6rXOt4yI+Vv/FotW6LdEUW6KaaY6REbRCumZynmVvjjGMcYxw+3j0AAAAAABG0fAHE1bhzEzt7lqmLN6fzUxyn1hLo3LKup7hA2NCu3uI4lTtR0nM02va/amaPKunnC4p2K7Y+MqK7Vtpn5R00W9HAAAAAAATEzTPaiZiY57w8mInqXsTxPMO/pPFGTixTbzd79r9Ufej+0C/Qxy7w6lZa3ks6+rO4XDB1DGzrXbxr0Vx5x5x8lTZVnXPGULuq+u2OcJbbW3AAAAAAAAAAAAAAAAAAIq6A841jV8zPvXKLlyabVNUxFunlHXzdBr61dcRMR25fa27bcpiZ6cxKQwAAAAAAExEzMREbzPKIg5g45nh3tI4Yycva7l72LP6fzVf0r79/HDrDuVnreNzs7s6hccDT8bAt9jGtRRHnPnPrKpstzsnnKV3VRXTHGENtrbgAAAAAAAAAAHxdt0XaJouURVTPWJjeJexMxPMMcsYyjiVY1bhO3Vvd02Yt1f6qp5T6T5LGjyE49WdqrY8ZE/Kr/AIqeTjXsS7NrItVW6/hUta7Mc45xlS2V51zxlHDEzYAAAAAAAOhoFVdGs4nYqmne5ETtPWEbbxiasuknTymL8eP9vSoc86tIAAAAAAAAAAAAAAAAAIq6SPJ+nlOR+Ive5V9XUYfjH6cfZ+eX7Y2TAAAAAA9QdDTNHy9Sq/xW5ptb87lXT/1Hu2q6o7+0qjTsvnqOl00nQMPT4iuKe9v+dyv+Pgpr9uy79L3X0q6e+OZdbb/qMmpAAAAAAAAAAAAABExuDWzcDHzrXd5NumuPL4x6M67M655xlqtpwtjjOOVR1bhW/jdq7gzN63+ifvR/a2o8hjn8bFJs+Mzw+Vfau1RVRVNNdM01RO0xMc4WETExzCsmJieJQ9eAAAADyRv6F4zh+7DTs+nL9JGp78XpcOcdYkAAAAAAAAAAAAAAAAAEVdAl5TkfiL3uVfV1GH4x+nHWfnl+2NkwAAAAZsXGv5d3usa1Vcr+FMdGGduFcc5S2V1Z2TxhHK26RwraszTd1Ce9r8rcT9mP7VN+/ll1X0utbxuOPys7lZaLdNFMU0RFNMdIhXzMzPMrWMYxjiH28egAAAAAAAAAAAAAAAAI2BzdU0XD1GmZu0di75XKOU/+pFOzZV9fSJsadV0dx2pmq6DmadvX2Ju2f10R9YW9G3XZ1zxKj2NG2meeOYcpLQgAAB5I39C8Zw/dhp2fTl+kjU9+L0uHOOsSAAAAAAAAAAAAAAAAACKugT9PKcj8Re9yr6uow/GP046z88v2xsmAAADu6Bw9OpURkX7nYsb7RFP3qv6QNnc/+U/xxjtZamhN0fzyniF0wsLHwrUW8a3TRT/zrKnssysnnKV7VThVHGMNpg2gAAAAAAAAAAAAAAAAAAAAPmqmJjaY5DziFP4v0zFxrVGVYt93XXXtVFM8p+S10L888v4z3Ck8lr14Y/zxjtVlqpwAB5I39C8Zw/dhp2fTl+kjU9+L0uHOOsSAAAAAAAAAAAAAAAAACKugT9PKcj8Re9yr6uow/GP046z88v2xsmAACY6gvvB/gtv90qHe90uk8b6HdQ1gAAAAAAAAAAAAAAAAAAAAAAArXHPh1r3YWHjvZKq8t6oUlcqAegA8kb+heM4fuw07Ppy/SRqe/F6XDnHWJAAAAAAAAAAAAAAAAABFXQJ+nlOR+Ive5V9XUYfjH6cdZ+eX7Y2TAAAjqC/8H+C2/wB0qHe90uk8b6HcQ1gAAAAAAAAAAAAAAAAAAAAAAArXHPh1r3YWHjvZKq8t6oUlcqAegA8kb+heM4fuw07Ppy/SRqe/F6XDnHWJAAAAAAAAAAAAAAAAABFXQJeU5H4i97lX1dRh+Mfpx1n55ftjZMAACOoL/wAH+C2/3Sod73S6TxvodxDWAAAAAAAAAAACNwSAAAAAAAAAACtcc+HWvdhYeO9kqry3qhSVyoB6ADyRv6F4zh+7DTs+nL9JGp78XpcOcdYkAAAAAAAAAAAAAAAAAEVdAn6eU5H4i97lX1dRh+Mfpx1n55ftjZMAACOoL/wf4Lb/AHSod73S6TxvodxDWAAAAAAAAACKpimN5naIDnhXtX4ox8XtWsPa/ejlvvtTH9p1GjnZ3l1Cs2fJYV/HDuVds8Salbyu/qu95E9bcx9nb/nwWGWlTOP8eOFZj5C+M/5TPK16TxBi6jtRv3V//XV5+kqu/Uzq/uFzr71d3X1LsQipqQAAAAAAAAVrjnw617sLDx3slVeW9UKSuVAPQAeSN/QvGcP3Yadn05fpI1Pfi9LhzjrEgAAAAAAAAAAAAAAAAAiroE/TynI/EXvcq+rqMPxj9OOs/PL9sbJgAAR1Bf8Ag/wW3+6VDve6XSeN9DuIawAAAAAAANwcnVdcw9Njs11d5d8rdHOfn8EmjVst+uoQ9jcrpjue/wClN1XXMzUpmmqru7P+unp8581vRqYVf3Kj2N2y7r6hzEpDAPQHc0riXKwtreRvkWY+P3o9JQb9HCzvHqVhr+Rsr+OfcLjp2p4uoW+1j3YmfOmeUx8lRbRnVPGUL2nYrujnGW9DU3gAAAAAAK1xz4da92Fh472SqvLeqFJXKgHoAPJG/oXjOH7sNOz6cv0kanvxelw5x1iQAAAAAAAAAAAAAAAAARV0Cfp5TkfiL3uVfV1GH4x+nHWfnl+2NkwAAI6gv/B/gtv90qHe90uk8b6HcQ1gAAAAAAA18+Zpw700zMTFEzExLOv8oarfwl5bMzVMzVMzM+cumjr6cjM8zzKHrwAAABt6VVVTqmJ2K6qd7tMcp2ab8YmvLn/TdrTMXY/t6fDm3XJAAAAAABWuOfDrXuwsPHeyVV5b1QpK5UA9AB5I39C8Zw/dhp2fTl+kjU9+L0uHOOsSAAAAAAAAAAAAAAAAACKugT9PKcj8Re9yr6uow/GP046z88v2xsmAABHUF/4P8Ft/ulQ73ul0njfQ7iGsAAAAAAAGtqH4K/7c/RnX+cNV34T+nlrpocgPQAAABtaV4ph+9S1X+vL9Nuv7sf29RhzTrwAAAAAAFa458Ote7Cw8d7JVXlvVCkrrhQAADyRv6F4zh+7DTs+nL9JGp78XpcOcdYkAAAAAAAAAAAAAAAAAEVdAn6eU5H4i97lX1dRh+Mfpx1n55ftjZMAAAkX/AIQiY0W1v+qVDve6XS+O9DuIaeAAAAAAA1tQ/BX/AG5+jOv84arvXP6eWumhyA9AAAAG1pXimH71LVf68v026/ux/b1GHNOvAAAAAAAcPizByM7AppxaO3VRXFU0785hM0rcarOclf5GnO2vjFQrlFVquqi5TNNcdYmNphe4zExzEucyxyxnjJ8vXgA8kb+heM4fuw07Ppy/SRqe/F6XDnHWJAAAAAAAAAAAAAAAAABFXQJeU5H4i97lX1dRh+Mfpx1n55ftjZMAGxh4WRnXe7xbVVdXnt0j1lqstwrjnKW2qnO2eMI5W3SuFLNns3M+YvV9exHKmP7VV+/ln1h1C61/GY4fKzuVjoopopimimKaY6REK+Zme5WkRERxD7HoAAAAAADW1D8Ff9ur6M6/zhqu9c/p5a6aHID0AAAAbWl+J4nvUtV3ry/Tdr+3H9vUYc064AAAAAAABz9T0nE1KjbIt/b8q6eVUN1V+dU/GUe/Vruj5Qp2q8OZmD2q7Ud/Yjn2qY5x6wt6N3CzqepUWxoWVd49w4qbCAEjf0LxnD92GjZ9OX6SNT34vS4c46xIAAAAAAAAAAAAAAAAAIq6BLynI/EXvcq+rqMPxj9OOs/PL9otW671cUWqKq656U0xvL3LKMY5yl5jjlnPGMcrLpPCddza7qNU00/6qZ5z6yrL/IcfGta63jJy+Vv1/pbMXEs4tuLePbpt0R5Uwq888s55ylc114VxxjHDOxbAAAAAAAAAHxXRFdNVNURMTymJInieXkxE9SqurcJxO93TZ2nrNqqeXylaUeQ46sU+x4uPyq/4qt+xdx7s2r9uqiuPKqFpjnjnHOMqfOvLCeMoY2XLAABs4WDk59zu8W1Nfxq8qfm1W3YVx8pbaaLLp4whb9G4Xs4lVF/Kq72/TO8R+WmVRfvZWR/HHqF5reOwqn+WXcrFCCs0gAAAAAAAAiYiR5w4mrcOYmdvctx3F/8AVT0n1hLp3LK+p7hB2NCu3uOpU7UdKy9OrmMi3PY8rlPOmVxTs12x0ortWymfl9GheM4fuwbXpyNT34vTIc46xIAAAAAAAAAAAAAAAAAIq6AomDw3lZuTcrvxNiz3lXOqPtTznpC6s3sK8YjHueHPV+Ostzmcuo5W7TtLxNPo2xrURVPWuecyqrb87Z5ylc061dMcYw3dmpISAAAAAAAAAAAADUz9Pxs+12Mq1TX8J6THpLZXbnXPOMtNtFdscZwp+rcMZOL2rmH2r9r4fmiP5W1G/jn1n1Kj2fG5194dw4Vu1cuXO6t266rm+3YiOe6dOeMRzM9IEYZTP8YjtZ9J4Tqr7NzUqppp/wBVE859ZVl/kP8AFa11/GTPyuWvHxbONbi3j26bdEeVMbKzLKc55yXOFeOEcYwzMWYAAAAAAAAAAAD4uWqLtE0XKYqpnrExvEkTOM8w8yxjKOJhwquGrFrUbGXhz3cW64qqtz0n0+CbG7nNc4Zd8q+fH4RbFmHXCwISxAAAAAAAAAAAAAAAAAAARsCQAAAAAAAAAAAAAAARtAMdGNZou1XaLVMXKutURzllOWUxxMsIwxieYhk2Ys0gAAAAAAAAAAAAAAjYEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjeASAAAAAAAAAAAAAAAAAAAAAACNwO1G+24cpAAAAAAAAAAAAAAAAAAAAAAAAAAAABEgpmvapl6dr1c49z7PYjeiedMrbWowto+UKLb2bKNjnGXX0niXEztrd7/Ben8tU8p9JRb9LOruO4TdbyFdvWXUu5E7oawSAAAAAAAAAAAAAAAAAACARXXTRTNVUxFMdZmehETP08mYiOZVrV+KrNne1gUxeu9O3P3Y/tYU6GWXefUKvY8njh1X3LV4UzMjN1e7dybtVdXdefSOfwbd6rCuqIxho8ddnbdM5SuKqXgAAAAAAAAAAAAAAAAAAAAAAAAAAACJBQOMPG6/2QvdD0ua8l73ETUB2NK4hy9P2oqnvrEdKK55x6Sh36VdncdSna+/ZV1PcLlpmsYmo0x3NcRX526vvQp7deyqe46XtG3VdHxl0IaUlIAAAAAAAAAAAAAAAImdpBx9V4hxNPiaN+9vf66PL1lKp1LLf6hB2N6unr7lTdT1jL1Kqe+rmm3vyt08oj1+K3o1q6fr7Uext2X/f056SirHwP4ld9r+Vd5L1wtPFe2V4UzoAAAAAAAAAAAAAAAAAAAAAAAAAAAAET0BQOMPG6/wBlK90PS5ryXvcROQB4Jpqqoqiqiqaao6TE7bPJiJ6mHuOU4zzismk8V3rO1vPpm7R/sj70evxV1/j4y7r/AOLXX8nlj8bO/wC1tw8zHzLfeY12K6f+T09VVnXlXPGULmq3C2OcZbLBtAAAAAAAAAAAAAaWo6ni6fb7eTc7PwpjnM/JtqpztnjGGi7YrpjnKVO1biXKze1bx97Fn/k/amPXyW9Gjhh3l3Ki2PIZ29Y9Q4X/AHz+KdHSvnsABY+B/ErvtfyrvJeuFp4r2yvCmdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAiegKBxh43X+yle6Hpc15L3uInIA8AAGbFyr+Jdi5jXardUfpnr6/FhZXjZHGUNldudc84Twtmk8V27sxb1CO6r6Rcj7s+vwVN+hOPdfa61/J45fG3pZrdyi5RFdFUVUz0mOcK+YmJ4laxlGUcw+3j0AAAAAAAA3BhyMmzjWpuX7lNFERzmpljjlnPGLDOzHCOcpVTVuLJq3t6bTtHTvao+kLOjx/+bFPseU5+NX/VYu3bl65Ny9cquVz1qqneVpjjjjHGMcKjLLLKecp5l8PWIAACx8D+JXfa/lXeS9cLTxXtleFM6AAAAAAAAAAAAAAAAAAAAAAAAAAAABE9AUDjDxuv9lK90PS5ryXvcROQB4AAABwN7TdWy9Nr3x7n+PfnRVziUe7Wrt+47SKNq2ifjPS5aTxHiZ21uv8Aw3v01Tyn0lUXadlXcdwvtffru6nqXa35boidykAAAACQfM1xTG8ztEdZnyI7+nkzx9q9q/FOPjdq1h7XrvTf8sJ9Ghnn8suoVux5LCv44dyqOdn5OddmvKu1V/CnflHpC2qpwrj4wpLb87Z5zlqtrSAAAAAsfA/iV32v5V3kvXC08V7ZXhTOgAAAAAAAAAAAAAAAAAAAAAAAAAAAARIKBxh43X+yle6Hpc35L3uImq8AAAAAAP6Ha0riPLwZii7M37O33ap5x6ShX6VdnePUp+vv2VdT3C46bq+HqNG+Pdjt7c7dXKqPkqLaLKp+UL2jarujnGW/EtKQkAEbg5Wq69h6fE01V95e8rdHX5/BIp1bLe/qEPY3aqf88ypuqa5malMxXX3dmelujlHz+K4o1K6v7lR7G7bd99R/pzPTklIYAAAAAACx8D+JXva/lXeS9cLTxXtleFM6AAAAAAAAAAAAAAAAAAAAAAAAAAAABEgr3EHD3/yN2cnHudm/tt2avuz/AEnau5/8o/jP0rdzQ/8AtP8APGe1Ny8TIw7s28qzVbqjpv0n0XFduNkc4yobas6p4yjhgbGsAAAAAAej6t11264rt1TTVHSYnZjljExxMMscpxnmJ4lZdJ4ru2uzb1GJuU9O8pjnHr8Vbf4+J7rWmt5PKPjatuJl2MuzF3Hu03KJ84VWdeWE8ZQuq7MLI5xljz9RxcC3NzKuxRHlHnPyZV052zxjDG2+uqOc5U/VuKMnK7VvE3sWZ5dr80x/C2o0MMO8+5Uex5LPP44dQ4FUzVMzVO8z1mVhERH0rpnmeZQPAAAAAAAG7p2l5eo1xGPans+dyrlTHzaLtjCr7lIo1rbp+MLroWhWtL7Vztzcv1RtVVPKPlCm2NrK7rjpfamljR3zzLsx0RU4AAAAAAAAAAAAAAAAAAAAAAAAAAAAABr5eJYzLU2si3TconyqZYZ5YTzjLXZVhZHGUKnq3Cly12runzNdHXu5nnHp8VrR5CJ6s+1Ns+MnH5V/X+lauUVW7k0XKZorjrTVG0rLHKMo5iVVljMTxMdvl6xAAAAAAAZsXLyMO53mLdrt1efZnr8mFleNkcZQ2V251TzhPD5v37uRdm5fuVV1z+ap7jhjhHGMMc88s55ynljZMQAAAAAAGXGx72Vci3j26rlc+UMM7McI5ynhnXXlZPGMcrXpXCdFO13UZ7dX+qmfsx6z5qu/yEz1WudbxkR8rf8Ai0WbVFmiKLdNNNMdIpjaIVszMzzK2xxjGOMY4h9vGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACJjkDQ1LSMTUaNsi3Hb25V08phuqvsqn4yj3atd0fKFN1XhzLwN67VM37Mfmop5x6wt6N3CzqepUWx4+yrvHuHG8k1AAAAAAAAAAAAAAATETVMU0xNVU9IiN5l5MxEcyREzPSw6TwtfyNrmdvZt9Ypj70/0rr9/HDrDuVpr+Nyz7s6hb8HAx8G1FvGtRREdZ85+arstzsnnKV3VThVHGENprbQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAETET1BxNW4cxM7tV247i9+qjpPrCXRuWVdfcIGzoV29x1KnalpWXptW2RbmaPK5Tzpn+lxTs12x1Kjv1bKZ+UdNHzb0YAAAAAAAAAAnlEzvAf5dXS9BzNRntRTNqz/ALK42/8Az4ot+5hV19yma+lZd/jiFy0rRMPTaYm3R27u3O5Xzn/xTXbNls9yvdfTqp+vt09mjhLSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADHct0XKJprpiqmesTG8PYmYnmHk4xlHEqzq3Clu52ruBVFuvrNuqfsz6T5LGjfyx6s7VWz4yMp/lX1KqZWNfxLs2si1Vbqjyla4WY2RzjKlsqyry4yhhZtYAAAAAADawNPytQu9jGtTV8ap+7HrLVbfhVHylvp17Lp4xhcNI4ZxsSabuVMX73/Y+zT6QqL97OzrHqF3reOwr7z7l36aYpiIiNojyQVk+gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAa2ZhWMy1NvJtU3KZ+LPCzLCecZa7asLI4zhUdW4VvWN7mBV3tvztz96Pn5rSjyEZdZ9KTY8Zlh8q55/pXKqZpqmmqJpqjrE8phZRMT3CrmJieJQ9eAAAPuzZuX7sWrNFVddXSmljnnjhHOUssMcsp4xhadJ4S37N3Uqv/AKaZ+squ/wAh/iv/AKuNbxn/AKt/4tVixbx7cW7NEUUR5UqzLKcp5lb44Y4RxjHDK8ZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI2BztU0bE1Kj/PRtc8rlPKqG+nYzqnqUW/Uruj5R2puq8PZenzNdMd9Y/XRHOPWFvRuV2/1Kj2NC2nuO4chMQUxG+0RzmeWxyO7pPDGVmdmvKmcez15/eq+XkgX7+OHWHcrLW8bnn8suoXDA03G0+32Ma3TT8avOfmqbbs7Z5yleU0YUxxhDciNmpuSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACJpieo84hwtX4axM2ZuWZ7i9PWqmOU/JMo3LK+p7hA2PHV29x1LPpOgYmnRFcU97e87lcfRhdt2W9fUM9fSrpj/AHLrbIyakAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH/2Q==" x="0" y="0" width="48" height="48" preserveAspectRatio="xMidYMid meet"/>
6
3
  </svg>
package/dist/addon.js CHANGED
@@ -26423,6 +26423,54 @@ var AmcrestCgiClient = class AmcrestCgiClient {
26423
26423
  const body = await this.getText(`/cgi-bin/configManager.cgi?action=setConfig&${query}`);
26424
26424
  if (!/\bOK\b/i.test(body)) throw new Error(`setConfig rejected: ${body.trim().slice(0, 200)}`);
26425
26425
  }
26426
+ /**
26427
+ * Open the Dahua CGI event-manager attach stream — a long-lived
26428
+ * `multipart/x-mixed-replace` GET that pushes camera events
26429
+ * (`VideoMotion` Start/Stop, heartbeats, …) until the connection drops
26430
+ * or the caller aborts. Performs the same digest handshake as `send`
26431
+ * (absorbing the initial 401) but — critically — installs NO
26432
+ * per-request timeout: the long-poll must never self-cancel, so
26433
+ * teardown is driven solely by `signal.abort()`. `codes` is passed
26434
+ * through unescaped (Dahua requires the literal `[...]` brackets, same
26435
+ * as `setConfig`), e.g. `'[VideoMotion]'` or `'[All]'`.
26436
+ */
26437
+ async openEventStream(codes, signal) {
26438
+ const path = `/cgi-bin/eventManager.cgi?action=attach&codes=${codes}`;
26439
+ const url = `${this.scheme}://${this.host}:${this.port}${path}`;
26440
+ const firstHeader = buildAuthHeader({
26441
+ state: this.digestState,
26442
+ method: "GET",
26443
+ uri: path,
26444
+ username: this.username,
26445
+ password: this.password
26446
+ });
26447
+ let res = await fetch(url, {
26448
+ method: "GET",
26449
+ headers: firstHeader ? { authorization: firstHeader } : {},
26450
+ signal
26451
+ });
26452
+ if (res.status === 401) {
26453
+ await res.body?.cancel().catch(() => {});
26454
+ const challenge = parseChallenge(res.headers.get("www-authenticate"));
26455
+ if (challenge) {
26456
+ this.digestState.challenge = challenge;
26457
+ this.digestState.nc = 0;
26458
+ const retryHeader = buildAuthHeader({
26459
+ state: this.digestState,
26460
+ method: "GET",
26461
+ uri: path,
26462
+ username: this.username,
26463
+ password: this.password
26464
+ });
26465
+ res = await fetch(url, {
26466
+ method: "GET",
26467
+ headers: retryHeader ? { authorization: retryHeader } : {},
26468
+ signal
26469
+ });
26470
+ }
26471
+ }
26472
+ return res;
26473
+ }
26426
26474
  async getEncodeConfig() {
26427
26475
  return this.getConfig("Encode");
26428
26476
  }
@@ -26449,21 +26497,26 @@ var AmcrestCgiClient = class AmcrestCgiClient {
26449
26497
  };
26450
26498
  }
26451
26499
  /** Begin a continuous PTZ move. `code` is a Dahua direction token
26452
- * (`Left`/`Right`/`Up`/`Down`). `speed` maps to arg2 (1..8 typical). */
26453
- async ptzStart(channel, code, speed) {
26454
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=${code}&arg1=0&arg2=${speed}&arg3=0`);
26500
+ * (`Left`/`Right`/`Up`/`Down`/`ZoomTele`/`ZoomWide`). `speed` maps to
26501
+ * arg2 (1..8 typical). `timeoutMs` (optional) lets the caller pass a
26502
+ * short per-tick deadline so a slow/blocked camera round-trip fails
26503
+ * fast rather than hanging the whole cap call. The Dahua `start`
26504
+ * action is fire-and-return — the head keeps moving until a matching
26505
+ * `stop` is sent — so this must resolve quickly. */
26506
+ async ptzStart(channel, code, speed, timeoutMs) {
26507
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=${code}&arg1=0&arg2=${speed}&arg3=0`, timeoutMs);
26455
26508
  }
26456
- async ptzStop(channel, code) {
26457
- await this.getText(`/cgi-bin/ptz.cgi?action=stop&channel=${channel}&code=${code}&arg1=0&arg2=0&arg3=0`);
26509
+ async ptzStop(channel, code, timeoutMs) {
26510
+ await this.getText(`/cgi-bin/ptz.cgi?action=stop&channel=${channel}&code=${code}&arg1=0&arg2=0&arg3=0`, timeoutMs);
26458
26511
  }
26459
- async ptzGotoPreset(channel, presetId) {
26460
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${presetId}&arg3=0`);
26512
+ async ptzGotoPreset(channel, presetId, timeoutMs) {
26513
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${presetId}&arg3=0`, timeoutMs);
26461
26514
  }
26462
- async ptzSetPreset(channel, presetId) {
26463
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=SetPreset&arg1=0&arg2=${presetId}&arg3=0`);
26515
+ async ptzSetPreset(channel, presetId, timeoutMs) {
26516
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=SetPreset&arg1=0&arg2=${presetId}&arg3=0`, timeoutMs);
26464
26517
  }
26465
- async ptzClearPreset(channel, presetId) {
26466
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=ClearPreset&arg1=0&arg2=${presetId}&arg3=0`);
26518
+ async ptzClearPreset(channel, presetId, timeoutMs) {
26519
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=ClearPreset&arg1=0&arg2=${presetId}&arg3=0`, timeoutMs);
26467
26520
  }
26468
26521
  /** Read stored PTZ presets from the `PtzPreset` config block. */
26469
26522
  async getPtzPresets(channel) {
@@ -26487,6 +26540,131 @@ var AmcrestCgiClient = class AmcrestCgiClient {
26487
26540
  }));
26488
26541
  }
26489
26542
  };
26543
+ /**
26544
+ * Subscribe to a camera's Dahua CGI event-manager attach stream. Returns
26545
+ * an `AbortController` — `controller.abort()` tears the subscription
26546
+ * down. Reconnect logic is the caller's responsibility (the parser stays
26547
+ * simple; the device class owns lifecycle / backoff timing). Mirrors the
26548
+ * Hikvision provider's `subscribeAlarms` shape so both HTTP-stream
26549
+ * providers read the same way.
26550
+ *
26551
+ * Unlike Hikvision's XML alert stream, Dahua parts are `text/plain`
26552
+ * key=value blocks — `parseAmcrestEventBody` handles the projection.
26553
+ */
26554
+ function subscribeAmcrestEvents(client, codes, handlers) {
26555
+ const controller = new AbortController();
26556
+ (async () => {
26557
+ try {
26558
+ const res = await client.openEventStream(codes, controller.signal);
26559
+ if (!res.ok || !res.body) {
26560
+ handlers.onError(/* @__PURE__ */ new Error(`event stream HTTP ${res.status} ${res.statusText}`));
26561
+ return;
26562
+ }
26563
+ const ct = res.headers.get("content-type") ?? "";
26564
+ const boundary = parseBoundary(ct);
26565
+ if (!boundary) {
26566
+ handlers.onError(/* @__PURE__ */ new Error(`event stream missing multipart boundary in Content-Type: "${ct}"`));
26567
+ return;
26568
+ }
26569
+ handlers.onConnected?.();
26570
+ await pumpEventStream(res.body, boundary, handlers);
26571
+ if (!controller.signal.aborted) handlers.onError(/* @__PURE__ */ new Error("event stream ended"));
26572
+ } catch (err) {
26573
+ if (controller.signal.aborted) return;
26574
+ handlers.onError(err);
26575
+ }
26576
+ })();
26577
+ return controller;
26578
+ }
26579
+ function parseBoundary(contentType) {
26580
+ const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
26581
+ return m ? m[1].trim() : null;
26582
+ }
26583
+ async function pumpEventStream(stream, boundary, handlers) {
26584
+ const reader = stream.getReader();
26585
+ const dashBoundary = `--${boundary}`;
26586
+ let bufferedBytes = new Uint8Array(0);
26587
+ try {
26588
+ while (true) {
26589
+ const { value, done } = await reader.read();
26590
+ if (done) break;
26591
+ if (!value || value.byteLength === 0) continue;
26592
+ bufferedBytes = concatBytes(bufferedBytes, value);
26593
+ const parts = splitOnBoundary(bufferedBytes, dashBoundary);
26594
+ bufferedBytes = parts.tail;
26595
+ for (const block of parts.blocks) {
26596
+ const parsed = parseMultipartBlock(block);
26597
+ if (!parsed) continue;
26598
+ const ev = parseAmcrestEventBody(parsed.bodyText);
26599
+ if (ev) handlers.onEvent(ev);
26600
+ }
26601
+ }
26602
+ } finally {
26603
+ try {
26604
+ reader.releaseLock();
26605
+ } catch {}
26606
+ }
26607
+ }
26608
+ function concatBytes(a, b) {
26609
+ const out = new Uint8Array(a.byteLength + b.byteLength);
26610
+ out.set(a, 0);
26611
+ out.set(b, a.byteLength);
26612
+ return out;
26613
+ }
26614
+ function splitOnBoundary(buf, dashBoundary) {
26615
+ const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
26616
+ if (parts.length <= 1) return {
26617
+ blocks: [],
26618
+ tail: buf
26619
+ };
26620
+ const tail = Buffer.from(parts.pop(), "binary");
26621
+ parts.shift();
26622
+ return {
26623
+ blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
26624
+ tail
26625
+ };
26626
+ }
26627
+ function parseMultipartBlock(block) {
26628
+ let start = 0;
26629
+ while (start < block.length && (block[start] === 13 || block[start] === 10)) start++;
26630
+ const headerEnd = findHeaderEnd(block, start);
26631
+ const bodyStart = headerEnd < 0 ? start : headerEnd + 4;
26632
+ const bodySlice = block.subarray(bodyStart);
26633
+ return { bodyText: Buffer.from(bodySlice).toString("utf8").replace(/\r?\n--$/m, "").trim() };
26634
+ }
26635
+ function findHeaderEnd(buf, from) {
26636
+ for (let i = from; i + 3 < buf.length; i++) if (buf[i] === 13 && buf[i + 1] === 10 && buf[i + 2] === 13 && buf[i + 3] === 10) return i;
26637
+ return -1;
26638
+ }
26639
+ /**
26640
+ * Parse a Dahua event block body. The salient line is a `;`-delimited
26641
+ * key=value list, e.g. `Code=VideoMotion;action=Start;index=0`. A
26642
+ * trailing multi-line `data={…}` JSON blob (present on some firmwares) is
26643
+ * ignored — motion needs only code/action/index. Returns null when no
26644
+ * `Code=` line is present (e.g. an empty heartbeat frame).
26645
+ */
26646
+ function parseAmcrestEventBody(body) {
26647
+ const codeLine = body.split(/\r?\n/).find((l) => l.includes("Code=")) ?? "";
26648
+ if (!codeLine) return null;
26649
+ const fields = /* @__PURE__ */ new Map();
26650
+ for (const pair of codeLine.split(";")) {
26651
+ const eq = pair.indexOf("=");
26652
+ if (eq < 0) continue;
26653
+ fields.set(pair.slice(0, eq).trim().toLowerCase(), pair.slice(eq + 1).trim());
26654
+ }
26655
+ const code = fields.get("code");
26656
+ const action = fields.get("action");
26657
+ if (!code || !action) return null;
26658
+ const indexRaw = fields.get("index");
26659
+ const index = indexRaw !== void 0 && /^\d+$/.test(indexRaw) ? Number.parseInt(indexRaw, 10) : null;
26660
+ return {
26661
+ observedAt: Date.now(),
26662
+ code,
26663
+ action,
26664
+ index,
26665
+ raw: body
26666
+ };
26667
+ }
26490
26668
  //#endregion
26491
26669
  //#region src/amcrest-camera.ts
26492
26670
  /**
@@ -26538,11 +26716,34 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26538
26716
  this.registerPtzCap();
26539
26717
  this.registerStreamParamsCap();
26540
26718
  this.registerMotionZonesCap();
26719
+ this.registerMotionCap();
26541
26720
  this.registerPrivacyMaskCap();
26542
26721
  this.registerOsdCap();
26543
26722
  this.registerDayNightCap();
26544
26723
  this.registerImageSettingsCap();
26545
26724
  }
26725
+ /** Dahua CGI `eventManager` attach codes — VideoMotion is the base
26726
+ * onboard-motion signal (Start/Stop). Smart sub-codes
26727
+ * (SmartMotionHuman/Vehicle) are camera-AI detections handled by a
26728
+ * separate cap and intentionally out of scope here. */
26729
+ static MOTION_EVENT_CODES = "[VideoMotion]";
26730
+ /** Re-emit interval (ms) for a sustained motion window. Dahua fires a
26731
+ * single `VideoMotion action=Start` for the whole window (no per-tick
26732
+ * repeats), but the pipeline-runner re-arms its `motionCooldownMs` on
26733
+ * every `detected:true`. Without a heartbeat a >cooldown motion window
26734
+ * would lapse the runner back to `watching`; a bounded ~0.1 Hz re-emit
26735
+ * holds it `active` until the `Stop`. */
26736
+ static MOTION_KEEPALIVE_MS = 1e4;
26737
+ /** Active event-stream subscription; aborted on dispose / conn change. */
26738
+ eventStreamController = null;
26739
+ /** Reconnect attempt counter — drives exponential backoff. */
26740
+ eventStreamReconnectAttempts = 0;
26741
+ /** Pending reconnect timer. */
26742
+ eventStreamReconnectTimer = null;
26743
+ /** True between a `VideoMotion Start` and its `Stop`. */
26744
+ motionActive = false;
26745
+ /** Keepalive re-emit timer for a sustained motion window. */
26746
+ motionKeepaliveTimer = null;
26546
26747
  /** Single-flight guard for the `stream-params` camera refresh. */
26547
26748
  streamParamsRefreshInFlight = null;
26548
26749
  /** Single-flight guard for the `motion-zones` camera refresh. */
@@ -26573,7 +26774,155 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26573
26774
  * (possibly just-edited) credentials/host. */
26574
26775
  async applySettingsPatch(patch) {
26575
26776
  await super.applySettingsPatch(patch);
26576
- if ("host" in patch || "port" in patch || "https" in patch || "username" in patch || "password" in patch) this.client = null;
26777
+ if ("host" in patch || "port" in patch || "https" in patch || "username" in patch || "password" in patch) {
26778
+ this.clearPtzAutoStop();
26779
+ this.client = null;
26780
+ const hadStream = this.eventStreamController !== null || this.eventStreamReconnectTimer !== null;
26781
+ this.teardownEventSubscription();
26782
+ if (hadStream) this.ensureEventSubscription();
26783
+ }
26784
+ }
26785
+ /** Register the native `motion` cap. Its mere presence (a `kind:'native'`
26786
+ * `motion` binding) is what the orchestrator reads to default a
26787
+ * freshly-adopted camera to `motionSources:['onboard']`. */
26788
+ registerMotionCap() {
26789
+ this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
26790
+ if (deviceId !== this.id) throw new Error(`AmcrestCamera: motion deviceId mismatch, expected ${this.id}, got ${deviceId}`);
26791
+ return this.getCapSlice(motionCapability)?.detected ?? false;
26792
+ } });
26793
+ this.setCapSlice(motionCapability, {
26794
+ detected: false,
26795
+ lastDetectedAt: null,
26796
+ autoClearAfterMs: null
26797
+ });
26798
+ this.ctx.logger.info("amcrest: motion cap registered", { tags: { deviceId: this.id } });
26799
+ }
26800
+ /** Reusable event-source identity for every emit on this device. */
26801
+ eventSource() {
26802
+ return {
26803
+ type: "device",
26804
+ id: this.id,
26805
+ addonId: AMCREST_ADDON_ID,
26806
+ deviceId: this.id
26807
+ };
26808
+ }
26809
+ /** Emit an onboard motion transition on the bus. The runner subscribes
26810
+ * and drives its phase machine via `reportMotion`. */
26811
+ emitMotion(detected, timestamp) {
26812
+ this.ctx.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, this.eventSource(), {
26813
+ deviceId: this.id,
26814
+ detected,
26815
+ timestamp,
26816
+ source: "onboard"
26817
+ }));
26818
+ }
26819
+ /** Idempotently open the eventManager attach stream. */
26820
+ ensureEventSubscription() {
26821
+ if (this.eventStreamController) return;
26822
+ const client = this.ensureClient();
26823
+ this.eventStreamController = subscribeAmcrestEvents(client, AmcrestCamera.MOTION_EVENT_CODES, {
26824
+ onConnected: () => {
26825
+ this.eventStreamReconnectAttempts = 0;
26826
+ if (this.motionActive) this.onMotionStop(Date.now());
26827
+ this.ctx.logger.info("amcrest: event stream connected", { tags: { deviceId: this.id } });
26828
+ },
26829
+ onEvent: (ev) => this.handleAmcrestEvent(ev),
26830
+ onError: (err) => {
26831
+ this.ctx.logger.warn("amcrest: event stream error — will reconnect", {
26832
+ tags: { deviceId: this.id },
26833
+ meta: { error: err instanceof Error ? err.message : String(err) }
26834
+ });
26835
+ this.eventStreamController = null;
26836
+ this.scheduleEventReconnect();
26837
+ }
26838
+ });
26839
+ }
26840
+ /** Single pending reconnect timer with capped exponential backoff
26841
+ * (1s → 60s) — same shape as the Hikvision alarm stream. */
26842
+ scheduleEventReconnect() {
26843
+ if (this.eventStreamReconnectTimer) return;
26844
+ const attempts = ++this.eventStreamReconnectAttempts;
26845
+ const delayMs = Math.min(6e4, 1e3 * 2 ** Math.min(attempts, 6));
26846
+ this.eventStreamReconnectTimer = setTimeout(() => {
26847
+ this.eventStreamReconnectTimer = null;
26848
+ this.ensureEventSubscription();
26849
+ }, delayMs);
26850
+ }
26851
+ handleAmcrestEvent(ev) {
26852
+ if (ev.code !== "VideoMotion") {
26853
+ this.ctx.logger.debug("amcrest: unhandled event", {
26854
+ tags: { deviceId: this.id },
26855
+ meta: {
26856
+ code: ev.code,
26857
+ action: ev.action
26858
+ }
26859
+ });
26860
+ return;
26861
+ }
26862
+ const action = ev.action.toLowerCase();
26863
+ if (action === "start" || action === "pulse") this.onMotionStart(ev.observedAt);
26864
+ else if (action === "stop") this.onMotionStop(ev.observedAt);
26865
+ }
26866
+ onMotionStart(now) {
26867
+ const wasActive = this.motionActive;
26868
+ this.motionActive = true;
26869
+ this.emitMotion(true, now);
26870
+ if (!wasActive) {
26871
+ this.setCapSlice(motionCapability, {
26872
+ detected: true,
26873
+ lastDetectedAt: now,
26874
+ autoClearAfterMs: null
26875
+ });
26876
+ this.ctx.logger.info("amcrest: onboard motion started", { tags: { deviceId: this.id } });
26877
+ }
26878
+ if (this.motionKeepaliveTimer === null) this.motionKeepaliveTimer = setInterval(() => {
26879
+ this.emitMotion(true, Date.now());
26880
+ }, AmcrestCamera.MOTION_KEEPALIVE_MS);
26881
+ }
26882
+ onMotionStop(now) {
26883
+ this.clearMotionKeepalive();
26884
+ if (!this.motionActive) return;
26885
+ this.motionActive = false;
26886
+ this.emitMotion(false, now);
26887
+ const lastDetectedAt = this.getCapSlice(motionCapability)?.lastDetectedAt ?? now;
26888
+ this.setCapSlice(motionCapability, {
26889
+ detected: false,
26890
+ lastDetectedAt,
26891
+ autoClearAfterMs: null
26892
+ });
26893
+ this.ctx.logger.info("amcrest: onboard motion stopped", { tags: { deviceId: this.id } });
26894
+ }
26895
+ clearMotionKeepalive() {
26896
+ if (this.motionKeepaliveTimer !== null) {
26897
+ clearInterval(this.motionKeepaliveTimer);
26898
+ this.motionKeepaliveTimer = null;
26899
+ }
26900
+ }
26901
+ /** Abort the stream + clear all motion timers. Idempotent. */
26902
+ teardownEventSubscription() {
26903
+ if (this.eventStreamReconnectTimer) {
26904
+ clearTimeout(this.eventStreamReconnectTimer);
26905
+ this.eventStreamReconnectTimer = null;
26906
+ }
26907
+ if (this.eventStreamController) {
26908
+ try {
26909
+ this.eventStreamController.abort();
26910
+ } catch {}
26911
+ this.eventStreamController = null;
26912
+ }
26913
+ this.clearMotionKeepalive();
26914
+ this.motionActive = false;
26915
+ }
26916
+ /** Phase 5 — device is live: open the onboard-motion event stream. */
26917
+ async onActivate() {
26918
+ this.ensureEventSubscription();
26919
+ }
26920
+ /** Teardown — stop the stream + motion timers, drop the client. */
26921
+ async removeDevice() {
26922
+ this.ctx.logger.info("Removing Amcrest camera", { tags: { deviceId: this.id } });
26923
+ this.teardownEventSubscription();
26924
+ this.clearPtzAutoStop();
26925
+ this.client = null;
26577
26926
  }
26578
26927
  registerStreamCatalogProvider() {
26579
26928
  this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
@@ -26680,16 +27029,41 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26680
27029
  invalidateCache: async () => {}
26681
27030
  });
26682
27031
  }
27032
+ /** Duration (ms) a one-shot `move` pulse runs before its safety
27033
+ * auto-stop fires. The `usePTZ` UI hook already issues its own paired
27034
+ * `stop` after a client-side pulse window; this timer is a backstop so
27035
+ * a lone `move` (no following `stop`) can't leave the head gliding. */
27036
+ static PTZ_PULSE_MS = 350;
27037
+ /** Short per-call deadline (ms) for the PTZ start/stop CGI so a slow or
27038
+ * blocked camera round-trip fails fast instead of exceeding the
27039
+ * hub-local-uds transport's abort window — the failure mode that
27040
+ * surfaced live as
27041
+ * `ptz.move ... hub-local-uds=This operation was aborted`. */
27042
+ static PTZ_CGI_TIMEOUT_MS = 2500;
26683
27043
  /** Dahua `ptz.cgi` direction/zoom codes currently started — tracked so
26684
27044
  * `stop()` / the next `continuousMove()` can cleanly halt exactly the
26685
27045
  * codes that are actually moving instead of guessing. */
26686
27046
  activePtzCodes = /* @__PURE__ */ new Set();
27047
+ /** Pending safety auto-stop for the last one-shot `move` pulse.
27048
+ * Cleared by any subsequent move/continuousMove/stop so pulses and
27049
+ * gestures never race a stale timer. */
27050
+ ptzAutoStopTimer = null;
27051
+ /** Cancel the pending one-shot `move` auto-stop, if any. */
27052
+ clearPtzAutoStop() {
27053
+ if (this.ptzAutoStopTimer !== null) {
27054
+ clearTimeout(this.ptzAutoStopTimer);
27055
+ this.ptzAutoStopTimer = null;
27056
+ }
27057
+ }
26687
27058
  /** Resolve a cap `PtzMoveCommand` into 0-2 Dahua CGI move commands
26688
27059
  * (one pan/tilt code — possibly diagonal — plus one zoom code).
26689
- * `pan`/`tilt`/`zoom` are normalized -1..1; magnitude maps to Dahua's
26690
- * 1-8 speed scale. */
26691
- resolvePtzCodes(pan, tilt, zoom) {
26692
- const speedFor = (magnitude) => Math.max(1, Math.min(8, Math.round(Math.abs(magnitude) * 8)));
27060
+ * `pan`/`tilt`/`zoom` are normalized -1..1. Speed maps to Dahua's 1-8
27061
+ * scale: the explicit cap-level `speed` (0..1, from the UI speed
27062
+ * slider) wins when supplied; otherwise the vector magnitude is used. */
27063
+ resolvePtzCodes(pan, tilt, zoom, speed) {
27064
+ const speedFor = (magnitude) => {
27065
+ return Math.max(1, Math.min(8, Math.round((speed !== void 0 ? Math.abs(speed) : Math.abs(magnitude)) * 8)));
27066
+ };
26693
27067
  const out = [];
26694
27068
  const p = pan ?? 0;
26695
27069
  const t = tilt ?? 0;
@@ -26715,7 +27089,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26715
27089
  const channel = this.channel;
26716
27090
  const codes = [...this.activePtzCodes];
26717
27091
  this.activePtzCodes.clear();
26718
- await Promise.all(codes.map((code) => client.ptzStop(channel, code).catch((err) => {
27092
+ await Promise.all(codes.map((code) => client.ptzStop(channel, code, AmcrestCamera.PTZ_CGI_TIMEOUT_MS).catch((err) => {
26719
27093
  this.ctx.logger.debug("amcrest: ptzStop failed (continuing)", {
26720
27094
  tags: { deviceId: this.id },
26721
27095
  meta: {
@@ -26728,31 +27102,57 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26728
27102
  async startPtzCodes(codes) {
26729
27103
  const client = this.ensureClient();
26730
27104
  const channel = this.channel;
26731
- for (const { code, speed } of codes) {
27105
+ await Promise.all(codes.map(({ code, speed }) => {
26732
27106
  this.activePtzCodes.add(code);
26733
- await client.ptzStart(channel, code, speed);
26734
- }
27107
+ return client.ptzStart(channel, code, speed, AmcrestCamera.PTZ_CGI_TIMEOUT_MS);
27108
+ }));
27109
+ }
27110
+ /** Serialized tail of PTZ CGI operations for this camera. */
27111
+ ptzChain = Promise.resolve();
27112
+ /** Serialize a PTZ CGI op onto the per-camera chain and return AT ONCE.
27113
+ * The UI models a short tap as `move`+`stop` and a long press as
27114
+ * `continuousMove`+`stop`. Two problems the chain solves together:
27115
+ * (1) awaiting the camera round-trip (digest handshake + CGI) in the cap
27116
+ * call makes the hub-local-uds transport abort it → `This operation was
27117
+ * aborted`, head never moves; (2) issuing start/stop as independent
27118
+ * fire-and-forget fetches RACES them — a `stop` fetch can land before its
27119
+ * `start`, leaving the head gliding or never moving (why tap "only worked
27120
+ * on long press"). Chaining runs each op to completion IN ORDER (start
27121
+ * before its paired stop) while the cap returns immediately. */
27122
+ enqueuePtz(label, op) {
27123
+ this.ptzChain = this.ptzChain.then(op).catch((err) => {
27124
+ this.ctx.logger.debug(`amcrest: ptz ${label} failed`, {
27125
+ tags: { deviceId: this.id },
27126
+ meta: { error: err instanceof Error ? err.message : String(err) }
27127
+ });
27128
+ });
26735
27129
  }
26736
27130
  registerPtzCap() {
26737
27131
  this.ctx.registerNativeCap(ptzCapability, {
26738
- move: async ({ deviceId, pan, tilt, zoom }) => {
27132
+ move: async ({ deviceId, pan, tilt, zoom, speed }) => {
26739
27133
  if (deviceId !== this.id) return;
26740
- await this.stopActivePtz();
26741
- const codes = this.resolvePtzCodes(pan, tilt, zoom);
27134
+ this.clearPtzAutoStop();
27135
+ const codes = this.resolvePtzCodes(pan, tilt, zoom, speed);
26742
27136
  if (codes.length === 0) return;
26743
- await this.startPtzCodes(codes);
26744
- await new Promise((resolve) => setTimeout(resolve, 500));
26745
- await this.stopActivePtz();
27137
+ this.enqueuePtz("move", () => this.startPtzCodes(codes));
27138
+ this.ptzAutoStopTimer = setTimeout(() => {
27139
+ this.ptzAutoStopTimer = null;
27140
+ this.enqueuePtz("auto-stop", () => this.stopActivePtz());
27141
+ }, AmcrestCamera.PTZ_PULSE_MS);
26746
27142
  },
26747
- continuousMove: async ({ deviceId, pan, tilt, zoom }) => {
27143
+ continuousMove: async ({ deviceId, pan, tilt, zoom, speed }) => {
26748
27144
  if (deviceId !== this.id) return;
26749
- await this.stopActivePtz();
26750
- const codes = this.resolvePtzCodes(pan, tilt, zoom);
26751
- if (codes.length > 0) await this.startPtzCodes(codes);
27145
+ this.clearPtzAutoStop();
27146
+ const codes = this.resolvePtzCodes(pan, tilt, zoom, speed);
27147
+ this.enqueuePtz("continuousMove", async () => {
27148
+ await this.stopActivePtz();
27149
+ if (codes.length > 0) await this.startPtzCodes(codes);
27150
+ });
26752
27151
  },
26753
27152
  stop: async ({ deviceId }) => {
26754
27153
  if (deviceId !== this.id) return;
26755
- await this.stopActivePtz();
27154
+ this.clearPtzAutoStop();
27155
+ this.enqueuePtz("stop", () => this.stopActivePtz());
26756
27156
  },
26757
27157
  getPresets: async ({ deviceId }) => {
26758
27158
  if (deviceId !== this.id) return [];
@@ -26764,8 +27164,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26764
27164
  goToPreset: async ({ deviceId, presetId }) => {
26765
27165
  if (deviceId !== this.id) return;
26766
27166
  const id = Number.parseInt(presetId, 10);
26767
- if (!Number.isFinite(id)) return;
26768
- await this.ensureClient().ptzGotoPreset(this.channel, id);
27167
+ if (!Number.isFinite(id) || id < 1) return;
27168
+ const client = this.ensureClient();
27169
+ this.enqueuePtz("goToPreset", () => client.ptzGotoPreset(this.channel, id, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
26769
27170
  },
26770
27171
  savePreset: async ({ deviceId, presetId, name }) => {
26771
27172
  if (deviceId !== this.id) return;
@@ -26801,7 +27202,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26801
27202
  },
26802
27203
  goHome: async ({ deviceId }) => {
26803
27204
  if (deviceId !== this.id) return;
26804
- await this.ensureClient().ptzGotoPreset(this.channel, 1);
27205
+ const client = this.ensureClient();
27206
+ this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, 1, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
26805
27207
  },
26806
27208
  getPosition: async () => {
26807
27209
  return {
@@ -36728,3 +37130,4 @@ exports.AmcrestCamera = AmcrestCamera;
36728
37130
  exports.AmcrestCgiClient = AmcrestCgiClient;
36729
37131
  exports.AmcrestProviderAddon = AmcrestProviderAddon;
36730
37132
  exports.amcrestCameraSchema = amcrestCameraSchema;
37133
+ exports.subscribeAmcrestEvents = subscribeAmcrestEvents;
package/dist/addon.mjs CHANGED
@@ -26424,6 +26424,54 @@ var AmcrestCgiClient = class AmcrestCgiClient {
26424
26424
  const body = await this.getText(`/cgi-bin/configManager.cgi?action=setConfig&${query}`);
26425
26425
  if (!/\bOK\b/i.test(body)) throw new Error(`setConfig rejected: ${body.trim().slice(0, 200)}`);
26426
26426
  }
26427
+ /**
26428
+ * Open the Dahua CGI event-manager attach stream — a long-lived
26429
+ * `multipart/x-mixed-replace` GET that pushes camera events
26430
+ * (`VideoMotion` Start/Stop, heartbeats, …) until the connection drops
26431
+ * or the caller aborts. Performs the same digest handshake as `send`
26432
+ * (absorbing the initial 401) but — critically — installs NO
26433
+ * per-request timeout: the long-poll must never self-cancel, so
26434
+ * teardown is driven solely by `signal.abort()`. `codes` is passed
26435
+ * through unescaped (Dahua requires the literal `[...]` brackets, same
26436
+ * as `setConfig`), e.g. `'[VideoMotion]'` or `'[All]'`.
26437
+ */
26438
+ async openEventStream(codes, signal) {
26439
+ const path = `/cgi-bin/eventManager.cgi?action=attach&codes=${codes}`;
26440
+ const url = `${this.scheme}://${this.host}:${this.port}${path}`;
26441
+ const firstHeader = buildAuthHeader({
26442
+ state: this.digestState,
26443
+ method: "GET",
26444
+ uri: path,
26445
+ username: this.username,
26446
+ password: this.password
26447
+ });
26448
+ let res = await fetch(url, {
26449
+ method: "GET",
26450
+ headers: firstHeader ? { authorization: firstHeader } : {},
26451
+ signal
26452
+ });
26453
+ if (res.status === 401) {
26454
+ await res.body?.cancel().catch(() => {});
26455
+ const challenge = parseChallenge(res.headers.get("www-authenticate"));
26456
+ if (challenge) {
26457
+ this.digestState.challenge = challenge;
26458
+ this.digestState.nc = 0;
26459
+ const retryHeader = buildAuthHeader({
26460
+ state: this.digestState,
26461
+ method: "GET",
26462
+ uri: path,
26463
+ username: this.username,
26464
+ password: this.password
26465
+ });
26466
+ res = await fetch(url, {
26467
+ method: "GET",
26468
+ headers: retryHeader ? { authorization: retryHeader } : {},
26469
+ signal
26470
+ });
26471
+ }
26472
+ }
26473
+ return res;
26474
+ }
26427
26475
  async getEncodeConfig() {
26428
26476
  return this.getConfig("Encode");
26429
26477
  }
@@ -26450,21 +26498,26 @@ var AmcrestCgiClient = class AmcrestCgiClient {
26450
26498
  };
26451
26499
  }
26452
26500
  /** Begin a continuous PTZ move. `code` is a Dahua direction token
26453
- * (`Left`/`Right`/`Up`/`Down`). `speed` maps to arg2 (1..8 typical). */
26454
- async ptzStart(channel, code, speed) {
26455
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=${code}&arg1=0&arg2=${speed}&arg3=0`);
26501
+ * (`Left`/`Right`/`Up`/`Down`/`ZoomTele`/`ZoomWide`). `speed` maps to
26502
+ * arg2 (1..8 typical). `timeoutMs` (optional) lets the caller pass a
26503
+ * short per-tick deadline so a slow/blocked camera round-trip fails
26504
+ * fast rather than hanging the whole cap call. The Dahua `start`
26505
+ * action is fire-and-return — the head keeps moving until a matching
26506
+ * `stop` is sent — so this must resolve quickly. */
26507
+ async ptzStart(channel, code, speed, timeoutMs) {
26508
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=${code}&arg1=0&arg2=${speed}&arg3=0`, timeoutMs);
26456
26509
  }
26457
- async ptzStop(channel, code) {
26458
- await this.getText(`/cgi-bin/ptz.cgi?action=stop&channel=${channel}&code=${code}&arg1=0&arg2=0&arg3=0`);
26510
+ async ptzStop(channel, code, timeoutMs) {
26511
+ await this.getText(`/cgi-bin/ptz.cgi?action=stop&channel=${channel}&code=${code}&arg1=0&arg2=0&arg3=0`, timeoutMs);
26459
26512
  }
26460
- async ptzGotoPreset(channel, presetId) {
26461
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${presetId}&arg3=0`);
26513
+ async ptzGotoPreset(channel, presetId, timeoutMs) {
26514
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=GotoPreset&arg1=0&arg2=${presetId}&arg3=0`, timeoutMs);
26462
26515
  }
26463
- async ptzSetPreset(channel, presetId) {
26464
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=SetPreset&arg1=0&arg2=${presetId}&arg3=0`);
26516
+ async ptzSetPreset(channel, presetId, timeoutMs) {
26517
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=SetPreset&arg1=0&arg2=${presetId}&arg3=0`, timeoutMs);
26465
26518
  }
26466
- async ptzClearPreset(channel, presetId) {
26467
- await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=ClearPreset&arg1=0&arg2=${presetId}&arg3=0`);
26519
+ async ptzClearPreset(channel, presetId, timeoutMs) {
26520
+ await this.getText(`/cgi-bin/ptz.cgi?action=start&channel=${channel}&code=ClearPreset&arg1=0&arg2=${presetId}&arg3=0`, timeoutMs);
26468
26521
  }
26469
26522
  /** Read stored PTZ presets from the `PtzPreset` config block. */
26470
26523
  async getPtzPresets(channel) {
@@ -26488,6 +26541,131 @@ var AmcrestCgiClient = class AmcrestCgiClient {
26488
26541
  }));
26489
26542
  }
26490
26543
  };
26544
+ /**
26545
+ * Subscribe to a camera's Dahua CGI event-manager attach stream. Returns
26546
+ * an `AbortController` — `controller.abort()` tears the subscription
26547
+ * down. Reconnect logic is the caller's responsibility (the parser stays
26548
+ * simple; the device class owns lifecycle / backoff timing). Mirrors the
26549
+ * Hikvision provider's `subscribeAlarms` shape so both HTTP-stream
26550
+ * providers read the same way.
26551
+ *
26552
+ * Unlike Hikvision's XML alert stream, Dahua parts are `text/plain`
26553
+ * key=value blocks — `parseAmcrestEventBody` handles the projection.
26554
+ */
26555
+ function subscribeAmcrestEvents(client, codes, handlers) {
26556
+ const controller = new AbortController();
26557
+ (async () => {
26558
+ try {
26559
+ const res = await client.openEventStream(codes, controller.signal);
26560
+ if (!res.ok || !res.body) {
26561
+ handlers.onError(/* @__PURE__ */ new Error(`event stream HTTP ${res.status} ${res.statusText}`));
26562
+ return;
26563
+ }
26564
+ const ct = res.headers.get("content-type") ?? "";
26565
+ const boundary = parseBoundary(ct);
26566
+ if (!boundary) {
26567
+ handlers.onError(/* @__PURE__ */ new Error(`event stream missing multipart boundary in Content-Type: "${ct}"`));
26568
+ return;
26569
+ }
26570
+ handlers.onConnected?.();
26571
+ await pumpEventStream(res.body, boundary, handlers);
26572
+ if (!controller.signal.aborted) handlers.onError(/* @__PURE__ */ new Error("event stream ended"));
26573
+ } catch (err) {
26574
+ if (controller.signal.aborted) return;
26575
+ handlers.onError(err);
26576
+ }
26577
+ })();
26578
+ return controller;
26579
+ }
26580
+ function parseBoundary(contentType) {
26581
+ const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
26582
+ return m ? m[1].trim() : null;
26583
+ }
26584
+ async function pumpEventStream(stream, boundary, handlers) {
26585
+ const reader = stream.getReader();
26586
+ const dashBoundary = `--${boundary}`;
26587
+ let bufferedBytes = new Uint8Array(0);
26588
+ try {
26589
+ while (true) {
26590
+ const { value, done } = await reader.read();
26591
+ if (done) break;
26592
+ if (!value || value.byteLength === 0) continue;
26593
+ bufferedBytes = concatBytes(bufferedBytes, value);
26594
+ const parts = splitOnBoundary(bufferedBytes, dashBoundary);
26595
+ bufferedBytes = parts.tail;
26596
+ for (const block of parts.blocks) {
26597
+ const parsed = parseMultipartBlock(block);
26598
+ if (!parsed) continue;
26599
+ const ev = parseAmcrestEventBody(parsed.bodyText);
26600
+ if (ev) handlers.onEvent(ev);
26601
+ }
26602
+ }
26603
+ } finally {
26604
+ try {
26605
+ reader.releaseLock();
26606
+ } catch {}
26607
+ }
26608
+ }
26609
+ function concatBytes(a, b) {
26610
+ const out = new Uint8Array(a.byteLength + b.byteLength);
26611
+ out.set(a, 0);
26612
+ out.set(b, a.byteLength);
26613
+ return out;
26614
+ }
26615
+ function splitOnBoundary(buf, dashBoundary) {
26616
+ const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
26617
+ if (parts.length <= 1) return {
26618
+ blocks: [],
26619
+ tail: buf
26620
+ };
26621
+ const tail = Buffer.from(parts.pop(), "binary");
26622
+ parts.shift();
26623
+ return {
26624
+ blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
26625
+ tail
26626
+ };
26627
+ }
26628
+ function parseMultipartBlock(block) {
26629
+ let start = 0;
26630
+ while (start < block.length && (block[start] === 13 || block[start] === 10)) start++;
26631
+ const headerEnd = findHeaderEnd(block, start);
26632
+ const bodyStart = headerEnd < 0 ? start : headerEnd + 4;
26633
+ const bodySlice = block.subarray(bodyStart);
26634
+ return { bodyText: Buffer.from(bodySlice).toString("utf8").replace(/\r?\n--$/m, "").trim() };
26635
+ }
26636
+ function findHeaderEnd(buf, from) {
26637
+ for (let i = from; i + 3 < buf.length; i++) if (buf[i] === 13 && buf[i + 1] === 10 && buf[i + 2] === 13 && buf[i + 3] === 10) return i;
26638
+ return -1;
26639
+ }
26640
+ /**
26641
+ * Parse a Dahua event block body. The salient line is a `;`-delimited
26642
+ * key=value list, e.g. `Code=VideoMotion;action=Start;index=0`. A
26643
+ * trailing multi-line `data={…}` JSON blob (present on some firmwares) is
26644
+ * ignored — motion needs only code/action/index. Returns null when no
26645
+ * `Code=` line is present (e.g. an empty heartbeat frame).
26646
+ */
26647
+ function parseAmcrestEventBody(body) {
26648
+ const codeLine = body.split(/\r?\n/).find((l) => l.includes("Code=")) ?? "";
26649
+ if (!codeLine) return null;
26650
+ const fields = /* @__PURE__ */ new Map();
26651
+ for (const pair of codeLine.split(";")) {
26652
+ const eq = pair.indexOf("=");
26653
+ if (eq < 0) continue;
26654
+ fields.set(pair.slice(0, eq).trim().toLowerCase(), pair.slice(eq + 1).trim());
26655
+ }
26656
+ const code = fields.get("code");
26657
+ const action = fields.get("action");
26658
+ if (!code || !action) return null;
26659
+ const indexRaw = fields.get("index");
26660
+ const index = indexRaw !== void 0 && /^\d+$/.test(indexRaw) ? Number.parseInt(indexRaw, 10) : null;
26661
+ return {
26662
+ observedAt: Date.now(),
26663
+ code,
26664
+ action,
26665
+ index,
26666
+ raw: body
26667
+ };
26668
+ }
26491
26669
  //#endregion
26492
26670
  //#region src/amcrest-camera.ts
26493
26671
  /**
@@ -26539,11 +26717,34 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26539
26717
  this.registerPtzCap();
26540
26718
  this.registerStreamParamsCap();
26541
26719
  this.registerMotionZonesCap();
26720
+ this.registerMotionCap();
26542
26721
  this.registerPrivacyMaskCap();
26543
26722
  this.registerOsdCap();
26544
26723
  this.registerDayNightCap();
26545
26724
  this.registerImageSettingsCap();
26546
26725
  }
26726
+ /** Dahua CGI `eventManager` attach codes — VideoMotion is the base
26727
+ * onboard-motion signal (Start/Stop). Smart sub-codes
26728
+ * (SmartMotionHuman/Vehicle) are camera-AI detections handled by a
26729
+ * separate cap and intentionally out of scope here. */
26730
+ static MOTION_EVENT_CODES = "[VideoMotion]";
26731
+ /** Re-emit interval (ms) for a sustained motion window. Dahua fires a
26732
+ * single `VideoMotion action=Start` for the whole window (no per-tick
26733
+ * repeats), but the pipeline-runner re-arms its `motionCooldownMs` on
26734
+ * every `detected:true`. Without a heartbeat a >cooldown motion window
26735
+ * would lapse the runner back to `watching`; a bounded ~0.1 Hz re-emit
26736
+ * holds it `active` until the `Stop`. */
26737
+ static MOTION_KEEPALIVE_MS = 1e4;
26738
+ /** Active event-stream subscription; aborted on dispose / conn change. */
26739
+ eventStreamController = null;
26740
+ /** Reconnect attempt counter — drives exponential backoff. */
26741
+ eventStreamReconnectAttempts = 0;
26742
+ /** Pending reconnect timer. */
26743
+ eventStreamReconnectTimer = null;
26744
+ /** True between a `VideoMotion Start` and its `Stop`. */
26745
+ motionActive = false;
26746
+ /** Keepalive re-emit timer for a sustained motion window. */
26747
+ motionKeepaliveTimer = null;
26547
26748
  /** Single-flight guard for the `stream-params` camera refresh. */
26548
26749
  streamParamsRefreshInFlight = null;
26549
26750
  /** Single-flight guard for the `motion-zones` camera refresh. */
@@ -26574,7 +26775,155 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26574
26775
  * (possibly just-edited) credentials/host. */
26575
26776
  async applySettingsPatch(patch) {
26576
26777
  await super.applySettingsPatch(patch);
26577
- if ("host" in patch || "port" in patch || "https" in patch || "username" in patch || "password" in patch) this.client = null;
26778
+ if ("host" in patch || "port" in patch || "https" in patch || "username" in patch || "password" in patch) {
26779
+ this.clearPtzAutoStop();
26780
+ this.client = null;
26781
+ const hadStream = this.eventStreamController !== null || this.eventStreamReconnectTimer !== null;
26782
+ this.teardownEventSubscription();
26783
+ if (hadStream) this.ensureEventSubscription();
26784
+ }
26785
+ }
26786
+ /** Register the native `motion` cap. Its mere presence (a `kind:'native'`
26787
+ * `motion` binding) is what the orchestrator reads to default a
26788
+ * freshly-adopted camera to `motionSources:['onboard']`. */
26789
+ registerMotionCap() {
26790
+ this.ctx.registerNativeCap(motionCapability, { isDetected: async ({ deviceId }) => {
26791
+ if (deviceId !== this.id) throw new Error(`AmcrestCamera: motion deviceId mismatch, expected ${this.id}, got ${deviceId}`);
26792
+ return this.getCapSlice(motionCapability)?.detected ?? false;
26793
+ } });
26794
+ this.setCapSlice(motionCapability, {
26795
+ detected: false,
26796
+ lastDetectedAt: null,
26797
+ autoClearAfterMs: null
26798
+ });
26799
+ this.ctx.logger.info("amcrest: motion cap registered", { tags: { deviceId: this.id } });
26800
+ }
26801
+ /** Reusable event-source identity for every emit on this device. */
26802
+ eventSource() {
26803
+ return {
26804
+ type: "device",
26805
+ id: this.id,
26806
+ addonId: AMCREST_ADDON_ID,
26807
+ deviceId: this.id
26808
+ };
26809
+ }
26810
+ /** Emit an onboard motion transition on the bus. The runner subscribes
26811
+ * and drives its phase machine via `reportMotion`. */
26812
+ emitMotion(detected, timestamp) {
26813
+ this.ctx.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, this.eventSource(), {
26814
+ deviceId: this.id,
26815
+ detected,
26816
+ timestamp,
26817
+ source: "onboard"
26818
+ }));
26819
+ }
26820
+ /** Idempotently open the eventManager attach stream. */
26821
+ ensureEventSubscription() {
26822
+ if (this.eventStreamController) return;
26823
+ const client = this.ensureClient();
26824
+ this.eventStreamController = subscribeAmcrestEvents(client, AmcrestCamera.MOTION_EVENT_CODES, {
26825
+ onConnected: () => {
26826
+ this.eventStreamReconnectAttempts = 0;
26827
+ if (this.motionActive) this.onMotionStop(Date.now());
26828
+ this.ctx.logger.info("amcrest: event stream connected", { tags: { deviceId: this.id } });
26829
+ },
26830
+ onEvent: (ev) => this.handleAmcrestEvent(ev),
26831
+ onError: (err) => {
26832
+ this.ctx.logger.warn("amcrest: event stream error — will reconnect", {
26833
+ tags: { deviceId: this.id },
26834
+ meta: { error: err instanceof Error ? err.message : String(err) }
26835
+ });
26836
+ this.eventStreamController = null;
26837
+ this.scheduleEventReconnect();
26838
+ }
26839
+ });
26840
+ }
26841
+ /** Single pending reconnect timer with capped exponential backoff
26842
+ * (1s → 60s) — same shape as the Hikvision alarm stream. */
26843
+ scheduleEventReconnect() {
26844
+ if (this.eventStreamReconnectTimer) return;
26845
+ const attempts = ++this.eventStreamReconnectAttempts;
26846
+ const delayMs = Math.min(6e4, 1e3 * 2 ** Math.min(attempts, 6));
26847
+ this.eventStreamReconnectTimer = setTimeout(() => {
26848
+ this.eventStreamReconnectTimer = null;
26849
+ this.ensureEventSubscription();
26850
+ }, delayMs);
26851
+ }
26852
+ handleAmcrestEvent(ev) {
26853
+ if (ev.code !== "VideoMotion") {
26854
+ this.ctx.logger.debug("amcrest: unhandled event", {
26855
+ tags: { deviceId: this.id },
26856
+ meta: {
26857
+ code: ev.code,
26858
+ action: ev.action
26859
+ }
26860
+ });
26861
+ return;
26862
+ }
26863
+ const action = ev.action.toLowerCase();
26864
+ if (action === "start" || action === "pulse") this.onMotionStart(ev.observedAt);
26865
+ else if (action === "stop") this.onMotionStop(ev.observedAt);
26866
+ }
26867
+ onMotionStart(now) {
26868
+ const wasActive = this.motionActive;
26869
+ this.motionActive = true;
26870
+ this.emitMotion(true, now);
26871
+ if (!wasActive) {
26872
+ this.setCapSlice(motionCapability, {
26873
+ detected: true,
26874
+ lastDetectedAt: now,
26875
+ autoClearAfterMs: null
26876
+ });
26877
+ this.ctx.logger.info("amcrest: onboard motion started", { tags: { deviceId: this.id } });
26878
+ }
26879
+ if (this.motionKeepaliveTimer === null) this.motionKeepaliveTimer = setInterval(() => {
26880
+ this.emitMotion(true, Date.now());
26881
+ }, AmcrestCamera.MOTION_KEEPALIVE_MS);
26882
+ }
26883
+ onMotionStop(now) {
26884
+ this.clearMotionKeepalive();
26885
+ if (!this.motionActive) return;
26886
+ this.motionActive = false;
26887
+ this.emitMotion(false, now);
26888
+ const lastDetectedAt = this.getCapSlice(motionCapability)?.lastDetectedAt ?? now;
26889
+ this.setCapSlice(motionCapability, {
26890
+ detected: false,
26891
+ lastDetectedAt,
26892
+ autoClearAfterMs: null
26893
+ });
26894
+ this.ctx.logger.info("amcrest: onboard motion stopped", { tags: { deviceId: this.id } });
26895
+ }
26896
+ clearMotionKeepalive() {
26897
+ if (this.motionKeepaliveTimer !== null) {
26898
+ clearInterval(this.motionKeepaliveTimer);
26899
+ this.motionKeepaliveTimer = null;
26900
+ }
26901
+ }
26902
+ /** Abort the stream + clear all motion timers. Idempotent. */
26903
+ teardownEventSubscription() {
26904
+ if (this.eventStreamReconnectTimer) {
26905
+ clearTimeout(this.eventStreamReconnectTimer);
26906
+ this.eventStreamReconnectTimer = null;
26907
+ }
26908
+ if (this.eventStreamController) {
26909
+ try {
26910
+ this.eventStreamController.abort();
26911
+ } catch {}
26912
+ this.eventStreamController = null;
26913
+ }
26914
+ this.clearMotionKeepalive();
26915
+ this.motionActive = false;
26916
+ }
26917
+ /** Phase 5 — device is live: open the onboard-motion event stream. */
26918
+ async onActivate() {
26919
+ this.ensureEventSubscription();
26920
+ }
26921
+ /** Teardown — stop the stream + motion timers, drop the client. */
26922
+ async removeDevice() {
26923
+ this.ctx.logger.info("Removing Amcrest camera", { tags: { deviceId: this.id } });
26924
+ this.teardownEventSubscription();
26925
+ this.clearPtzAutoStop();
26926
+ this.client = null;
26578
26927
  }
26579
26928
  registerStreamCatalogProvider() {
26580
26929
  this.ctx.registerNativeCap(streamCatalogCapability, { getCatalog: async ({ deviceId }) => {
@@ -26681,16 +27030,41 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26681
27030
  invalidateCache: async () => {}
26682
27031
  });
26683
27032
  }
27033
+ /** Duration (ms) a one-shot `move` pulse runs before its safety
27034
+ * auto-stop fires. The `usePTZ` UI hook already issues its own paired
27035
+ * `stop` after a client-side pulse window; this timer is a backstop so
27036
+ * a lone `move` (no following `stop`) can't leave the head gliding. */
27037
+ static PTZ_PULSE_MS = 350;
27038
+ /** Short per-call deadline (ms) for the PTZ start/stop CGI so a slow or
27039
+ * blocked camera round-trip fails fast instead of exceeding the
27040
+ * hub-local-uds transport's abort window — the failure mode that
27041
+ * surfaced live as
27042
+ * `ptz.move ... hub-local-uds=This operation was aborted`. */
27043
+ static PTZ_CGI_TIMEOUT_MS = 2500;
26684
27044
  /** Dahua `ptz.cgi` direction/zoom codes currently started — tracked so
26685
27045
  * `stop()` / the next `continuousMove()` can cleanly halt exactly the
26686
27046
  * codes that are actually moving instead of guessing. */
26687
27047
  activePtzCodes = /* @__PURE__ */ new Set();
27048
+ /** Pending safety auto-stop for the last one-shot `move` pulse.
27049
+ * Cleared by any subsequent move/continuousMove/stop so pulses and
27050
+ * gestures never race a stale timer. */
27051
+ ptzAutoStopTimer = null;
27052
+ /** Cancel the pending one-shot `move` auto-stop, if any. */
27053
+ clearPtzAutoStop() {
27054
+ if (this.ptzAutoStopTimer !== null) {
27055
+ clearTimeout(this.ptzAutoStopTimer);
27056
+ this.ptzAutoStopTimer = null;
27057
+ }
27058
+ }
26688
27059
  /** Resolve a cap `PtzMoveCommand` into 0-2 Dahua CGI move commands
26689
27060
  * (one pan/tilt code — possibly diagonal — plus one zoom code).
26690
- * `pan`/`tilt`/`zoom` are normalized -1..1; magnitude maps to Dahua's
26691
- * 1-8 speed scale. */
26692
- resolvePtzCodes(pan, tilt, zoom) {
26693
- const speedFor = (magnitude) => Math.max(1, Math.min(8, Math.round(Math.abs(magnitude) * 8)));
27061
+ * `pan`/`tilt`/`zoom` are normalized -1..1. Speed maps to Dahua's 1-8
27062
+ * scale: the explicit cap-level `speed` (0..1, from the UI speed
27063
+ * slider) wins when supplied; otherwise the vector magnitude is used. */
27064
+ resolvePtzCodes(pan, tilt, zoom, speed) {
27065
+ const speedFor = (magnitude) => {
27066
+ return Math.max(1, Math.min(8, Math.round((speed !== void 0 ? Math.abs(speed) : Math.abs(magnitude)) * 8)));
27067
+ };
26694
27068
  const out = [];
26695
27069
  const p = pan ?? 0;
26696
27070
  const t = tilt ?? 0;
@@ -26716,7 +27090,7 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26716
27090
  const channel = this.channel;
26717
27091
  const codes = [...this.activePtzCodes];
26718
27092
  this.activePtzCodes.clear();
26719
- await Promise.all(codes.map((code) => client.ptzStop(channel, code).catch((err) => {
27093
+ await Promise.all(codes.map((code) => client.ptzStop(channel, code, AmcrestCamera.PTZ_CGI_TIMEOUT_MS).catch((err) => {
26720
27094
  this.ctx.logger.debug("amcrest: ptzStop failed (continuing)", {
26721
27095
  tags: { deviceId: this.id },
26722
27096
  meta: {
@@ -26729,31 +27103,57 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26729
27103
  async startPtzCodes(codes) {
26730
27104
  const client = this.ensureClient();
26731
27105
  const channel = this.channel;
26732
- for (const { code, speed } of codes) {
27106
+ await Promise.all(codes.map(({ code, speed }) => {
26733
27107
  this.activePtzCodes.add(code);
26734
- await client.ptzStart(channel, code, speed);
26735
- }
27108
+ return client.ptzStart(channel, code, speed, AmcrestCamera.PTZ_CGI_TIMEOUT_MS);
27109
+ }));
27110
+ }
27111
+ /** Serialized tail of PTZ CGI operations for this camera. */
27112
+ ptzChain = Promise.resolve();
27113
+ /** Serialize a PTZ CGI op onto the per-camera chain and return AT ONCE.
27114
+ * The UI models a short tap as `move`+`stop` and a long press as
27115
+ * `continuousMove`+`stop`. Two problems the chain solves together:
27116
+ * (1) awaiting the camera round-trip (digest handshake + CGI) in the cap
27117
+ * call makes the hub-local-uds transport abort it → `This operation was
27118
+ * aborted`, head never moves; (2) issuing start/stop as independent
27119
+ * fire-and-forget fetches RACES them — a `stop` fetch can land before its
27120
+ * `start`, leaving the head gliding or never moving (why tap "only worked
27121
+ * on long press"). Chaining runs each op to completion IN ORDER (start
27122
+ * before its paired stop) while the cap returns immediately. */
27123
+ enqueuePtz(label, op) {
27124
+ this.ptzChain = this.ptzChain.then(op).catch((err) => {
27125
+ this.ctx.logger.debug(`amcrest: ptz ${label} failed`, {
27126
+ tags: { deviceId: this.id },
27127
+ meta: { error: err instanceof Error ? err.message : String(err) }
27128
+ });
27129
+ });
26736
27130
  }
26737
27131
  registerPtzCap() {
26738
27132
  this.ctx.registerNativeCap(ptzCapability, {
26739
- move: async ({ deviceId, pan, tilt, zoom }) => {
27133
+ move: async ({ deviceId, pan, tilt, zoom, speed }) => {
26740
27134
  if (deviceId !== this.id) return;
26741
- await this.stopActivePtz();
26742
- const codes = this.resolvePtzCodes(pan, tilt, zoom);
27135
+ this.clearPtzAutoStop();
27136
+ const codes = this.resolvePtzCodes(pan, tilt, zoom, speed);
26743
27137
  if (codes.length === 0) return;
26744
- await this.startPtzCodes(codes);
26745
- await new Promise((resolve) => setTimeout(resolve, 500));
26746
- await this.stopActivePtz();
27138
+ this.enqueuePtz("move", () => this.startPtzCodes(codes));
27139
+ this.ptzAutoStopTimer = setTimeout(() => {
27140
+ this.ptzAutoStopTimer = null;
27141
+ this.enqueuePtz("auto-stop", () => this.stopActivePtz());
27142
+ }, AmcrestCamera.PTZ_PULSE_MS);
26747
27143
  },
26748
- continuousMove: async ({ deviceId, pan, tilt, zoom }) => {
27144
+ continuousMove: async ({ deviceId, pan, tilt, zoom, speed }) => {
26749
27145
  if (deviceId !== this.id) return;
26750
- await this.stopActivePtz();
26751
- const codes = this.resolvePtzCodes(pan, tilt, zoom);
26752
- if (codes.length > 0) await this.startPtzCodes(codes);
27146
+ this.clearPtzAutoStop();
27147
+ const codes = this.resolvePtzCodes(pan, tilt, zoom, speed);
27148
+ this.enqueuePtz("continuousMove", async () => {
27149
+ await this.stopActivePtz();
27150
+ if (codes.length > 0) await this.startPtzCodes(codes);
27151
+ });
26753
27152
  },
26754
27153
  stop: async ({ deviceId }) => {
26755
27154
  if (deviceId !== this.id) return;
26756
- await this.stopActivePtz();
27155
+ this.clearPtzAutoStop();
27156
+ this.enqueuePtz("stop", () => this.stopActivePtz());
26757
27157
  },
26758
27158
  getPresets: async ({ deviceId }) => {
26759
27159
  if (deviceId !== this.id) return [];
@@ -26765,8 +27165,9 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26765
27165
  goToPreset: async ({ deviceId, presetId }) => {
26766
27166
  if (deviceId !== this.id) return;
26767
27167
  const id = Number.parseInt(presetId, 10);
26768
- if (!Number.isFinite(id)) return;
26769
- await this.ensureClient().ptzGotoPreset(this.channel, id);
27168
+ if (!Number.isFinite(id) || id < 1) return;
27169
+ const client = this.ensureClient();
27170
+ this.enqueuePtz("goToPreset", () => client.ptzGotoPreset(this.channel, id, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
26770
27171
  },
26771
27172
  savePreset: async ({ deviceId, presetId, name }) => {
26772
27173
  if (deviceId !== this.id) return;
@@ -26802,7 +27203,8 @@ var AmcrestCamera = class AmcrestCamera extends BaseDevice {
26802
27203
  },
26803
27204
  goHome: async ({ deviceId }) => {
26804
27205
  if (deviceId !== this.id) return;
26805
- await this.ensureClient().ptzGotoPreset(this.channel, 1);
27206
+ const client = this.ensureClient();
27207
+ this.enqueuePtz("goHome", () => client.ptzGotoPreset(this.channel, 1, AmcrestCamera.PTZ_CGI_TIMEOUT_MS));
26806
27208
  },
26807
27209
  getPosition: async () => {
26808
27210
  return {
@@ -36724,4 +37126,4 @@ var AmcrestProviderAddon = class extends BaseDeviceProvider {
36724
37126
  }
36725
37127
  };
36726
37128
  //#endregion
36727
- export { AmcrestProviderAddon, amcrestCameraSchema as i, AmcrestCgiClient as n, AMCREST_ADDON_ID as r, AmcrestCamera as t };
37129
+ export { AmcrestProviderAddon, amcrestCameraSchema as a, AMCREST_ADDON_ID as i, AmcrestCgiClient as n, subscribeAmcrestEvents as r, AmcrestCamera as t };
package/dist/index.js CHANGED
@@ -5,3 +5,4 @@ exports.AmcrestCamera = require_addon.AmcrestCamera;
5
5
  exports.AmcrestCgiClient = require_addon.AmcrestCgiClient;
6
6
  exports.AmcrestProviderAddon = require_addon.AmcrestProviderAddon;
7
7
  exports.amcrestCameraSchema = require_addon.amcrestCameraSchema;
8
+ exports.subscribeAmcrestEvents = require_addon.subscribeAmcrestEvents;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { AmcrestProviderAddon, i as amcrestCameraSchema, n as AmcrestCgiClient, r as AMCREST_ADDON_ID, t as AmcrestCamera } from "./addon.mjs";
2
- export { AMCREST_ADDON_ID, AmcrestCamera, AmcrestCgiClient, AmcrestProviderAddon, amcrestCameraSchema };
1
+ import { AmcrestProviderAddon, a as amcrestCameraSchema, i as AMCREST_ADDON_ID, n as AmcrestCgiClient, r as subscribeAmcrestEvents, t as AmcrestCamera } from "./addon.mjs";
2
+ export { AMCREST_ADDON_ID, AmcrestCamera, AmcrestCgiClient, AmcrestProviderAddon, amcrestCameraSchema, subscribeAmcrestEvents };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-amcrest",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Amcrest/Dahua camera device provider addon for CamStack — Dahua CGI over HTTP(S) with digest auth (snapshot, RTSP catalog, PTZ, image/day-night config)",
5
5
  "keywords": [
6
6
  "camstack",