├── .cargo └── config.toml ├── .dockerignore ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yml │ ├── dockerprune.yml │ └── style_checks.yml ├── .gitignore ├── .markdownlint.yml ├── .pre-commit-config.yaml ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── build.rs ├── crates ├── core │ ├── Cargo.toml │ └── src │ │ ├── bc │ │ ├── codex.rs │ │ ├── crypto.rs │ │ ├── de.rs │ │ ├── mod.rs │ │ ├── model.rs │ │ ├── samples │ │ │ ├── EncryptionProtocol02_login.pcapng │ │ │ ├── battery_enc.bin │ │ │ ├── e1_firmwareupgrade.pcapng │ │ │ ├── model_sample_legacy_login.bin │ │ │ ├── model_sample_modern_login.bin │ │ │ ├── modern_login_failed.bin │ │ │ ├── modern_login_success.bin │ │ │ ├── modern_video_start1.bin │ │ │ ├── modern_video_start2.bin │ │ │ ├── xml_crypto_sample1.bin │ │ │ ├── xml_crypto_sample1_plaintext.bin │ │ │ ├── xml_externstream_b800.bin │ │ │ ├── xml_mainstream_b800.bin │ │ │ └── xml_substream_b800.bin │ │ ├── ser.rs │ │ └── xml.rs │ │ ├── bc_protocol.rs │ │ ├── bc_protocol │ │ ├── abilityinfo.rs │ │ ├── battery.rs │ │ ├── connection │ │ │ ├── bcconn.rs │ │ │ ├── bcsub.rs │ │ │ ├── discovery.rs │ │ │ ├── mod.rs │ │ │ ├── tcpsource.rs │ │ │ └── udpsource.rs │ │ ├── credentials.rs │ │ ├── email.rs │ │ ├── errors.rs │ │ ├── floodlight.rs │ │ ├── keepalive.rs │ │ ├── ledstate.rs │ │ ├── link.rs │ │ ├── login.rs │ │ ├── logout.rs │ │ ├── motion.rs │ │ ├── ping.rs │ │ ├── pirstate.rs │ │ ├── ptz.rs │ │ ├── pushinfo.rs │ │ ├── reboot.rs │ │ ├── resolution.rs │ │ ├── services.rs │ │ ├── siren.rs │ │ ├── snap.rs │ │ ├── stream.rs │ │ ├── stream_info.rs │ │ ├── support.rs │ │ ├── talk.rs │ │ ├── time.rs │ │ ├── uid.rs │ │ ├── users.rs │ │ └── version.rs │ │ ├── bcmedia │ │ ├── codex.rs │ │ ├── de.rs │ │ ├── mod.rs │ │ ├── model.rs │ │ ├── samples │ │ │ ├── adpcm_0.raw │ │ │ ├── argus2_iframe_0.raw │ │ │ ├── argus2_iframe_1.raw │ │ │ ├── argus2_iframe_2.raw │ │ │ ├── argus2_iframe_3.raw │ │ │ ├── argus2_iframe_4.raw │ │ │ ├── argus2_pframe_0.raw │ │ │ ├── argus2_pframe_1.raw │ │ │ ├── argus2_pframe_10.raw │ │ │ ├── argus2_pframe_11.raw │ │ │ ├── argus2_pframe_12.raw │ │ │ ├── argus2_pframe_13.raw │ │ │ ├── argus2_pframe_14.raw │ │ │ ├── argus2_pframe_15.raw │ │ │ ├── argus2_pframe_16.raw │ │ │ ├── argus2_pframe_17.raw │ │ │ ├── argus2_pframe_2.raw │ │ │ ├── argus2_pframe_3.raw │ │ │ ├── argus2_pframe_4.raw │ │ │ ├── argus2_pframe_5.raw │ │ │ ├── argus2_pframe_6.raw │ │ │ ├── argus2_pframe_7.raw │ │ │ ├── argus2_pframe_8.raw │ │ │ ├── argus2_pframe_9.raw │ │ │ ├── iframe_0.raw │ │ │ ├── iframe_1.raw │ │ │ ├── iframe_2.raw │ │ │ ├── iframe_3.raw │ │ │ ├── iframe_4.raw │ │ │ ├── info_v1.raw │ │ │ ├── pframe_0.raw │ │ │ ├── pframe_1.raw │ │ │ ├── video_stream_swan_00.raw │ │ │ ├── video_stream_swan_01.raw │ │ │ ├── video_stream_swan_02.raw │ │ │ ├── video_stream_swan_03.raw │ │ │ ├── video_stream_swan_04.raw │ │ │ ├── video_stream_swan_05.raw │ │ │ ├── video_stream_swan_06.raw │ │ │ ├── video_stream_swan_07.raw │ │ │ ├── video_stream_swan_08.raw │ │ │ └── video_stream_swan_09.raw │ │ └── ser.rs │ │ ├── bcudp │ │ ├── codex.rs │ │ ├── crc.rs │ │ ├── de.rs │ │ ├── mod.rs │ │ ├── model.rs │ │ ├── samples │ │ │ ├── udp_ack.bin │ │ │ ├── udp_data.bin │ │ │ ├── udp_multi_0.bin │ │ │ ├── udp_multi_1.bin │ │ │ ├── udp_multi_2.bin │ │ │ ├── udp_multi_3.bin │ │ │ ├── udp_multi_4.bin │ │ │ ├── udp_multi_5.bin │ │ │ ├── udp_multi_6.bin │ │ │ ├── udp_multi_7.bin │ │ │ ├── udp_multi_8.bin │ │ │ ├── udp_multi_9.bin │ │ │ ├── udp_negotiate_camcfm.bin │ │ │ ├── udp_negotiate_camt.bin │ │ │ ├── udp_negotiate_clientt.bin │ │ │ ├── udp_negotiate_disc.bin │ │ │ ├── xml_crypto_sample1.bin │ │ │ └── xml_crypto_sample1_plaintext.bin │ │ ├── ser.rs │ │ ├── xml.rs │ │ └── xml_crypto.rs │ │ └── lib.rs ├── decoder │ ├── Cargo.toml │ └── src │ │ └── main.rs ├── mailnoti │ ├── Cargo.toml │ ├── README.md │ └── src │ │ ├── config.rs │ │ ├── main.rs │ │ ├── opt.rs │ │ └── utils.rs └── pushnoti │ ├── Cargo.toml │ ├── README.md │ └── src │ ├── config.rs │ ├── main.rs │ ├── opt.rs │ └── utils.rs ├── deny.toml ├── dissector ├── README.md ├── baichuan.lua ├── mediapacket.md ├── messages.md ├── protocol.md └── udp.md ├── docker └── entrypoint.sh ├── docs ├── Setting Up Neolink For Use With Blue Iris.md ├── screenshots │ ├── login_messages.JPG │ ├── new_camera.JPG │ ├── new_camera_config.JPG │ ├── taskfinishwindow.JPG │ └── tasksettingstab.JPG ├── unix_service.md └── unix_setup.md ├── kubernetes ├── README.md └── manifest.yaml ├── rustfmt.toml ├── sample_config.toml └── src ├── battery ├── cmdline.rs └── mod.rs ├── cmdline.rs ├── common ├── camthread.rs ├── instance.rs ├── instance │ ├── gst.rs │ └── pushnoti.rs ├── mdthread.rs ├── mod.rs ├── neocam.rs ├── pushnoti.rs ├── reactor.rs └── usecounter.rs ├── config.rs ├── errors.rs ├── image ├── cmdline.rs ├── gst.rs └── mod.rs ├── main.rs ├── mqtt ├── cmdline.rs ├── discovery.rs ├── mod.rs └── mqttc.rs ├── pir ├── cmdline.rs └── mod.rs ├── ptz ├── cmdline.rs └── mod.rs ├── reboot ├── cmdline.rs └── mod.rs ├── rtsp ├── adpcm.rs ├── cmdline.rs ├── factory.rs ├── gst.rs ├── gst │ ├── factory.rs │ ├── server.rs │ └── shared.rs ├── mod.rs └── stream.rs ├── services ├── cmdline.rs └── mod.rs ├── statusled ├── cmdline.rs └── mod.rs ├── talk ├── cmdline.rs ├── gst.rs └── mod.rs ├── users ├── cmdline.rs └── mod.rs └── utils.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-C", "target-feature=+crt-static", "--cfg", "tokio_unstable"] 3 | 4 | # TODO not good as a default 5 | [target.armv7-unknown-linux-gnueabihf] 6 | linker = "arm-linux-gnueabihf-gcc" 7 | 8 | [target.aarch64-unknown-linux-gnu] 9 | linker = "aarch64-linux-gnu-gcc" 10 | 11 | [target.i686-unknown-linux-gnu] 12 | linker = "i686-linux-gnu-gcc" 13 | 14 | [build] 15 | rustflags = ["--cfg", "tokio_unstable"] -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | target/ 2 | Dockerfile 3 | .dockerignore 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | Cargo.lock binary 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: qesoul 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Something isn't working 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. Example: 15 | 1. Create this configuration file: 16 | 2. Launch Neolink: 17 | 3. Click on ... in Blue Iris 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Versions** 23 | NVR software: 24 | Neolink software: 25 | Reolink camera model and firmware: 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex: "I'm always frustrated when [...]" 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /.github/workflows/dockerprune.yml: -------------------------------------------------------------------------------- 1 | name: DockerHub 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | pre_job: 7 | # continue-on-error: true # Uncomment once integration is finished 8 | runs-on: ubuntu-latest 9 | # Map a step output to a job output 10 | outputs: 11 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 12 | steps: 13 | - id: skip_check 14 | uses: fkirc/skip-duplicate-actions@v5 15 | with: 16 | # All of these options are optional, so you can remove them if you are happy with the defaults 17 | concurrent_skipping: "same_content_newer" 18 | skip_after_successful_duplicate: "false" 19 | 20 | native: 21 | needs: pre_job 22 | if: needs.pre_job.outputs.should_skip != 'true' 23 | name: native 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Check token is set 27 | id: vars 28 | shell: bash 29 | run: | 30 | unset HAS_SECRET 31 | if [ -n $SECRET ]; then HAS_SECRET='true' ; fi 32 | echo "HAS_SECRET_TOKEN=${HAS_SECRET}" >> $GITHUB_OUTPUT 33 | env: 34 | SECRET: "${{ secrets.DOCKER_PRUNE_TOKEN }}" 35 | - name: Convert username to lower case for docker 36 | id: string_user 37 | if: ${{ steps.vars.outputs.HAS_SECRET_TOKEN }} 38 | uses: ASzc/change-string-case-action@v6 39 | with: 40 | string: ${{ github.repository_owner }} 41 | - name: Convert repo to lower case for docker 42 | id: string_repo 43 | if: ${{ steps.vars.outputs.HAS_SECRET_TOKEN }} 44 | uses: ASzc/change-string-case-action@v6 45 | with: 46 | string: ${{ github.event.repository.name }} 47 | - name: Run Clean Script 48 | shell: bash 49 | if: ${{ steps.vars.outputs.HAS_SECRET_TOKEN }} 50 | run: | 51 | #!/bin/bash 52 | 53 | #Script will delete all images in all repositories of your docker hub account which are older than x days 54 | 55 | set -e 56 | 57 | 58 | # set username and password 59 | UNAME="${OWNER}" 60 | TOKEN="${TOKEN}" 61 | DAYS="${DAYS}" 62 | REPO_NAME="${REPO_NAME}" 63 | 64 | # get list of namespaces accessible by user (not in use right now) 65 | #NAMESPACES=$(curl -s -H "Authorization: JWT ${TOKEN}" https://hub.docker.com/v2/repositories/namespaces/ | jq -r '.namespaces|.[]') 66 | 67 | #echo $TOKEN 68 | echo 69 | # get list of repos for that user account 70 | echo "List of Repositories in ${UNAME} Docker Hub account" 71 | REPO_LIST="$(curl -s -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${UNAME}/?page_size=10000" | jq -r '.results|.[]|.name')" 72 | echo "$REPO_LIST" 73 | echo 74 | # build a list of all images & tags 75 | for i in ${REPO_LIST} 76 | do 77 | # get tags for repo 78 | IMAGE_TAGS="$(curl -s -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${UNAME}/${i}/tags/?page_size=10000" | jq -r '.results|.[]|.name')" 79 | 80 | # build a list of images from tags 81 | for j in ${IMAGE_TAGS} 82 | do 83 | # add each tag to list 84 | FULL_IMAGE_LIST="${FULL_IMAGE_LIST} ${UNAME}/${i}:${j}" 85 | 86 | done 87 | done 88 | 89 | # output list of all docker images 90 | echo 91 | echo "List of all docker images in ${UNAME} Docker Hub account" 92 | for i in ${FULL_IMAGE_LIST} 93 | do 94 | echo "${i}" 95 | done 96 | 97 | echo 98 | echo "Identifying and deleting images which are older than ${DAYS} days in ${UNAME} docker hub account" 99 | # Note!!! Please un-comment below line if you wanna perform operation on all repositories of your Docker Hub account 100 | #for i in ${REPO_LIST} 101 | 102 | repos=( 103 | "${REPO_NAME}" 104 | ) 105 | 106 | for i in "${repos[@]}"; do 107 | # get tags for repo 108 | echo 109 | echo "Looping Through $i repository in ${UNAME} account" 110 | IMAGE_TAGS="$(curl -s -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${UNAME}/${i}/tags/?page_size=10000" | jq -r '.results|.[]|.name')" 111 | 112 | # build a list of images from tags 113 | for j in ${IMAGE_TAGS} 114 | do 115 | if [[ "${j}" =~ ^[v]?[0-9][.][0-9][.][0-9]$ ]]; then 116 | echo "Version tag (${j}) will not be deleted" 117 | continue 118 | fi 119 | echo 120 | # add last_updated_time 121 | updated_time="$(curl -s -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${UNAME}/${i}/tags/${j}/?page_size=10000" | jq -r '.last_updated')" 122 | echo "$updated_time" 123 | datetime=$updated_time 124 | timeago="${DAYS} days ago" 125 | 126 | dtSec=$(date --date "$datetime" +'%s') 127 | taSec=$(date --date "$timeago" +'%s') 128 | 129 | echo "INFO: dtSec=$dtSec, taSec=$taSec" 130 | 131 | if [ "$dtSec" -lt "$taSec" ] 132 | then 133 | echo "This image ${UNAME}/${i}:${j} is older than ${DAYS} days, deleting this image" 134 | ## Please uncomment below line to delete docker hub images of docker hub repositories 135 | curl -s -X DELETE -H "Authorization: JWT ${TOKEN}" "https://hub.docker.com/v2/repositories/${UNAME}/${i}/tags/${j}/" 136 | else 137 | echo "This image ${UNAME}/${i}:${j} is within ${DAYS} days time range, keep this image" 138 | fi 139 | done 140 | done 141 | 142 | echo "Script execution ends" 143 | env: 144 | DAYS: "100" 145 | REPO_NAME: ${{ steps.string_repo.outputs.lowercase }} 146 | OWNER: ${{ vars.DOCKER_USERNAME || steps.string_user.outputs.lowercase }} 147 | TOKEN: ${{ secrets.DOCKER_PRUNE_TOKEN }} 148 | -------------------------------------------------------------------------------- /.github/workflows/style_checks.yml: -------------------------------------------------------------------------------- 1 | name: Style 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | pre_job: 7 | # continue-on-error: true # Uncomment once integration is finished 8 | runs-on: ubuntu-latest 9 | # Map a step output to a job output 10 | outputs: 11 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 12 | steps: 13 | - id: skip_check 14 | uses: fkirc/skip-duplicate-actions@v5 15 | with: 16 | # All of these options are optional, so you can remove them if you are happy with the defaults 17 | concurrent_skipping: "same_content_newer" 18 | skip_after_successful_duplicate: "false" 19 | 20 | check_clippy: 21 | needs: pre_job 22 | if: needs.pre_job.outputs.should_skip != 'true' 23 | name: Clippy 24 | runs-on: ubuntu-22.04 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: apt install gstreamer and deps 28 | run: | 29 | sudo apt update 30 | sudo apt install -y aptitude 31 | sudo aptitude install -y libgstrtspserver-1.0-dev libgstreamer1.0-dev libgtk2.0-dev protobuf-compiler 32 | - name: Install nightly rust 33 | run: | 34 | rustup toolchain install nightly --component clippy 35 | rustup override set nightly 36 | - name: Run clippy manually 37 | run: | 38 | echo "All Features" 39 | cargo +nightly clippy --workspace --all-targets --all-features || exit 1 40 | echo "No Features" 41 | cargo +nightly clippy --workspace --all-targets --no-default-features || exit 1 42 | echo "Gstreamer Only" 43 | cargo +nightly clippy --workspace --all-targets --no-default-features --features=gstreamer || exit 1 44 | echo "Pushnoti Only" 45 | cargo +nightly clippy --workspace --all-targets --no-default-features --features=pushnoti || exit 1 46 | 47 | check_fmt: 48 | needs: pre_job 49 | if: needs.pre_job.outputs.should_skip != 'true' 50 | name: Rust-fmt 51 | runs-on: ubuntu-latest 52 | steps: 53 | - uses: actions/checkout@v4 54 | - name: Install nightly rust 55 | run: | 56 | rustup toolchain install nightly --component rustfmt 57 | rustup override set nightly 58 | - name: rustfmt 59 | run: | 60 | cargo +nightly fmt --all -- --check 61 | 62 | check_lua: 63 | needs: pre_job 64 | if: needs.pre_job.outputs.should_skip != 'true' 65 | name: Luacheck 66 | runs-on: ubuntu-latest 67 | steps: 68 | - uses: actions/checkout@v4 69 | - name: Run luacheck 70 | uses: nebularg/actions-luacheck@v1 71 | with: 72 | files: "dissector/baichuan.lua" 73 | args: --globals Dissector Proto ProtoField base ByteArray DESEGMENT_ONE_MORE_SEGMENT DissectorTable Pref 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/rust 3 | # Edit at https://www.gitignore.io/?templates=rust 4 | 5 | ### Rust ### 6 | # Generated by Cargo 7 | # will have compiled files and executables 8 | /target/ 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # End of https://www.gitignore.io/api/rust 14 | 15 | # Committing IntelliJ project files is a violation of intergalactic law 16 | .idea/ 17 | 18 | # We prefer to keep .vscode files outside the repo 19 | .vscode/ 20 | # MacOS 21 | .DS_Store 22 | ._.DS_Store 23 | **/.DS_Store 24 | **/._.DS_Store 25 | 26 | # The token file for the FCM tests 27 | token.toml 28 | 29 | # Debug FCM build 30 | crates/fcm-push-listener 31 | -------------------------------------------------------------------------------- /.markdownlint.yml: -------------------------------------------------------------------------------- 1 | MD013: 2 | code_blocks: false 3 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/doublify/pre-commit-rust 3 | rev: eeee35a 4 | hooks: 5 | - id: fmt 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.4.0 # Use the ref you want to point at 8 | hooks: 9 | - id: trailing-whitespace 10 | - id: check-case-conflict 11 | - id: check-merge-conflict 12 | - id: check-toml 13 | - id: mixed-line-ending 14 | - repo: https://github.com/igorshubovych/markdownlint-cli 15 | rev: v0.33.0 16 | hooks: 17 | - id: markdownlint-fix 18 | files: README.md 19 | args: [--disable, MD041, --] 20 | 21 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "neolink" 3 | description = "A standards-compliant bridge to Reolink IP cameras" 4 | version = "0.6.3-rc.3" 5 | authors = ["George Hilliard ", "Andrew King "] 6 | edition = "2018" 7 | license = "AGPL-3.0-or-later" 8 | 9 | [workspace] 10 | members = [ 11 | "crates/*", 12 | ] 13 | 14 | [dependencies] 15 | anyhow = "1.0.70" 16 | base64 = "0.22.0" 17 | byte-slice-cast = {version = "1.2.2", optional = true} 18 | bytes = "1.6.0" 19 | clap = { version = "4.2.2", features = ["derive", "cargo"] } 20 | crossbeam-channel = {version = "0.5.8", optional = true} 21 | dirs = {version = "5.0.1", optional = true} 22 | env_logger = "0.11.3" 23 | fcm-push-listener = {version = "2.0.3", optional = true} 24 | futures = "0.3.28" 25 | gstreamer = {version = "0.23.0", optional = true} 26 | gstreamer-app = { version = "0.23.0", features = ["v1_20"], optional = true } 27 | gstreamer-rtsp = { version = "0.23.0", features = ["v1_20"], optional = true } 28 | gstreamer-rtsp-server = { version = "0.23.0", features = ["v1_20"], optional = true } 29 | heck = "0.5.0" 30 | log = { version = "0.4.17", features = [ "release_max_level_debug" ] } 31 | md5 = {version = "0.7.0", optional = true} 32 | neolink_core = { path = "crates/core", version = "0.6.3-rc.3" } 33 | once_cell = "1.19.0" 34 | quick-xml = { version = "0.36.1", features = ["serialize"] } 35 | regex = "1.7.3" 36 | rumqttc = "0.24.0" 37 | serde = { version = "1.0.160", features = ["derive"] } 38 | serde_json = "1.0.96" 39 | tokio = { version = "1.27.0", features = ["rt-multi-thread", "macros", "io-util", "tracing"] } 40 | tokio-stream = "0.1.12" 41 | tokio-util = { version = "0.7.7", features = ["full", "tracing"] } 42 | toml = "0.8.2" 43 | uuid = { version = "1.8.0", features = ["v4"] } 44 | validator = {version="0.18.1", features = ["derive"] } 45 | 46 | [target.'cfg(not(target_env = "msvc"))'.dependencies] 47 | tikv-jemallocator = "0.5" 48 | 49 | [features] 50 | default = ["gstreamer"] 51 | gstreamer = [ 52 | "dep:gstreamer", 53 | "dep:gstreamer-app", 54 | "dep:gstreamer-rtsp", 55 | "dep:gstreamer-rtsp-server", 56 | "dep:byte-slice-cast", 57 | "dep:crossbeam-channel" 58 | ] 59 | pushnoti = [ 60 | "dep:fcm-push-listener", 61 | "dep:dirs", 62 | "dep:md5" 63 | ] 64 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Neolink Docker image build scripts 2 | # Copyright (c) 2020 George Hilliard, 3 | # Andrew King, 4 | # Miroslav Šedivý 5 | # SPDX-License-Identifier: AGPL-3.0-only 6 | 7 | FROM docker.io/rust:slim-bookworm AS build 8 | ARG TARGETPLATFORM 9 | 10 | ENV DEBIAN_FRONTEND=noninteractive 11 | WORKDIR /usr/local/src/neolink 12 | COPY . /usr/local/src/neolink 13 | 14 | # Build the main program or copy from artifact 15 | # 16 | # We prefer copying from artifact to reduce 17 | # build time on the github runners 18 | # 19 | # Because of this though, during normal 20 | # github runner ops we are not testing the 21 | # docker to see if it will build from scratch 22 | # so if it is failing please make a PR 23 | # 24 | # hadolint ignore=DL3008 25 | RUN echo "TARGETPLATFORM: ${TARGETPLATFORM}"; \ 26 | if [ -f "${TARGETPLATFORM}/neolink" ]; then \ 27 | echo "Restoring from artifact"; \ 28 | mkdir -p /usr/local/src/neolink/target/release/; \ 29 | cp "${TARGETPLATFORM}/neolink" "/usr/local/src/neolink/target/release/neolink"; \ 30 | else \ 31 | echo "Building from scratch"; \ 32 | apt-get update && \ 33 | apt-get upgrade -y && \ 34 | apt-get install -y --no-install-recommends \ 35 | build-essential \ 36 | openssl \ 37 | libssl-dev \ 38 | ca-certificates \ 39 | libgstrtspserver-1.0-dev \ 40 | libgstreamer1.0-dev \ 41 | libgtk2.0-dev \ 42 | protobuf-compiler \ 43 | libglib2.0-dev && \ 44 | apt-get clean -y && rm -rf /var/lib/apt/lists/* ; \ 45 | cargo build --release; \ 46 | fi 47 | 48 | # Create the release container. Match the base OS used to build 49 | FROM debian:bookworm-slim 50 | ARG TARGETPLATFORM 51 | ARG REPO 52 | ARG VERSION 53 | ARG OWNER 54 | 55 | LABEL description="An image for the neolink program which is a reolink camera to rtsp translator" 56 | LABEL repository="$REPO" 57 | LABEL version="$VERSION" 58 | LABEL maintainer="$OWNER" 59 | 60 | # hadolint ignore=DL3008 61 | RUN apt-get update && \ 62 | apt-get upgrade -y && \ 63 | apt-get install -y --no-install-recommends \ 64 | openssl \ 65 | dnsutils \ 66 | iputils-ping \ 67 | ca-certificates \ 68 | libgstrtspserver-1.0-0 \ 69 | libgstreamer1.0-0 \ 70 | gstreamer1.0-tools \ 71 | gstreamer1.0-x \ 72 | gstreamer1.0-plugins-base \ 73 | gstreamer1.0-plugins-good \ 74 | gstreamer1.0-plugins-bad \ 75 | gstreamer1.0-libav && \ 76 | apt-get clean -y && rm -rf /var/lib/apt/lists/* 77 | 78 | COPY --from=build \ 79 | /usr/local/src/neolink/target/release/neolink \ 80 | /usr/local/bin/neolink 81 | COPY docker/entrypoint.sh /entrypoint.sh 82 | 83 | RUN gst-inspect-1.0; \ 84 | chmod +x "/usr/local/bin/neolink" && \ 85 | "/usr/local/bin/neolink" --version && \ 86 | mkdir -m 0700 /root/.config/ 87 | 88 | ENV NEO_LINK_MODE="rtsp" NEO_LINK_PORT=8554 89 | 90 | CMD /usr/local/bin/neolink "${NEO_LINK_MODE}" --config /etc/neolink.toml 91 | ENTRYPOINT ["/entrypoint.sh"] 92 | EXPOSE ${NEO_LINK_PORT} 93 | 94 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::process::Command; 3 | 4 | fn main() { 5 | build_ver(); 6 | platform_cfg(); 7 | } 8 | 9 | fn build_ver() { 10 | let cargo_ver = env::var("CARGO_PKG_VERSION").unwrap(); 11 | let version = git_ver().unwrap_or(format!("{} (unknown commit)", cargo_ver)); 12 | 13 | println!("cargo:rustc-env=NEOLINK_VERSION={}", version); 14 | println!( 15 | "cargo:rustc-env=NEOLINK_PROFILE={}", 16 | env::var("PROFILE").unwrap() 17 | ); 18 | } 19 | 20 | fn git_ver() -> Option { 21 | github_ver().or_else(git_cmd_ver) 22 | } 23 | 24 | fn git_cmd_ver() -> Option { 25 | let mut git_cmd = Command::new("git"); 26 | git_cmd.args(["describe", "--tags"]); 27 | 28 | if let Some(true) = git_cmd.status().ok().map(|exit| exit.success()) { 29 | println!("cargo:rerun-if-changed=.git/HEAD"); 30 | git_cmd 31 | .output() 32 | .ok() 33 | .map(|o| String::from_utf8(o.stdout).unwrap()) 34 | } else { 35 | None 36 | } 37 | } 38 | 39 | fn github_ver() -> Option { 40 | if let Ok(sha1) = env::var("GITHUB_SHA") { 41 | println!("cargo:rerun-if-env-changed=GITHUB_SHA"); 42 | Some(sha1) 43 | } else { 44 | None 45 | } 46 | } 47 | 48 | #[cfg(target_os = "windows")] 49 | fn platform_cfg() { 50 | let gstreamer_dir = env::var_os("GSTREAMER_1_0_ROOT_X86_64") 51 | .and_then(|x| x.into_string().ok()) 52 | .unwrap_or_else(|| { 53 | env::var_os("GSTREAMER_1_0_ROOT_MSVC_X86_64") 54 | .and_then(|x| x.into_string().ok()) 55 | .unwrap_or_else(|| r#"C:\gstreamer\1.0\x86_64\"#.to_string()) 56 | }); 57 | 58 | println!(r"cargo:rustc-link-search=native={}\lib", gstreamer_dir); 59 | } 60 | 61 | #[cfg(target_os = "macos")] 62 | fn platform_cfg() { 63 | let gstreamer_dir = env::var_os("GSTREAMER_1_0_ROOT_MACOSX") 64 | .and_then(|x| x.into_string().ok()) 65 | .unwrap_or_else(|| r"/Library/Frameworks/GStreamer.framework/Versions/1.0".to_string()); 66 | let openssl_dir = env::var_os("OPENSSL_1_1_ROOT_MACOSX") 67 | .and_then(|x| x.into_string().ok()) 68 | .unwrap_or_else(|| r"/usr/local/opt/openssl@1.1".to_string()); 69 | 70 | println!( 71 | r"cargo:rustc-link-search=native={}/lib;{}/lib", 72 | gstreamer_dir, openssl_dir 73 | ); 74 | println!( 75 | r"cargo:rustc-link-arg=-Wl,-rpath,{}/lib;{}/lib/", 76 | gstreamer_dir, openssl_dir 77 | ); 78 | } 79 | 80 | #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] 81 | fn platform_cfg() {} 82 | -------------------------------------------------------------------------------- /crates/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "neolink_core" 3 | description = "Core services and structure for Reolink IP cameras" 4 | version = "0.6.3-rc.3" 5 | authors = ["George Hilliard ", "Andrew King "] 6 | edition = "2018" 7 | license = "AGPL-3.0-or-later" 8 | 9 | [dependencies] 10 | aes = "0.8.2" 11 | bytes = "1.4.0" 12 | cfb-mode = "0.8.2" 13 | cookie-factory = "0.3.2" 14 | crc32fast = "1.3.2" 15 | crossbeam-channel = "0.5.8" 16 | delegate = "0.12.0" 17 | futures = "0.3.28" 18 | get_if_addrs = "0.5.3" 19 | lazy_static = "1.4.0" 20 | log = "0.4.17" 21 | md5 = "0.7.0" 22 | nom = { version = "7.1.3", features = ["alloc"] } 23 | quick-xml = { version = "0.36.0", features = ["serialize"] } 24 | rand = "0.8.5" 25 | regex = "1.7.3" 26 | serde = { version = "1.0.106", features = ["derive"] } 27 | thiserror = "1.0.58" 28 | time = { version = "0.3.36" , features = [ "macros", "parsing", "local-offset" ]} 29 | tokio = { version = "1.27.0", features = ["full"] } 30 | tokio-stream = { version = "0.1.12", features = ["sync", "time", "net"] } 31 | tokio-util = { version = "0.7.7", features = ["full", "tracing"] } 32 | 33 | [dev-dependencies] 34 | assert_matches = "1.5.0" 35 | env_logger = "*" 36 | indoc = "2.0.1" 37 | -------------------------------------------------------------------------------- /crates/core/src/bc/crypto.rs: -------------------------------------------------------------------------------- 1 | use aes::{ 2 | cipher::{AsyncStreamCipher, KeyIvInit}, 3 | Aes128, 4 | }; 5 | use cfb_mode::{Decryptor, Encryptor}; 6 | 7 | type Aes128CfbEnc = Encryptor; 8 | type Aes128CfbDec = Decryptor; 9 | 10 | const XML_KEY: [u8; 8] = [0x1F, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0xFF]; 11 | const IV: &[u8] = b"0123456789abcdef"; 12 | 13 | /// These are the encyption modes supported by the camera 14 | /// 15 | /// The mode is negotiated during login 16 | #[derive(Debug, Clone)] 17 | pub enum EncryptionProtocol { 18 | /// Older camera use no encryption 19 | Unencrypted, 20 | /// Camera/Firmwares before 2021 use BCEncrypt which is a simple XOr 21 | BCEncrypt, 22 | /// Latest cameras/firmwares use Aes with the key derived from 23 | /// the camera's password and the negotiated NONCE 24 | Aes { 25 | /// The encryptor 26 | enc: Aes128CfbEnc, 27 | /// The decryptor 28 | dec: Aes128CfbDec, 29 | }, 30 | /// Same as Aes but the media stream is also encrypted and not just 31 | /// the control commands 32 | FullAes { 33 | /// The encryptor 34 | enc: Aes128CfbEnc, 35 | /// The decryptor 36 | dec: Aes128CfbDec, 37 | }, 38 | } 39 | 40 | impl EncryptionProtocol { 41 | /// Helper to make unencrypted 42 | pub fn unencrypted() -> Self { 43 | EncryptionProtocol::Unencrypted 44 | } 45 | /// Helper to make bcencrypted 46 | pub fn bcencrypt() -> Self { 47 | EncryptionProtocol::BCEncrypt 48 | } 49 | /// Helper to make aes 50 | pub fn aes(key: [u8; 16]) -> Self { 51 | EncryptionProtocol::Aes { 52 | enc: Aes128CfbEnc::new(key.as_slice().into(), IV.into()), 53 | dec: Aes128CfbDec::new(key.as_slice().into(), IV.into()), 54 | } 55 | } 56 | /// Helper to make full aes 57 | pub fn full_aes(key: [u8; 16]) -> Self { 58 | EncryptionProtocol::FullAes { 59 | enc: Aes128CfbEnc::new(key.as_slice().into(), IV.into()), 60 | dec: Aes128CfbDec::new(key.as_slice().into(), IV.into()), 61 | } 62 | } 63 | 64 | /// Decrypt the data, offset comes from the header of the packet 65 | pub fn decrypt(&self, offset: u32, buf: &[u8]) -> Vec { 66 | match self { 67 | EncryptionProtocol::Unencrypted => buf.to_vec(), 68 | EncryptionProtocol::BCEncrypt => { 69 | let key_iter = XML_KEY.iter().cycle().skip(offset as usize % 8); 70 | key_iter 71 | .zip(buf) 72 | .map(|(key, i)| *i ^ key ^ (offset as u8)) 73 | .collect() 74 | } 75 | EncryptionProtocol::Aes { dec, .. } | EncryptionProtocol::FullAes { dec, .. } => { 76 | // AES decryption 77 | 78 | let mut decrypted = buf.to_vec(); 79 | dec.clone().decrypt(&mut decrypted); 80 | decrypted 81 | } 82 | } 83 | } 84 | 85 | /// Encrypt the data, offset comes from the header of the packet 86 | pub fn encrypt(&self, offset: u32, buf: &[u8]) -> Vec { 87 | match self { 88 | EncryptionProtocol::Unencrypted => { 89 | // Encrypt is the same as decrypt 90 | self.decrypt(offset, buf) 91 | } 92 | EncryptionProtocol::BCEncrypt => { 93 | // Encrypt is the same as decrypt 94 | self.decrypt(offset, buf) 95 | } 96 | EncryptionProtocol::Aes { enc, .. } | EncryptionProtocol::FullAes { enc, .. } => { 97 | // AES encryption 98 | let mut encrypted = buf.to_vec(); 99 | enc.clone().encrypt(&mut encrypted); 100 | encrypted 101 | } 102 | } 103 | } 104 | } 105 | 106 | #[test] 107 | fn test_xml_crypto() { 108 | let sample = include_bytes!("samples/xml_crypto_sample1.bin"); 109 | let should_be = include_bytes!("samples/xml_crypto_sample1_plaintext.bin"); 110 | 111 | let decrypted = EncryptionProtocol::BCEncrypt.decrypt(0, &sample[..]); 112 | assert_eq!(decrypted, &should_be[..]); 113 | } 114 | 115 | #[test] 116 | fn test_xml_crypto_roundtrip() { 117 | let zeros: [u8; 256] = [0; 256]; 118 | 119 | let decrypted = EncryptionProtocol::BCEncrypt.encrypt(0, &zeros[..]); 120 | let encrypted = EncryptionProtocol::BCEncrypt.decrypt(0, &decrypted[..]); 121 | assert_eq!(encrypted, &zeros[..]); 122 | } 123 | -------------------------------------------------------------------------------- /crates/core/src/bc/mod.rs: -------------------------------------------------------------------------------- 1 | //! The Baichuan message format is a 20 byte header, the contents of which vary between legacy and 2 | //! modern messages: 3 | //! 4 | //! 5 | //! 6 | //! This header is followed by the message body. In legacy messages, the bodies are 7 | //! message-specific binary formats. Currently, we only attempt to interpret the legacy login 8 | //! message, as it is all that is needed to upgrade to the modern XML-based messages. Modern 9 | //! messages are either "encrypted" XML (the encryption is a simple XOR routine or AES) 10 | //! or binary data. 11 | //! 12 | //! --- 13 | //! 14 | //! # Payloads 15 | //! Messages contain one-two payloads seperated by the payload_offset in the header 16 | //! 17 | //! ## Extension Payload 18 | //! The first payload prior to the payload_offset is the extension xml 19 | //! 20 | //! This contains meta data on the following payload such as channel_id or content type 21 | //! (xml or binary) 22 | //! 23 | //! ## Payload 24 | //! The second payload which is the primary payload coming after the payload offset 25 | //! depends on the MsgID. 26 | //! 27 | //! It is usually XML except in the case of video and talk MsgIDs 28 | //! which are binary data in the bc media packet format 29 | //! 30 | 31 | /// Contains the structure of the messages such as headers and payloads 32 | pub mod model; 33 | 34 | /// Contains code related to the deserialisation of the bc packets 35 | pub mod de; 36 | /// `Contains code related to the serialisation of the bc packets 37 | pub mod ser; 38 | /// Contains the structs for the know xmls of payloads and extension 39 | pub mod xml; 40 | 41 | /// Contains the encryption protocols 42 | pub mod crypto; 43 | 44 | pub(crate) mod codex; 45 | -------------------------------------------------------------------------------- /crates/core/src/bc/samples/EncryptionProtocol02_login.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/EncryptionProtocol02_login.pcapng -------------------------------------------------------------------------------- /crates/core/src/bc/samples/battery_enc.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/battery_enc.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/e1_firmwareupgrade.pcapng: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/e1_firmwareupgrade.pcapng -------------------------------------------------------------------------------- /crates/core/src/bc/samples/model_sample_legacy_login.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/model_sample_legacy_login.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/model_sample_modern_login.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/model_sample_modern_login.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/modern_login_failed.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/modern_login_failed.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/modern_login_success.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/modern_login_success.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/modern_video_start1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/modern_video_start1.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/modern_video_start2.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/modern_video_start2.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/xml_crypto_sample1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/xml_crypto_sample1.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/xml_crypto_sample1_plaintext.bin: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | md5 5 | 9E6D1FCB9E69846D 6 | 7 | 8 | -------------------------------------------------------------------------------- /crates/core/src/bc/samples/xml_externstream_b800.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/xml_externstream_b800.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/xml_mainstream_b800.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/xml_mainstream_b800.bin -------------------------------------------------------------------------------- /crates/core/src/bc/samples/xml_substream_b800.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bc/samples/xml_substream_b800.bin -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/abilityinfo.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | use log::*; 4 | 5 | impl BcCamera { 6 | /// Get the ability info xml for the current user 7 | pub async fn get_abilityinfo(&self) -> Result { 8 | let connection = self.get_connection(); 9 | let msg_num = self.new_message_num(); 10 | let mut sub_get = connection.subscribe(MSG_ID_ABILITY_INFO, msg_num).await?; 11 | let get = Bc { 12 | meta: BcMeta { 13 | msg_id: MSG_ID_ABILITY_INFO, 14 | channel_id: self.channel_id, 15 | msg_num, 16 | response_code: 0, 17 | stream_type: 0, 18 | class: 0x6414, 19 | }, 20 | body: BcBody::ModernMsg(ModernMsg { 21 | extension: Some(Extension { 22 | user_name: Some(self.get_credentials().username.clone()), 23 | token: Some("system, streaming, PTZ, IO, security, replay, disk, network, alarm, record, video, image".to_string()), 24 | ..Default::default() 25 | }), 26 | payload: None, 27 | }), 28 | }; 29 | 30 | sub_get.send(get).await?; 31 | let msg = sub_get.recv().await?; 32 | if msg.meta.response_code != 200 { 33 | return Err(Error::CameraServiceUnavailable { 34 | id: msg.meta.msg_id, 35 | code: msg.meta.response_code, 36 | }); 37 | } 38 | 39 | if let BcBody::ModernMsg(ModernMsg { 40 | payload: 41 | Some(BcPayloads::BcXml(BcXml { 42 | ability_info: Some(ability_info), 43 | .. 44 | })), 45 | .. 46 | }) = msg.body 47 | { 48 | Ok(ability_info) 49 | } else { 50 | Err(Error::UnintelligibleReply { 51 | reply: std::sync::Arc::new(Box::new(msg)), 52 | why: "Expected AbilityInfo xml but it was not recieved", 53 | }) 54 | } 55 | } 56 | 57 | /// Populate ability list of the camera 58 | pub async fn polulate_abilities(&self) -> Result<()> { 59 | let info = self.get_abilityinfo().await?; 60 | let mut ser_buf = bytes::BytesMut::new(); 61 | let info_res = quick_xml::se::to_writer(&mut ser_buf, &info).map(|_| ser_buf); 62 | if let Ok(Ok(info_str)) = info_res.map(|b| std::str::from_utf8(&b).map(|a| a.to_owned())) { 63 | debug!("Abilities: {}", info_str); 64 | } 65 | 66 | let mut abilities: Vec = vec![]; 67 | 68 | let mut tokens: Vec> = vec![ 69 | info.system.as_ref(), 70 | info.network.as_ref(), 71 | info.alarm.as_ref(), 72 | info.image.as_ref(), 73 | info.video.as_ref(), 74 | info.security.as_ref(), 75 | info.replay.as_ref(), 76 | info.ptz.as_ref(), 77 | info.io.as_ref(), 78 | info.streaming.as_ref(), 79 | ]; 80 | 81 | for token in tokens.drain(..).flatten() { 82 | for sub_module in token.sub_module.iter() { 83 | abilities.extend( 84 | sub_module 85 | .ability_value 86 | .replace(' ', "") 87 | .split(',') 88 | .map(|s| s.to_string()), 89 | ); 90 | } 91 | } 92 | 93 | let mut locked_abilities = self.abilities.write().await; 94 | for ability in abilities.iter() { 95 | let mut abilities_ro = ability.split('_').map(|s| s.to_string()); 96 | if let (Some(ability_name), Some(ability_kind)) = 97 | (abilities_ro.next(), abilities_ro.next()) 98 | { 99 | match ability_kind.as_str() { 100 | "rw" => { 101 | locked_abilities.insert(ability_name, super::ReadKind::ReadWrite); 102 | } 103 | "ro" => { 104 | locked_abilities.insert(ability_name, super::ReadKind::ReadOnly); 105 | } 106 | _ => { 107 | continue; 108 | } 109 | } 110 | } 111 | } 112 | 113 | Ok(()) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/battery.rs: -------------------------------------------------------------------------------- 1 | //! Handles battery related messages 2 | //! 3 | //! There are primarily two messages: 4 | //! - BatteryInfoList which the camera sends as part of its login info 5 | //! - BatteryInfo which the client can request on demand 6 | //! 7 | 8 | use super::{BcCamera, PrintFormat, Result}; 9 | use crate::{ 10 | bc::{model::*, xml::BatteryInfo}, 11 | Error, 12 | }; 13 | 14 | impl BcCamera { 15 | /// Create a handller to respond to battery messages 16 | /// These messages are sent by the camera on login and maybe 17 | /// also on low battery events 18 | pub async fn monitor_battery(&self, format: PrintFormat) -> Result<()> { 19 | let connection = self.get_connection(); 20 | connection 21 | .handle_msg(MSG_ID_BATTERY_INFO_LIST, move |bc| { 22 | Box::pin(async move { 23 | if let Bc { 24 | body: 25 | BcBody::ModernMsg(ModernMsg { 26 | payload: 27 | Some(BcPayloads::BcXml(BcXml { 28 | battery_list: Some(battery_list), 29 | .. 30 | })), 31 | .. 32 | }), 33 | .. 34 | } = bc 35 | { 36 | for battery in battery_list.battery_info.iter() { 37 | match format { 38 | PrintFormat::None => {} 39 | PrintFormat::Human => { 40 | println!( 41 | "==Battery==\n\ 42 | Charge: {}%,\n\ 43 | Temperature: {}°C,\n\ 44 | LowPower: {},\n\ 45 | Adapter: {},\n\ 46 | ChargeStatus: {},\n\ 47 | ", 48 | battery.battery_percent, 49 | battery.temperature, 50 | if battery.low_power == 1 { 51 | "true" 52 | } else { 53 | "false" 54 | }, 55 | battery.adapter_status, 56 | battery.charge_status, 57 | ); 58 | } 59 | PrintFormat::Xml => { 60 | let bat_ser = String::from_utf8({ 61 | let mut ser_buf = bytes::BytesMut::new(); 62 | let parsed = 63 | quick_xml::se::to_writer(&mut ser_buf, &battery) 64 | .map(|_| ser_buf); 65 | parsed.expect("Could not serialise data").to_vec() 66 | }) 67 | .expect("Should be UTF8"); 68 | println!("{}", bat_ser); 69 | } 70 | } 71 | } 72 | } 73 | Option::::None 74 | }) 75 | }) 76 | .await?; 77 | Ok(()) 78 | } 79 | 80 | /// Requests the current battery status of the camera 81 | pub async fn battery_info(&self) -> Result { 82 | let connection = self.get_connection(); 83 | 84 | let msg_num = self.new_message_num(); 85 | let mut sub = connection.subscribe(MSG_ID_BATTERY_INFO, msg_num).await?; 86 | 87 | let msg = Bc { 88 | meta: BcMeta { 89 | msg_id: MSG_ID_BATTERY_INFO, 90 | channel_id: self.channel_id, 91 | msg_num, 92 | stream_type: 0, 93 | response_code: 0, 94 | class: 0x6414, 95 | }, 96 | body: BcBody::ModernMsg(ModernMsg { 97 | extension: Some(Extension { 98 | channel_id: Some(self.channel_id), 99 | ..Default::default() 100 | }), 101 | payload: None, 102 | }), 103 | }; 104 | 105 | sub.send(msg).await?; 106 | let msg = sub.recv().await?; 107 | 108 | if let Bc { 109 | meta: BcMeta { 110 | response_code: 200, .. 111 | }, 112 | body: 113 | BcBody::ModernMsg(ModernMsg { 114 | payload: 115 | Some(BcPayloads::BcXml(BcXml { 116 | battery_info: Some(battery_info), 117 | .. 118 | })), 119 | .. 120 | }), 121 | } = msg 122 | { 123 | Ok(battery_info) 124 | } else { 125 | Err(Error::UnintelligibleReply { 126 | reply: std::sync::Arc::new(Box::new(msg)), 127 | why: "The camera did not accept the battery info (maybe no battery) command.", 128 | }) 129 | } 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/connection/bcsub.rs: -------------------------------------------------------------------------------- 1 | use super::BcConnection; 2 | use crate::bcmedia::codex::BcMediaCodex; 3 | use crate::{bc::model::*, bcmedia::model::*, Error, Result}; 4 | use futures::stream::{Stream, TryStreamExt}; 5 | use std::io::{Error as IoError, ErrorKind, Result as IoResult}; 6 | use std::pin::Pin; 7 | use std::task::{Context, Poll}; 8 | use tokio::sync::mpsc::Receiver; 9 | use tokio_stream::{wrappers::ReceiverStream, StreamExt}; 10 | use tokio_util::codec::FramedRead; 11 | use tokio_util::compat::FuturesAsyncReadCompatExt; 12 | 13 | pub struct BcSubscription<'a> { 14 | rx: ReceiverStream>, 15 | msg_num: Option, 16 | conn: &'a BcConnection, 17 | } 18 | 19 | pub struct BcStream<'a> { 20 | rx: &'a mut ReceiverStream>, 21 | } 22 | 23 | impl Unpin for BcStream<'_> {} 24 | 25 | impl Stream for BcStream<'_> { 26 | type Item = Result; 27 | 28 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>> { 29 | let mut this = self.as_mut(); 30 | match Pin::new(&mut this.rx).poll_next(cx) { 31 | Poll::Ready(Some(bc)) => Poll::Ready(Some(bc)), 32 | Poll::Ready(None) => Poll::Ready(None), 33 | Poll::Pending => Poll::Pending, 34 | } 35 | } 36 | } 37 | 38 | impl<'a> BcSubscription<'a> { 39 | pub fn new( 40 | rx: Receiver>, 41 | msg_num: Option, 42 | conn: &'a BcConnection, 43 | ) -> BcSubscription<'a> { 44 | BcSubscription { 45 | rx: ReceiverStream::new(rx), 46 | msg_num, 47 | conn, 48 | } 49 | } 50 | 51 | pub async fn send(&self, bc: Bc) -> Result<()> { 52 | if let Some(msg_num) = self.msg_num { 53 | assert!(bc.meta.msg_num as u32 == msg_num); 54 | } else { 55 | log::debug!("Sending message before msg_num has been aquired"); 56 | } 57 | self.conn.send(bc).await?; 58 | Ok(()) 59 | } 60 | 61 | pub async fn recv(&mut self) -> Result { 62 | let bc = self.rx.next().await.ok_or(Error::DroppedSubscriber)?; 63 | if let Ok(bc) = &bc { 64 | if let Some(msg_num) = self.msg_num { 65 | assert!(bc.meta.msg_num as u32 == msg_num); 66 | } else { 67 | // Leaning number now 68 | self.msg_num = Some(bc.meta.msg_num as u32); 69 | } 70 | } 71 | bc 72 | } 73 | 74 | #[allow(unused)] 75 | pub fn bc_stream(&'_ mut self) -> BcStream<'_> { 76 | BcStream { rx: &mut self.rx } 77 | } 78 | 79 | pub fn payload_stream(&'_ mut self) -> impl Stream>> + '_ { 80 | (&mut self.rx).filter_map(|x| match x { 81 | Ok(Bc { 82 | meta: BcMeta { .. }, 83 | body: 84 | BcBody::ModernMsg(ModernMsg { 85 | payload: Some(BcPayloads::Binary(data)), 86 | .. 87 | }), 88 | }) => Some(Ok(data)), 89 | Ok(_) => None, 90 | Err(e) => Some(Err(IoError::new(ErrorKind::Other, e))), 91 | }) 92 | } 93 | 94 | pub fn bcmedia_stream(&'_ mut self, strict: bool) -> impl Stream> + '_ { 95 | let async_read = self 96 | .payload_stream() 97 | .map(|frame| frame) 98 | .into_async_read() 99 | .compat(); 100 | FramedRead::new(async_read, BcMediaCodex::new(strict)).map(|frame| frame) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/connection/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module handles connections and subscribers 2 | //! 3 | //! This includes a tcp and udp connections. As well 4 | //! as subscribers to binary streams that are encoded 5 | //! in the bc packets. 6 | //! 7 | use std::net::SocketAddr; 8 | use std::sync::Arc; 9 | use tokio::net::UdpSocket; 10 | 11 | mod bcconn; 12 | mod bcsub; 13 | mod discovery; 14 | mod tcpsource; 15 | mod udpsource; 16 | 17 | pub(crate) use self::{ 18 | bcconn::BcConnection, bcconn::*, bcsub::BcSubscription, discovery::Discovery, 19 | tcpsource::TcpSource, udpsource::UdpSource, 20 | }; 21 | 22 | pub(crate) struct DiscoveryResult { 23 | socket: Arc, 24 | addr: SocketAddr, 25 | client_id: i32, 26 | camera_id: i32, 27 | } 28 | 29 | impl DiscoveryResult { 30 | /// Get the address discovered 31 | pub(crate) fn get_addr(&self) -> &SocketAddr { 32 | &self.addr 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/connection/tcpsource.rs: -------------------------------------------------------------------------------- 1 | use crate::bc::model::*; 2 | use crate::Result; 3 | use crate::{bc::codex::BcCodex, Credentials}; 4 | use delegate::delegate; 5 | use futures::{sink::Sink, stream::Stream}; 6 | use std::net::SocketAddr; 7 | use std::pin::Pin; 8 | use std::task::{Context, Poll}; 9 | use tokio::net::{TcpSocket, TcpStream}; 10 | use tokio_util::codec::{Decoder, Encoder, Framed}; 11 | 12 | pub(crate) struct TcpSource { 13 | inner: Framed, 14 | } 15 | 16 | impl TcpSource { 17 | pub(crate) async fn new, U: Into>( 18 | addr: SocketAddr, 19 | username: T, 20 | password: Option, 21 | debug: bool, 22 | ) -> Result { 23 | let stream = connect_to(addr).await?; 24 | 25 | let codex = if debug { 26 | BcCodex::new_with_debug(Credentials::new(username, password)) 27 | } else { 28 | BcCodex::new(Credentials::new(username, password)) 29 | }; 30 | Ok(Self { 31 | inner: Framed::new(stream, codex), 32 | }) 33 | } 34 | } 35 | 36 | impl Stream for TcpSource { 37 | type Item = std::result::Result<::Item, ::Error>; 38 | 39 | delegate! { 40 | to Pin::new(&mut self.inner) { 41 | fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; 42 | } 43 | } 44 | 45 | delegate! { 46 | to self.inner { 47 | fn size_hint(&self) -> (usize, Option); 48 | } 49 | } 50 | } 51 | 52 | impl Sink for TcpSource { 53 | type Error = >::Error; 54 | 55 | delegate! { 56 | to Pin::new(&mut self.inner) { 57 | fn poll_ready(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; 58 | fn start_send(mut self: Pin<&mut Self>, item: Bc) -> std::result::Result<(), Self::Error>; 59 | fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; 60 | fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll>; 61 | } 62 | } 63 | } 64 | 65 | /// Helper to create a TcpStream with a connect timeout 66 | async fn connect_to(addr: SocketAddr) -> Result { 67 | let socket = match addr { 68 | SocketAddr::V4(_) => TcpSocket::new_v4()?, 69 | SocketAddr::V6(_) => TcpSocket::new_v6()?, 70 | }; 71 | 72 | Ok(socket.connect(addr).await?) 73 | } 74 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/credentials.rs: -------------------------------------------------------------------------------- 1 | //! Handles credentials for camera including default reolink password 2 | 3 | use std::convert::TryInto; 4 | 5 | /// Used for caching and supplying the credentials 6 | #[derive(Clone)] 7 | pub struct Credentials { 8 | /// The username to login to the camera with 9 | pub username: String, 10 | /// The password to use for login. Some camera allow this to be omitted 11 | pub password: Option, 12 | } 13 | 14 | impl Default for Credentials { 15 | /// Default credentials for some reolink cameras 16 | fn default() -> Self { 17 | Self { 18 | username: "admin".to_string(), 19 | password: Some("123456".to_string()), 20 | } 21 | } 22 | } 23 | 24 | impl std::fmt::Debug for Credentials { 25 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 26 | f.debug_map() 27 | .entry(&"username", &self.username) 28 | .entry(&"password", &"******") 29 | .finish() 30 | } 31 | } 32 | 33 | impl Credentials { 34 | pub(crate) fn new, U: Into>(username: T, password: Option) -> Self { 35 | Self { 36 | username: username.into(), 37 | password: password.map(|t| t.into()), 38 | } 39 | } 40 | 41 | /// This is a convience function to make an AES key from the login password and the NONCE 42 | /// negotiated during login 43 | pub(crate) fn make_aeskey>(&self, nonce: T) -> [u8; 16] { 44 | let key_phrase = format!( 45 | "{}-{}", 46 | nonce.as_ref(), 47 | self.password.clone().unwrap_or_default() 48 | ); 49 | let key_phrase_hash = format!("{:X}\0", md5::compute(key_phrase)) 50 | .to_uppercase() 51 | .into_bytes(); 52 | key_phrase_hash[0..16].try_into().unwrap() 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/keepalive.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Result}; 2 | use crate::bc::model::*; 3 | 4 | impl BcCamera { 5 | /// Create a handler to respond to keep alive messages 6 | /// These messages are sent by the camera so we listen to 7 | /// a message ID rather than setting a message number and 8 | /// responding to it 9 | pub async fn keepalive(&self) -> Result<()> { 10 | let connection = self.get_connection(); 11 | connection 12 | .handle_msg(MSG_ID_UDP_KEEP_ALIVE, |bc| { 13 | Box::pin(async move { 14 | Some(Bc { 15 | meta: BcMeta { 16 | msg_id: MSG_ID_UDP_KEEP_ALIVE, 17 | channel_id: bc.meta.channel_id, 18 | msg_num: bc.meta.msg_num, 19 | stream_type: bc.meta.stream_type, 20 | response_code: 200, 21 | class: 0x6414, 22 | }, 23 | body: BcBody::ModernMsg(ModernMsg { 24 | ..Default::default() 25 | }), 26 | }) 27 | }) 28 | }) 29 | .await?; 30 | Ok(()) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/ledstate.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | impl BcCamera { 5 | /// Get the [LedState] xml which contains the LED status of the camera 6 | pub async fn get_ledstate(&self) -> Result { 7 | self.has_ability_ro("ledState").await?; 8 | let connection = self.get_connection(); 9 | let msg_num = self.new_message_num(); 10 | let mut sub_get = connection.subscribe(MSG_ID_GET_LED_STATUS, msg_num).await?; 11 | let get = Bc { 12 | meta: BcMeta { 13 | msg_id: MSG_ID_GET_LED_STATUS, 14 | channel_id: self.channel_id, 15 | msg_num, 16 | response_code: 0, 17 | stream_type: 0, 18 | class: 0x6414, 19 | }, 20 | body: BcBody::ModernMsg(ModernMsg { 21 | extension: Some(Extension { 22 | channel_id: Some(self.channel_id), 23 | ..Default::default() 24 | }), 25 | payload: None, 26 | }), 27 | }; 28 | 29 | sub_get.send(get).await?; 30 | let msg = sub_get.recv().await?; 31 | if msg.meta.response_code != 200 { 32 | return Err(Error::CameraServiceUnavailable { 33 | id: msg.meta.msg_id, 34 | code: msg.meta.response_code, 35 | }); 36 | } 37 | 38 | if let BcBody::ModernMsg(ModernMsg { 39 | payload: 40 | Some(BcPayloads::BcXml(BcXml { 41 | led_state: Some(ledstate), 42 | .. 43 | })), 44 | .. 45 | }) = msg.body 46 | { 47 | Ok(ledstate) 48 | } else { 49 | Err(Error::UnintelligibleReply { 50 | reply: std::sync::Arc::new(Box::new(msg)), 51 | why: "Expected LEDState xml but it was not recieved", 52 | }) 53 | } 54 | } 55 | 56 | /// Set the led lights using the [LedState] xml 57 | pub async fn set_ledstate(&self, mut led_state: LedState) -> Result<()> { 58 | self.has_ability_rw("ledState").await?; 59 | let connection = self.get_connection(); 60 | 61 | let msg_num = self.new_message_num(); 62 | let mut sub_set = connection.subscribe(MSG_ID_SET_LED_STATUS, msg_num).await?; 63 | 64 | // led_version is a field recieved from the camera but not sent 65 | // we set to None to ensure we don't send it to the camera 66 | led_state.led_version = None; 67 | let get = Bc { 68 | meta: BcMeta { 69 | msg_id: MSG_ID_SET_LED_STATUS, 70 | channel_id: self.channel_id, 71 | msg_num, 72 | response_code: 0, 73 | stream_type: 0, 74 | class: 0x6414, 75 | }, 76 | body: BcBody::ModernMsg(ModernMsg { 77 | extension: Some(Extension { 78 | channel_id: Some(self.channel_id), 79 | ..Default::default() 80 | }), 81 | payload: Some(BcPayloads::BcXml(BcXml { 82 | led_state: Some(led_state), 83 | ..Default::default() 84 | })), 85 | }), 86 | }; 87 | 88 | sub_set.send(get).await?; 89 | if let Ok(reply) = 90 | tokio::time::timeout(tokio::time::Duration::from_millis(500), sub_set.recv()).await 91 | { 92 | let msg = reply?; 93 | 94 | if let BcMeta { 95 | response_code: 200, .. 96 | } = msg.meta 97 | { 98 | Ok(()) 99 | } else { 100 | Err(Error::UnintelligibleReply { 101 | reply: std::sync::Arc::new(Box::new(msg)), 102 | why: "The camera did not except the LEDState xml", 103 | }) 104 | } 105 | } else { 106 | // Some cameras seem to just not send a reply on success, so after 500ms we return Ok 107 | Ok(()) 108 | } 109 | } 110 | 111 | /// This is a convience function to control the IR LED lights 112 | /// 113 | /// This is for the RED IR lights that can come on automaitcally 114 | /// during low light. 115 | pub async fn irled_light_set(&self, state: LightState) -> Result<()> { 116 | let mut led_state = self.get_ledstate().await?; 117 | led_state.state = match state { 118 | LightState::On => "open".to_string(), 119 | LightState::Off => "close".to_string(), 120 | LightState::Auto => "auto".to_string(), 121 | }; 122 | self.set_ledstate(led_state).await?; 123 | Ok(()) 124 | } 125 | 126 | /// This is a convience function to control the LED light 127 | /// True is on and false is off 128 | /// 129 | /// This is for the little blue on light of some camera 130 | pub async fn led_light_set(&self, state: bool) -> Result<()> { 131 | let mut led_state = self.get_ledstate().await?; 132 | led_state.light_state = match state { 133 | true => "open".to_string(), 134 | false => "close".to_string(), 135 | }; 136 | self.set_ledstate(led_state).await?; 137 | Ok(()) 138 | } 139 | } 140 | 141 | /// This is pased to `irled_light_set` to turn it on, off or set it to light based auto 142 | pub enum LightState { 143 | /// Turn the light on 144 | On, 145 | /// Turn the light off 146 | Off, 147 | /// Set the light to light based auto 148 | Auto, 149 | } 150 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/link.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | impl BcCamera { 5 | /// Get the [LinkType] xml which contains the connection status of the camera 6 | /// 7 | /// This is the same as `ping()` but with the return type 8 | pub async fn get_linktype(&self) -> Result { 9 | let connection = self.get_connection(); 10 | let msg_num = self.new_message_num(); 11 | let mut sub_get = connection.subscribe(MSG_ID_PING, msg_num).await?; 12 | let get = Bc { 13 | meta: BcMeta { 14 | msg_id: MSG_ID_PING, 15 | channel_id: self.channel_id, 16 | msg_num, 17 | response_code: 0, 18 | stream_type: 0, 19 | class: 0x6414, 20 | }, 21 | body: BcBody::ModernMsg(ModernMsg { 22 | extension: None, 23 | payload: None, 24 | }), 25 | }; 26 | 27 | sub_get.send(get).await?; 28 | let msg = sub_get.recv().await?; 29 | if msg.meta.response_code != 200 { 30 | return Err(Error::CameraServiceUnavailable { 31 | id: msg.meta.msg_id, 32 | code: msg.meta.response_code, 33 | }); 34 | } 35 | 36 | if let BcBody::ModernMsg(ModernMsg { 37 | payload: 38 | Some(BcPayloads::BcXml(BcXml { 39 | link_type: Some(link_type), 40 | .. 41 | })), 42 | .. 43 | }) = msg.body 44 | { 45 | Ok(link_type) 46 | } else { 47 | Err(Error::UnintelligibleReply { 48 | reply: std::sync::Arc::new(Box::new(msg)), 49 | why: "Expected LinkType xml but it was not recieved", 50 | }) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/logout.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | use std::sync::atomic::Ordering; 4 | 5 | impl BcCamera { 6 | /// Logout from the camera 7 | pub async fn logout(&self) -> Result<()> { 8 | if self.logged_in.load(Ordering::Relaxed) { 9 | let credentials = self.get_credentials(); 10 | let connection = self.get_connection(); 11 | let msg_num = self.new_message_num(); 12 | let sub_logout = connection.subscribe(MSG_ID_LOGOUT, msg_num).await?; 13 | 14 | let username = credentials.username.clone(); 15 | let password = credentials.password.as_ref().cloned().unwrap_or_default(); 16 | 17 | let modern_logout = Bc::new_from_xml( 18 | BcMeta { 19 | msg_id: MSG_ID_LOGOUT, 20 | channel_id: self.channel_id, 21 | msg_num, 22 | stream_type: 0, 23 | response_code: 0, 24 | class: 0x6414, 25 | }, 26 | BcXml { 27 | login_user: Some(LoginUser { 28 | version: xml_ver(), 29 | user_name: username, 30 | password, 31 | user_ver: 1, 32 | }), 33 | login_net: Some(LoginNet::default()), 34 | ..Default::default() 35 | }, 36 | ); 37 | 38 | sub_logout.send(modern_logout).await?; 39 | } 40 | self.logged_in.store(false, Ordering::Relaxed); 41 | Ok(()) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/ping.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Result}; 2 | use crate::bc::model::*; 3 | 4 | impl BcCamera { 5 | /// Ping the camera will either return Ok(()) which means a sucess reply 6 | /// or error 7 | pub async fn ping(&self) -> Result<()> { 8 | let connection = self.get_connection(); 9 | let msg_num = self.new_message_num(); 10 | let mut sub_ping = connection.subscribe(MSG_ID_PING, msg_num).await?; 11 | 12 | let ping = Bc { 13 | meta: BcMeta { 14 | msg_id: MSG_ID_PING, 15 | channel_id: self.channel_id, 16 | msg_num, 17 | stream_type: 0, 18 | response_code: 0, 19 | class: 0x6414, 20 | }, 21 | body: BcBody::ModernMsg(ModernMsg { 22 | ..Default::default() 23 | }), 24 | }; 25 | 26 | sub_ping.send(ping).await?; 27 | 28 | sub_ping.recv().await?; 29 | 30 | log::trace!("Ping complete"); 31 | Ok(()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/pirstate.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | use tokio::time::{interval, Duration}; 4 | 5 | impl BcCamera { 6 | /// Get the [RfAlarmCfg] xml which contains the PIR status of the camera 7 | pub async fn get_pirstate(&self) -> Result { 8 | self.has_ability_ro("rfAlarm").await?; 9 | let connection = self.get_connection(); 10 | let mut reties: usize = 0; 11 | let mut retry_interval = interval(Duration::from_millis(500)); 12 | loop { 13 | retry_interval.tick().await; 14 | let msg_num = self.new_message_num(); 15 | let mut sub_get = connection.subscribe(MSG_ID_GET_PIR_ALARM, msg_num).await?; 16 | let get = Bc { 17 | meta: BcMeta { 18 | msg_id: MSG_ID_GET_PIR_ALARM, 19 | channel_id: self.channel_id, 20 | msg_num, 21 | response_code: 0, 22 | stream_type: 0, 23 | class: 0x6414, 24 | }, 25 | body: BcBody::ModernMsg(ModernMsg { 26 | extension: Some(Extension { 27 | rf_id: Some(self.channel_id), 28 | ..Default::default() 29 | }), 30 | payload: None, 31 | }), 32 | }; 33 | 34 | sub_get.send(get).await?; 35 | let msg = sub_get.recv().await?; 36 | if msg.meta.response_code == 400 { 37 | // Retryable 38 | if reties < 5 { 39 | reties += 1; 40 | continue; 41 | } else { 42 | return Err(Error::CameraServiceUnavailable { 43 | id: msg.meta.msg_id, 44 | code: msg.meta.response_code, 45 | }); 46 | } 47 | } else if msg.meta.response_code != 200 { 48 | return Err(Error::CameraServiceUnavailable { 49 | id: msg.meta.msg_id, 50 | code: msg.meta.response_code, 51 | }); 52 | } else { 53 | // Valid message with response_code == 200 54 | if let BcBody::ModernMsg(ModernMsg { 55 | payload: 56 | Some(BcPayloads::BcXml(BcXml { 57 | rf_alarm_cfg: Some(pirstate), 58 | .. 59 | })), 60 | .. 61 | }) = msg.body 62 | { 63 | return Ok(pirstate); 64 | } else { 65 | return Err(Error::UnintelligibleReply { 66 | reply: std::sync::Arc::new(Box::new(msg)), 67 | why: "Expected PirSate xml but it was not recieved", 68 | }); 69 | } 70 | } 71 | } 72 | } 73 | 74 | /// Set the PIR sensor using the [RfAlarmCfg] xml 75 | pub async fn set_pirstate(&self, rf_alarm_cfg: RfAlarmCfg) -> Result<()> { 76 | self.has_ability_rw("rfAlarm").await?; 77 | let connection = self.get_connection(); 78 | let msg_num = self.new_message_num(); 79 | let mut sub_set = connection 80 | .subscribe(MSG_ID_START_PIR_ALARM, msg_num) 81 | .await?; 82 | 83 | let get = Bc { 84 | meta: BcMeta { 85 | msg_id: MSG_ID_START_PIR_ALARM, 86 | channel_id: self.channel_id, 87 | msg_num, 88 | response_code: 0, 89 | stream_type: 0, 90 | class: 0x6414, 91 | }, 92 | body: BcBody::ModernMsg(ModernMsg { 93 | extension: Some(Extension { 94 | rf_id: Some(self.channel_id), 95 | ..Default::default() 96 | }), 97 | payload: Some(BcPayloads::BcXml(BcXml { 98 | rf_alarm_cfg: Some(rf_alarm_cfg), 99 | ..Default::default() 100 | })), 101 | }), 102 | }; 103 | 104 | sub_set.send(get).await?; 105 | if let Ok(reply) = 106 | tokio::time::timeout(tokio::time::Duration::from_millis(500), sub_set.recv()).await 107 | { 108 | let msg = reply?; 109 | if msg.meta.response_code != 200 { 110 | return Err(Error::CameraServiceUnavailable { 111 | id: msg.meta.msg_id, 112 | code: msg.meta.response_code, 113 | }); 114 | } 115 | 116 | if let BcMeta { 117 | response_code: 200, .. 118 | } = msg.meta 119 | { 120 | Ok(()) 121 | } else { 122 | Err(Error::UnintelligibleReply { 123 | reply: std::sync::Arc::new(Box::new(msg)), 124 | why: "The camera did not except the RfAlarmCfg xml", 125 | }) 126 | } 127 | } else { 128 | // Some cameras seem to just not send a reply on success, so after 500ms we return Ok 129 | Ok(()) 130 | } 131 | } 132 | 133 | /// This is a convience function to control the PIR status 134 | /// True is on and false is off 135 | pub async fn pir_set(&self, state: bool) -> Result<()> { 136 | let mut pir_state = self.get_pirstate().await?; 137 | // println!("{:?}", pir_state); 138 | pir_state.enable = match state { 139 | true => 1, 140 | false => 0, 141 | }; 142 | self.set_pirstate(pir_state).await?; 143 | Ok(()) 144 | } 145 | } 146 | 147 | /// Turn PIR ON or OFF 148 | pub enum PirState { 149 | /// Turn the PIR on 150 | On, 151 | /// Turn the PIR off 152 | Off, 153 | } 154 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/pushinfo.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | /// Specifies the phone type for the push notification 5 | pub enum PhoneType { 6 | /// Specify that this is an ios push notfication 7 | /// 8 | /// In this case the token must be the APNS 9 | Ios, 10 | /// Specify that this is an andriod push notfication 11 | /// 12 | /// In this case the token must firebase cloud messaging token 13 | Android, 14 | } 15 | 16 | impl BcCamera { 17 | /// Convenience method for andriod of `[send_pushinfo]` 18 | pub async fn send_pushinfo_android(&self, token: &str, client_id: &str) -> Result<()> { 19 | self.send_pushinfo(token, client_id, PhoneType::Android) 20 | .await 21 | } 22 | /// Convenience method for andriod of `[send_pushinfo]` 23 | pub async fn send_pushinfo_ios(&self, token: &str, client_id: &str) -> Result<()> { 24 | self.send_pushinfo(token, client_id, PhoneType::Ios).await 25 | } 26 | /// Send the push info to regsiter for push notfications 27 | pub async fn send_pushinfo( 28 | &self, 29 | token: &str, 30 | client_id: &str, 31 | phone_type: PhoneType, 32 | ) -> Result<()> { 33 | let connection = self.get_connection(); 34 | let msg_num = self.new_message_num(); 35 | let mut sub = connection.subscribe(MSG_ID_PUSH_INFO, msg_num).await?; 36 | 37 | let phone_type_str = match phone_type { 38 | PhoneType::Ios => "reo_iphone", 39 | PhoneType::Android => "reo_fcm", 40 | }; 41 | 42 | let msg = Bc { 43 | meta: BcMeta { 44 | msg_id: MSG_ID_PUSH_INFO, 45 | channel_id: self.channel_id, 46 | msg_num, 47 | response_code: 0, 48 | stream_type: 0, 49 | class: 0x6414, 50 | }, 51 | body: BcBody::ModernMsg(ModernMsg { 52 | extension: None, 53 | payload: Some(BcPayloads::BcXml(BcXml { 54 | push_info: Some(PushInfo { 55 | token: token.to_owned(), 56 | phone_type: phone_type_str.to_owned(), 57 | client_id: client_id.to_owned(), 58 | }), 59 | ..Default::default() 60 | })), 61 | }), 62 | }; 63 | 64 | sub.send(msg).await?; 65 | let msg = sub.recv().await?; 66 | if msg.meta.response_code != 200 { 67 | return Err(Error::CameraServiceUnavailable { 68 | id: msg.meta.msg_id, 69 | code: msg.meta.response_code, 70 | }); 71 | } 72 | 73 | Ok(()) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/reboot.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::model::*; 3 | 4 | impl BcCamera { 5 | /// Reboot the camera 6 | pub async fn reboot(&self) -> Result<()> { 7 | self.has_ability_rw("reboot").await?; 8 | let connection = self.get_connection(); 9 | let msg_num = self.new_message_num(); 10 | let mut sub = connection.subscribe(MSG_ID_REBOOT, msg_num).await?; 11 | 12 | let msg = Bc { 13 | meta: BcMeta { 14 | msg_id: MSG_ID_REBOOT, 15 | channel_id: self.channel_id, 16 | msg_num, 17 | stream_type: 0, 18 | response_code: 0, 19 | class: 0x6414, 20 | }, 21 | body: BcBody::ModernMsg(ModernMsg { 22 | ..Default::default() 23 | }), 24 | }; 25 | 26 | sub.send(msg).await?; 27 | let msg = sub.recv().await?; 28 | 29 | if let BcMeta { 30 | response_code: 200, .. 31 | } = msg.meta 32 | { 33 | Ok(()) 34 | } else { 35 | Err(Error::UnintelligibleReply { 36 | reply: std::sync::Arc::new(Box::new(msg)), 37 | why: "The camera did not accept the reboot command", 38 | }) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/siren.rs: -------------------------------------------------------------------------------- 1 | //! Trigger for the siren 2 | 3 | use super::{BcCamera, Error, Result}; 4 | use crate::bc::{model::*, xml::*}; 5 | 6 | impl BcCamera { 7 | /// Trigger the siren 8 | pub async fn siren(&self) -> Result<()> { 9 | let connection = self.get_connection(); 10 | let msg_num = self.new_message_num(); 11 | let mut sub_get = connection.subscribe(MSG_ID_PLAY_AUDIO, msg_num).await?; 12 | let get = Bc { 13 | meta: BcMeta { 14 | msg_id: MSG_ID_PLAY_AUDIO, 15 | channel_id: self.channel_id, 16 | msg_num, 17 | response_code: 0, 18 | stream_type: 0, 19 | class: 0x6414, 20 | }, 21 | body: BcBody::ModernMsg(ModernMsg { 22 | extension: Some(Extension { 23 | channel_id: Some(self.channel_id), 24 | ..Default::default() 25 | }), 26 | payload: Some(BcPayloads::BcXml(BcXml { 27 | audio_play_info: Some(AudioPlayInfo { 28 | channel_id: self.channel_id, 29 | play_mode: 0, 30 | play_duration: 0, 31 | play_times: 1, 32 | on_off: 0, 33 | }), 34 | ..Default::default() 35 | })), 36 | }), 37 | }; 38 | 39 | sub_get.send(get).await?; 40 | let msg = sub_get.recv().await?; 41 | if msg.meta.response_code != 200 { 42 | return Err(Error::CameraServiceUnavailable { 43 | id: msg.meta.msg_id, 44 | code: msg.meta.response_code, 45 | }); 46 | } 47 | 48 | Ok(()) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/stream_info.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | impl BcCamera { 5 | /// Get the [StreamInfoList] xml which contains the supported camera streams 6 | pub async fn get_stream_info(&self) -> Result { 7 | let connection = self.get_connection(); 8 | let msg_num = self.new_message_num(); 9 | let mut sub_get = connection 10 | .subscribe(MSG_ID_STREAM_INFO_LIST, msg_num) 11 | .await?; 12 | let get = Bc { 13 | meta: BcMeta { 14 | msg_id: MSG_ID_STREAM_INFO_LIST, 15 | channel_id: self.channel_id, 16 | msg_num, 17 | response_code: 0, 18 | stream_type: 0, 19 | class: 0x6414, 20 | }, 21 | body: BcBody::ModernMsg(ModernMsg { 22 | extension: None, 23 | payload: None, 24 | }), 25 | }; 26 | 27 | sub_get.send(get).await?; 28 | let msg = sub_get.recv().await?; 29 | if msg.meta.response_code != 200 { 30 | return Err(Error::CameraServiceUnavailable { 31 | id: msg.meta.msg_id, 32 | code: msg.meta.response_code, 33 | }); 34 | } 35 | 36 | if let BcBody::ModernMsg(ModernMsg { 37 | payload: 38 | Some(BcPayloads::BcXml(BcXml { 39 | stream_info_list: Some(data), 40 | .. 41 | })), 42 | .. 43 | }) = msg.body 44 | { 45 | Ok(data) 46 | } else { 47 | Err(Error::UnintelligibleReply { 48 | reply: std::sync::Arc::new(Box::new(msg)), 49 | why: "Expected StreamInfoList xml but it was not recieved", 50 | }) 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/support.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | impl BcCamera { 5 | /// Get the [Support] xml which contains the ptz/talk support 6 | /// 7 | pub async fn get_support(&self) -> Result { 8 | let connection = self.get_connection(); 9 | let msg_num = self.new_message_num(); 10 | let mut sub_get = connection.subscribe(MSG_ID_GET_SUPPORT, msg_num).await?; 11 | let get = Bc { 12 | meta: BcMeta { 13 | msg_id: MSG_ID_GET_SUPPORT, 14 | channel_id: self.channel_id, 15 | msg_num, 16 | response_code: 0, 17 | stream_type: 0, 18 | class: 0x6414, 19 | }, 20 | body: BcBody::ModernMsg(ModernMsg { 21 | extension: None, 22 | payload: None, 23 | }), 24 | }; 25 | 26 | sub_get.send(get).await?; 27 | let msg = sub_get.recv().await?; 28 | if msg.meta.response_code != 200 { 29 | return Err(Error::CameraServiceUnavailable { 30 | id: msg.meta.msg_id, 31 | code: msg.meta.response_code, 32 | }); 33 | } 34 | 35 | if let BcBody::ModernMsg(ModernMsg { 36 | payload: 37 | Some(BcPayloads::BcXml(BcXml { 38 | support: Some(xml), .. 39 | })), 40 | .. 41 | }) = msg.body 42 | { 43 | Ok(xml) 44 | } else { 45 | Err(Error::UnintelligibleReply { 46 | reply: std::sync::Arc::new(Box::new(msg)), 47 | why: "Expected Support xml but it was not recieved", 48 | }) 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/uid.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | impl BcCamera { 5 | /// Get the [Uid] xml which contains the uid of the camera 6 | pub async fn get_uid(&self) -> Result { 7 | let connection = self.get_connection(); 8 | let msg_num = self.new_message_num(); 9 | let mut sub_get = connection.subscribe(MSG_ID_UID, msg_num).await?; 10 | let get = Bc { 11 | meta: BcMeta { 12 | msg_id: MSG_ID_UID, 13 | channel_id: self.channel_id, 14 | msg_num, 15 | response_code: 0, 16 | stream_type: 0, 17 | class: 0x6414, 18 | }, 19 | body: BcBody::ModernMsg(ModernMsg { 20 | extension: None, 21 | payload: None, 22 | }), 23 | }; 24 | 25 | sub_get.send(get).await?; 26 | let msg = sub_get.recv().await?; 27 | if msg.meta.response_code != 200 { 28 | return Err(Error::CameraServiceUnavailable { 29 | id: msg.meta.msg_id, 30 | code: msg.meta.response_code, 31 | }); 32 | } 33 | 34 | if let BcBody::ModernMsg(ModernMsg { 35 | payload: 36 | Some(BcPayloads::BcXml(BcXml { 37 | uid: Some(uid_xml), .. 38 | })), 39 | .. 40 | }) = msg.body 41 | { 42 | Ok(uid_xml) 43 | } else { 44 | Err(Error::UnintelligibleReply { 45 | reply: std::sync::Arc::new(Box::new(msg)), 46 | why: "Expected Uid xml but it was not recieved", 47 | }) 48 | } 49 | } 50 | 51 | /// Get the UID 52 | pub async fn uid(&self) -> Result { 53 | Ok(self.get_uid().await?.uid) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /crates/core/src/bc_protocol/version.rs: -------------------------------------------------------------------------------- 1 | use super::{BcCamera, Error, Result}; 2 | use crate::bc::{model::*, xml::*}; 3 | 4 | impl BcCamera { 5 | /// Request the [VersionInfo] xml 6 | pub async fn version(&self) -> Result { 7 | self.has_ability_ro("version").await?; 8 | let connection = self.get_connection(); 9 | let msg_num = self.new_message_num(); 10 | let mut sub_version = connection.subscribe(MSG_ID_VERSION, msg_num).await?; 11 | 12 | let version = Bc { 13 | meta: BcMeta { 14 | msg_id: MSG_ID_VERSION, 15 | channel_id: self.channel_id, 16 | msg_num, 17 | stream_type: 0, 18 | response_code: 0, 19 | class: 0x6414, // IDK why 20 | }, 21 | body: BcBody::ModernMsg(ModernMsg { 22 | ..Default::default() 23 | }), 24 | }; 25 | 26 | sub_version.send(version).await?; 27 | 28 | let modern_reply = sub_version.recv().await?; 29 | if modern_reply.meta.response_code != 200 { 30 | return Err(Error::CameraServiceUnavailable { 31 | id: modern_reply.meta.msg_id, 32 | code: modern_reply.meta.response_code, 33 | }); 34 | } 35 | let version_info; 36 | match modern_reply.body { 37 | BcBody::ModernMsg(ModernMsg { 38 | payload: 39 | Some(BcPayloads::BcXml(BcXml { 40 | version_info: Some(info), 41 | .. 42 | })), 43 | .. 44 | }) => { 45 | version_info = info; 46 | } 47 | _ => { 48 | return Err(Error::UnintelligibleReply { 49 | reply: std::sync::Arc::new(Box::new(modern_reply)), 50 | why: "Expected a VersionInfo message", 51 | }) 52 | } 53 | } 54 | 55 | Ok(version_info) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /crates/core/src/bcmedia/codex.rs: -------------------------------------------------------------------------------- 1 | //! Handles sending and recieving messages as packets 2 | //! 3 | //! BcMediaCodex is used with a `[tokio_util::codec::Framed]` to form complete packets 4 | //! 5 | use crate::bcmedia::model::*; 6 | use crate::{Error, Result}; 7 | use bytes::BytesMut; 8 | use log::*; 9 | use tokio_util::codec::{Decoder, Encoder}; 10 | 11 | pub struct BcMediaCodex { 12 | /// If true we will not search for the start of the next packet 13 | /// in the event that the stream appears to be corrupted 14 | strict: bool, 15 | amount_skipped: usize, 16 | } 17 | 18 | impl BcMediaCodex { 19 | pub(crate) fn new(strict: bool) -> Self { 20 | Self { 21 | strict, 22 | amount_skipped: 0, 23 | } 24 | } 25 | } 26 | 27 | impl Encoder for BcMediaCodex { 28 | type Error = Error; 29 | 30 | fn encode(&mut self, item: BcMedia, dst: &mut BytesMut) -> Result<()> { 31 | let buf: Vec = Default::default(); 32 | let buf = item.serialize(buf)?; 33 | dst.extend_from_slice(buf.as_slice()); 34 | Ok(()) 35 | } 36 | } 37 | 38 | impl Decoder for BcMediaCodex { 39 | type Item = BcMedia; 40 | type Error = Error; 41 | 42 | /// Since frames can cross EOF boundaries we overload this so it doesn't error if 43 | /// there are bytes left on the stream 44 | fn decode_eof(&mut self, buf: &mut BytesMut) -> Result> { 45 | match self.decode(buf)? { 46 | Some(frame) => Ok(Some(frame)), 47 | None => Ok(None), 48 | } 49 | } 50 | 51 | fn decode(&mut self, src: &mut BytesMut) -> Result> { 52 | loop { 53 | match BcMedia::deserialize(src) { 54 | Ok(bc) => { 55 | if self.amount_skipped > 0 { 56 | trace!("Amount skipped to restore stream: {}", self.amount_skipped); 57 | self.amount_skipped = 0; 58 | } 59 | return Ok(Some(bc)); 60 | } 61 | Err(Error::NomIncomplete(_)) => { 62 | if self.amount_skipped > 0 { 63 | trace!("Amount skipped to restore stream: {}", self.amount_skipped); 64 | self.amount_skipped = 0; 65 | } 66 | return Ok(None); 67 | } 68 | Err(e) => { 69 | if self.strict { 70 | return Err(e); 71 | } else if src.is_empty() { 72 | return Ok(None); 73 | } else { 74 | if self.amount_skipped == 0 { 75 | debug!("Error in stream attempting to restore"); 76 | trace!(" Stream Error: {:?}", e); 77 | } 78 | // Drop the whole packet and wait for a packet that starts with magic 79 | self.amount_skipped += src.len(); 80 | src.clear(); 81 | continue; 82 | } 83 | } 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /crates/core/src/bcmedia/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod codex; 2 | /// Deserlizer for BCMedia 3 | pub mod de; 4 | /// Structure model for BCMedia 5 | pub mod model; 6 | /// Serlizer for BCMedia 7 | pub mod ser; 8 | -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/adpcm_0.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/adpcm_0.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_iframe_0.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_iframe_0.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_iframe_1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_iframe_1.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_iframe_2.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_iframe_2.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_iframe_3.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_iframe_3.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_iframe_4.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_iframe_4.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_0.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_0.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_1.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_10.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_10.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_11.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_11.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_12.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_12.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_13.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_13.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_14.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_14.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_15.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_15.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_16.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_16.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_17.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_17.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_2.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_2.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_3.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_3.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_4.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_4.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_5.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_5.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_6.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_6.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_7.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_7.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_8.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_8.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/argus2_pframe_9.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/argus2_pframe_9.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/iframe_0.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/iframe_0.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/iframe_1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/iframe_1.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/iframe_2.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/iframe_2.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/iframe_3.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/iframe_3.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/iframe_4.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/iframe_4.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/info_v1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/info_v1.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/pframe_0.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/pframe_0.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/pframe_1.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/pframe_1.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_00.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_00.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_01.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_01.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_02.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_02.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_03.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_03.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_04.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_04.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_05.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_05.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_06.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_06.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_07.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_07.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_08.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_08.raw -------------------------------------------------------------------------------- /crates/core/src/bcmedia/samples/video_stream_swan_09.raw: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcmedia/samples/video_stream_swan_09.raw -------------------------------------------------------------------------------- /crates/core/src/bcudp/codex.rs: -------------------------------------------------------------------------------- 1 | //! Handles sending and recieving messages as complete packets 2 | //! 3 | //! BcUdpCodex is used with a `[tokio_util::codec::Framed]` to form complete packets 4 | //! 5 | use crate::bcudp::model::*; 6 | use crate::{Error, Result}; 7 | use bytes::BytesMut; 8 | use tokio_util::codec::{Decoder, Encoder}; 9 | 10 | use super::xml::UdpXml; 11 | 12 | pub(crate) struct BcUdpCodex {} 13 | 14 | impl BcUdpCodex { 15 | pub(crate) fn new() -> Self { 16 | Self {} 17 | } 18 | } 19 | 20 | impl Encoder for BcUdpCodex { 21 | type Error = Error; 22 | 23 | fn encode(&mut self, item: BcUdp, dst: &mut BytesMut) -> Result<()> { 24 | log::trace!("Encoding: {item:?}"); 25 | let buf: Vec = Default::default(); 26 | let buf = item.serialize(buf)?; 27 | dst.extend_from_slice(buf.as_slice()); 28 | log::trace!(" Encoding: Done: {}", buf.len()); 29 | Ok(()) 30 | } 31 | } 32 | 33 | impl Decoder for BcUdpCodex { 34 | type Item = BcUdp; 35 | type Error = Error; 36 | 37 | fn decode(&mut self, src: &mut BytesMut) -> Result> { 38 | log::trace!("Decoding:"); 39 | if src.is_empty() { 40 | return Ok(None); 41 | } 42 | match BcUdp::deserialize(src) { 43 | Ok(BcUdp::Discovery(UdpDiscovery { 44 | payload: UdpXml::R2cDisc(_), 45 | .. 46 | })) => { 47 | log::trace!(" Decoding: Relay terminate"); 48 | Err(Error::RelayTerminate) 49 | } 50 | Ok(BcUdp::Discovery(UdpDiscovery { 51 | payload: UdpXml::D2cDisc(_), 52 | .. 53 | })) => { 54 | log::trace!(" Decoding:Camera terminate"); 55 | Err(Error::CameraTerminate) 56 | } 57 | Ok(bc) => { 58 | log::trace!(" Decoding: Ok"); 59 | Ok(Some(bc)) 60 | } 61 | Err(Error::NomIncomplete(_)) => { 62 | log::trace!(" Decoding: Incomplete: {:0X?}", src); 63 | Ok(None) 64 | } 65 | Err(e) => { 66 | log::trace!(" Decoding: Err"); 67 | Err(e) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /crates/core/src/bcudp/crc.rs: -------------------------------------------------------------------------------- 1 | use crc32fast::Hasher; 2 | 3 | pub(crate) fn calc_crc(payload: &[u8]) -> u32 { 4 | // Bc uses a non standard crc. 5 | // 6 | // It uses the polynomial 0x04c11db7 7 | // It uses the inital value or 0x00000000 8 | // It uses the xorout of 0x00000000 9 | // 10 | // The crc32fast has an odd behavior were it bitwise negates 11 | // the initial value before the loop. In order to have 12 | // an effective initial value of 0x00000000 we need to provide 13 | // the value 0xffffffff 14 | let mut hasher = Hasher::new_with_initial(0xffffffff); 15 | hasher.update(payload); 16 | // crc32fast uses the algorithm CRC-32/ISO-HDLC 17 | // This has an xorout of 0xffffffff 18 | // we must undo this xorout 19 | hasher.finalize() ^ 0xffffffff_u32 20 | } 21 | -------------------------------------------------------------------------------- /crates/core/src/bcudp/mod.rs: -------------------------------------------------------------------------------- 1 | //! This module contains the protocol for dealing with UDP. 2 | //! 3 | //! There are three types of packets 4 | //! 5 | //! - Discovery 6 | //! - Ack 7 | //! - Data 8 | //! 9 | //! --- 10 | //! 11 | //! **Discovery**: Deals with setting up the initial connection and including their 12 | //! connection IDs and the MTU 13 | //! 14 | //! --- 15 | //! 16 | //! **Ack**: Is sent after every packet is recieved 17 | //! 18 | //! --- 19 | //! 20 | //! **Data**: Contains a Bc packet payload. This is a stream and one Bc Packet may 21 | //! be split accross multiple UDP Data packets 22 | //! 23 | 24 | pub(crate) mod codex; 25 | mod crc; 26 | /// Functions to deserialize udp packets 27 | pub mod de; 28 | /// Contains the model describing the top level structures 29 | pub mod model; 30 | /// Functions to serialize udp packets 31 | pub mod ser; 32 | /// Contains the udp related xml payloads 33 | pub mod xml; 34 | /// Constains routines to de/encrypt udp xml 35 | mod xml_crypto; 36 | -------------------------------------------------------------------------------- /crates/core/src/bcudp/model.rs: -------------------------------------------------------------------------------- 1 | //! The BcUdp Model 2 | //! 3 | 4 | use super::xml::*; 5 | 6 | /// Top level udp packet 7 | #[derive(Debug, PartialEq, Eq, Clone)] 8 | #[allow(clippy::large_enum_variant)] 9 | pub enum BcUdp { 10 | /// Packet from the negotiate stage when connection info is exchanged 11 | Discovery(UdpDiscovery), 12 | /// Packet to acknowledge receipt of a data packet 13 | Ack(UdpAck), 14 | /// Packet containing the data (or part of the data) of a Bc packet 15 | Data(UdpData), 16 | } 17 | 18 | impl BcUdp { 19 | /// Gets a connection ID for any kind packet 20 | pub fn get_connection_id(&self) -> i32 { 21 | match self { 22 | Self::Discovery(_) => 0, 23 | Self::Ack(data) => data.connection_id, 24 | Self::Data(data) => data.connection_id, 25 | } 26 | } 27 | } 28 | 29 | /// Magic for the UDP Discovery packet 30 | pub const MAGIC_HEADER_UDP_NEGO: u32 = 0x2a87cf3a; 31 | 32 | /// The Discovery packet is sent and received to init a connection 33 | #[derive(Debug, PartialEq, Eq, Clone)] 34 | pub struct UdpDiscovery { 35 | // The packet also contains these header fields not deserialized into this struct: 36 | // 4 Bytes Magic 37 | // 4 Byte payload size 38 | // 4 Bytes unknown always `01000000` 39 | /// The transmission id is unique to a message and used as an encryption key 40 | pub tid: u32, 41 | // The checksum of the payload 42 | // pub checksum: u32, 43 | /// The payload 44 | pub payload: UdpXml, 45 | } 46 | 47 | /// Magic for the UDP Ack packet 48 | pub const MAGIC_HEADER_UDP_ACK: u32 = 0x2a87cf20; 49 | 50 | /// Send to acknoledge a [`UdpData`] packet. If this is not sent then the camera will 51 | /// resend the packet 52 | #[derive(Debug, PartialEq, Eq, Clone)] 53 | pub struct UdpAck { 54 | /// The connection ID 55 | /// 56 | /// This is negotiated during [`UdpDiscovery`] as cid for the client and did for the camera 57 | /// 58 | /// When receiving from the camera it will be cid 59 | /// 60 | /// When sending to the camera it should be did 61 | /// 62 | /// We use i32 because when we send the connection id to the reolink 63 | /// register and then download the same connection id from the register_address 64 | /// it comes back in an xml that is encoded as i32 (i.e. can be negative string) 65 | pub connection_id: i32, 66 | /// Unknown 4 bytes always 0 for normal ack or -1 for signalling no packets received yet 67 | /// Tentativly assigned as a kinda group id 68 | pub group_id: u32, 69 | /// The ID of the last data packet [`UdpData`] 70 | pub packet_id: u32, 71 | /// 4 Bytes Unknown: Observed values `00000000`, `d6010000`, `d7160000`, `09e00000`, 72 | /// `0`, `54785`, `55062`, `2528`, 73 | /// Unknown but seems to change randomly every second 74 | /// Might be a latency for the recieved acknoledge packets 75 | pub maybe_latency: u32, 76 | // 2 Bytes size of a payload 77 | // 78 | /// Payload of `00 01 01 01 01` where `01` is added after every repeat 79 | /// 80 | /// This is a truth table of packets after `packet_id` that have not been recieved 81 | pub payload: Vec, 82 | } 83 | 84 | impl UdpAck { 85 | /// Create an empty ack packet to signifiy no packets recieved yet 86 | /// These are a little different in that they also set `group_id` 87 | pub fn empty(connection_id: i32) -> Self { 88 | Self { 89 | connection_id, 90 | group_id: 0xffffffff, 91 | packet_id: 0xffffffff, 92 | maybe_latency: 0x0, 93 | payload: vec![], 94 | } 95 | } 96 | } 97 | 98 | /// Magic for the UDP Data packet 99 | pub const MAGIC_HEADER_UDP_DATA: u32 = 0x2a87cf10; 100 | 101 | /// Contains the data of a [`crate::bc::model::Bc`] packet 102 | #[derive(PartialEq, Eq, Clone)] 103 | pub struct UdpData { 104 | /// The connection ID of the other party 105 | /// 106 | /// This is negotiated during [`UdpDiscovery`] as cid for the client and did for the camera 107 | /// 108 | /// When receiving from the camera it will be cid 109 | /// 110 | /// When sending to the camera it should be did 111 | /// 112 | /// We use i32 because when we send the connection id to the reolink 113 | /// register and then download the same connection id from the register_address 114 | /// it comes back in an xml that is encoded as i32 (i.e. can be negative string) 115 | pub connection_id: i32, 116 | // Unknown 4 bytes always 0 117 | /// The ID of the data packet 118 | pub packet_id: u32, 119 | // Unknown 4 bytes always 0 120 | // 4 Byte payload size 121 | /// The payload 122 | pub payload: Vec, 123 | } 124 | 125 | impl std::fmt::Debug for UdpData { 126 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 127 | f.debug_map() 128 | .entry(&"connection_id", &self.connection_id) 129 | .entry(&"packet_id", &self.packet_id) 130 | .entry(&"payload_len", &self.payload.len()) 131 | .finish() 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_ack.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_ack.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_data.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_data.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_0.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_0.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_1.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_2.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_2.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_3.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_3.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_4.bin: -------------------------------------------------------------------------------- 1 | χ*P -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_5.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_5.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_6.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_6.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_7.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_7.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_8.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_8.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_multi_9.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_multi_9.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_negotiate_camcfm.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_negotiate_camcfm.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_negotiate_camt.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_negotiate_camt.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_negotiate_clientt.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_negotiate_clientt.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/udp_negotiate_disc.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/udp_negotiate_disc.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/xml_crypto_sample1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/crates/core/src/bcudp/samples/xml_crypto_sample1.bin -------------------------------------------------------------------------------- /crates/core/src/bcudp/samples/xml_crypto_sample1_plaintext.bin: -------------------------------------------------------------------------------- 1 | 2 | 3 | 62097899 4 | local 5 | 82000 6 | 80 7 | 8 | 9 | -------------------------------------------------------------------------------- /crates/core/src/bcudp/xml_crypto.rs: -------------------------------------------------------------------------------- 1 | const XML_KEY: [u32; 8] = [ 2 | 0x1f2d3c4b, 0x5a6c7f8d, 0x38172e4b, 0x8271635a, 0x863f1a2b, 0xa5c6f7d8, 0x8371e1b4, 0x17f2d3a5, 3 | ]; 4 | 5 | pub(crate) fn decrypt(offset: u32, buf: &[u8]) -> Vec { 6 | let key = XML_KEY 7 | .iter() 8 | .flat_map(|i| (i + offset).to_le_bytes()) 9 | .cycle(); 10 | buf.iter().zip(key).map(|(byte, key)| key ^ byte).collect() 11 | } 12 | 13 | pub(crate) fn encrypt(offset: u32, buf: &[u8]) -> Vec { 14 | decrypt(offset, buf) 15 | } 16 | 17 | #[test] 18 | fn test_udp_xml_crypto() { 19 | let sample = include_bytes!("samples/xml_crypto_sample1.bin"); 20 | let should_be = include_bytes!("samples/xml_crypto_sample1_plaintext.bin"); 21 | 22 | let decrypted = decrypt(87, &sample[..]); 23 | assert_eq!(decrypted, &should_be[..]); 24 | } 25 | 26 | #[test] 27 | fn test_udp_xml_crypto_roundtrip() { 28 | let zeros: [u8; 256] = [0; 256]; 29 | 30 | let decrypted = encrypt(0, &zeros[..]); 31 | let encrypted = decrypt(0, &decrypted[..]); 32 | assert_eq!(encrypted, &zeros[..]); 33 | } 34 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(unused_crate_dependencies)] 2 | #![warn(missing_docs)] 3 | //! # Neolink-Core 4 | //! 5 | //! Neolink-Core is a rust library for interacting with reolink and family cameras. 6 | //! 7 | //! Most high level camera controls are in the [`bc_protocol`] module 8 | //! 9 | //! A camera can be initialised with 10 | //! 11 | //! ```no_run 12 | //! # tokio::runtime::Runtime::new().unwrap().block_on(async { 13 | //! use neolink_core::bc_protocol::{BcCamera, BcCameraOpt, DiscoveryMethods, ConnectionProtocol, Credentials}; 14 | //! let options = BcCameraOpt { 15 | //! name: "CamName".to_string(), 16 | //! channel_id: 0, 17 | //! addrs: ["192.168.1.1".parse().unwrap()].to_vec(), 18 | //! port: Some(9000), 19 | //! uid: Some("CAMUID".to_string()), 20 | //! protocol: ConnectionProtocol::TcpUdp, 21 | //! discovery: DiscoveryMethods::Relay, 22 | //! credentials: Credentials { 23 | //! username: "username".to_string(), 24 | //! password: Some("password".to_string()), 25 | //! }, 26 | //! debug: false, 27 | //! max_discovery_retries: 10, 28 | //! }; 29 | //! let mut camera = BcCamera::new(&options).await.unwrap(); 30 | //! # }) 31 | //! ``` 32 | //! 33 | //! After that login can be conducted with 34 | //! 35 | //! ```no_run 36 | //! # tokio::runtime::Runtime::new().unwrap().block_on(async { 37 | //! # use neolink_core::bc_protocol::{BcCamera, BcCameraOpt, DiscoveryMethods, ConnectionProtocol, Credentials}; 38 | //! # let options = BcCameraOpt { 39 | //! # name: "CamName".to_string(), 40 | //! # channel_id: 0, 41 | //! # addrs: ["192.168.1.1".parse().unwrap()].to_vec(), 42 | //! # port: Some(9000), 43 | //! # uid: Some("CAMUID".to_string()), 44 | //! # protocol: ConnectionProtocol::TcpUdp, 45 | //! # discovery: DiscoveryMethods::Relay, 46 | //! # credentials: Credentials { 47 | //! # username: "username".to_string(), 48 | //! # password: Some("password".to_string()), 49 | //! # }, 50 | //! # debug: false, 51 | //! # max_discovery_retries: 10, 52 | //! # }; 53 | //! # let mut camera = BcCamera::new(&options).await.unwrap(); 54 | //! camera.login().await; 55 | //! # }) 56 | //! ``` 57 | //! For further commands see the [`bc_protocol::BcCamera`] struct. 58 | //! 59 | 60 | /// Contains low level BC structures and formats 61 | pub mod bc; 62 | /// Contains high level interfaces for the camera 63 | pub mod bc_protocol; 64 | /// Contains low level structures and formats for the media substream 65 | pub mod bcmedia; 66 | /// Contains low level structures and formats for the udpstream 67 | pub mod bcudp; 68 | 69 | /// This is the top level error structure of the library 70 | /// 71 | /// Most commands will either return their `Ok(result)` or this `Err(Error)` 72 | pub use bc_protocol::Error; 73 | 74 | pub(crate) use bc_protocol::{Credentials, Result}; 75 | 76 | pub(crate) type NomErrorType<'a> = nom::error::VerboseError<&'a [u8]>; 77 | -------------------------------------------------------------------------------- /crates/decoder/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "neolink_decoder" 3 | description = "A cli decoder for the AES encrypted packets" 4 | version = "0.6.3-rc.3" 5 | authors = ["Andrew King "] 6 | edition = "2018" 7 | license = "AGPL-3.0-or-later" 8 | 9 | [dependencies] 10 | aes = "0.8.2" 11 | anyhow = "1.0.42" 12 | cfb-mode = "0.8.2" 13 | env_logger = "*" 14 | hex-string = "0.1.0" 15 | md5 = "0.7.0" 16 | requestty = "0.5.0" 17 | log = "0.4.17" 18 | -------------------------------------------------------------------------------- /crates/decoder/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(unused_crate_dependencies)] 2 | #![warn(missing_docs)] 3 | #![warn(clippy::todo)] 4 | //! Use to decode AES packet data 5 | use hex_string::HexString; 6 | use requestty::Question; 7 | use std::convert::TryInto; 8 | 9 | use aes::{ 10 | cipher::{AsyncStreamCipher, KeyIvInit}, 11 | Aes128, 12 | }; 13 | use cfb_mode::Decryptor; 14 | 15 | type Aes128CfbDec = Decryptor; 16 | 17 | const IV: &[u8] = b"0123456789abcdef"; 18 | 19 | fn decrypt(buf: &[u8], aeskey: &[u8; 16]) -> Vec { 20 | let mut decrypted = buf.to_vec(); 21 | Aes128CfbDec::new(aeskey.into(), IV.into()).decrypt(&mut decrypted); 22 | decrypted 23 | } 24 | 25 | fn make_aeskey>(password: T, nonce: T) -> [u8; 16] { 26 | let key_phrase = format!("{}-{}", nonce.as_ref(), password.as_ref(),); 27 | let key_phrase_hash = format!("{:X}\0", md5::compute(key_phrase)) 28 | .to_uppercase() 29 | .into_bytes(); 30 | key_phrase_hash[0..16].try_into().unwrap() 31 | } 32 | 33 | fn main() -> Result<(), anyhow::Error> { 34 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); 35 | 36 | let question = Question::input("hex") 37 | .message("Enter the hex string to decode") 38 | .build(); 39 | 40 | let source_str: String = requestty::prompt_one(question)? 41 | .as_string() 42 | .ok_or_else(|| anyhow::anyhow!("Did not get reply"))? 43 | .to_string(); 44 | 45 | let question = Question::password("password") 46 | .message("Enter Camera Password") 47 | .mask('*') 48 | .build(); 49 | let pass = requestty::prompt_one(question)? 50 | .as_string() 51 | .ok_or_else(|| anyhow::anyhow!("Did not get reply"))? 52 | .to_string(); 53 | 54 | let question = Question::password("nonce") 55 | .message("Enter Login Nonce") 56 | .mask('*') 57 | .build(); 58 | let nonce = requestty::prompt_one(question)? 59 | .as_string() 60 | .ok_or_else(|| anyhow::anyhow!("Did not get reply"))? 61 | .to_string(); 62 | 63 | let source_hex = HexString::from_string(&source_str).unwrap(); 64 | 65 | let decrypted = decrypt(&source_hex.as_bytes(), &make_aeskey(&pass, &nonce)); 66 | log::info!("Bytes: {:X?}", decrypted); 67 | log::info!("Text: {}", String::from_utf8(decrypted)?); 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /crates/mailnoti/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "mailnoti" 3 | version = "0.6.3-rc.3" 4 | edition = "2021" 5 | 6 | 7 | [dependencies] 8 | anyhow = "1.0.71" 9 | async-std = "1.12.0" 10 | clap = { version = "4.2.2", features = ["derive", "cargo"] } 11 | env_logger = "0.10.0" 12 | get_if_addrs = "0.5.3" 13 | lazy_static = "1.4.0" 14 | log = { version = "0.4.17", features = [ "release_max_level_debug" ] } 15 | mailin-embedded = "0.8.2" 16 | neolink_core = { path = "../core", version = "0.6.3-rc.3" } 17 | once_cell = "1.19.0" 18 | regex = "1.8.1" 19 | serde = "1.0.163" 20 | tokio = { version = "1.28.1", features = ["full"] } 21 | toml = "0.8.2" 22 | validator = {version="0.18.1", features = ["derive"] } 23 | -------------------------------------------------------------------------------- /crates/mailnoti/README.md: -------------------------------------------------------------------------------- 1 | # Reolink Mail Notifications 2 | 3 | Because google FCM has removed the push notification api we use 4 | we now must resort to email notifications for PIR messages 5 | 6 | This is a test crate to investigate this 7 | -------------------------------------------------------------------------------- /crates/mailnoti/src/config.rs: -------------------------------------------------------------------------------- 1 | use neolink_core::bc_protocol::DiscoveryMethods; 2 | use once_cell::sync::Lazy; 3 | use regex::Regex; 4 | use serde::Deserialize; 5 | use std::clone::Clone; 6 | use validator::{Validate, ValidationError}; 7 | 8 | static RE_MAXENC_SRC: Lazy = Lazy::new(|| { 9 | Regex::new(r"^([nN]one|[Aa][Ee][Ss]|[Bb][Cc][Ee][Nn][Cc][Rr][Yy][Pp][Tt])$").unwrap() 10 | }); 11 | 12 | #[derive(Debug, Deserialize, Validate, Clone)] 13 | pub(crate) struct Config { 14 | #[validate(nested)] 15 | pub(crate) cameras: Vec, 16 | } 17 | 18 | #[derive(Debug, Deserialize, Validate, Clone)] 19 | #[validate(schema(function = "validate_camera_config"))] 20 | pub(crate) struct CameraConfig { 21 | pub(crate) name: String, 22 | 23 | #[serde(rename = "address")] 24 | pub(crate) camera_addr: Option, 25 | 26 | #[serde(rename = "uid")] 27 | pub(crate) camera_uid: Option, 28 | 29 | pub(crate) username: String, 30 | pub(crate) password: Option, 31 | 32 | #[validate(range(min = 0, max = 31, message = "Invalid channel", code = "channel_id"))] 33 | #[serde(default = "default_channel_id", alias = "channel")] 34 | pub(crate) channel_id: u8, 35 | 36 | #[serde(default = "default_discovery")] 37 | pub(crate) discovery: DiscoveryMethods, 38 | 39 | #[serde(default = "default_maxenc")] 40 | #[validate(regex( 41 | path = *RE_MAXENC_SRC, 42 | message = "Invalid maximum encryption method", 43 | code = "max_encryption" 44 | ))] 45 | pub(crate) max_encryption: String, 46 | } 47 | 48 | fn default_discovery() -> DiscoveryMethods { 49 | DiscoveryMethods::Relay 50 | } 51 | 52 | fn default_maxenc() -> String { 53 | "Aes".to_string() 54 | } 55 | 56 | fn default_channel_id() -> u8 { 57 | 0 58 | } 59 | 60 | fn validate_camera_config(camera_config: &CameraConfig) -> Result<(), ValidationError> { 61 | match (&camera_config.camera_addr, &camera_config.camera_uid) { 62 | (None, None) => Err(ValidationError::new( 63 | "Either camera address or uid must be given", 64 | )), 65 | _ => Ok(()), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/mailnoti/src/opt.rs: -------------------------------------------------------------------------------- 1 | use clap::{crate_authors, crate_version, Parser}; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | 5 | /// A standards-compliant bridge to Reolink IP cameras 6 | /// 7 | /// Neolink is free software released under the GNU AGPL v3. 8 | /// You can find its source code at https://github.com/thirtythreeforty/neolink 9 | #[derive(Parser, Debug)] 10 | #[command(name = "pushnoti", arg_required_else_help = true, version = crate_version!(), author = crate_authors!("\n"))] 11 | pub struct Opt { 12 | #[arg(short, long, value_parser = PathBuf::from_str)] 13 | pub config: Option, 14 | /// The name of the camera. Must be a name in the config 15 | pub camera: String, 16 | } 17 | -------------------------------------------------------------------------------- /crates/mailnoti/src/utils.rs: -------------------------------------------------------------------------------- 1 | //! Contains code that is not specific to any of the subcommands 2 | //! 3 | use log::*; 4 | 5 | use super::config::{CameraConfig, Config}; 6 | use anyhow::{anyhow, Context, Error, Result}; 7 | use neolink_core::bc_protocol::{ 8 | BcCamera, BcCameraOpt, ConnectionProtocol, Credentials, DiscoveryMethods, MaxEncryption, 9 | }; 10 | use std::{ 11 | fmt::{Display, Error as FmtError, Formatter}, 12 | net::{IpAddr, ToSocketAddrs}, 13 | str::FromStr, 14 | }; 15 | 16 | pub(crate) enum AddressOrUid { 17 | Address(String), 18 | #[allow(dead_code)] 19 | Uid(String, DiscoveryMethods), 20 | #[allow(dead_code)] 21 | AddressWithUid(String, String, DiscoveryMethods), 22 | } 23 | 24 | impl Display for AddressOrUid { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { 26 | match self { 27 | AddressOrUid::AddressWithUid(addr, uid, _) => { 28 | write!(f, "Address: {}, UID: {}", addr, uid) 29 | } 30 | AddressOrUid::Address(host) => write!(f, "Address: {}", host), 31 | AddressOrUid::Uid(host, _) => write!(f, "UID: {}", host), 32 | } 33 | } 34 | } 35 | 36 | impl AddressOrUid { 37 | // Created by translating the config fields directly 38 | pub(crate) fn new( 39 | address: &Option, 40 | uid: &Option, 41 | method: &DiscoveryMethods, 42 | ) -> Result { 43 | match (address, uid) { 44 | (None, None) => Err(anyhow!("Neither address or uid given")), 45 | (Some(host), Some(uid)) => Ok(AddressOrUid::AddressWithUid( 46 | host.clone(), 47 | uid.clone(), 48 | *method, 49 | )), 50 | (Some(host), None) => Ok(AddressOrUid::Address(host.clone())), 51 | (None, Some(host)) => Ok(AddressOrUid::Uid(host.clone(), *method)), 52 | } 53 | } 54 | 55 | // Convience method to get the BcCamera with the appropiate method 56 | // from a camera_config 57 | pub(crate) async fn connect_camera( 58 | &self, 59 | camera_config: &CameraConfig, 60 | ) -> Result { 61 | let (port, addrs) = { 62 | if let Some(addr_str) = camera_config.camera_addr.as_ref() { 63 | match addr_str.to_socket_addrs() { 64 | Ok(addr_iter) => { 65 | let mut port = None; 66 | let mut ipaddrs = vec![]; 67 | for addr in addr_iter { 68 | port = Some(addr.port()); 69 | ipaddrs.push(addr.ip()); 70 | } 71 | Ok((port, ipaddrs)) 72 | } 73 | Err(_) => match IpAddr::from_str(addr_str) { 74 | Ok(ip) => Ok((None, vec![ip])), 75 | Err(_) => Err(anyhow!("Could not parse address in config")), 76 | }, 77 | } 78 | } else { 79 | Ok((None, vec![])) 80 | } 81 | }?; 82 | 83 | let options = BcCameraOpt { 84 | name: camera_config.name.clone(), 85 | channel_id: camera_config.channel_id, 86 | addrs, 87 | port, 88 | uid: camera_config.camera_uid.clone(), 89 | protocol: ConnectionProtocol::TcpUdp, 90 | discovery: camera_config.discovery, 91 | max_discovery_retries: 10, 92 | credentials: Credentials { 93 | username: camera_config.username.clone(), 94 | password: camera_config.password.clone(), 95 | }, 96 | debug: false, 97 | }; 98 | 99 | trace!("Camera Info: {:?}", options); 100 | 101 | Ok(BcCamera::new(&options).await?) 102 | } 103 | } 104 | 105 | pub(crate) async fn find_and_connect(config: &Config, name: &str) -> Result { 106 | let camera_config = find_camera_by_name(config, name)?; 107 | connect_and_login(camera_config).await 108 | } 109 | 110 | pub(crate) async fn connect_and_login(camera_config: &CameraConfig) -> Result { 111 | let camera_addr = AddressOrUid::new( 112 | &camera_config.camera_addr, 113 | &camera_config.camera_uid, 114 | &camera_config.discovery, 115 | ) 116 | .unwrap(); 117 | info!( 118 | "{}: Connecting to camera at {}", 119 | camera_config.name, camera_addr 120 | ); 121 | 122 | let camera = camera_addr 123 | .connect_camera(camera_config) 124 | .await 125 | .with_context(|| { 126 | format!( 127 | "Failed to connect to camera {} at {} on channel {}", 128 | camera_config.name, camera_addr, camera_config.channel_id 129 | ) 130 | })?; 131 | 132 | let max_encryption = match camera_config.max_encryption.to_lowercase().as_str() { 133 | "none" => MaxEncryption::None, 134 | "bcencrypt" => MaxEncryption::BcEncrypt, 135 | "aes" => MaxEncryption::Aes, 136 | _ => MaxEncryption::Aes, 137 | }; 138 | info!("{}: Logging in", camera_config.name); 139 | camera 140 | .login_with_maxenc(max_encryption) 141 | .await 142 | .with_context(|| format!("Failed to login to {}", camera_config.name))?; 143 | 144 | info!("{}: Connected and logged in", camera_config.name); 145 | 146 | Ok(camera) 147 | } 148 | 149 | pub(crate) fn find_camera_by_name<'a>(config: &'a Config, name: &str) -> Result<&'a CameraConfig> { 150 | config 151 | .cameras 152 | .iter() 153 | .find(|c| c.name == name) 154 | .ok_or_else(|| anyhow!("Camera {} not found in the config file", name)) 155 | } 156 | -------------------------------------------------------------------------------- /crates/pushnoti/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pushnoti" 3 | version = "0.6.3-rc.3" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.71" 10 | clap = { version = "4.2.2", features = ["derive", "cargo"] } 11 | env_logger = "0.10.0" 12 | fcm-push-listener = { version = "2.0.3" } 13 | lazy_static = "1.4.0" 14 | log = { version = "0.4.17", features = [ "release_max_level_debug" ] } 15 | neolink_core = { path = "../core", version = "0.6.3-rc.3" } 16 | once_cell = "1.19.0" 17 | regex = "1.8.1" 18 | serde = "1.0.163" 19 | tokio = { version = "1.28.1", features = ["full"] } 20 | toml = "0.8.2" 21 | validator = {version="0.18.1", features = ["derive"] } 22 | -------------------------------------------------------------------------------- /crates/pushnoti/src/config.rs: -------------------------------------------------------------------------------- 1 | use neolink_core::bc_protocol::DiscoveryMethods; 2 | use once_cell::sync::Lazy; 3 | use regex::Regex; 4 | use serde::Deserialize; 5 | use std::clone::Clone; 6 | use validator::{Validate, ValidationError}; 7 | 8 | static RE_MAXENC_SRC: Lazy = Lazy::new(|| { 9 | Regex::new(r"^([nN]one|[Aa][Ee][Ss]|[Bb][Cc][Ee][Nn][Cc][Rr][Yy][Pp][Tt])$").unwrap() 10 | }); 11 | 12 | #[derive(Debug, Deserialize, Validate, Clone)] 13 | pub(crate) struct Config { 14 | #[validate(nested)] 15 | pub(crate) cameras: Vec, 16 | } 17 | 18 | #[derive(Debug, Deserialize, Validate, Clone)] 19 | #[validate(schema(function = "validate_camera_config"))] 20 | pub(crate) struct CameraConfig { 21 | pub(crate) name: String, 22 | 23 | #[serde(rename = "address")] 24 | pub(crate) camera_addr: Option, 25 | 26 | #[serde(rename = "uid")] 27 | pub(crate) camera_uid: Option, 28 | 29 | pub(crate) username: String, 30 | pub(crate) password: Option, 31 | 32 | #[validate(range(min = 0, max = 31, message = "Invalid channel", code = "channel_id"))] 33 | #[serde(default = "default_channel_id", alias = "channel")] 34 | pub(crate) channel_id: u8, 35 | 36 | #[serde(default = "default_discovery")] 37 | pub(crate) discovery: DiscoveryMethods, 38 | 39 | #[serde(default = "default_maxenc")] 40 | #[validate(regex( 41 | path = *RE_MAXENC_SRC, 42 | message = "Invalid maximum encryption method", 43 | code = "max_encryption" 44 | ))] 45 | pub(crate) max_encryption: String, 46 | } 47 | 48 | fn default_discovery() -> DiscoveryMethods { 49 | DiscoveryMethods::Relay 50 | } 51 | 52 | fn default_maxenc() -> String { 53 | "Aes".to_string() 54 | } 55 | 56 | fn default_channel_id() -> u8 { 57 | 0 58 | } 59 | 60 | fn validate_camera_config(camera_config: &CameraConfig) -> Result<(), ValidationError> { 61 | match (&camera_config.camera_addr, &camera_config.camera_uid) { 62 | (None, None) => Err(ValidationError::new( 63 | "Either camera address or uid must be given", 64 | )), 65 | _ => Ok(()), 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/pushnoti/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use clap::Parser; 3 | use fcm_push_listener::*; 4 | use log::*; 5 | use std::{fs, path::PathBuf}; 6 | use validator::Validate; 7 | 8 | mod config; 9 | mod opt; 10 | mod utils; 11 | 12 | use config::Config; 13 | use opt::Opt; 14 | use utils::find_and_connect; 15 | 16 | #[tokio::main] 17 | async fn main() -> Result<()> { 18 | env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); 19 | 20 | let opt = Opt::parse(); 21 | 22 | let conf_path = opt.config.context("Must supply --config file")?; 23 | let config: Config = toml::from_str( 24 | &fs::read_to_string(&conf_path) 25 | .with_context(|| format!("Failed to read {:?}", conf_path))?, 26 | ) 27 | .with_context(|| format!("Failed to parse the {:?} config file", conf_path))?; 28 | 29 | config 30 | .validate() 31 | .with_context(|| format!("Failed to validate the {:?} config file", conf_path))?; 32 | 33 | let camera = find_and_connect(&config, &opt.camera).await?; 34 | 35 | // 696841269229 is the reo_iphone FCM Sender_ID 36 | // let registration = fcm_push_listener::register("696841269229").await?; 37 | // 743639030586 is the reo_fcm FCM Sender_ID 38 | // 263684512460 is my test Sender_ID 39 | // let registration = fcm_push_listener::register("743639030586").await? 40 | // I have confirmed that I can recieve test messages with this SenderID 41 | // into this program 42 | // let registration = fcm_push_listener::register("263684512460").await?; 43 | 44 | // let firebase_app_id = "1:743639030586:android:86f60a4fb7143876"; 45 | // let firebase_project_id = "reolink-login"; 46 | // let firebase_api_key = "AIzaSyBEUIuWHnnOEwFahxWgQB4Yt4NsgOmkPyE"; 47 | // let vapid_key = ""; 48 | 49 | let sender_id = "743639030586"; // andriod 50 | 51 | // let sender_id = "696841269229"; // ios 52 | 53 | // let sender_id = "263684512460"; // test 54 | 55 | let token_path = PathBuf::from("./token.toml"); 56 | let registration = if let Ok(Ok(registration)) = 57 | fs::read_to_string(&token_path).map(|v| toml::from_str::(&v)) 58 | { 59 | info!("Loaded token"); 60 | registration 61 | } else { 62 | info!("Registering new token"); 63 | let registration = fcm_push_listener::register(sender_id).await?; 64 | // let registration = fcm_push_listener::register( 65 | // firebase_app_id, 66 | // firebase_project_id, 67 | // firebase_api_key, 68 | // vapid_key, 69 | // ) 70 | // .await?; 71 | let new_token = toml::to_string(®istration)?; 72 | fs::write(token_path, new_token)?; 73 | registration 74 | }; 75 | 76 | // Send registration.fcm_token to the server to allow it to send push messages to you. 77 | info!("registration.fcm_token: {}", registration.fcm_token); 78 | let uid = "6A5443E486511B0D828543445DC55A7D"; // MD5 Hash of "WHY_REOLINK" 79 | camera 80 | .send_pushinfo_android(®istration.fcm_token, uid) 81 | .await?; 82 | 83 | info!("Listening"); 84 | let mut listener = FcmPushListener::create( 85 | registration, 86 | |message: FcmMessage| { 87 | info!("Message JSON: {}", message.payload_json); 88 | info!("Persistent ID: {:?}", message.persistent_id); 89 | }, 90 | vec![], 91 | ); 92 | listener.connect().await?; 93 | Ok(()) 94 | } 95 | -------------------------------------------------------------------------------- /crates/pushnoti/src/opt.rs: -------------------------------------------------------------------------------- 1 | use clap::{crate_authors, crate_version, Parser}; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | 5 | /// A standards-compliant bridge to Reolink IP cameras 6 | /// 7 | /// Neolink is free software released under the GNU AGPL v3. 8 | /// You can find its source code at https://github.com/thirtythreeforty/neolink 9 | #[derive(Parser, Debug)] 10 | #[command(name = "pushnoti", arg_required_else_help = true, version = crate_version!(), author = crate_authors!("\n"))] 11 | pub struct Opt { 12 | #[arg(short, long, value_parser = PathBuf::from_str)] 13 | pub config: Option, 14 | /// The name of the camera. Must be a name in the config 15 | pub camera: String, 16 | } 17 | -------------------------------------------------------------------------------- /crates/pushnoti/src/utils.rs: -------------------------------------------------------------------------------- 1 | //! Contains code that is not specific to any of the subcommands 2 | //! 3 | use log::*; 4 | 5 | use super::config::{CameraConfig, Config}; 6 | use anyhow::{anyhow, Context, Error, Result}; 7 | use neolink_core::bc_protocol::{ 8 | BcCamera, BcCameraOpt, ConnectionProtocol, Credentials, DiscoveryMethods, MaxEncryption, 9 | }; 10 | use std::{ 11 | fmt::{Display, Error as FmtError, Formatter}, 12 | net::{IpAddr, ToSocketAddrs}, 13 | str::FromStr, 14 | }; 15 | 16 | pub(crate) enum AddressOrUid { 17 | Address(String), 18 | #[allow(dead_code)] 19 | Uid(String, DiscoveryMethods), 20 | #[allow(dead_code)] 21 | AddressWithUid(String, String, DiscoveryMethods), 22 | } 23 | 24 | impl Display for AddressOrUid { 25 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { 26 | match self { 27 | AddressOrUid::AddressWithUid(addr, uid, _) => { 28 | write!(f, "Address: {}, UID: {}", addr, uid) 29 | } 30 | AddressOrUid::Address(host) => write!(f, "Address: {}", host), 31 | AddressOrUid::Uid(host, _) => write!(f, "UID: {}", host), 32 | } 33 | } 34 | } 35 | 36 | impl AddressOrUid { 37 | // Created by translating the config fields directly 38 | pub(crate) fn new( 39 | address: &Option, 40 | uid: &Option, 41 | method: &DiscoveryMethods, 42 | ) -> Result { 43 | match (address, uid) { 44 | (None, None) => Err(anyhow!("Neither address or uid given")), 45 | (Some(host), Some(uid)) => Ok(AddressOrUid::AddressWithUid( 46 | host.clone(), 47 | uid.clone(), 48 | *method, 49 | )), 50 | (Some(host), None) => Ok(AddressOrUid::Address(host.clone())), 51 | (None, Some(host)) => Ok(AddressOrUid::Uid(host.clone(), *method)), 52 | } 53 | } 54 | 55 | // Convience method to get the BcCamera with the appropiate method 56 | // from a camera_config 57 | pub(crate) async fn connect_camera( 58 | &self, 59 | camera_config: &CameraConfig, 60 | ) -> Result { 61 | let (port, addrs) = { 62 | if let Some(addr_str) = camera_config.camera_addr.as_ref() { 63 | match addr_str.to_socket_addrs() { 64 | Ok(addr_iter) => { 65 | let mut port = None; 66 | let mut ipaddrs = vec![]; 67 | for addr in addr_iter { 68 | port = Some(addr.port()); 69 | ipaddrs.push(addr.ip()); 70 | } 71 | Ok((port, ipaddrs)) 72 | } 73 | Err(_) => match IpAddr::from_str(addr_str) { 74 | Ok(ip) => Ok((None, vec![ip])), 75 | Err(_) => Err(anyhow!("Could not parse address in config")), 76 | }, 77 | } 78 | } else { 79 | Ok((None, vec![])) 80 | } 81 | }?; 82 | 83 | let options = BcCameraOpt { 84 | name: camera_config.name.clone(), 85 | channel_id: camera_config.channel_id, 86 | addrs, 87 | port, 88 | uid: camera_config.camera_uid.clone(), 89 | protocol: ConnectionProtocol::TcpUdp, 90 | discovery: camera_config.discovery, 91 | max_discovery_retries: 10, 92 | credentials: Credentials { 93 | username: camera_config.username.clone(), 94 | password: camera_config.password.clone(), 95 | }, 96 | debug: false, 97 | }; 98 | 99 | trace!("Camera Info: {:?}", options); 100 | 101 | Ok(BcCamera::new(&options).await?) 102 | } 103 | } 104 | 105 | pub(crate) async fn find_and_connect(config: &Config, name: &str) -> Result { 106 | let camera_config = find_camera_by_name(config, name)?; 107 | connect_and_login(camera_config).await 108 | } 109 | 110 | pub(crate) async fn connect_and_login(camera_config: &CameraConfig) -> Result { 111 | let camera_addr = AddressOrUid::new( 112 | &camera_config.camera_addr, 113 | &camera_config.camera_uid, 114 | &camera_config.discovery, 115 | ) 116 | .unwrap(); 117 | info!( 118 | "{}: Connecting to camera at {}", 119 | camera_config.name, camera_addr 120 | ); 121 | 122 | let camera = camera_addr 123 | .connect_camera(camera_config) 124 | .await 125 | .with_context(|| { 126 | format!( 127 | "Failed to connect to camera {} at {} on channel {}", 128 | camera_config.name, camera_addr, camera_config.channel_id 129 | ) 130 | })?; 131 | 132 | let max_encryption = match camera_config.max_encryption.to_lowercase().as_str() { 133 | "none" => MaxEncryption::None, 134 | "bcencrypt" => MaxEncryption::BcEncrypt, 135 | "aes" => MaxEncryption::Aes, 136 | _ => MaxEncryption::Aes, 137 | }; 138 | info!("{}: Logging in", camera_config.name); 139 | camera 140 | .login_with_maxenc(max_encryption) 141 | .await 142 | .with_context(|| format!("Failed to login to {}", camera_config.name))?; 143 | 144 | info!("{}: Connected and logged in", camera_config.name); 145 | 146 | Ok(camera) 147 | } 148 | 149 | pub(crate) fn find_camera_by_name<'a>(config: &'a Config, name: &str) -> Result<&'a CameraConfig> { 150 | config 151 | .cameras 152 | .iter() 153 | .find(|c| c.name == name) 154 | .ok_or_else(|| anyhow!("Camera {} not found in the config file", name)) 155 | } 156 | -------------------------------------------------------------------------------- /dissector/README.md: -------------------------------------------------------------------------------- 1 | # Using the dissector 2 | 3 | The dissector can be used with `wireshark` and `tshark`. It requires another wireshark module [`luagcrypt`](https://github.com/Lekensteyn/luagcrypt) which is not packaged in most Linux distributions and needs building from source. Instructions here are suitable for Debian and its derivatives (tested on Debian 12 Bookworm amd64). 4 | 5 | ## Build `luagcrypt.so` 6 | ``` 7 | sudo apt install lua5.2 liblua5.2-dev libgcrypt20-dev libgpg-error-dev 8 | git clone https://github.com/Lekensteyn/luagcrypt.git 9 | cd luagcrypt 10 | make LUA_DIR=/usr 11 | ``` 12 | ## Install `luagcrypt.so` 13 | The shared object library should be copied to `/usr/local/lib/lua/5.2/` 14 | ``` 15 | mkdir --parents /usr/local/lib/lua/5.2/ 16 | cp luagcrypt.so /usr/local/lib/lua/5.2/ 17 | ``` 18 | Additionally, the system where the dissector is used needs these packages installing (if not already present): `libgcrypt20 libgpg-error0` 19 | ## Install `baichuan.lua` 20 | Copy the dissector to the host where `wireshark` (or `tshark`) will be used to analyse the captured packets: 21 | ``` 22 | mkdir --parents $HOME/.local/lib/wireshark/plugins/ 23 | cp baichuan.lua $HOME/.local/lib/wireshark/plugins/ 24 | ``` 25 | 26 | -------------------------------------------------------------------------------- /dissector/mediapacket.md: -------------------------------------------------------------------------------- 1 | # Media Packets 2 | 3 | This file attempts to document the encapsulated stream that makes up the binary 4 | data of message ID 3. This stream represents the video and audio data. 5 | 6 | The are six types of media packets 7 | 8 | - Info V1 9 | 10 | - Info V2 11 | 12 | - I Frame 13 | 14 | - P Frame 15 | 16 | - AAC 17 | 18 | - ADPCM 19 | 20 | The I and P frames come in two formats H264 and H265. 21 | 22 | The ADPCM is DVI-4 encoded with an extra header that represents the block size. 23 | 24 | ## Magic 25 | 26 | Each packet can be distinguished with the following magic bytes 27 | 28 | - Info V1: 0x31, 0x30, 0x30, 0x31 29 | - Info V2: 0x31, 0x30, 0x30, 0x32 30 | - I Frame: 0x30, 0x30, 0x64, 0x63 31 | - P Frame: 0x30, 0x31, 0x64, 0x63 32 | - AAC: 0x30, 0x35, 0x77, 0x62 33 | - ADPCM: 0x30, 0x31, 0x77, 0x62 34 | 35 | 36 | ## Headers 37 | 38 | The full headers for each of these are as follows: 39 | 40 | Most of this information comes from the work of @twisteddx at his 41 | [site](https://www.wasteofcash.com/BCConvert/BC_fileformat.txt) 42 | 43 | - Info V1/V2: 44 | 45 | - 4 bytes magic 46 | - 4 bytes data size of header itself 47 | - 4 bytes video width 48 | - 4 bytes video height 49 | - 1 byte unknown. Known values 00/01 50 | - 1 byte Frames per second (Reolink=FPS, Swann=Appears to be the index value of the FPS setting) 51 | - 1 byte Start UTC year since 1900 52 | - 1 byte Start UTC month 53 | - 1 byte Start UTC date 54 | - 1 byte Start UTC hour 55 | - 1 byte Start UTC minute 56 | - 1 byte Start UTC seconds 57 | - 1 byte End UTC year since 1900 58 | - 1 byte End UTC month 59 | - 1 byte End UTC date 60 | - 1 byte End UTC hour 61 | - 1 byte End UTC minute 62 | - 1 byte End UTC seconds 63 | - 2 bytes reserved 64 | 65 | - I Frame 66 | 67 | - 4 bytes magic 68 | - 4 bytes video type (ASCII text of either H264 or H265) 69 | - 4 bytes data size of payload after header 70 | - 4 bytes unknown. NVR channel count? Known values 1-00/08 2-00 3-00 4-00 71 | - 4 bytes Microseconds 72 | - 4 bytes unknown. Known values 1-00/23/5A 2-00 3-00 4-00 73 | - 4 bytes POSIX time_t 32bit UTC time (seconds since 00:00:00 Jan 1 1970) 74 | - 4 bytes unknown. Known values 1-00/06/29 2-00/01 3-00/C3 4-00 75 | 76 | - P Frame 77 | - 4 bytes magic 78 | - 4 bytes video type (eg H264 or H265) 79 | - 4 bytes data size of payload after header 80 | - 4 bytes unknown. Known values 1-00 2-00 3-00 4-00 81 | - 4 bytes Microseconds 82 | - 4 bytes unknown. Known values 1-00/5A 2-00 3-00 4-00 83 | 84 | - AAC 85 | 86 | - 4 bytes magic 87 | - 2 bytes data size of payload after header 88 | - 2 bytes data size of payload after header (Same as previous) 89 | 90 | - ADPCM 91 | 92 | - 4 bytes magic 93 | - 2 bytes data size of payload after header 94 | - 2 bytes data size of payload after header 95 | 96 | ### ADPCM 97 | 98 | After the ADPCM header is another header of the form 99 | 100 | - 2 bytes Magic either 0x00 0x01 or 0x00 0x7a 101 | - 2 Bytes DVI-4 Block size divided in bytes. 102 | 103 | After this header the adpcm DVI-4 data follows should be 4+Block size bytes. 104 | 105 | ## Processing 106 | 107 | The data in ID 3 messages represents an encapsulated stream. BC messages may 108 | terminate mid media packet messages. Clients should create a buffer of the 109 | BC ID 3 messages then read the media packet magic and expected length. Once 110 | length is known they should wait for more BC message ID 3 packets until a 111 | complete media packet is received before processing it. 112 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Allows Ctrl-C, by letting this sh process act as PID 1 4 | exit_func() { 5 | exit 1 6 | } 7 | trap exit_func TERM INT 8 | 9 | ulimit -n 65535 10 | 11 | echo "Running: ${*}" 12 | "$@" 13 | -------------------------------------------------------------------------------- /docs/screenshots/login_messages.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/docs/screenshots/login_messages.JPG -------------------------------------------------------------------------------- /docs/screenshots/new_camera.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/docs/screenshots/new_camera.JPG -------------------------------------------------------------------------------- /docs/screenshots/new_camera_config.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/docs/screenshots/new_camera_config.JPG -------------------------------------------------------------------------------- /docs/screenshots/taskfinishwindow.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/docs/screenshots/taskfinishwindow.JPG -------------------------------------------------------------------------------- /docs/screenshots/tasksettingstab.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QuantumEntangledAndy/neolink/6e05e7844b5b50f89787d30bffcbbd3471bfcfde/docs/screenshots/tasksettingstab.JPG -------------------------------------------------------------------------------- /docs/unix_service.md: -------------------------------------------------------------------------------- 1 | # Creating a Service for Neolink 2 | 3 | This will guide you through creating a service for neolink that 4 | can be used on linux machines such as ubuntu and debian 5 | buster/stetch or any other os that uses systemd. 6 | 7 | The general steps are: 8 | 9 | 1. Create an unprivileged user to run neolink 10 | 2. Set up neolink somewhere the unprivileged user can run it 11 | 3. Creating the service file 12 | 13 | ## Creating the unprivileged user 14 | 15 | For this I will use the username neolinker. Any unused name would be fine. 16 | 17 | ```bash 18 | sudo adduser --system --no-create-home --shell /bin/false neolinker 19 | ``` 20 | 21 | Depending on your flavour of linux `adduser` may have also created 22 | a group of the same name. If it did not, you can create a group with: 23 | 24 | ```bash 25 | sudo addgroup --system neolinker 26 | ``` 27 | 28 | ## Setting up neolink 29 | 30 | For this we will put neolink in `/usr/local/bin` and the config in `/usr/local/etc` but any directory readable by the `neolinker` user would be fine. 31 | 32 | We will also secure the config file so that only neolinker (and root) can read it. We want to do this because it contains passwords. 33 | 34 | ```bash 35 | sudo cp neolink /usr/local/bin/neolink 36 | sudo cp my_config.toml /usr/local/etc/neolink_config.toml 37 | sudo chmod 755 /usr/local/bin/neolink 38 | sudo chown neolinker:neolinker /usr/local/etc/neolink_config.toml 39 | sudo chmod 600 /usr/local/etc/neolink_config.toml 40 | ``` 41 | 42 | ## Creating the service 43 | 44 | We will create a systemd service. This service will need to point to our files for using neolink and also instruct it to start with our unprivileged user. 45 | 46 | Create a file here `/etc/systemd/system/neolink.service` with the following contents (you will need admin privileges to write to this location): 47 | 48 | ``` 49 | [Install] 50 | WantedBy=multi-user.target 51 | 52 | [Unit] 53 | Description=Neolink service 54 | 55 | [Service] 56 | Type=simple 57 | ExecStart=/usr/local/bin/neolink rtsp --config /usr/local/etc/neolink_config.toml 58 | Restart=on-failure 59 | User=neolinker 60 | Group=neolinker 61 | 62 | ``` 63 | 64 | And that's it 65 | 66 | ## Controlling the Service 67 | 68 | You can now control the service with the usual commands 69 | 70 | To start it use: 71 | 72 | ```bash 73 | systemctl start neolink 74 | ``` 75 | 76 | To stop it use: 77 | 78 | ```bash 79 | systemctl stop neolink 80 | ``` 81 | 82 | To check it's running use: 83 | 84 | ```bash 85 | systemctl status neolink 86 | ``` 87 | 88 | To make it run at startup from now on: 89 | 90 | ```bash 91 | systemctl enable neolink 92 | ``` 93 | 94 | You can check it's log file with 95 | 96 | ```bash 97 | journalctl -xeu neolink 98 | ``` 99 | -------------------------------------------------------------------------------- /kubernetes/README.md: -------------------------------------------------------------------------------- 1 | ### Neolink in Kubernetes 2 | 3 | #### Introduction 4 | 5 | This README provides guidance on deploying the NeoLink application on Kubernetes using the provided manifest files. The deployment is designed to set up a NeoLink application within its own namespace, with a specific configuration for node affinity, resource limits, and service exposure. 6 | 7 | #### Prerequisites 8 | 9 | - Kubernetes Cluster 10 | - Command-line tool (kubectl) 11 | - Basic understanding of Kubernetes concepts (Pods, Services, Deployments, ConfigMaps) 12 | 13 | #### Deployment Overview 14 | 15 | The deployment consists of four main parts: 16 | 17 | 1. Namespace Creation: A new namespace neolink is created to isolate the resources. 18 | 19 | 2. Deployment Configuration: The neolink-app-deployment is set up with: 20 | - A single replica. 21 | - Affinity to arm64 nodes (Though this is optional). 22 | - Resource limits and requests for CPU and memory. 23 | - A custom command and args to use a specific configuration file. 24 | 25 | 3. Service Exposure: A LoadBalancer type service named neolink exposes the application on port 8554. 26 | 27 | 4. ConfigMap: Contains the application configuration neolink.toml. 28 | 29 | #### Steps to Deploy 30 | 31 | ```bash 32 | kubectl apply -f manifest.yaml 33 | ``` 34 | 35 | ### Configuration Details 36 | 37 | - The neolink.toml file in the ConfigMap provides the application configuration. Customize it as per your requirements. 38 | - The deployment is set to favor arm64 architecture nodes via a `nodeAffinity`. This is a personal preference as in my cluster all my raspberrpis (except one) run 32bit raspian OS. And to try and get more bang for my buck I was to run neolink on my arm64 8Gb node. Again, this is totaly optional for you. 39 | - Resource limits are set to ensure efficient use of cluster resources. 40 | - The LoadBalancer service will make the application accessible externally. 41 | 42 | #### Troubleshooting 43 | 44 | - If the pod fails to start, check the pod logs and describe the pod for more details. 45 | - Ensure that the arm64 node is available and schedulable in your cluster (if you're using nodeAffinity). 46 | - Verify that the ConfigMap is correctly applied and mounted. 47 | 48 | #### Conclusion 49 | 50 | This guide provides a step-by-step approach to deploying the NeoLink application on a Kubernetes cluster. Modify the manifests as necessary to fit your specific requirements and environment. 51 | -------------------------------------------------------------------------------- /kubernetes/manifest.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: v1 3 | kind: Namespace 4 | metadata: 5 | name: neolink 6 | --- 7 | apiVersion: apps/v1 8 | kind: Deployment 9 | metadata: 10 | name: neolink-app-deployment 11 | namespace: neolink 12 | labels: 13 | app: neolink 14 | spec: 15 | replicas: 1 16 | selector: 17 | matchLabels: 18 | app: neolink 19 | template: 20 | metadata: 21 | labels: 22 | app: neolink 23 | spec: 24 | containers: 25 | - name: neolink 26 | image: quantumentangledandy/neolink 27 | ports: 28 | - containerPort: 8554 29 | command: ["/bin/sh", "-c"] 30 | args: ["cp /config/neolink.toml /etc/neolink.toml && /usr/local/bin/neolink rtsp --config /etc/neolink.toml"] 31 | volumeMounts: 32 | - name: config-volume 33 | mountPath: /config 34 | resources: 35 | limits: 36 | memory: "600Mi" 37 | cpu: "300m" 38 | requests: 39 | memory: "200Mi" 40 | cpu: "150m" 41 | volumes: 42 | - name: config-volume 43 | configMap: 44 | name: default-config 45 | --- 46 | apiVersion: v1 47 | kind: Service 48 | metadata: 49 | labels: 50 | app: neolink 51 | name: neolink 52 | namespace: neolink 53 | spec: 54 | ports: 55 | - name: tcp-interface-neolink 56 | port: 8554 57 | protocol: TCP 58 | targetPort: 8554 59 | selector: 60 | app: neolink 61 | type: LoadBalancer 62 | --- 63 | apiVersion: v1 64 | kind: ConfigMap 65 | metadata: 66 | name: default-config 67 | namespace: neolink 68 | data: 69 | neolink.toml: | 70 | # A bind value of 0.0.0.0 means any network this computer can access 71 | # You can chage this to a specfic network e.g. "192.168.1.101" here 72 | # Or to no networks e.g. this computer only "127.0.0.1" 73 | bind = "0.0.0.0" 74 | 75 | # Default port is 8554 but you can change it by uncommenting the following 76 | # bind_port = 8554 77 | 78 | # Uncomment the following and supply a path to a valid PEM 79 | # to activate TLS encryption. 80 | # The PEM should contain the certificate and the private key 81 | # If TLS is activated you must connect with "rtsps://" and not "rtsp://" 82 | # certificate = "/path/to/pem/with/cert/and/key" 83 | 84 | # Choose if the client is required to provide a certificate signed by the server's CA. 85 | # none|requested|required - default none 86 | # tls_client_auth = "required" 87 | 88 | # You can password protect the rtsp server mount points by adding users 89 | # like the following me and someone. If you do not add [[users]] 90 | # then anyone can connect without a password or username 91 | # To access such a stream try using a url such as "rtsp://me:mepass@192.168.1.101/driveway" 92 | 93 | # [[users]] 94 | # name = "me" 95 | # pass = "mepass" 96 | # 97 | # [[users]] 98 | # name = "someone" 99 | # pass = "someonepass" 100 | 101 | # Uncomment to enable MQTT 102 | #[mqtt] 103 | # mqtt.broker_addr = "192.168.1.122" 104 | # mqtt.port = 1883 105 | # mqtt.credentials = ["mqtt_user", "mqtt_password"] 106 | 107 | 108 | [[cameras]] 109 | name = "driveway" 110 | username = "admin" 111 | password = "12345678" 112 | address = "192.168.1.187:9000" 113 | # MQTT Discovery: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery 114 | # mqtt.discovery.topic = "homeassistant" # Uncomment to enable 115 | # If using discovery, _ characters are replaced with spaces in the name and title case is applied 116 | # mqtt.discovery.features = ["floodlight"] # Uncomment if this camera has a spotlight/floodlight 117 | 118 | # If you use a battery camera: **Instead** of an `address` supply the uid 119 | # as follows 120 | # uid = "ABCD01234567890EFG" 121 | 122 | # By default any of the users can connect (or anyone at all if no users are specfied) 123 | # You can uncomment the following to permit only specfic users 124 | # permitted_users = [ "me" ] 125 | 126 | # By default "both" "mainStream" and "subStream" are connected 127 | # If your device has user connection limits try a single stream instead. 128 | # stream = "mainStream" 129 | 130 | # By default neolink will use any means to connect to the camera 131 | # from a UID 132 | # This include relaying via reolink servers 133 | # This variable `discovery` controls the method of UID discovery 134 | # - Possible values 135 | # "relay" # Any means including connecting and transmitting through reolink 136 | # "map" # Register our local ip address with reolink and ask the camera to connect to us but don't relay data through reolink 137 | # "remote" # Register our local ip address with reolink but only permit same network connections (useful if broadcast is not possible) 138 | # "local" # Do not contact reolink servers at all. Rely soley on local UDP broadcast based discovery 139 | # 140 | # "cellular" # Cellular camera only support Relay and Map to speed up connecting to them this option will skip the local/remote 141 | # 142 | # discovery = "relay" 143 | 144 | # Certain types of camera emit status messages (such as battery levels) 145 | # 146 | # By default we hide these status messages from the user but you can instead requst that 147 | # they be printed to stdout using print_format 148 | # 149 | # Valid values are: 150 | # - None 151 | # - Human 152 | # - Xml 153 | # 154 | # print_format = "None" 155 | 156 | 157 | [[cameras]] 158 | name = "storage shed" 159 | username = "admin" 160 | password = "987654321" 161 | address = "192.168.1.245:9000" 162 | # If you use a battery camera: **Instead** of an `address` supply the uid 163 | # as follows 164 | # uid = "ABCD01234567890EFG" 165 | 166 | # If you use an NVR that relays several camera connections you can choose which 167 | # camera to connect to with by setting the `channel_id` 168 | # 169 | # By default channel_id = 0. Eg the first connected camera on the device 170 | # **Note**: that unlike in the offical client the numbering starts from 0 not 1. 171 | # An 8 channel NVR would have channels 0 through 7 172 | # channel_id = 0 173 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | -------------------------------------------------------------------------------- /sample_config.toml: -------------------------------------------------------------------------------- 1 | # A bind value of 0.0.0.0 means any network this computer can access 2 | # You can chage this to a specfic network e.g. "192.168.1.101" here 3 | # Or to no networks e.g. this computer only "127.0.0.1" 4 | bind = "0.0.0.0" 5 | 6 | # Default port is 8554 but you can change it by uncommenting the following 7 | # bind_port = 8554 8 | 9 | # Uncomment the following and supply a path to a valid PEM 10 | # to activate TLS encryption. 11 | # The PEM should contain the certificate and the private key 12 | # If TLS is activated you must connect with "rtsps://" and not "rtsp://" 13 | # certificate = "/path/to/pem/with/cert/and/key" 14 | 15 | # Choose if the client is required to provide a certificate signed by the server's CA. 16 | # none|requested|required - default none 17 | # tls_client_auth = "required" 18 | 19 | # You can password protect the rtsp server mount points by adding users 20 | # like the following me and someone. If you do not add [[users]] 21 | # then anyone can connect without a password or username 22 | # To access such a stream try using a url such as "rtsp://me:mepass@192.168.1.101/driveway" 23 | 24 | # [[users]] 25 | # name = "me" 26 | # pass = "mepass" 27 | # 28 | # [[users]] 29 | # name = "someone" 30 | # pass = "someonepass" 31 | 32 | # Uncomment to enable MQTT 33 | #[mqtt] 34 | # mqtt.broker_addr = "192.168.1.122" 35 | # mqtt.port = 1883 36 | # mqtt.credentials = ["mqtt_user", "mqtt_password"] 37 | 38 | 39 | [[cameras]] 40 | name = "driveway" 41 | username = "admin" 42 | password = "12345678" 43 | address = "192.168.1.187:9000" 44 | # MQTT Discovery: https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery 45 | # mqtt.discovery.topic = "homeassistant" # Uncomment to enable 46 | # If using discovery, _ characters are replaced with spaces in the name and title case is applied 47 | # mqtt.discovery.features = ["floodlight"] # Uncomment if this camera has a spotlight/floodlight 48 | 49 | # If you use a battery camera: **Instead** of an `address` supply the uid 50 | # as follows 51 | # uid = "ABCD01234567890EFG" 52 | 53 | # By default any of the users can connect (or anyone at all if no users are specfied) 54 | # You can uncomment the following to permit only specfic users 55 | # permitted_users = [ "me" ] 56 | 57 | # By default "both" "mainStream" and "subStream" are connected 58 | # If your device has user connection limits try a single stream instead. 59 | # stream = "mainStream" 60 | 61 | # By default neolink will use any means to connect to the camera 62 | # from a UID 63 | # This include relaying via reolink servers 64 | # This variable `discovery` controls the method of UID discovery 65 | # - Possible values 66 | # "relay" # Any means including connecting and transmitting through reolink 67 | # "map" # Register our local ip address with reolink and ask the camera to connect to us but don't relay data through reolink 68 | # "remote" # Register our local ip address with reolink but only permit same network connections (useful if broadcast is not possible) 69 | # "local" # Do not contact reolink servers at all. Rely soley on local UDP broadcast based discovery 70 | # 71 | # "cellular" # Cellular camera only support Relay and Map to speed up connecting to them this option will skip the local/remote 72 | # 73 | # discovery = "relay" 74 | 75 | # Certain types of camera emit status messages (such as battery levels) 76 | # 77 | # By default we hide these status messages from the user but you can instead requst that 78 | # they be printed to stdout using print_format 79 | # 80 | # Valid values are: 81 | # - None 82 | # - Human 83 | # - Xml 84 | # 85 | # print_format = "None" 86 | 87 | 88 | [[cameras]] 89 | name = "storage shed" 90 | username = "admin" 91 | password = "987654321" 92 | address = "192.168.1.245:9000" 93 | # If you use a battery camera: **Instead** of an `address` supply the uid 94 | # as follows 95 | # uid = "ABCD01234567890EFG" 96 | 97 | # If you use an NVR that relays several camera connections you can choose which 98 | # camera to connect to with by setting the `channel_id` 99 | # 100 | # By default channel_id = 0. Eg the first connected camera on the device 101 | # **Note**: that unlike in the official client the numbering starts from 0 not 1. 102 | # An 8 channel NVR would have channels 0 through 7 103 | # channel_id = 0 104 | -------------------------------------------------------------------------------- /src/battery/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | /// The battery command will dump the battery status to XML 4 | #[derive(Parser, Debug)] 5 | pub struct Opt { 6 | /// The name of the camera. Must be a name in the config 7 | pub camera: String, 8 | } 9 | -------------------------------------------------------------------------------- /src/battery/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink Battery 3 | /// 4 | /// This module handles the printing of the Battery status 5 | /// in xml format 6 | /// 7 | /// # Usage 8 | /// 9 | /// ```bash 10 | /// neolink battery --config=config.toml CameraName 11 | /// ``` 12 | /// 13 | use anyhow::{Context, Result}; 14 | 15 | mod cmdline; 16 | 17 | use crate::common::NeoReactor; 18 | 19 | pub(crate) use cmdline::Opt; 20 | 21 | /// Entry point for the battery subcommand 22 | /// 23 | /// Opt is the command line options 24 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 25 | let camera = reactor.get(&opt.camera).await?; 26 | 27 | let state = camera 28 | .run_task(|cam| { 29 | Box::pin(async move { 30 | cam.battery_info() 31 | .await 32 | .context("Unable to get camera Battery state") 33 | }) 34 | }) 35 | .await?; 36 | 37 | let ser = String::from_utf8({ 38 | let mut buf = bytes::BytesMut::new(); 39 | quick_xml::se::to_writer(&mut buf, &state).expect("Should Ser the struct"); 40 | buf.to_vec() 41 | }) 42 | .expect("Should be UTF8"); 43 | println!("{}", ser); 44 | 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /src/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::{crate_authors, crate_version, Parser}; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | 5 | /// A standards-compliant bridge to Reolink IP cameras 6 | /// 7 | /// Neolink is free software released under the GNU AGPL v3. 8 | /// You can find its source code at https://github.com/thirtythreeforty/neolink 9 | #[derive(Parser, Debug)] 10 | #[command(name = "neolink", arg_required_else_help = true, version = crate_version!(), author = crate_authors!("\n"))] 11 | pub struct Opt { 12 | #[arg(short, long, global = true, value_parser = PathBuf::from_str)] 13 | pub config: Option, 14 | #[structopt(subcommand)] 15 | pub cmd: Option, 16 | } 17 | 18 | #[derive(Parser, Debug)] 19 | pub enum Command { 20 | #[cfg(feature = "gstreamer")] 21 | Rtsp(super::rtsp::Opt), 22 | StatusLight(super::statusled::Opt), 23 | Reboot(super::reboot::Opt), 24 | Pir(super::pir::Opt), 25 | Ptz(super::ptz::Opt), 26 | #[cfg(feature = "gstreamer")] 27 | Talk(super::talk::Opt), 28 | Mqtt(super::mqtt::Opt), 29 | #[cfg(feature = "gstreamer")] 30 | MqttRtsp(super::mqtt::Opt), 31 | #[cfg(feature = "gstreamer")] 32 | Image(super::image::Opt), 33 | Battery(super::battery::Opt), 34 | Services(super::services::Opt), 35 | Users(super::users::Opt), 36 | } 37 | -------------------------------------------------------------------------------- /src/common/instance/pushnoti.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | use crate::common::PushNoti; 4 | use tokio::sync::watch::channel as watch; 5 | 6 | impl NeoInstance { 7 | pub(crate) async fn uid(&self) -> Result { 8 | let (reply_tx, reply_rx) = oneshot(); 9 | self.camera_control 10 | .send(NeoCamCommand::GetUid(reply_tx)) 11 | .await?; 12 | Ok(reply_rx.await?) 13 | } 14 | 15 | pub(crate) async fn push_notifications(&self) -> Result>> { 16 | let uid = self.uid().await?; 17 | let (instance_tx, instance_rx) = oneshot(); 18 | self.camera_control 19 | .send(NeoCamCommand::PushNoti(instance_tx)) 20 | .await?; 21 | let mut source_watch = instance_rx.await?; 22 | 23 | let (fwatch_tx, fwatch_rx) = watch(None); 24 | tokio::task::spawn(async move { 25 | loop { 26 | match source_watch 27 | .wait_for(|i| { 28 | fwatch_tx.borrow().as_ref() != i.as_ref() 29 | && i.as_ref() 30 | .is_some_and(|i| i.message.contains(&format!("\"{uid}\""))) 31 | }) 32 | .await 33 | { 34 | Ok(pn) => { 35 | log::trace!("Forwarding push notification about {}", uid); 36 | let _ = fwatch_tx.send_replace(pn.clone()); 37 | } 38 | Err(e) => { 39 | break Err(e); 40 | } 41 | } 42 | }?; 43 | AnyResult::Ok(()) 44 | }); 45 | 46 | Ok(fwatch_rx) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/common/mdthread.rs: -------------------------------------------------------------------------------- 1 | //! This thread will listen to motion messages 2 | //! from the camera. 3 | 4 | use anyhow::Context; 5 | use std::sync::Arc; 6 | use tokio::{ 7 | sync::{ 8 | mpsc::Receiver as MpscReceiver, 9 | oneshot::Sender as OneshotSender, 10 | watch::{channel as watch, Receiver as WatchReceiver, Sender as WatchSender}, 11 | }, 12 | time::{sleep, Duration, Instant}, 13 | }; 14 | use tokio_util::sync::CancellationToken; 15 | 16 | use super::NeoInstance; 17 | use crate::{AnyResult, Result}; 18 | use neolink_core::bc_protocol::MotionStatus; 19 | 20 | #[derive(Clone, Debug)] 21 | #[allow(dead_code)] 22 | pub(crate) enum MdState { 23 | Start(Instant), 24 | Stop(Instant), 25 | Unknown, 26 | } 27 | 28 | pub(crate) struct NeoCamMdThread { 29 | md_watcher: Arc>, 30 | md_request_rx: MpscReceiver, 31 | cancel: CancellationToken, 32 | instance: NeoInstance, 33 | } 34 | 35 | impl NeoCamMdThread { 36 | pub(crate) async fn new( 37 | md_request_rx: MpscReceiver, 38 | instance: NeoInstance, 39 | ) -> Result { 40 | let (md_watcher, _) = watch(MdState::Unknown); 41 | let md_watcher = Arc::new(md_watcher); 42 | Ok(Self { 43 | md_watcher, 44 | md_request_rx, 45 | cancel: CancellationToken::new(), 46 | instance, 47 | }) 48 | } 49 | 50 | pub(crate) async fn run(&mut self) -> Result<()> { 51 | let thread_cancel = self.cancel.clone(); 52 | let watcher = self.md_watcher.clone(); 53 | let md_instance = self.instance.clone(); 54 | tokio::select! { 55 | _ = thread_cancel.cancelled() => { 56 | Ok(()) 57 | }, 58 | v = async { 59 | while let Some(request) = self.md_request_rx.recv().await { 60 | match request { 61 | MdRequest::Get { 62 | sender 63 | } => { 64 | let _ = sender.send(self.md_watcher.subscribe()); 65 | }, 66 | } 67 | } 68 | Ok(()) 69 | } => v, 70 | v = async { 71 | loop { 72 | let r: AnyResult<()> = md_instance.run_passive_task(|cam| { 73 | let watcher = watcher.clone(); 74 | Box::pin( 75 | async move { 76 | let mut md = cam.listen_on_motion().await.with_context(|| "Error in getting MD listen_on_motion")?; 77 | loop { 78 | let event = md.next_motion().await.with_context(|| "Error in getting MD next_motion")?; 79 | match event { 80 | MotionStatus::Start(at) => { 81 | watcher.send_replace( 82 | MdState::Start(at.into()) 83 | ); 84 | } 85 | MotionStatus::Stop(at) => { 86 | watcher.send_replace( 87 | MdState::Stop(at.into()) 88 | ); 89 | } 90 | MotionStatus::NoChange(_) => {}, 91 | } 92 | } 93 | } 94 | )}).await; 95 | log::debug!("Error in MD task Restarting: {:?}", r); 96 | sleep(Duration::from_secs(1)).await; 97 | } 98 | } => v 99 | } 100 | } 101 | } 102 | 103 | impl Drop for NeoCamMdThread { 104 | fn drop(&mut self) { 105 | log::trace!("Drop NeoCamMdThread"); 106 | self.cancel.cancel(); 107 | log::trace!("Dropped NeoCamMdThread"); 108 | } 109 | } 110 | 111 | /// Used to pass messages to the MdThread 112 | pub(crate) enum MdRequest { 113 | Get { 114 | sender: OneshotSender>, 115 | }, 116 | } 117 | -------------------------------------------------------------------------------- /src/common/mod.rs: -------------------------------------------------------------------------------- 1 | mod camthread; 2 | mod instance; 3 | mod mdthread; 4 | mod neocam; 5 | #[cfg(feature = "pushnoti")] 6 | mod pushnoti; 7 | mod reactor; 8 | mod usecounter; 9 | 10 | pub(crate) use camthread::*; 11 | pub(crate) use instance::*; 12 | pub(crate) use mdthread::*; 13 | pub(crate) use neocam::*; 14 | #[cfg(feature = "pushnoti")] 15 | pub(crate) use pushnoti::*; 16 | pub(crate) use reactor::*; 17 | pub(crate) use usecounter::*; 18 | -------------------------------------------------------------------------------- /src/common/usecounter.rs: -------------------------------------------------------------------------------- 1 | //! Used to track number of users of a service 2 | use tokio::{ 3 | sync::{ 4 | mpsc::{channel as mpsc, Sender as MpscSender}, 5 | watch::{channel as watch, Receiver as WatchReceiver}, 6 | }, 7 | task::JoinSet, 8 | }; 9 | use tokio_util::sync::CancellationToken; 10 | 11 | use crate::{AnyResult, Result}; 12 | 13 | /// Counts the active users of the stream 14 | pub(crate) struct UseCounter { 15 | value: WatchReceiver, 16 | notifier_tx: MpscSender, 17 | cancel: CancellationToken, 18 | set: JoinSet>, 19 | } 20 | 21 | impl UseCounter { 22 | pub(crate) async fn new() -> Self { 23 | let (notifier_tx, mut notifier) = mpsc(100); 24 | let (value_tx, value) = watch(0); 25 | let cancel = CancellationToken::new(); 26 | let mut set = JoinSet::new(); 27 | 28 | let thread_cancel = cancel.clone(); 29 | set.spawn(async move { 30 | let r = tokio::select! { 31 | _ = thread_cancel.cancelled() => { 32 | AnyResult::Ok(()) 33 | }, 34 | v = async { 35 | while let Some(noti) = notifier.recv().await { 36 | value_tx.send_modify(|value| { 37 | if noti { 38 | log::trace!("Usecounter: {}->{}", *value, (*value) + 1); 39 | *value += 1; 40 | } else { 41 | log::trace!("Usecounter: {}->{}", *value, (*value) - 1); 42 | *value -= 1; 43 | } 44 | }); 45 | } 46 | AnyResult::Ok(()) 47 | } => v, 48 | }; 49 | log::trace!("End Use Counter: {r:?}"); 50 | r 51 | }); 52 | Self { 53 | value, 54 | notifier_tx, 55 | cancel, 56 | set, 57 | } 58 | } 59 | 60 | pub(crate) async fn create_activated(&self) -> Result { 61 | let mut res = Permit::new(self); 62 | res.activate().await?; 63 | Ok(res) 64 | } 65 | 66 | #[cfg(feature = "gstreamer")] 67 | #[allow(dead_code)] 68 | pub(crate) async fn create_deactivated(&self) -> Result { 69 | Ok(Permit::new(self)) 70 | } 71 | } 72 | 73 | impl Drop for UseCounter { 74 | fn drop(&mut self) { 75 | log::trace!("Drop UseCounter"); 76 | self.cancel.cancel(); 77 | 78 | let mut set = std::mem::take(&mut self.set); 79 | let _gt = tokio::runtime::Handle::current().enter(); 80 | tokio::task::spawn(async move { while set.join_next().await.is_some() {} }); 81 | log::trace!("Dropped UseCounter"); 82 | } 83 | } 84 | 85 | pub(crate) struct Permit { 86 | is_active: bool, 87 | value: WatchReceiver, 88 | notifier: MpscSender, 89 | } 90 | 91 | impl Permit { 92 | #[cfg(feature = "gstreamer")] 93 | #[allow(dead_code)] 94 | pub(crate) fn subscribe(&self) -> Self { 95 | Self { 96 | is_active: false, 97 | value: self.value.clone(), 98 | notifier: self.notifier.clone(), 99 | } 100 | } 101 | 102 | fn new(source: &UseCounter) -> Self { 103 | Self { 104 | is_active: false, 105 | value: source.value.clone(), 106 | notifier: source.notifier_tx.clone(), 107 | } 108 | } 109 | 110 | pub(crate) async fn activate(&mut self) -> Result<()> { 111 | if !self.is_active { 112 | self.is_active = true; 113 | self.notifier.send(self.is_active).await?; 114 | } 115 | Ok(()) 116 | } 117 | 118 | pub(crate) async fn deactivate(&mut self) -> Result<()> { 119 | if self.is_active { 120 | self.is_active = false; 121 | self.notifier.send(self.is_active).await?; 122 | } 123 | Ok(()) 124 | } 125 | 126 | pub(crate) async fn aquired_users(&self) -> Result<()> { 127 | self.value 128 | .clone() 129 | .wait_for(|curr| { 130 | log::trace!("aquired_users: {}", *curr); 131 | *curr > 0 132 | }) 133 | .await?; 134 | Ok(()) 135 | } 136 | 137 | pub(crate) async fn dropped_users(&self) -> Result<()> { 138 | self.value 139 | .clone() 140 | .wait_for(|curr| { 141 | log::trace!("dropped_users: {}", *curr); 142 | *curr == 0 143 | }) 144 | .await?; 145 | Ok(()) 146 | } 147 | 148 | #[cfg(feature = "gstreamer")] 149 | #[allow(dead_code)] 150 | pub(crate) fn get_counter(&self) -> WatchReceiver { 151 | self.value.clone() 152 | } 153 | } 154 | 155 | impl Drop for Permit { 156 | fn drop(&mut self) { 157 | if self.is_active { 158 | self.is_active = false; 159 | let _gt = tokio::runtime::Handle::current().enter(); 160 | let notifier = self.notifier.clone(); 161 | let is_active = self.is_active; 162 | tokio::task::spawn(async move { 163 | let _ = notifier.send(is_active).await; 164 | }); 165 | } 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Debug, Error)] 4 | #[allow(clippy::large_enum_variant)] 5 | pub enum Error { 6 | #[error("RTSP Error")] 7 | Rtsp(#[from] super::rtsp::Error), 8 | #[error("Status LED Error")] 9 | StatusLight(#[from] super::statusled::Error), 10 | #[error("Reboot Error")] 11 | Reboot(#[from] super::reboot::Error), 12 | #[error("Talk Error")] 13 | Talk(#[from] super::talk::Error), 14 | #[error("Talk Error")] 15 | Mqtt(#[from] super::mqtt::Error), 16 | } 17 | -------------------------------------------------------------------------------- /src/image/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | 5 | /// The image command will dump a still image from the camera 6 | #[derive(Parser, Debug)] 7 | pub struct Opt { 8 | /// The name of the camera to get the image from. Must be a name in the config 9 | pub camera: String, 10 | /// The path of the output. 11 | #[structopt(short, long, value_parser = PathBuf::from_str)] 12 | pub file_path: PathBuf, 13 | /// If set then the image will pull from the live stream, if not it will be pulled from the cameras snap feature 14 | /// 15 | /// Using the snap feature, is preffered unless your camera does not support it 16 | #[structopt(short, long)] 17 | pub use_stream: bool, 18 | } 19 | -------------------------------------------------------------------------------- /src/image/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink Image 3 | /// 4 | /// This module can be used to dump a still image from the camera 5 | /// 6 | /// 7 | /// # Usage 8 | /// ```bash 9 | /// neolink image --config=config.toml --file-path=filepath CameraName 10 | /// ``` 11 | /// 12 | /// Cameras that do not support the SNAP command need to use `--use_stream` 13 | /// which will make the camera play the stream and transcode the video into a jpeg 14 | /// e.g.: 15 | /// 16 | /// ```bash 17 | /// neolink image --config=config.toml --use_stream --file-path=filepath CameraName 18 | /// ``` 19 | /// 20 | use anyhow::{anyhow, Result}; 21 | use log::*; 22 | use neolink_core::{ 23 | bc_protocol::*, 24 | bcmedia::model::{BcMedia, BcMediaIframe, BcMediaPframe}, 25 | }; 26 | use std::sync::Arc; 27 | use tokio::{fs::File, io::AsyncWriteExt, sync::RwLock}; 28 | 29 | mod cmdline; 30 | mod gst; 31 | 32 | use crate::common::NeoReactor; 33 | pub(crate) use cmdline::Opt; 34 | 35 | /// Entry point for the image subcommand 36 | /// 37 | /// Opt is the command line options 38 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 39 | let camera = reactor.get(&opt.camera).await?; 40 | 41 | if opt.use_stream { 42 | let (stream_data_tx, mut stream_data_rx) = tokio::sync::mpsc::channel(100); 43 | 44 | // Spawn a video stream 45 | let thread_camera = camera.clone(); 46 | let (stream_type_tx, stream_type_rx) = tokio::sync::oneshot::channel(); 47 | let stream_type_tx = Arc::new(RwLock::new(Some(stream_type_tx))); 48 | tokio::task::spawn(async move { 49 | thread_camera 50 | .run_task(|cam| { 51 | let stream_type_tx = stream_type_tx.clone(); 52 | let stream_data_tx = stream_data_tx.clone(); 53 | 54 | Box::pin(async move { 55 | let mut stream = cam.start_video(StreamKind::Main, 100, false).await?; 56 | while let Ok(frame) = stream.get_data().await { 57 | let frame = frame?; 58 | match frame { 59 | BcMedia::Iframe(BcMediaIframe { 60 | data, video_type, .. 61 | }) 62 | | BcMedia::Pframe(BcMediaPframe { 63 | data, video_type, .. 64 | }) => { 65 | if let Some(stream_type_tx) = 66 | stream_type_tx.write().await.take() 67 | { 68 | let _ = stream_type_tx.send(video_type); 69 | } 70 | stream_data_tx.send(Arc::new(data)).await?; 71 | } 72 | _ => {} 73 | } 74 | } 75 | Result::Ok(()) 76 | }) 77 | }) 78 | .await 79 | }); 80 | 81 | let vid_type = stream_type_rx.await?; 82 | let buf = stream_data_rx 83 | .recv() 84 | .await 85 | .ok_or(anyhow!("No frames recieved"))?; 86 | 87 | let mut sender = gst::from_input(vid_type, &opt.file_path).await?; 88 | sender.send(buf).await?; // Send first iframe 89 | 90 | // Keep sending both IFrame or PFrame until finished 91 | while sender.is_finished().await.is_none() { 92 | if let Some(buf) = stream_data_rx.recv().await { 93 | debug!("Sending frame data to gstreamer"); 94 | if sender.send(buf).await.is_err() { 95 | // Assume that the sender is closed 96 | // because the pipeline is finished 97 | break; 98 | } 99 | } else { 100 | log::error!("Camera stopped sending frames before decoding could complete"); 101 | break; 102 | } 103 | } 104 | debug!("Sending EOS"); 105 | let _ = sender.eos().await; // Ignore return because if pipeline is finished this will error 106 | let _ = sender.join().await; 107 | } else { 108 | // Simply use the snap command 109 | debug!("Using the snap command"); 110 | let file_path = opt.file_path.with_extension("jpeg"); 111 | let mut buffer = File::create(file_path).await?; 112 | let jpeg_data = camera 113 | .run_task(|camera| Box::pin(async move { Ok(camera.get_snapshot().await?) })) 114 | .await; 115 | if jpeg_data.is_err() { 116 | log::debug!("jpeg_data: {:?}", jpeg_data); 117 | } 118 | let jpeg_data = jpeg_data?; 119 | buffer.write_all(jpeg_data.as_slice()).await?; 120 | } 121 | 122 | Ok(()) 123 | } 124 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(unused_crate_dependencies)] 2 | #![warn(missing_docs)] 3 | #![warn(clippy::todo)] 4 | //! 5 | //! # Neolink 6 | //! 7 | //! Neolink is a small program that acts a general contol interface for Reolink IP cameras. 8 | //! 9 | //! It contains sub commands for running an rtsp proxy which can be used on Reolink cameras 10 | //! that do not nativly support RTSP. 11 | //! 12 | //! This program is free software: you can redistribute it and/or modify it under the terms of the 13 | //! GNU General Public License as published by the Free Software Foundation, either version 3 of 14 | //! the License, or (at your option) any later version. 15 | //! 16 | //! This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; 17 | //! without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See 18 | //! the GNU General Public License for more details. 19 | //! 20 | //! You should have received a copy of the GNU General Public License along with this program. If 21 | //! not, see . 22 | //! 23 | //! Neolink source code is available online at 24 | //! 25 | #[cfg(not(target_env = "msvc"))] 26 | use tikv_jemallocator::Jemalloc; 27 | 28 | #[cfg(not(target_env = "msvc"))] 29 | #[global_allocator] 30 | static GLOBAL: Jemalloc = Jemalloc; 31 | 32 | use anyhow::{Context, Result}; 33 | use clap::Parser; 34 | use env_logger::Env; 35 | use log::*; 36 | use std::fs; 37 | use validator::Validate; 38 | 39 | mod battery; 40 | mod cmdline; 41 | mod common; 42 | mod config; 43 | #[cfg(feature = "gstreamer")] 44 | mod image; 45 | mod mqtt; 46 | mod pir; 47 | mod ptz; 48 | mod reboot; 49 | #[cfg(feature = "gstreamer")] 50 | mod rtsp; 51 | mod services; 52 | mod statusled; 53 | #[cfg(feature = "gstreamer")] 54 | mod talk; 55 | mod users; 56 | mod utils; 57 | 58 | use cmdline::{Command, Opt}; 59 | use common::NeoReactor; 60 | use config::Config; 61 | 62 | pub(crate) type AnyResult = Result; 63 | 64 | #[tokio::main] 65 | async fn main() -> Result<()> { 66 | env_logger::Builder::from_env(Env::default().default_filter_or("info")).init(); 67 | 68 | info!( 69 | "Neolink {} {}", 70 | env!("NEOLINK_VERSION"), 71 | env!("NEOLINK_PROFILE") 72 | ); 73 | 74 | let opt = Opt::parse(); 75 | 76 | let conf_path = opt.config.context("Must supply --config file")?; 77 | let config: Config = toml::from_str( 78 | &fs::read_to_string(&conf_path) 79 | .with_context(|| format!("Failed to read {:?}", conf_path))?, 80 | ) 81 | .with_context(|| format!("Failed to parse the {:?} config file", conf_path))?; 82 | 83 | config 84 | .validate() 85 | .with_context(|| format!("Failed to validate the {:?} config file", conf_path))?; 86 | 87 | let neo_reactor = NeoReactor::new(config.clone()).await; 88 | 89 | match opt.cmd { 90 | #[cfg(feature = "gstreamer")] 91 | None => { 92 | warn!( 93 | "Deprecated command line option. Please use: `neolink rtsp --config={:?}`", 94 | conf_path 95 | ); 96 | rtsp::main(rtsp::Opt {}, neo_reactor.clone()).await?; 97 | } 98 | #[cfg(not(feature = "gstreamer"))] 99 | None => { 100 | // When gstreamer is disabled the default command is MQTT 101 | warn!( 102 | "Deprecated command line option. Please use: `neolink mqtt --config={:?}`", 103 | conf_path 104 | ); 105 | mqtt::main(mqtt::Opt {}, neo_reactor.clone()).await?; 106 | } 107 | #[cfg(feature = "gstreamer")] 108 | Some(Command::Rtsp(opts)) => { 109 | rtsp::main(opts, neo_reactor.clone()).await?; 110 | } 111 | Some(Command::StatusLight(opts)) => { 112 | statusled::main(opts, neo_reactor.clone()).await?; 113 | } 114 | Some(Command::Reboot(opts)) => { 115 | reboot::main(opts, neo_reactor.clone()).await?; 116 | } 117 | Some(Command::Pir(opts)) => { 118 | pir::main(opts, neo_reactor.clone()).await?; 119 | } 120 | Some(Command::Ptz(opts)) => { 121 | ptz::main(opts, neo_reactor.clone()).await?; 122 | } 123 | #[cfg(feature = "gstreamer")] 124 | Some(Command::Talk(opts)) => { 125 | talk::main(opts, neo_reactor.clone()).await?; 126 | } 127 | Some(Command::Mqtt(opts)) => { 128 | mqtt::main(opts, neo_reactor.clone()).await?; 129 | } 130 | #[cfg(feature = "gstreamer")] 131 | Some(Command::MqttRtsp(opts)) => { 132 | tokio::select! { 133 | v = mqtt::main(opts, neo_reactor.clone()) => v, 134 | v = rtsp::main(rtsp::Opt {}, neo_reactor.clone()) => v, 135 | }?; 136 | } 137 | #[cfg(feature = "gstreamer")] 138 | Some(Command::Image(opts)) => { 139 | image::main(opts, neo_reactor.clone()).await?; 140 | } 141 | Some(Command::Battery(opts)) => { 142 | battery::main(opts, neo_reactor.clone()).await?; 143 | } 144 | Some(Command::Services(opts)) => { 145 | services::main(opts, neo_reactor.clone()).await?; 146 | } 147 | Some(Command::Users(opts)) => { 148 | users::main(opts, neo_reactor.clone()).await?; 149 | } 150 | } 151 | 152 | Ok(()) 153 | } 154 | -------------------------------------------------------------------------------- /src/mqtt/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | #[derive(Parser, Debug)] 4 | pub struct Opt {} 5 | -------------------------------------------------------------------------------- /src/pir/cmdline.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | 4 | fn onoff_parse(src: &str) -> Result { 5 | match src { 6 | "true" | "on" | "yes" => Ok(true), 7 | "false" | "off" | "no" => Ok(false), 8 | _ => Err(anyhow!( 9 | "Could not understand {}, check your input, should be true/false, on/off or yes/no", 10 | src 11 | )), 12 | } 13 | } 14 | 15 | /// The pir command will control the PIR status of the camera 16 | #[derive(Parser, Debug)] 17 | pub struct Opt { 18 | /// The name of the camera. Must be a name in the config 19 | pub camera: String, 20 | /// Whether to turn the PIR ON or OFF 21 | #[arg(value_parser = onoff_parse, action = clap::ArgAction::Set, name = "on|off")] 22 | pub on: Option, 23 | } 24 | -------------------------------------------------------------------------------- /src/pir/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink PIR 3 | /// 4 | /// This module handles the controls of the pir sensor alarm 5 | /// 6 | /// 7 | /// # Usage 8 | /// 9 | /// ```bash 10 | /// # To turn the pir sensor on 11 | /// neolink pir --config=config.toml CameraName on 12 | /// # Or off 13 | /// neolink pir --config=config.toml CameraName off 14 | /// ``` 15 | /// 16 | use anyhow::{Context, Result}; 17 | 18 | mod cmdline; 19 | 20 | use crate::common::NeoReactor; 21 | pub(crate) use cmdline::Opt; 22 | 23 | /// Entry point for the pir subcommand 24 | /// 25 | /// Opt is the command line options 26 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 27 | let camera = reactor.get(&opt.camera).await?; 28 | 29 | if let Some(on) = opt.on { 30 | camera 31 | .run_task(|cam| { 32 | Box::pin(async move { 33 | cam.pir_set(on) 34 | .await 35 | .context("Unable to set camera PIR state") 36 | }) 37 | }) 38 | .await?; 39 | } else { 40 | let pir_state = camera 41 | .run_task(|cam| { 42 | Box::pin(async move { 43 | cam.get_pirstate() 44 | .await 45 | .context("Unable to get camera PIR state") 46 | }) 47 | }) 48 | .await?; 49 | let pir_ser = String::from_utf8( 50 | { 51 | let mut buf = bytes::BytesMut::new(); 52 | quick_xml::se::to_writer(&mut buf, &pir_state).map(|_| buf.to_vec()) 53 | } 54 | .expect("Should Ser the struct"), 55 | ) 56 | .expect("Should be UTF8"); 57 | println!("{}", pir_ser); 58 | } 59 | 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /src/ptz/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | #[derive(clap::ValueEnum, Clone, Debug)] 4 | pub enum CmdDirection { 5 | Left, 6 | Right, 7 | Up, 8 | Down, 9 | Stop, 10 | } 11 | 12 | /// The ptz command will control the positioning of the camera 13 | #[derive(Parser, Debug)] 14 | pub struct Opt { 15 | /// The name of the camera to change the lights of. Must be a name in the config 16 | pub camera: String, 17 | 18 | #[command(subcommand)] 19 | pub cmd: PtzCommand, 20 | } 21 | 22 | #[derive(Parser, Debug)] 23 | pub enum PtzCommand { 24 | /// Move to a stored preset 25 | Preset { preset_id: Option }, 26 | /// Assign the current position to a preset with a given name 27 | Assign { preset_id: u8, name: String }, 28 | /// Performs a movement in the given direction 29 | Control { 30 | /// The amount to move 31 | amount: u32, 32 | /// The direction command 33 | #[clap(value_enum)] 34 | command: CmdDirection, 35 | /// The speed to move at 36 | speed: Option, 37 | }, 38 | Zoom { 39 | /// The amount to zoom to 40 | amount: f32, 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /src/ptz/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink PTZ Control 3 | /// 4 | /// This module handles the controls of the PTZ commands 5 | /// 6 | /// # Usage 7 | /// 8 | /// ```bash 9 | /// # Rotate left by 32 10 | /// neolink ptz --config=config.toml CameraName control 32 left 11 | /// # Rotate left by 32 at speed 10 (speed not supported on most camera) 12 | /// neolink ptz --config=config.toml CameraName control 32 left 10 13 | /// # Print the list of preset positions 14 | /// neolink ptz --config=config.toml CameraName preset 15 | /// # Move the camera to preset ID 0 16 | /// neolink ptz --config=config.toml CameraName preset 0 17 | /// # Save the current position as preset ID 0 with name PresetName 18 | /// neolink ptz --config=config.toml CameraName assign 0 PresetName 19 | /// ``` 20 | /// 21 | use anyhow::{Context, Result}; 22 | use tokio::time::{sleep, Duration}; 23 | 24 | mod cmdline; 25 | 26 | use crate::common::NeoReactor; 27 | use crate::ptz::cmdline::CmdDirection; 28 | use crate::ptz::cmdline::PtzCommand; 29 | pub(crate) use cmdline::Opt; 30 | use neolink_core::bc_protocol::Direction; 31 | 32 | /// Entry point for the ptz subcommand 33 | /// 34 | /// Opt is the command line options 35 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 36 | let camera = reactor.get(&opt.camera).await?; 37 | 38 | match opt.cmd { 39 | PtzCommand::Preset { preset_id } => { 40 | if let Some(preset_id) = preset_id { 41 | camera 42 | .run_task(|cam| { 43 | Box::pin(async move { 44 | cam.moveto_ptz_preset(preset_id) 45 | .await 46 | .context("Unable to move to PTZ preset")?; 47 | Ok(()) 48 | }) 49 | }) 50 | .await?; 51 | } else { 52 | let preset_list = camera 53 | .run_task(|cam| { 54 | Box::pin(async move { 55 | let preset_list = cam 56 | .get_ptz_preset() 57 | .await 58 | .context("Unable to get PTZ presets")?; 59 | Ok(preset_list) 60 | }) 61 | }) 62 | .await?; 63 | 64 | println!("Available presets:\nID Name"); 65 | for preset in preset_list.preset_list.preset { 66 | println!("{:<2} {:?}", preset.id, preset.name); 67 | } 68 | } 69 | } 70 | PtzCommand::Assign { preset_id, name } => { 71 | camera 72 | .run_task(|cam| { 73 | let name = name.clone(); 74 | Box::pin(async move { 75 | cam.set_ptz_preset(preset_id, name) 76 | .await 77 | .context("Unable to set PTZ preset")?; 78 | Ok(()) 79 | }) 80 | }) 81 | .await?; 82 | } 83 | PtzCommand::Control { 84 | amount, 85 | command, 86 | speed, 87 | } => { 88 | let direction = match command { 89 | CmdDirection::Left => Direction::Left, 90 | CmdDirection::Right => Direction::Right, 91 | CmdDirection::Up => Direction::Up, 92 | CmdDirection::Down => Direction::Down, 93 | CmdDirection::Stop => Direction::Stop, 94 | }; 95 | let speed = speed.unwrap_or(32) as f32; 96 | let seconds = amount as f32 / speed; 97 | let duration = Duration::from_secs_f32(seconds); 98 | camera 99 | .run_task(|cam| { 100 | Box::pin(async move { 101 | cam.send_ptz(direction, speed) 102 | .await 103 | .context("Unable to execute PTZ move command")?; 104 | Ok(()) 105 | }) 106 | }) 107 | .await?; 108 | 109 | sleep(duration).await; 110 | camera 111 | .run_task(|cam| { 112 | Box::pin(async move { 113 | cam.send_ptz(Direction::Stop, 0_f32) 114 | .await 115 | .context("Unable to execute PTZ move command")?; 116 | Ok(()) 117 | }) 118 | }) 119 | .await?; 120 | } 121 | PtzCommand::Zoom { amount } => { 122 | camera 123 | .run_task(|cam| { 124 | Box::pin(async move { 125 | cam.zoom_to((amount * 1000.0) as u32) 126 | .await 127 | .context("Unable to execute PTZ move command")?; 128 | Ok(()) 129 | }) 130 | }) 131 | .await?; 132 | sleep(Duration::from_secs(1)).await; 133 | } 134 | }; 135 | 136 | Ok(()) 137 | } 138 | -------------------------------------------------------------------------------- /src/reboot/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | /// The reboot command will reboot the camera 4 | #[derive(Parser, Debug)] 5 | pub struct Opt { 6 | /// The name of the camera to change the lights of. Must be a name in the config 7 | pub camera: String, 8 | } 9 | -------------------------------------------------------------------------------- /src/reboot/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink Reboot 3 | /// 4 | /// This module handles the reboot subcommand 5 | /// 6 | /// The subcommand attepts to reboot the camera. 7 | /// 8 | /// # Usage 9 | /// 10 | /// ```bash 11 | /// neolink reboot --config=config.toml CameraName 12 | /// ``` 13 | /// 14 | use anyhow::{Context, Result}; 15 | 16 | mod cmdline; 17 | 18 | use crate::common::NeoReactor; 19 | pub(crate) use cmdline::Opt; 20 | 21 | /// Entry point for the reboot subcommand 22 | /// 23 | /// Opt is the command line options 24 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 25 | let camera = reactor.get(&opt.camera).await?; 26 | 27 | camera 28 | .run_task(|camera| { 29 | Box::pin(async move { 30 | camera 31 | .reboot() 32 | .await 33 | .context("Could not send reboot command to the camera") 34 | }) 35 | }) 36 | .await?; 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /src/rtsp/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | 3 | /// The rtsp command will serve all cameras in the config over the rtsp protocol 4 | #[derive(Parser, Debug)] 5 | pub struct Opt {} 6 | -------------------------------------------------------------------------------- /src/rtsp/gst.rs: -------------------------------------------------------------------------------- 1 | //! This module provides an "RtspServer" abstraction that allows consumers of its API to feed it 2 | //! data using an ordinary std::io::Write interface. 3 | 4 | mod factory; 5 | mod server; 6 | mod shared; 7 | 8 | pub(crate) use factory::*; 9 | 10 | pub(crate) use self::server::NeoRtspServer; 11 | 12 | type AnyResult = std::result::Result; 13 | -------------------------------------------------------------------------------- /src/rtsp/gst/shared.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/rtsp/stream.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use gstreamer_rtsp_server::prelude::*; 3 | use std::collections::HashSet; 4 | 5 | use crate::{common::NeoInstance, AnyResult}; 6 | use neolink_core::bc_protocol::StreamKind; 7 | 8 | use super::{factory::*, gst::NeoRtspServer}; 9 | 10 | /// This handles the stream itself by creating the factory and pushing messages into it 11 | pub(crate) async fn stream_main( 12 | camera: NeoInstance, 13 | stream: StreamKind, 14 | rtsp: &NeoRtspServer, 15 | users: &HashSet, 16 | paths: &[String], 17 | ) -> AnyResult<()> { 18 | let name = camera.config().await?.borrow().name.clone(); 19 | // Create the factory and connect the stream 20 | let mounts = rtsp 21 | .mount_points() 22 | .ok_or(anyhow!("RTSP server lacks mount point"))?; 23 | // Create the factory 24 | let (factory, thread) = make_factory(camera, stream).await?; 25 | 26 | factory.add_permitted_roles(users); 27 | 28 | for path in paths.iter() { 29 | log::debug!("Path: {}", path); 30 | mounts.add_factory(path, factory.clone()); 31 | } 32 | log::info!("{}: Available at {}", name, paths.join(", ")); 33 | 34 | thread.await??; 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /src/services/cmdline.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::{Parser, ValueEnum}; 3 | 4 | fn onoff_parse(src: &str) -> Result { 5 | match src { 6 | "true" | "on" | "yes" => Ok(true), 7 | "false" | "off" | "no" => Ok(false), 8 | _ => Err(anyhow!( 9 | "Could not understand {}, check your input, should be true/false, on/off or yes/no", 10 | src 11 | )), 12 | } 13 | } 14 | 15 | /// The services command will control the ports for http/https/rtmp/rtsp/onvif 16 | #[derive(Parser, Debug)] 17 | pub struct Opt { 18 | /// The name of the camera. Must be a name in the config 19 | pub camera: String, 20 | /// service to change 21 | pub service: Services, 22 | /// The action to perform 23 | #[command(subcommand)] 24 | pub cmd: PortAction, 25 | } 26 | 27 | #[derive(Parser, Debug, Clone, ValueEnum)] 28 | pub enum Services { 29 | Baichuan, 30 | Http, 31 | Https, 32 | Rtmp, 33 | Rtsp, 34 | Onvif, 35 | } 36 | 37 | #[derive(Parser, Debug)] 38 | pub enum PortAction { 39 | /// Get the current details 40 | Get, 41 | /// Turn the service ON 42 | On, 43 | /// Turn the service OFF 44 | Off, 45 | /// Set both port and on/off 46 | Set { 47 | /// The new port 48 | port: u32, 49 | /// On/off 50 | #[arg(value_parser = onoff_parse, action = clap::ArgAction::Set, name = "on|off")] 51 | enabled: bool, 52 | }, 53 | /// Set the port 54 | Port { 55 | /// The new port 56 | port: u32, 57 | }, 58 | } 59 | -------------------------------------------------------------------------------- /src/statusled/cmdline.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Result}; 2 | use clap::Parser; 3 | 4 | fn onoff_parse(src: &str) -> Result { 5 | match src { 6 | "true" | "on" | "yes" => Ok(true), 7 | "false" | "off" | "no" => Ok(false), 8 | _ => Err(anyhow!( 9 | "Could not understand {}, check your input, should be true/false, on/off or yes/no", 10 | src 11 | )), 12 | } 13 | } 14 | 15 | /// The status-light command will control the blue status light on the camera 16 | #[derive(Parser, Debug)] 17 | pub struct Opt { 18 | /// The name of the camera to change the lights of. Must be a name in the config 19 | pub camera: String, 20 | /// Whether to turn the light on or off 21 | #[arg(value_parser = onoff_parse, action = clap::ArgAction::Set, name = "on|off")] 22 | pub on: bool, 23 | } 24 | -------------------------------------------------------------------------------- /src/statusled/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink Status LED 3 | /// 4 | /// This module handles the controls of the blue led status light 5 | /// 6 | /// The subcommand attepts to set the LED status light not the IR 7 | /// lights or the flood lights. 8 | /// 9 | /// # Usage 10 | /// 11 | /// ```bash 12 | /// # To turn the light on 13 | /// neolink status-light --config=config.toml CameraName on 14 | /// # Or off 15 | /// neolink status-light --config=config.toml CameraName off 16 | /// ``` 17 | /// 18 | use anyhow::{Context, Result}; 19 | 20 | mod cmdline; 21 | 22 | use crate::common::NeoReactor; 23 | pub(crate) use cmdline::Opt; 24 | 25 | /// Entry point for the ledstatus subcommand 26 | /// 27 | /// Opt is the command line options 28 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 29 | let camera = reactor.get(&opt.camera).await?; 30 | 31 | let on = opt.on; 32 | camera 33 | .run_task(|camera| { 34 | Box::pin(async move { 35 | camera 36 | .led_light_set(on) 37 | .await 38 | .context("Unable to set camera light state") 39 | }) 40 | }) 41 | .await?; 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /src/talk/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::Parser; 2 | use std::path::PathBuf; 3 | use std::str::FromStr; 4 | 5 | /// The talk command will send audio for the camera to say 6 | /// 7 | /// This data should be encoded in a way that gstreamer can understand. 8 | /// This should be ok with most common formats. 9 | /// 10 | /// `gst-launch` can be used to prepare this data 11 | #[derive(Parser, Debug)] 12 | pub struct Opt { 13 | /// The name of the camera to talk through. Must be a name in the config 14 | pub camera: String, 15 | /// The path to the audio file. 16 | #[arg(short, long, value_parser = PathBuf::from_str, conflicts_with = "microphone")] 17 | pub file_path: Option, 18 | /// Use the microphone as the source. Defaults to autoaudiosrc - Which microphone depends 19 | /// on [gstreamer](https://gstreamer.freedesktop.org/documentation/autodetect/autoaudiosrc.html?gi-language=c#autoaudiosrc-page) 20 | #[arg(short, long, conflicts_with = "file_path")] 21 | pub microphone: bool, 22 | /// Use a specific microphone like "alsasrc device=hw:1" 23 | #[arg( 24 | short, 25 | long, 26 | default_value = "autoaudiosrc", 27 | conflicts_with = "file_path" 28 | )] 29 | pub input_src: String, 30 | /// Use to change the volume of the input 31 | #[arg(short, long, default_value = "1.0")] 32 | pub volume: f32, 33 | } 34 | -------------------------------------------------------------------------------- /src/talk/mod.rs: -------------------------------------------------------------------------------- 1 | /// 2 | /// # Neolink Talk 3 | /// 4 | /// This module can be used to send adpcm data for the camera to play 5 | /// 6 | /// The adpcm data needs to be in DVI-4 layout 7 | /// 8 | /// # Usage 9 | /// 10 | /// ```bash 11 | /// neolink talk --config=config.toml --adpcm-file=data.adpcm --sample-rate=16000 --block-size=512 CameraName 12 | /// ``` 13 | /// 14 | use anyhow::{anyhow, Context, Result}; 15 | use neolink_core::bc::xml::TalkConfig; 16 | 17 | mod cmdline; 18 | mod gst; 19 | 20 | use crate::common::NeoReactor; 21 | pub(crate) use cmdline::Opt; 22 | 23 | /// Entry point for the talk subcommand 24 | /// 25 | /// Opt is the command line options 26 | pub(crate) async fn main(opt: Opt, reactor: NeoReactor) -> Result<()> { 27 | let camera = reactor.get(&opt.camera).await?; 28 | let config = camera.config().await?.borrow().clone(); 29 | let name = config.name.clone(); 30 | 31 | let talk_ability = camera 32 | .run_task(|cam| { 33 | Box::pin(async move { 34 | let talk_ability = cam.talk_ability().await?; 35 | Ok(talk_ability) 36 | }) 37 | }) 38 | .await 39 | .with_context(|| format!("Camera {} does not support talk", name))?; 40 | 41 | if talk_ability.duplex_list.is_empty() 42 | || talk_ability.audio_stream_mode_list.is_empty() 43 | || talk_ability.audio_config_list.is_empty() 44 | { 45 | return Err(anyhow!("Camera {} does not support talk", name)); 46 | } 47 | 48 | // Just copy that data from the first talk ability in the config have never seen more 49 | // than one ability 50 | let config_id = 0; 51 | 52 | let talk_config = TalkConfig { 53 | channel_id: config.channel_id, 54 | duplex: talk_ability.duplex_list[config_id].duplex.clone(), 55 | audio_stream_mode: talk_ability.audio_stream_mode_list[config_id] 56 | .audio_stream_mode 57 | .clone(), 58 | audio_config: talk_ability.audio_config_list[config_id] 59 | .audio_config 60 | .clone(), 61 | ..Default::default() 62 | }; 63 | 64 | let block_size = (talk_config.audio_config.length_per_encoder / 2) + 4; 65 | let sample_rate = talk_config.audio_config.sample_rate; 66 | if block_size == 0 || sample_rate == 0 { 67 | return Err(anyhow!( 68 | "The camera {} does not support talk with adpcm", 69 | name 70 | )); 71 | } 72 | 73 | let (mut set, rx) = match (&opt.file_path, &opt.microphone) { 74 | (Some(path), false) => gst::from_input( 75 | &format!( 76 | "filesrc location={}", 77 | path.to_str().expect("File path not UTF8 complient") 78 | ), 79 | opt.volume, 80 | block_size, 81 | sample_rate, 82 | ) 83 | .with_context(|| format!("Failed to setup gst with the file: {:?}", path))?, 84 | (None, true) => gst::from_input(&opt.input_src, opt.volume, block_size, sample_rate) 85 | .context("Failed to setup gst using the microphone")?, 86 | _ => unreachable!(), 87 | }; 88 | 89 | camera 90 | .run_task(|cam| { 91 | let rx = rx.clone(); 92 | let talk_config = talk_config.clone(); 93 | Box::pin(async move { 94 | cam.talk_stream(rx, talk_config).await?; 95 | Ok(()) 96 | }) 97 | }) 98 | .await 99 | .context("Talk stream ended early")?; 100 | 101 | drop(rx); 102 | while set.join_next().await.is_some() {} 103 | 104 | Ok(()) 105 | } 106 | -------------------------------------------------------------------------------- /src/users/cmdline.rs: -------------------------------------------------------------------------------- 1 | use clap::{Parser, ValueEnum}; 2 | 3 | #[derive(Parser, Debug)] 4 | pub struct Opt { 5 | /// The name of the camera. Must be a name in the config 6 | pub camera: String, 7 | /// The action to perform 8 | #[command(subcommand)] 9 | pub cmd: UserAction, 10 | } 11 | 12 | #[derive(Parser, Debug)] 13 | pub enum UserAction { 14 | /// List users 15 | List, 16 | /// Create a new user 17 | Add { 18 | /// The username of the new user 19 | user_name: String, 20 | /// The password of the new user 21 | password: String, 22 | /// User type 23 | user_type: UserType, 24 | }, 25 | Password { 26 | /// The username of the new user 27 | user_name: String, 28 | /// The password of the new user 29 | password: String, 30 | }, 31 | Delete { 32 | /// The username of the user to delete 33 | user_name: String, 34 | }, 35 | } 36 | 37 | #[derive(Parser, Debug, Clone, ValueEnum)] 38 | pub enum UserType { 39 | /// user_level = 0 40 | User, 41 | /// user_level = 1 42 | Administrator, 43 | } 44 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | //! Contains code that is not specific to any of the subcommands 2 | //! 3 | use log::*; 4 | 5 | use super::config::CameraConfig; 6 | use anyhow::{anyhow, Context, Error, Result}; 7 | use neolink_core::bc_protocol::{ 8 | BcCamera, BcCameraOpt, ConnectionProtocol, Credentials, DiscoveryMethods, MaxEncryption, 9 | }; 10 | use std::{ 11 | fmt::{Display, Error as FmtError, Formatter}, 12 | net::{IpAddr, ToSocketAddrs}, 13 | str::FromStr, 14 | }; 15 | 16 | pub(crate) fn timeout(future: F) -> tokio::time::Timeout 17 | where 18 | F: std::future::Future, 19 | { 20 | tokio::time::timeout(tokio::time::Duration::from_secs(15), future) 21 | } 22 | 23 | pub(crate) enum AddressOrUid { 24 | Address(String), 25 | #[allow(dead_code)] 26 | Uid(String, DiscoveryMethods), 27 | #[allow(dead_code)] 28 | AddressWithUid(String, String, DiscoveryMethods), 29 | } 30 | 31 | impl Display for AddressOrUid { 32 | fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), FmtError> { 33 | match self { 34 | AddressOrUid::AddressWithUid(addr, uid, _) => { 35 | write!(f, "Address: {}, UID: {}", addr, uid) 36 | } 37 | AddressOrUid::Address(host) => write!(f, "Address: {}", host), 38 | AddressOrUid::Uid(host, _) => write!(f, "UID: {}", host), 39 | } 40 | } 41 | } 42 | 43 | impl AddressOrUid { 44 | // Created by translating the config fields directly 45 | pub(crate) fn new( 46 | address: &Option, 47 | uid: &Option, 48 | method: &DiscoveryMethods, 49 | ) -> Result { 50 | match (address, uid) { 51 | (None, None) => Err(anyhow!("Neither address or uid given")), 52 | (Some(host), Some(uid)) => Ok(AddressOrUid::AddressWithUid( 53 | host.clone(), 54 | uid.clone(), 55 | *method, 56 | )), 57 | (Some(host), None) => Ok(AddressOrUid::Address(host.clone())), 58 | (None, Some(host)) => Ok(AddressOrUid::Uid(host.clone(), *method)), 59 | } 60 | } 61 | 62 | // Convience method to get the BcCamera with the appropiate method 63 | // from a camera_config 64 | pub(crate) async fn connect_camera( 65 | &self, 66 | camera_config: &CameraConfig, 67 | ) -> Result { 68 | let (port, addrs) = { 69 | if let Some(addr_str) = camera_config.camera_addr.as_ref() { 70 | match addr_str.to_socket_addrs() { 71 | Ok(addr_iter) => { 72 | let mut port = None; 73 | let mut ipaddrs = vec![]; 74 | for addr in addr_iter { 75 | port = Some(addr.port()); 76 | ipaddrs.push(addr.ip()); 77 | } 78 | Ok((port, ipaddrs)) 79 | } 80 | Err(_) => match IpAddr::from_str(addr_str) { 81 | Ok(ip) => Ok((None, vec![ip])), 82 | Err(_) => Err(anyhow!("Could not parse address in config")), 83 | }, 84 | } 85 | } else { 86 | Ok((None, vec![])) 87 | } 88 | }?; 89 | 90 | let options = BcCameraOpt { 91 | name: camera_config.name.clone(), 92 | channel_id: camera_config.channel_id, 93 | addrs, 94 | port, 95 | uid: camera_config.camera_uid.clone(), 96 | protocol: ConnectionProtocol::TcpUdp, 97 | discovery: camera_config.discovery, 98 | credentials: Credentials { 99 | username: camera_config.username.clone(), 100 | password: camera_config.password.clone(), 101 | }, 102 | debug: camera_config.debug, 103 | max_discovery_retries: camera_config.max_discovery_retries, 104 | }; 105 | 106 | trace!("Camera Info: {:?}", options); 107 | 108 | Ok(BcCamera::new(&options).await?) 109 | } 110 | } 111 | 112 | pub(crate) async fn connect_and_login(camera_config: &CameraConfig) -> Result { 113 | let camera_addr = AddressOrUid::new( 114 | &camera_config.camera_addr, 115 | &camera_config.camera_uid, 116 | &camera_config.discovery, 117 | ) 118 | .unwrap(); 119 | info!( 120 | "{}: Connecting to camera at {}", 121 | camera_config.name, camera_addr 122 | ); 123 | 124 | let camera = camera_addr 125 | .connect_camera(camera_config) 126 | .await 127 | .with_context(|| { 128 | format!( 129 | "Failed to connect to camera {} at {} on channel {}", 130 | camera_config.name, camera_addr, camera_config.channel_id 131 | ) 132 | })?; 133 | 134 | let max_encryption = match camera_config.max_encryption.to_lowercase().as_str() { 135 | "none" => MaxEncryption::None, 136 | "bcencrypt" => MaxEncryption::BcEncrypt, 137 | "aes" => MaxEncryption::Aes, 138 | _ => MaxEncryption::Aes, 139 | }; 140 | info!("{}: Logging in", camera_config.name); 141 | timeout(camera.login_with_maxenc(max_encryption)) 142 | .await 143 | .with_context(|| format!("Failed to login to {}", camera_config.name))??; 144 | 145 | info!("{}: Connected and logged in", camera_config.name); 146 | 147 | Ok(camera) 148 | } 149 | --------------------------------------------------------------------------------