Так получилось, что у меня в доме давно используются видеокамеры наблюдения.
Сначала это было всего лишь «посмотреть, что там перед воротами», потом — неплохо бы контролировать что делают кошки‑собаки, ну и в общем, получилось довольно много в разных местах.
Много, но недостаточно: например, однажды вышла глупая ситуация, когда закончился пакет корма для собаки, при этом все считали что в кладовке уже лежит запасной, а оказалось — не лежит!
Пришлось срочно ехать в магазин за маленьким пакетиком «не того», пока привезут «тот».
А всего‑то надо было просто заглянуть в кладовку заранее — но это же надо идти туда...
Аналогично — когда нужно просто заглянуть в гараж, например, или еще в какое хозяйственное помещение.
Конечно, камеры там тоже можно повесить, но возникает неожиданнная проблема: неудобно смотреть!
Во‑первых, у регистратора всего 16 «окошек». То есть, сколько их не перелистывай — еще одну камеру туда не подключить, это ограниченный ресурс. Можно поставить еще один регистратор — но тогда надо будет при просмотре переключаться еще и между ними, что добавляет неудобств.
Во‑вторых, через регистратор вообще не очень удобно смотреть — это телевизор, мышь управления — намного удобнее через приложение на планшете, «прибитом к стене гвоздями» — но тогда переключаться будет еще неудобнее.
Запускать же новое приложение на смартфоне — нудно и долго.
Да и подключать, занимать канал ради того, что не нужно записывать и хранить, а только иногда заглядывать «здесь и сейчас» — как‑то неправильно.
Хотелось бы просто «проходя мимо экрана — тыкнуть пальцем и увидеть» — тем более что как раз для этого есть планшет с экраном мониторинга систем (не Home Assistant) — туда и вывести.
Причем, видео даже не нужно — достаточно снимка. К счастью, многие камеры дают такую возможность, получить текущий скриншот. Остаётся только это настроить.
Для этого нужно сначала узнать URL, по которому камера отдает такой скриншот.
URL могут быть разными, хотя и есть несколько типовых, но можно попробовать найти их через ONVIF, «стандарт» работы с видеокамерами.
Стандарт в кавычках, потому что очень уж он иногда бывает нестандартный...
Для начала — посмотрим что там за порты открыты на камере:
nmap 192.1168..1.10
...
80/tcp open http
554/tcp open rtsp
8899/tcp open ospf-lite Порт 80 — это вебинтерфейс, он не интересует, потому что скорее всего там предложат установить ActiveX компонент, который работает далеко не в каждом браузере и только под Виндовс.
Порт 554 — это видеопоток RTSP, но сейчас он нам не нужен.
А вот 8899 — это ONVIF, туда можно отправлять запросы.
Например, можно запросить список профилей для камеры:
curl 192.168.1.10:8899/onvif/Media \
-d '<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope" xmlns:trt="http://www.onvif.org/ver10/media/wsdl">
<soap:Body><trt:GetProfiles/></soap:Body>
</soap:Envelope>'
Но это неточно. Запрос может быть /onvif/device_service, или /onvif/media_service, или не тот и не другой, или тот и другой, или вообще URL not found.
Если повезло — ответ будет примерно таким:
Многабукв
<?xml version="1.0" encoding="UTF-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:SOAP-ENC="http://www.w3.org/2003/05/soap-encoding" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xs="http://www.w3.org/2000/10/XMLSchema" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:wsa5="http://www.w3.org/2005/08/addressing" xmlns:xop="http://www.w3.org/2004/08/xop/include" xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:ns1="http://www.w3.org/2005/05/xmlmime" xmlns:wstop="http://docs.oasis-open.org/wsn/t-1" xmlns:ns7="http://docs.oasis-open.org/wsrf/r-2" xmlns:ns2="http://docs.oasis-open.org/wsrf/bf-2" xmlns:dndl="http://www.onvif.org/ver10/network/wsdl/DiscoveryLookupBinding" xmlns:dnrd="http://www.onvif.org/ver10/network/wsdl/RemoteDiscoveryBinding" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl" xmlns:ns10="http://www.onvif.org/ver10/replay/wsdl" xmlns:ns11="http://www.onvif.org/ver10/search/wsdl" xmlns:ns13="http://www.onvif.org/ver20/analytics/wsdl/RuleEngineBinding" xmlns:ns14="http://www.onvif.org/ver20/analytics/wsdl/AnalyticsEngineBinding" xmlns:tan="http://www.onvif.org/ver20/analytics/wsdl" xmlns:ns15="http://www.onvif.org/ver10/events/wsdl/PullPointSubscriptionBinding" xmlns:ns16="http://www.onvif.org/ver10/events/wsdl/EventBinding" xmlns:tev="http://www.onvif.org/ver10/events/wsdl" xmlns:ns17="http://www.onvif.org/ver10/events/wsdl/SubscriptionManagerBinding" xmlns:ns18="http://www.onvif.org/ver10/events/wsdl/NotificationProducerBinding" xmlns:ns19="http://www.onvif.org/ver10/events/wsdl/NotificationConsumerBinding" xmlns:ns20="http://www.onvif.org/ver10/events/wsdl/PullPointBinding" xmlns:ns21="http://www.onvif.org/ver10/events/wsdl/CreatePullPointBinding" xmlns:ns22="http://www.onvif.org/ver10/events/wsdl/PausableSubscriptionManagerBinding" xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2" xmlns:ns3="http://www.onvif.org/ver10/analyticsdevice/wsdl" xmlns:ns4="http://www.onvif.org/ver10/deviceIO/wsdl" xmlns:ns5="http://www.onvif.org/ver10/display/wsdl" xmlns:ns8="http://www.onvif.org/ver10/receiver/wsdl" xmlns:ns9="http://www.onvif.org/ver10/recording/wsdl" xmlns:tds="http://www.onvif.org/ver10/device/wsdl" xmlns:timg="http://www.onvif.org/ver20/imaging/wsdl" xmlns:tptz="http://www.onvif.org/ver20/ptz/wsdl" xmlns:trt="http://www.onvif.org/ver10/media/wsdl" xmlns:trt2="http://www.onvif.org/ver20/media/wsdl" xmlns:ter="http://www.onvif.org/ver10/error" xmlns:tns1="http://www.onvif.org/ver10/topics" xmlns:tnsn="http://www.eventextension.com/2011/event/topics"><SOAP-ENV:Body><trt:GetProfilesResponse><trt:Profiles fixed="true" token="000"><tt:Name>Profile_000</tt:Name><tt:VideoSourceConfiguration token="000"><tt:Name>VideoS_000</tt:Name><tt:UseCount>3</tt:UseCount><tt:SourceToken>000</tt:SourceToken><tt:Bounds height="1520" width="2592" y="0" x="0"></tt:Bounds></tt:VideoSourceConfiguration><tt:AudioSourceConfiguration token="000"><tt:Name>Audio_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:SourceToken>000</tt:SourceToken></tt:AudioSourceConfiguration><tt:VideoEncoderConfiguration token="000"><tt:Name>VideoE_000</tt:Name><tt:UseCount>1</tt:UseCount><tt:Encoding>H264</tt:Encoding><tt:Resolution><tt:Width>2304</tt:Width><tt:Height>1296</tt:Height></tt:Resolution><tt:Quality>4</tt:Quality><tt:RateControl><tt:FrameRateLimit>16</tt:FrameRateLimit><tt:EncodingInterval>1</tt:EncodingInterval><tt:BitrateLimit>2774</tt:BitrateLimit></tt:RateControl><tt:H264><tt:GovLength>2</tt:GovLength><tt:H264Profile>High</tt:H264Profile></tt:H264><tt:Multicast><tt:Address><tt:Type>IPv4</tt:Type><tt:IPv4Address>224.1.2.3</tt:IPv4Address></tt:Address><tt:Port>0</tt:Port><tt:TTL>0</tt:TTL><tt:AutoStart>false</tt:AutoStart></tt:Multicast><tt:SessionTimeout>PT10S</tt:SessionTimeout></tt:VideoEncoderConfiguration><tt:AudioEncoderConfiguration token="000"><tt:Name>AudioE_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:Encoding>G711</tt:Encoding><tt:Bitrate>64</tt:Bitrate><tt:SampleRate>8</tt:SampleRate><tt:Multicast><tt:Address><tt:Type>IPv4</tt:Type><tt:IPv4Address>224.1.2.3</tt:IPv4Address></tt:Address><tt:Port>0</tt:Port><tt:TTL>0</tt:TTL><tt:AutoStart>false</tt:AutoStart></tt:Multicast><tt:SessionTimeout>PT10S</tt:SessionTimeout></tt:AudioEncoderConfiguration><tt:VideoAnalyticsConfiguration token="000"><tt:Name>Analytics_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:AnalyticsEngineConfiguration><tt:AnalyticsModule Type="tt:CellMotionEngine" Name="MyCellMotionEngine"><tt:Parameters><tt:SimpleItem Value="50" Name="Sensitivity"></tt:SimpleItem><tt:ElementItem Name="Layout"><tt:CellLayout Columns="22" Rows="18"><tt:Transformation><tt:Translate x="-1.0" y="-1.0" /><tt:Scale x="0.09090" y="0.111111" /></tt:Transformation></tt:CellLayout></tt:ElementItem></tt:Parameters></tt:AnalyticsModule><tt:AnalyticsModule Type="tt:TamperEngine" Name="MyTamperEngine"><tt:Parameters><tt:SimpleItem Value="50" Name="Sensitivity"></tt:SimpleItem><tt:ElementItem Name="Field"><tt:PolygonConfiguration><tt:Polygon><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/></tt:Polygon></tt:PolygonConfiguration></tt:ElementItem><tt:ElementItem Name="Transform"><tt:Transformation><tt:Translate x="-1.0" y="-1.0"/><tt:Scale x="0.001250" y="0.001667"/></tt:Transformation></tt:ElementItem></tt:Parameters></tt:AnalyticsModule></tt:AnalyticsEngineConfiguration><tt:RuleEngineConfiguration><tt:Rule Type="tt:CellMotionDetector" Name="MyMotionDetectorRule"><tt:Parameters><tt:SimpleItem Value="+QACwAAD/wAADP8AADD/ABHAAH8AAfwAP/AH/8A//wH//D/1/wDw" Name="ActiveCells"></tt:SimpleItem><tt:SimpleItem Value="1000" Name="AlarmOffDelay"></tt:SimpleItem><tt:SimpleItem Value="1000" Name="AlarmOnDelay"></tt:SimpleItem><tt:SimpleItem Value="4" Name="MinCount"></tt:SimpleItem></tt:Parameters></tt:Rule><tt:Rule Type="tt:TamperDetector" Name="MyTamperDetectorRule"><tt:Parameters><tt:ElementItem Name="Field"><tt:PolygonConfiguration><tt:Polygon><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/></tt:Polygon></tt:PolygonConfiguration></tt:ElementItem></tt:Parameters></tt:Rule></tt:RuleEngineConfiguration></tt:VideoAnalyticsConfiguration><tt:PTZConfiguration token="000"><tt:Name>PTZ_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:NodeToken>000</tt:NodeToken><tt:DefaultRelativePanTiltTranslationSpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace</tt:DefaultRelativePanTiltTranslationSpace><tt:DefaultRelativeZoomTranslationSpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace</tt:DefaultRelativeZoomTranslationSpace><tt:DefaultContinuousPanTiltVelocitySpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace</tt:DefaultContinuousPanTiltVelocitySpace><tt:DefaultContinuousZoomVelocitySpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/VelocityGenericSpace</tt:DefaultContinuousZoomVelocitySpace><tt:DefaultPTZSpeed><tt:PanTilt space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace" y="1" x="1"></tt:PanTilt><tt:Zoom space="http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace" x="1"></tt:Zoom></tt:DefaultPTZSpeed><tt:DefaultPTZTimeout>PT1S</tt:DefaultPTZTimeout><tt:PanTiltLimits><tt:Range><tt:URI>http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace</tt:URI><tt:XRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:XRange><tt:YRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:YRange></tt:Range></tt:PanTiltLimits><tt:ZoomLimits><tt:Range><tt:URI>http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace</tt:URI><tt:XRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:XRange></tt:Range></tt:ZoomLimits></tt:PTZConfiguration></trt:Profiles><trt:Profiles fixed="true" token="001"><tt:Name>Profile_001</tt:Name><tt:VideoSourceConfiguration token="000"><tt:Name>VideoS_000</tt:Name><tt:UseCount>3</tt:UseCount><tt:SourceToken>000</tt:SourceToken><tt:Bounds height="1520" width="2592" y="0" x="0"></tt:Bounds></tt:VideoSourceConfiguration><tt:AudioSourceConfiguration token="000"><tt:Name>Audio_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:SourceToken>000</tt:SourceToken></tt:AudioSourceConfiguration><tt:VideoEncoderConfiguration token="001"><tt:Name>VideoE_001</tt:Name><tt:UseCount>1</tt:UseCount><tt:Encoding>H264</tt:Encoding><tt:Resolution><tt:Width>704</tt:Width><tt:Height>576</tt:Height></tt:Resolution><tt:Quality>4</tt:Quality><tt:RateControl><tt:FrameRateLimit>25</tt:FrameRateLimit><tt:EncodingInterval>1</tt:EncodingInterval><tt:BitrateLimit>998</tt:BitrateLimit></tt:RateControl><tt:H264><tt:GovLength>2</tt:GovLength><tt:H264Profile>High</tt:H264Profile></tt:H264><tt:Multicast><tt:Address><tt:Type>IPv4</tt:Type><tt:IPv4Address>224.1.2.3</tt:IPv4Address></tt:Address><tt:Port>0</tt:Port><tt:TTL>0</tt:TTL><tt:AutoStart>false</tt:AutoStart></tt:Multicast><tt:SessionTimeout>PT10S</tt:SessionTimeout></tt:VideoEncoderConfiguration><tt:AudioEncoderConfiguration token="000"><tt:Name>AudioE_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:Encoding>G711</tt:Encoding><tt:Bitrate>64</tt:Bitrate><tt:SampleRate>8</tt:SampleRate><tt:Multicast><tt:Address><tt:Type>IPv4</tt:Type><tt:IPv4Address>224.1.2.3</tt:IPv4Address></tt:Address><tt:Port>0</tt:Port><tt:TTL>0</tt:TTL><tt:AutoStart>false</tt:AutoStart></tt:Multicast><tt:SessionTimeout>PT10S</tt:SessionTimeout></tt:AudioEncoderConfiguration><tt:VideoAnalyticsConfiguration token="000"><tt:Name>Analytics_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:AnalyticsEngineConfiguration><tt:AnalyticsModule Type="tt:CellMotionEngine" Name="MyCellMotionEngine"><tt:Parameters><tt:SimpleItem Value="50" Name="Sensitivity"></tt:SimpleItem><tt:ElementItem Name="Layout"><tt:CellLayout Columns="22" Rows="18"><tt:Transformation><tt:Translate x="-1.0" y="-1.0" /><tt:Scale x="0.09090" y="0.111111" /></tt:Transformation></tt:CellLayout></tt:ElementItem></tt:Parameters></tt:AnalyticsModule><tt:AnalyticsModule Type="tt:TamperEngine" Name="MyTamperEngine"><tt:Parameters><tt:SimpleItem Value="50" Name="Sensitivity"></tt:SimpleItem><tt:ElementItem Name="Field"><tt:PolygonConfiguration><tt:Polygon><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/></tt:Polygon></tt:PolygonConfiguration></tt:ElementItem><tt:ElementItem Name="Transform"><tt:Transformation><tt:Translate x="-1.0" y="-1.0"/><tt:Scale x="0.001250" y="0.001667"/></tt:Transformation></tt:ElementItem></tt:Parameters></tt:AnalyticsModule></tt:AnalyticsEngineConfiguration><tt:RuleEngineConfiguration><tt:Rule Type="tt:CellMotionDetector" Name="MyMotionDetectorRule"><tt:Parameters><tt:SimpleItem Value="+QACwAAD/wAADP8AADD/ABHA1000" Name="ActiveCells"></tt:SimpleItem><tt:SimpleItem Value="1000" Name="AlarmOffDelay"></tt:SimpleItem><tt:SimpleItem Value="1000" Name="AlarmOnDelay"></tt:SimpleItem><tt:SimpleItem Value="4" Name="MinCount"></tt:SimpleItem></tt:Parameters></tt:Rule><tt:Rule Type="tt:TamperDetector" Name="MyTamperDetectorRule"><tt:Parameters><tt:ElementItem Name="Field"><tt:PolygonConfiguration><tt:Polygon><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/><tt:Point x="0" y="0"/></tt:Polygon></tt:PolygonConfiguration></tt:ElementItem></tt:Parameters></tt:Rule></tt:RuleEngineConfiguration></tt:VideoAnalyticsConfiguration><tt:PTZConfiguration token="000"><tt:Name>PTZ_000</tt:Name><tt:UseCount>2</tt:UseCount><tt:NodeToken>000</tt:NodeToken><tt:DefaultRelativePanTiltTranslationSpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationGenericSpace</tt:DefaultRelativePanTiltTranslationSpace><tt:DefaultRelativeZoomTranslationSpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/TranslationGenericSpace</tt:DefaultRelativeZoomTranslationSpace><tt:DefaultContinuousPanTiltVelocitySpace>http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace</tt:DefaultContinuousPanTiltVelocitySpace><tt:DefaultContinuousZoomVelocitySpace>http://www.onvif.org/ver10/tptz/ZoomSpaces/VelocityGenericSpace</tt:DefaultContinuousZoomVelocitySpace><tt:DefaultPTZSpeed><tt:PanTilt space="http://www.onvif.org/ver10/tptz/PanTiltSpaces/GenericSpeedSpace" y="1" x="1"></tt:PanTilt><tt:Zoom space="http://www.onvif.org/ver10/tptz/ZoomSpaces/ZoomGenericSpeedSpace" x="1"></tt:Zoom></tt:DefaultPTZSpeed><tt:DefaultPTZTimeout>PT1S</tt:DefaultPTZTimeout><tt:PanTiltLimits><tt:Range><tt:URI>http://www.onvif.org/ver10/tptz/PanTiltSpaces/PositionGenericSpace</tt:URI><tt:XRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:XRange><tt:YRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:YRange></tt:Range></tt:PanTiltLimits><tt:ZoomLimits><tt:Range><tt:URI>http://www.onvif.org/ver10/tptz/ZoomSpaces/PositionGenericSpace</tt:URI><tt:XRange><tt:Min>-1</tt:Min><tt:Max>1</tt:Max></tt:XRange></tt:Range></tt:ZoomLimits></tt:PTZConfiguration></trt:Profiles></trt:GetProfilesResponse></SOAP-ENV:Body></SOAP-ENV:Envelope>
Как видите, очень подробно и многословно — но не факт, что оно всё соответствует реальности, например, у этой конкретной камеры вообще нет PTZ, хотя оно упоминается.
Из всего этого нужно только Profile token — строка типа «000» (которая иногда может быть «Profile_1», или просто «0» — поэтому и приходится ее запрашивать)
Теперь запросим URL snapshot:
curl 192.168.1.10:8899/onvif/Media \
-d '<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope"
xmlns:trt="http://www.onvif.org/ver10/media/wsdl"
xmlns:tt="http://www.onvif.org/ver10/schema">
<soap:Body>
<trt:GetSnapshotUri>
<trt:ProfileToken>000</trt:ProfileToken>
</trt:GetSnapshotUri>
</soap:Body>
</soap:Envelope> '
В ответ еще пара страниц XML‑текста, значимая часть которого сводится к строке
<tt:Uri>http://192.168.1.10/webcapture.jpg?command=snap&channel=1&user=admin&password=uyyTCCjO </tt:Uri>Причем даже с логином‑паролем (который на самом деле может быть вшит в ответ и не проверяться при запросе. А может и проверяться, как повезет).
Конкретно вот эта камера, на примере которой сейчас пишу, картинку дает, и даже логин‑пароль проверяет при этом, но заметьте — на ONVIF‑запрос никакого пароля не требовалось... Безопасность такая безопасность...
То есть теперь, теоретически, можно сделать запрос по указаному URL и получить текущую картинку. А можно и не получить, без обьяснения причин.
Казалось бы, дальше всё просто: делаем в веб‑интерфейсе страницу, например, кладовки, при переходе на которую будет показана картинка.
То самое «просто тыкнул пальцем и посмотрел»:
<html>
...
<img src="http://192.168.1.10/webcapture.jpg?command=snap&channel=1&user=admin&password=uyyTCCjO" style="width:200px">
...
</html>
«Не надо торопиться!» ©
Современные браузеры не хотят одновременно работать с https и http, а сам «сайт» умного дома пришлось сделать через https, чтобы запустить его в полноэкранном режиме, как PWA.
Поэтому пришлось добавить прокси‑запрос: браузер запрашивает картинку не у камеры, а у сервера, и сервер сам запрашивает ее у камеры.
<html>
...
<img src="/getpict?url=http://192.168.1.10/webcapture.jpg?command=snap&channel=1&user=admin&password=uyyTCCjO" style="width:200px">
...
</html>
Для этого нужно только добавить метод, в Perl Mojolicious это делается так:
sub getpict {
my $c = shift;
my $url = $c->param('url');
return $c->render(text => 'Missing or invalid URL', status => 400)
unless $url && $url =~ m{^https?://};
my $tx = $c->ua->get($url);
return $c->render(text => 'Upstream error: ' . $tx->error->{message}, status => 502)
if my $err = $tx->error and !$tx->res->code;
my $res = $tx->result;
$c->res->headers->content_type($res->headers->content_type);
$c->res->code($res->code);
$c->render(data => $res->body);
}
И вот теперь — всё.
Нажимаем иконку «кладовки» — открывается страничка с картинкой. Посмотрели — закрыли. Минимум усилий, и ходить лишний раз никуда не нужно.
Можно навесить кнопки включения света, например — но это уже совсем другая история.
