Polycam
EagleEYE IV PTZ (2021 – 2025)
Bienvenue dans le monde magique de la récupération de matériel pour lui offrir une nouvelle vie.
Au menu du jour, une caméra Polycom EagleEYE IV sauvée de la corbeille. Alors si tu en as une sous la main, ce projet est pour toi !
Dites m’en plus
Ce travail de reverse-engineering permet de connecter une caméra EagleEYE IV à l’ordinateur, pour l’utiliser comme webcam et piloter sa position. Il est même possible de la transformer en caméra de surveillance compatible ONVIF en bonne et due forme.
Comment ?
Soyons honnêtes, on ne parle pas d’un projet de 3 minutes, mais avec les bons outils, entièrement réalisable.
Vois-tu, la caméra est connectée à son contrôleur d’origine via un câble HDCI (cf. documentation dans le GitHub associé). Ce câble fournit un lien HDMI, une alimentation et une connexion série pour la transmission de commandes PTZ.
En ouvrant ce câble, ou en créant une nouvelle terminaison avec une prise LFH60 femelle, tu peux accéder à ces trois connexions. Les brins HDMI peuvent être utilisés directement avec une HDMI breakout board, les données série connectées en USB avec l’excellent adaptateur de CircuitSurgery. Ajoute une carte d’acquisition HDMI et le tour est joué !
Le code source, les exécutables et plus d’informations sont disponibles sur Github.
Tombé dans le terrier
Pourquoi ? Comment ? Porché ? Si tu savais…
J’ai mis la main sur un ensemble Polycom complet, comprenant l’écran de contrôle, le boitier TV, micro et caméra. Vu la qualité du matériel ça semblait très dommage qu’il finisse à la corbeille, alors après l’avoir gardé quelques années sur mon bureau, le temps était enfin venu d’attaquer le projet. Faites chauffer le tournevis !
La bête
Étape 1 : qu’est-ce qu’on a sous la main ?
Le boitier TV est le cerveau de l’opération, j’ai démarré mon périple en essayant de comprendre comment il était réalisé. Bien que le logiciel préinstallé n’affiche pas d’indice particulier, j’ai eu de la chance après une réinitialisation complète. Le boitier a redémarré et a commencé son cycle de mise à jour, affichant le téléchargement de paquets tels que software-xxx.apk. Alors là pas de doute, c’est une plateforme Android !
Et effectivement, une fois ouvert, on peut accéder à la carte SD qui contient tout le système. Ce qui est exactement ce que j’ai fait, pour mettre la carte dans mon ordinateur et modifié quelques options pour activer notre cher adb. Une fois redémarré :
adb shell getprop | egrep "(ro.board|ro.product.cpu|arm.variant)"
[ro.board.platform]: [omap3]
[ro.product.cpu.abi2]: [armeabi]
[ro.product.cpu.abi]: [armeabi-v7a]
Après ce premier succès, il fallait encore comprendre comment la caméra était connectée au boitier.
adb shell dmesg
# après connexion de la caméra
<6>adv7604 1-0020: adv7604_worker: dev_id = 0
<6>rx_get_interrupts: pid = 2358, tgid = 2358
<6>adv7604 1-0020: rx_get_interrupts: Digital camera detected
<6>adv7604 1-0020: get_hdmi_resolution: Resolution value got is 1080p60
<6>adv7604 1-0020: rx_get_interrupts: Port1: Encrypted value from reg is 0
<6>adv7604 1-0020: rx_get_interrupts: Port1 has normal video
<6>adv7604 1-0020: rx_get_interrupts: Port1 state changed
<6>adv7604 2-0020: rx_get_interrupts: No sources present on Port2
<6>adv7604 2-0020: rx_get_interrupts: Port2 has Blue screen output
Une recherche rapide indique que ADV7604 est une puce d’acquisition numérique et analogique, ce qui indique une forte possibilité que la caméra envoie des données HDMI directement dans son cable.
Par chance la documentation officielle nous confirme bien ça.
Documentation officielle pour "HDCI Polycom EagleEye IV Digital Camera Cable"
T’as remarqué quelque chose d’autre ? Tout à fait, deux fils nommés RS232 Rx et Tx ! Ça risque de devenir utile plus tard…
Étape 2 : tell me more, tell me more!
Avant d’essayer d’envoyer des données sur les brains RS232, on a besoin de determiner comment parler à la caméra. Rien dans la documentation n’indique le protocol utilisé, alors essayons de capturer quel type de données on doit envoyer. Allons vers notre cher adb pour fouiller un peu.
adb shell busybox tail -F /data/log/messages
# Extrait du journal après avoir connecté la caméra
18:12:30.559 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: got message...
18:12:30.559 DEBUG SMan: hd[0]: CameraBase: In: Command: 01
18:12:30.559 INFO SMan: hd[0]: CameraJCCP: composeOrientationCmd: JCCP camera got orientation change to mode 1
18:12:30.559 INFO SMan: hd[0]: CameraJCCP: Send 4 bytes: 83 41 3e 1
18:12:30.559 INFO SMan: hd[0]: SrcMan: 83 41 3E 01
18:12:30.560 INFO SMan: hd[0]: SrcMan: PortNear SrcManPortNear1 Controller 0 SendMessage: component_id: "cam1" serial_write { serial_bytes: "\203A>\001" }
18:12:30.560 INFO SMan: hd[0]: SrcMan: SendMessage: local port controller
18:12:30.560 INFO SMan: hd[0]: SrcMan: SendToEndpoint from SrcManPortNear1 to PortController
18:12:30.560 INFO PCon: hd[0]: PcThreads: PC: NETRA0 input thread received CMD/REQ Msg - Comp_ID = cam1
18:12:30.560 INFO PCon: hd[0]: PcThreads: PC_ProcessMsg: component_id: "cam1" serial_write { serial_bytes: "\203A>\001" }
18:12:30.560 INFO PCon: hd[0]: PcSerial: Not CAM CTRL mode: Send to CAMx ser HDCI
18:12:30.561 INFO SMan: hd[0]: SrcMan: SendMessage: component_id: "cam1" error: kErrNoError
18:12:30.561 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: qHandler QLength is 0
18:12:30.561 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: blocking on dequeue...
18:12:30.567 INFO PCon: hd[0]: PcSerial: PC: Serial read - sent notify from: PC:CAM1, num chars:1
18:12:30.567 INFO PCon: hd[0]: PcSerial: CAM_Serial_ReadThread: component_id: "cam1" serial_bytes: "\240"
18:12:30.567 INFO SMan: hd[0]: SrcMan: SMPlatformComponentNtfyHdlr, context 0x7b97c
18:12:30.567 INFO SMan: hd[0]: SrcMan: PlatformComponentNtfyHdlr: component_id: "cam1" serial_bytes: "\240"
18:12:30.568 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: got message...
18:12:30.568 DEBUG SMan: hd[0]: CameraBase: In: Response: a0
18:12:30.568 INFO SMan: hd[0]: CameraJCCP: QRead 1 bytes
18:12:30.568 INFO SMan: hd[0]: CameraJCCP: ProcessMessage: cam response: a0
18:12:30.568 INFO SMan: hd[0]: CameraJCCP: rx: JCCP ACK
18:12:30.568 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: qHandler QLength is 0
18:12:30.568 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: blocking on dequeue...
18:12:30.568 INFO PCon: hd[0]: PcSerial: PC: Serial read - sent notify from: PC:CAM1, num chars:1
18:12:30.568 INFO PCon: hd[0]: PcSerial: CAM_Serial_ReadThread: component_id: "cam1" serial_bytes: "\222"
18:12:30.568 INFO SMan: hd[0]: SrcMan: SMPlatformComponentNtfyHdlr, context 0x7b97c
18:12:30.569 INFO SMan: hd[0]: SrcMan: PlatformComponentNtfyHdlr: component_id: "cam1" serial_bytes: "\222"
18:12:30.569 INFO PCon: hd[0]: PcSerial: PC: Serial read - sent notify from: PC:CAM1, num chars:1
18:12:30.569 INFO PCon: hd[0]: PcSerial: CAM_Serial_ReadThread: component_id: "cam1" serial_bytes: "@"
18:12:30.569 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: got message...
18:12:30.569 DEBUG SMan: hd[0]: CameraBase: In: Response: 92
18:12:30.569 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: qHandler QLength is 1
18:12:30.569 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: blocking on dequeue...
18:12:30.569 INFO SMan: hd[0]: SrcMan: SMPlatformComponentNtfyHdlr, context 0x7b97c
18:12:30.570 INFO SMan: hd[0]: SrcMan: PlatformComponentNtfyHdlr: component_id: "cam1" serial_bytes: "@"
18:12:30.570 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: got message...
18:12:30.570 DEBUG SMan: hd[0]: CameraBase: In: Response: 40
18:12:30.570 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: qHandler QLength is 2
18:12:30.570 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: blocking on dequeue...
18:12:30.570 INFO PCon: hd[0]: PcSerial: PC: Serial read - sent notify from: PC:CAM1, num chars:1
18:12:30.570 INFO PCon: hd[0]: PcSerial: CAM_Serial_ReadThread: component_id: "cam1" serial_bytes: "\000"
18:12:30.570 INFO SMan: hd[0]: SrcMan: SMPlatformComponentNtfyHdlr, context 0x7b97c
18:12:30.571 INFO SMan: hd[0]: SrcMan: PlatformComponentNtfyHdlr: component_id: "cam1" serial_bytes: "\000"
18:12:30.571 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: got message...
18:12:30.571 DEBUG SMan: hd[0]: CameraBase: In: Response: 00
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: QRead 3 bytes
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: ProcessMessage: cam response: 92 40 00
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: rx: complete message 92 40 00
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: rx: Excuted
18:12:30.571 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: qHandler QLength is 0
18:12:30.571 DEBUG SMan: hd[0]: CameraBase: SM: WaitForNewCmdMsg: blocking on dequeue...
18:12:30.575 INFO PCon: hd[0]: PcConfig: Camera CHANGE callback: node sourceman.camera, NodeInst 1, sVar orientation, nVarInst 0
Youhou ! Il y a quelques pépites par ici :
18:12:30.559 INFO SMan: hd[0]: CameraJCCP: composeOrientationCmd: JCCP camera got orientation change to mode 1
18:12:30.559 INFO SMan: hd[0]: CameraJCCP: Send 4 bytes: 83 41 3e 1
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: ProcessMessage: cam response: 92 40 00
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: rx: complete message 92 40 00
18:12:30.571 INFO SMan: hd[0]: CameraJCCP: rx: Excuted
Ceci indique que pour définir l’orientation de la caméra à 1 (inversé ? normal ?) il faut envoyer 83 41 3E 01, et on est censé recevoir 92 40 00 comme confirmation.
La documentation officiel décrit une API telnet, activable depuis l’admin web, permettant d’envoyer des commandes telles que camera near setposition X Y Z. Cette découverte m’a permis d’écrire une automatisation qui essaye toutes les commandes connues et enregistre les logs associés. Tu pourras trouver les résultats par ici.
Certaines commandes ont été rapides à comprendre, comme définir l’orientation de l’image, le contraste, etc ; alors que d’autres ont demandé plus de travail pour les décortiquer.
Petit exercice pour toi lecteur•ice, essaye de comprendre comment la position est encodée dans les données suivantes.
(0 -50000 0) -> 8d 41 51 04 00 03 68 00 00 00 03 00 04 7a
(0 -45000 0) -> 8d 41 51 04 00 03 68 00 00 19 03 00 04 7a
(0 -35000 0) -> 8d 41 51 04 00 03 68 00 00 4b 03 00 04 7a
(0 -30000 0) -> 8d 41 51 04 00 03 68 00 00 64 03 00 04 7a
(0 -25000 0) -> 8d 41 51 04 00 03 68 00 00 7d 03 00 04 7a
(0 -20000 0) -> 8d 41 51 24 00 03 68 00 00 16 03 00 04 7a
(0 -15000 0) -> 8d 41 51 24 00 03 68 00 00 2f 03 00 04 7a
(0 -10000 0) -> 8d 41 51 24 00 03 68 00 00 48 03 00 04 7a
(0 -5000 0) -> 8d 41 51 24 00 03 68 00 00 61 03 00 04 7a
(0 -3000 0) -> 8d 41 51 24 00 03 68 00 00 6b 03 00 04 7a
(0 -1000 0) -> 8d 41 51 24 00 03 68 00 00 75 03 00 04 7a
(0 -500 0) -> 8d 41 51 24 00 03 68 00 00 78 03 00 04 7a
(0 -200 0) -> 8d 41 51 24 00 03 68 00 00 79 03 00 04 7a
(0 -100 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -10 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -9 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -8 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -7 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -6 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -5 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -4 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -3 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 -2 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 0 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 1 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 2 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 3 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 4 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 5 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 6 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 7 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 8 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 9 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 10 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 100 0) -> 8d 41 51 24 00 03 68 00 00 7a 03 00 04 7a
(0 200 0) -> 8d 41 51 24 00 03 68 00 00 7b 03 00 04 7a
(0 500 0) -> 8d 41 51 24 00 03 68 00 00 7c 03 00 04 7a
(0 1000 0) -> 8d 41 51 24 00 03 68 00 00 7f 03 00 04 7a
(0 3000 0) -> 8d 41 51 04 00 03 68 00 01 09 03 00 04 7a
(0 5000 0) -> 8d 41 51 04 00 03 68 00 01 13 03 00 04 7a
(0 10000 0) -> 8d 41 51 04 00 03 68 00 01 2c 03 00 04 7a
(0 15000 0) -> 8d 41 51 04 00 03 68 00 01 45 03 00 04 7a
(0 20000 0) -> 8d 41 51 04 00 03 68 00 01 5e 03 00 04 7a
(0 25000 0) -> 8d 41 51 04 00 03 68 00 01 77 03 00 04 7a
(0 35000 0) -> 8d 41 51 24 00 03 68 00 01 29 03 00 04 7a
(0 40000 0) -> 8d 41 51 24 00 03 68 00 01 42 03 00 04 7a
(0 45000 0) -> 8d 41 51 24 00 03 68 00 01 5b 03 00 04 7a
(0 50000 0) -> 8d 41 51 24 00 03 68 00 01 74 03 00 04 7a
Étape 3 : faire bouger la bête !
On ne va pas se mentir, les premières étapes ont pris bien plus de temps qu’il n’y parait à la relecture. Mais celle qui arrive ? Doux jésus.
J’avais à présent un moyen physique d’envoyer des données, le savoir logiciel pour savoir quelles données envoyer et quelles données attendre en retour. C’est le moment de brancher quelques cables ! Après quelques essais j’ai identifié les brins RS232 dans le cable HDCI, et les ai connecté à un adaptateur série USB. Mais après avoir envoyé plusieurs commandes, rien ne se passait. La caméra ne bougeait pas, et je ne recevais aucune réponse.
[Deux ans plus tard.gif]
Je soupçonnais fortement que les niveaux de tension sur le fil n’étaient pas les bons et j’ai attendu d’avoir un peu d’argent de poche à dépenser sur un oscilloscope numérique pour confirmer.
L'Oscilloscope™: pour capturer l'instant présent
Tadaaaa ! Les données envoyées entre la caméra et son controleur utilisent des niveaux logiques différents du RS232, situés à environ -6V et +6V, avec un niveau au repos à -6V. Un coup de wikipédia indique que cela correspond au standard moins connu RS423, comme utilisé sur l’ancien BBC Microcomputer. C’est d’ailleurs un adaptateur fait pour cet ordinateur qui me sauvera la mise, création de (CircuitSurgery)[https://www.circuitsurgery.com/bbcrs423adapt.html] !
Après un tit coup de soudure et une anticipation à couper au couteau, j’ai pu faire bouger la caméra depuis mon code Swift !
Lève toi et marche
Il n’y a plus qu’à rassembler toutes les commandes découvertes dans un outil ligne de commande Swift, et SwiftPTZ est né.
Étape 4 : que la lumière soit
Bon, au tour de la vidéo. Bien que les brins aient été identifiés plus tot, rien n’a été capturé encore, et ça semble être le bon moment pour s’y mettre.
Ça parait pas mal
Après avoir créé une prise HDMI (terrifiante), il est venu le temps de l’essayer. Par chance, j’avais récupéré une vieille TV dans la rue deux mois avant, qui sera utilisée pour ces tests, histoire d’éviter de fondre mon écran habituel, mercibeaucoupbonsoir.
Dernière danse
Étape 5 : fini ! (non)
Okay, on a une communication série adaptée, une interface HDMI fonctionnelle et un outil de commande de la caméra, tout va bien dans le meilleur des mondes et le projet est enfin fini.
AHAHAHAHA.
Non.
Enfin… J’ai une bonne liste de commandes, clairement, mais s’il en existant d’autres, inutilisées par le controleur d’origine ? Et si elles étaient trop bien ? Tu te rends compte ? Moi oui 🥴.
Alors allons y pour l’étape 5 : fuzzing !
Tu vois, toutes les commandes commencent par 8w xx yy [zz], w étant le nombre d’octets dans la transmission envoyée, xx étant un indicateur de groupe, yy signale la commande dans le groupe, et parfois zz est ajouté pour représenter un paramètre. Dans les commandes déjà identifiées, on retrouve les groupes suivants :
- 01 : lecture de propriété, pour les attributs de base tels que “allumé”
- 02 : lecture de propriété, majoritairement autofocus, exposition et similaires
- 03 : lecture de propriété, majoritairement pour la calibration des couleurs
- 06 : une seule commande par ici, qui répond avec les infos de la caméra (version firmware, modèle)
- 41-43 : écriture de propriété, correspondant aux groupes 01-03
- 45 : actions, de type “tourne vers la gauche”
Connaissant déjà la structure d’encodage des messages ainsi qu’une liste de commandes fonctionnelles, j’ai décidé de fuzzer toutes les commandes potentielles de la caméra, en envoyant des données aléatoires, jusqu’à ce qu’elle réponde avec un code de succès, puis en testant pour chaque nouvelle commande potentielle une liste de paramètres possibles. Et j’en ai découvert un certain nombre ! Comprendre leur utilité aura finalement pris plus de temps, en changeant de valeur jusqu’à observer une différence dans la vidéo capturée (ne me parle meme pas de la matrice de calibration des couleurs), mais c’était une belle aventure d’explorer cette boite noire et révéler quelques secrets !
Comme un rêve d'antan
La liste complète des commandes, leur signification et leur méthode de découverte est résumée dans ce document.
Étape 6 : rassembler ses petits
Ce que tu ne sais pas, cher•e lecteur•ice, c’est que ce projet a commencé parce que mon père s’est retrouvé avec deux lots complets Polycom, et se demandait ce qu’on pouvait en faire d’utile. On avait en tête de les transformer en caméra de surveillance, pour surveiller leurs nombreux animaux à l’extérieur.
D’après le site de Synology, le standard pour les caméras de surveillance s’appelle ONVIF. Par chance Roleo avait déjà fait un merveilleux serveur ONVIF entièrement configurable, il n’y avait “plus qu’à” améliorer SwiftPTZ pour supporter quelques commandes PTZ incrémentales et ajouter le support du zoom dans le serveur ONVIF.
Tu peux maintenant découvrir le fruit d’intenses labeurs, et observer avec merveille la caméra pilotée par IP Camera Viewer, que je recommande grandement.
ENFIN
Ceci termine cette aventure longue de trois ans. Je suis très content des résultats, particulièrement des nouvelles commandes découvertes !