Turning a Chinese IoT camera into an owl livestream - Vox Silva
Pangram verdict · v3.3
We believe that this document is fully human-written
AI likelihood · overall
HumanArticle text · 1,573 words · 7 segments analyzed
April 15, 2026 My brother Spencer works as an artist in Asheville. He's also into nature conservation, leading a nature journaling class and regularly weaving ecology and local wildlife into his work. 1 In honor of Artemis II's recent mission, here's a topical piece (buy a print)! When UNC Asheville announced plans to turn a local urban forest into a soccer stadium, he pushed back. 2 For more information check out the Save the Woods website, sign the petition, or follow him on Instagram (@beals.art) for more on-the-ground updates. To raise awareness of the benefits of the forest he started organizing naturalist walks, putting together art installations like Batland 3 At least until it was removed by UNC Asheville. But it's found a new home if you're in the area and want to visit! and more recently leading birding sessions to show off a Great Horned Owl family and their three owlets. Spencer set up a 24/7 camera filming their nest but wanted to livestream that footage and couldn't figure out how — enter me, stage right! While it wasn't easy 4 Some might say that's because it's overengineered, and I should have just screen recorded a phone. I'd say they're haters. here's how I eventually made it work (though unfortunately by the time you're reading this they've flown away and the livestream has ended).
The camera and app experience The camera that my brother ended up buying was an S4 Pro from Premium Invention which he promptly put 65ft up in a tree. The solar panels and 4G support make it great for set-and-forget security, but the app experience isn't great for livestreaming; the feed is controlled by an app, NiView, but the only way to see it is if Spence (as the camera owner) shares a link.
Though "control" is a little misleading, given that the joystick has 5s latency and the camera angle seems to reset every time you reconnect to the device. Main screen listing available camera Controllable view 5
Requiring people to download an app and get a link doesn't scale well, and multiple connections quickly cut into the camera's battery life.
6 This is some mild foreshadowing. But it's already installed and he's not climbing back up there. What if instead we could figure out how the camera feed was being rendered in the app, locally cache it, and then rebroadcast it on his website to anyone who wanted to watch? I figured there would be some web viewer or at the very least some .m3u8 7 A standard streaming playlist file, which backs the vast majority of live broadcasts.[citation needed] file that we could grab and we'd be good to go. Web viewer dead end The first thing I did was check if there was a web viewer. While this is sold as an S4 Pro from Premium Invention it's actually just a repackaged Y5 Camera from Nice Intelligence (and available from AliExpress for half the price).
The NiView app is Nice Intelligence's app, but while they had Android apks 8 Complete with a separate APK for Mainland China, which I found interesting. Not sure why: I didn't diff them. to go along with their iOS app, none of the links I looked through at https://niview.app/ and https://www.niceviewer.com/ had a web browser, 9 Even though the Premium Invention website claimed they did. Maybe this was referring to this Windows wrapper of the app, only available in China? making extracting it substantially harder. mitmproxy dead end My post on reverse-engineering the Letterboxd API goes into more depth on how I set this up if you're curious, but generally mitmproxy is software to set up an HTTP proxy on my laptop. Pointing my phone to it runs all URLs 10 Normally apps can prevent this for certain URLs by saying "only succeed if I'm talking to the real server" but because my phone is jailbroken and I'm running "SSL Kill Switch 3" I can bypass the certificate pinning responsible for that check. that the phone looks up through that proxy, and we can see what it's querying and what it's getting back. As a reminder I'm looking for a clear request to some camera service that I can repurpose into a broadcast.
Unfortunately that wasn't the case: while I got requests to https://cloud-us1.niceviewer.com:8463/v1/device/devices and https://cloud-us1.niceviewer.com:8463/v2/device/getDeviceStatus they were just information about which cams I could see. 11 Based on the error page this API is using Spring, and based on the main IP 301-ing to https://niview-prod-na-pic-1302374016.cos.na-siliconvalley.myqcloud.com/server_static/index-na.html this is hosted by Tencent Cloud, their AWS equivalent. No trivial API documentation that I could find.
Furthermore the request responses were all encrypted. If I wanted to dig any further it was clear that I'd have to start poking around in the app.
Dump the app Apple apps are encrypted with FairPlay by default, but we can dump the decrypted app that's loaded into memory from a jailbroken iPhone with bfdecrypt (see my post dumping the Fitness SF app to reverse-engineer the QR code for more details). All we need to do is install bfdecryptor from https://alias20.gitlab.io/apt/ and enable it for the NiView app. Then when we open the app it will automatically download it as decrypted-app.ipa. We can run this command to list all downloaded bundles... find /private/var/mobile/Containers/Data/Application -name decrypted-app.ipa -type f | while read f; do \ dir=$(dirname "$f"); \ plist="$dir/../.com.apple.mobile_container_manager.metadata.plist"; \ bundle=$(grep -ao 'com\.[a-zA-Z0-9._]*' "$plist" 2>/dev/null | head -1); \ [ -n "$bundle" ] && echo "$bundle: $f"; \ done ...with com.niceviewer.nview as the one that corresponds to NiView. 12 Always more posts in progress, Bay Wheels post Coming Soon™.
com.fitnesssf.ios: ./9F262F33-1140-4FCB-B76E-6DB1594D2E8F/Documents/decrypted-app.ipa com.motivateco.gobike: ./E9B21C12-C082-4315-B630-271290AC2902/Documents/decrypted-app.ipa com.niceviewer.nview: ./CD61972E-8C82-4F94-99D9-4C701C6638D9/Documents/decrypted-app.ipa After copying this locally with scp mobile@192.168.0.247:/private/var/mobile/Containers/Data/Application/CD61972E-8C82-4F94-99D9-4C701C6638D9/Documents/decrypted-app.ipa 13 This is a relatively small file, a 48 MB .ipa. For comparison, Instagram is currently listed at 535 MB. and renaming to niview.zip we can extract and examine the internal files. Hopper and LLDB The main way that I do this is with a disassembler (I use Hopper) and lldb. When you click into a feed it shows messages like "Device is connecting..." and "The communication module is networking...". Despite the grammatical errors I figured this was an okay starting point to check if the class was initializing a server connection.
Normally you can find this by searching the .app Strings section in Hopper, but it wasn't turning anything up, so I instead checked the localization in en.lproj/Localizable.plist, 14 rg --binary "The communication module is networking" also helps to narrow down the location. which helpfully mapped this to... Loading_Fake_Tips_Networking.
Hopper Strings view Localizable.plist A quick check with Hopper shows that it just appends one of these Loading_Fake_Tips every few seconds to the UI as a placeholder while it does the actual action in the background.
Let's try a different approach with lldb!
With ps aux I can identify the PID of the running app as 508: > ps aux | grep Bundle/Application mobile 632 2.1 0.2 407918656 3120 s000 R+ 9:24AM 0:00.03 grep Bundle/Application mobile 599 0.0 0.6 407955328 11728 ?? Ss 9:23AM 0:00.34 /private/var/containers/Bundle/Application/390E30D0-E390-4DBC-BA3B-46666CA028E4/SequoiaTranslator.app/PlugIns/CacheDeleteExtension.appex/CacheDeleteExtension -AppleLanguages ("en-US") mobile 569 0.0 0.0 408064400 464 ?? Ss 9:23AM 0:00.00 /var/containers/Bundle/Application/60422DE1-AF5C-457A-9114-D12247C5C353/Spotify.app/Spotify mobile 508 0.0 2.0 408613936 42000 ?? Ss 9:22AM 0:02.39 /var/containers/Bundle/Application/65F285D6-1496-42D2-8525-09E971EF236E/NiView.app/NiView Then on my iPhone, I can use debugserver-16 "0.0.0.0:1234" --attach=508 to set up an LLDB server that I can attach to. 15 You can see more details in this previous post.
From another Terminal window on my laptop I can run lldb and process connect connect://192.168.0.247:1234 to attach to that running instance. Navigating to the camera feeds and running process interrupt and po [[UIWindow keyWindow] recursiveDescription] give a pretty good sense of the UI layout as well. In particular NVLivePlayerView and IVVideoRender feel like the best candidates. <UIWindow: 0x125d8e4a0; frame = (0 0; 375 667); autoresize = W+H; gestureRecognizers = <NSArray: 0x282535ec0>; layer = <UIWindowLayer: 0x282535ce0>> | <UITransitionView: 0x125db2730; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282b1d340>> | | <UIDropShadowView: 0x125d933e0; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282b1d600>> | | | <UILayoutContainerView: 0x125d8f710; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282b0f9c0>> | | | | <UITransitionView: 0x125d8f510; frame = (0 0; 375 667); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x282b707e0>> | | | | | <UIViewControllerWrapperView: 0x125db16b0; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282b1c180>> | | | | |
| <UILayoutContainerView: 0x125da1570; frame = (0 0; 375 667); clipsToBounds = YES; autoresize = W+H; gestureRecognizers = <NSArray: 0x2825c5230>; layer = <CALayer: 0x282b077e0>> | | | | | | | <UINavigationTransitionView: 0x125da23f0; frame = (0 0; 375 667); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x282b078a0>> | | | | | | | | <UIViewControllerWrapperView: 0x125f9d9f0; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282b29100>> | | | | | | | | | <UIView: 0x125fe1820; frame = (0 0; 375 667); autoresize = W+H; layer = <CALayer: 0x282be05c0>> | | | | | | | | | | <UIView: 0x125faad60; frame = (0 64; 375 50); layer = <CALayer: 0x282be2640>> | | | | | | | | | | | <CALayer: 0x282be28c0> (layer) | | | | | | | | | | <NiView.NVLivePlayerView: 0x126041600; frame = (0 0; 375 587); layer = <CALayer: 0x282be29a0>> | | | | | | | | | | | <UIScrollView: 0x1260c3e00; frame = (0 0; 375 587);