├── .prettierrc.json ├── src ├── index.ts ├── modem │ ├── constants.ts │ ├── web-diagnose.ts │ ├── tools │ │ ├── __fixtures__ │ │ │ ├── base_95x.js │ │ │ ├── index.php.html │ │ │ └── status_docsis_data.php.html │ │ ├── html-parser.test.ts │ │ ├── crypto.test.ts │ │ ├── crypto.ts │ │ ├── html-parser.ts │ │ └── __snapshots__ │ │ │ └── html-parser.test.ts.snap │ ├── web-diagnose.test.ts │ ├── factory.ts │ ├── host-exposure.ts │ ├── __fixtures__ │ │ ├── docsisStatus_normalized_minimal.json │ │ ├── docsisStatus_arris_normalized.json │ │ ├── docsisStatus_normalized.json │ │ ├── docsisStatus_ofdm_arris.json │ │ ├── docsisStatus_arris.json │ │ ├── docsisStatus_ofdma_technicolor.json │ │ └── docsisStatus_technicolor.json │ ├── arris-modem.test.ts │ ├── printer.ts │ ├── printer.test.ts │ ├── technicolor-modem.test.ts │ ├── modem.test.ts │ ├── discovery.ts │ ├── modem.ts │ ├── __snapshots__ │ │ └── printer.test.ts.snap │ ├── arris-modem.ts │ ├── technicolor-modem.ts │ ├── discovery.test.ts │ └── docsis-diagnose.ts ├── base-command.test.ts ├── base-command.ts ├── commands │ ├── discover.ts │ ├── host-exposure │ │ ├── enable.ts │ │ ├── disable.ts │ │ ├── get.ts │ │ └── set.ts │ ├── restart.ts │ ├── diagnose.ts │ ├── docsis.ts │ └── docsis.test.ts └── logger.ts ├── bin ├── dev.cmd ├── run.cmd ├── run └── dev ├── telegraf ├── telegraf ├── setup.sh └── docsis_reports.conf ├── scripts ├── Dockerfile.test └── run-in-docker.sh ├── .editorconfig ├── Dockerfile ├── tsconfig.json ├── .github ├── dependabot.yml └── workflows │ └── main.yml ├── plan.md ├── jest.config.ts ├── .vscode └── settings.json ├── LICENSE ├── eslint.config.js ├── .gitignore ├── package.json └── CHANGELOG.md /.prettierrc.json: -------------------------------------------------------------------------------- 1 | "@oclif/prettier-config" 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {run} from '@oclif/core' 2 | -------------------------------------------------------------------------------- /bin/dev.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\dev" %* -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /src/modem/constants.ts: -------------------------------------------------------------------------------- 1 | export const BAD_MODEM_POWER_LEVEL = -100 2 | -------------------------------------------------------------------------------- /telegraf/telegraf: -------------------------------------------------------------------------------- 1 | INFLUX_TOKEN= 2 | INFLUX_SERVER_UR= 3 | DOCSIS_REPORTS_FOLDER= 4 | -------------------------------------------------------------------------------- /telegraf/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | sudo ln -s ${PWD}/docsis_reports.conf /etc/telegraf/telegraf.d/ 3 | sudo ln -s ${PWD}/telegraf/etc/default/telegraf -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | oclif.run().then(require('@oclif/core/flush')).catch(require('@oclif/core/handle')) 6 | -------------------------------------------------------------------------------- /scripts/Dockerfile.test: -------------------------------------------------------------------------------- 1 | FROM node:24-slim 2 | WORKDIR /app 3 | COPY package.json yarn.lock ./ 4 | RUN yarn install 5 | COPY . . 6 | RUN yarn build 7 | RUN yarn link 8 | ENTRYPOINT ["vodafone-station-cli"] -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /scripts/run-in-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | #docker run --rm -it -v $(pwd):/opt --entrypoint bash node:24 4 | docker build -t vodafone-cli-test -f scripts/Dockerfile.test . 5 | docker run vodafone-cli-test discover 6 | docker run vodafone-cli-test diagnose 7 | docker run vodafone-cli-test docsis -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine AS build 2 | WORKDIR /usr/src/app 3 | COPY . . 4 | RUN yarn && npm install vodafone-station-cli &&\ 5 | npm prune --production 6 | 7 | FROM alpine 8 | RUN apk add --no-cache nodejs 9 | WORKDIR /usr/src/app 10 | COPY --from=build /usr/src/app/node_modules ./node_modules 11 | ENTRYPOINT ["/usr/src/app/node_modules/vodafone-station-cli/bin/run"] 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "target": "esnext", 10 | "esModuleInterop": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true 13 | }, 14 | "include": [ 15 | "src/**/*" 16 | ] 17 | } -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | versioning-strategy: increase 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | labels: 9 | - "dependencies" 10 | open-pull-requests-limit: 100 11 | pull-request-branch-name: 12 | separator: "-" 13 | ignore: 14 | - dependency-name: "fs-extra" 15 | - dependency-name: "*" 16 | update-types: ["version-update:semver-major"] 17 | -------------------------------------------------------------------------------- /plan.md: -------------------------------------------------------------------------------- 1 | # 1.5.1 Release Plan 2 | 3 | ## ✅ Completed Tasks 4 | - [x] Version bumped in package.json (1.5.0 → 1.5.1) 5 | - [x] README.md updated with latest dependency versions 6 | 7 | ## 🔄 Tasks in Progress 8 | - [ ] Update CHANGELOG.md with 1.5.1 entry 9 | - [ ] Commit changes 10 | - [ ] Create git tag for v1.5.1 11 | - [ ] Push changes and tag to repository 12 | 13 | ## 📝 Release Notes for 1.5.1 14 | - **Documentation updates**: Updated README.md with latest dependency versions 15 | - **Maintenance**: Minor version bump for consistency -------------------------------------------------------------------------------- /src/modem/web-diagnose.ts: -------------------------------------------------------------------------------- 1 | import {brotliCompressSync} from 'node:zlib'; 2 | 3 | import type {DocsisStatus} from './modem'; 4 | 5 | export function compressDocsisStatus(docsisStatus: DocsisStatus): string { 6 | const json = JSON.stringify(docsisStatus) 7 | const compressed = brotliCompressSync(Buffer.from(json, 'utf8')) 8 | return compressed.toString('base64url') 9 | } 10 | 11 | export function webDiagnoseLink(docsisStatus: DocsisStatus): string { 12 | return `https://docsis-diagnose.totev.dev/#docsis=${compressDocsisStatus(docsisStatus)}` 13 | } 14 | -------------------------------------------------------------------------------- /src/modem/tools/__fixtures__/base_95x.js: -------------------------------------------------------------------------------- 1 | const attrs = {}; 2 | attrs["Credential"] = ""; 3 | 4 | function eraseCookie(cookieName) { 5 | delete attrs[cookieName]; 6 | } 7 | 8 | eraseCookie("credential"); 9 | 10 | function getCredential() { 11 | const credential = attrs["Credential"]; 12 | return credential; 13 | } 14 | 15 | function createCookie(cookieName, cookiePayload) { 16 | attrs[cookieName] = cookiePayload; 17 | return attrs; 18 | } 19 | createCookie( 20 | "credential", 21 | "someRandomCatchyHash37f1f79255b66b5c02348e3dc6ff5fcd559654e2" 22 | ); 23 | -------------------------------------------------------------------------------- /telegraf/docsis_reports.conf: -------------------------------------------------------------------------------- 1 | [[outputs.influxdb_v2]] 2 | urls = ["$INFLUX_SERVER_URL"] 3 | token = "$INFLUX_TOKEN" 4 | organization = "home" 5 | bucket = "cable-modem" 6 | 7 | [[inputs.file]] 8 | files = ["$DOCSIS_REPORTS_FOLDER"] 9 | data_format = "json" 10 | name_override = "docsis" 11 | ## GJSON query paths are described here: 12 | ## https://github.com/tidwall/gjson#path-syntax 13 | json_query = "" 14 | tag_keys = [ 15 | "docsis", 16 | "cable", 17 | "modem" 18 | ] 19 | json_string_fields = [] 20 | json_name_key = "docsis" 21 | json_time_key = "time" 22 | json_time_format = "2006-01-02T15:04:05Z07:00" -------------------------------------------------------------------------------- /bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const oclif = require('@oclif/core') 4 | 5 | const path = require('path') 6 | const project = path.join(__dirname, '..', 'tsconfig.json') 7 | 8 | // In dev mode -> use ts-node and dev plugins 9 | process.env.NODE_ENV = 'development' 10 | 11 | // Clear ts-node's cache to ensure using the latest code 12 | require('ts-node').register({ 13 | project, 14 | transpileOnly: true, 15 | compilerOptions: { 16 | module: 'commonjs' 17 | } 18 | }) 19 | 20 | // In dev mode, always show stack traces 21 | oclif.settings.debug = true; 22 | 23 | // Start the CLI 24 | oclif.run().then(oclif.flush).catch(oclif.Errors.handle) 25 | -------------------------------------------------------------------------------- /src/modem/web-diagnose.test.ts: -------------------------------------------------------------------------------- 1 | import { brotliDecompressSync } from "zlib" 2 | import { DocsisStatus } from "./modem" 3 | import { compressDocsisStatus } from "./web-diagnose" 4 | import fixtureDocsisStatus from './__fixtures__/docsisStatus_normalized.json' 5 | 6 | test('should compress json status with brotli', () => { 7 | const status = compressDocsisStatus(fixtureDocsisStatus as DocsisStatus) 8 | console.log(status) 9 | const decompressed = brotliDecompressSync((Buffer.from(status, "base64url"))) 10 | const decompressedStatus = JSON.parse(decompressed.toString("utf-8")) 11 | 12 | expect(decompressedStatus).toStrictEqual(fixtureDocsisStatus) 13 | }) -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types' 2 | 3 | // Sync object 4 | const config: Config.InitialOptions = { 5 | verbose: true, 6 | preset: 'ts-jest/presets/default-esm', 7 | extensionsToTreatAsEsm: ['.ts'], 8 | testEnvironment: 'node', 9 | roots: ['src'], 10 | moduleNameMapper: { 11 | '^(\\.{1,2}/.*)\\.js$': '$1' 12 | }, 13 | transform: { 14 | '^.+\\.ts$': ['ts-jest', { 15 | useESM: true 16 | }], 17 | '^.+\\.js$': ['ts-jest', { 18 | useESM: true 19 | }] 20 | }, 21 | transformIgnorePatterns: [ 22 | 'node_modules/(?!(axios-cookiejar-support|http-cookie-agent))' 23 | ] 24 | } 25 | export default config 26 | -------------------------------------------------------------------------------- /src/base-command.test.ts: -------------------------------------------------------------------------------- 1 | import { ipFlag } from './base-command'; 2 | 3 | describe('Base Command', () => { 4 | describe('ipFlag', () => { 5 | it('should create an IP flag with correct properties', () => { 6 | const flag = ipFlag(); 7 | 8 | expect(flag).toBeDefined(); 9 | expect(flag.char).toBe('i'); 10 | expect(flag.description).toBe('IP address of the modem/router (default: try 192.168.100.1 and 192.168.0.1)'); 11 | expect(flag.required).toBeUndefined(); 12 | }); 13 | 14 | it('should be compatible with OCLIF flags', () => { 15 | const flags = { 16 | ip: ipFlag() 17 | }; 18 | 19 | expect(flags.ip).toBeDefined(); 20 | }); 21 | }); 22 | }); -------------------------------------------------------------------------------- /src/modem/factory.ts: -------------------------------------------------------------------------------- 1 | import type {Modem} from './modem' 2 | 3 | import {ConsoleLogger, Log} from '../logger' 4 | import {Arris} from './arris-modem' 5 | import {ModemInformation} from './discovery' 6 | import {Technicolor} from './technicolor-modem' 7 | 8 | export function modemFactory(modemInfo: ModemInformation, logger: Log = new ConsoleLogger()): Modem { 9 | switch (modemInfo.deviceType) { 10 | case 'Arris': { 11 | return new Arris(modemInfo.ipAddress, modemInfo.protocol, logger) 12 | } 13 | 14 | case 'Technicolor': { 15 | return new Technicolor(modemInfo.ipAddress, modemInfo.protocol, logger) 16 | } 17 | 18 | default: { 19 | throw new Error(`Unsupported modem ${modemInfo.deviceType}`) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/base-command.ts: -------------------------------------------------------------------------------- 1 | import {Command, Flags} from '@oclif/core' 2 | import {config} from 'dotenv' 3 | 4 | import {Log, OclifLogger} from './logger' 5 | config() 6 | 7 | export const ipFlag = () => Flags.string({ 8 | char: 'i', 9 | description: 'IP address of the modem/router (default: try 192.168.100.1 and 192.168.0.1)', 10 | env: 'VODAFONE_ROUTER_IP', 11 | }) 12 | 13 | export default abstract class BaseCommand extends Command { 14 | get logger(): Log { 15 | return new OclifLogger( 16 | this.log.bind(this), 17 | this.warn.bind(this) as (input: Error | string) => Error | string, 18 | this.debug.bind(this), 19 | this.error.bind(this) as (input: Error | string, ...options: unknown[]) => void, 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "activityBar.activeBackground": "#88d7ea", 4 | "activityBar.activeBorder": "#de41bf", 5 | "activityBar.background": "#88d7ea", 6 | "activityBar.foreground": "#15202b", 7 | "activityBar.inactiveForeground": "#15202b99", 8 | "activityBarBadge.background": "#de41bf", 9 | "activityBarBadge.foreground": "#e7e7e7", 10 | "sash.hoverBorder": "#88d7ea", 11 | "statusBar.background": "#5dc9e2", 12 | "statusBar.foreground": "#15202b", 13 | "statusBarItem.hoverBackground": "#32bbda", 14 | "statusBarItem.remoteBackground": "#5dc9e2", 15 | "statusBarItem.remoteForeground": "#15202b", 16 | "titleBar.activeBackground": "#5dc9e2", 17 | "titleBar.activeForeground": "#15202b", 18 | "titleBar.inactiveBackground": "#5dc9e299", 19 | "titleBar.inactiveForeground": "#15202b99", 20 | "commandCenter.border": "#15202b99" 21 | }, 22 | "peacock.color": "#5dc9e2" 23 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dobroslav Totev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import oclif from 'eslint-config-oclif'; 2 | 3 | export default [ 4 | { 5 | ignores: [ 6 | 'node_modules/', 7 | 'dist/', 8 | 'tmp/', 9 | 'package/', 10 | 'reports/', 11 | 'new/', 12 | 'save/', 13 | 'telegraf/', 14 | '*.tgz', 15 | '*.log', 16 | 'test/', 17 | 'coverage/', 18 | '.nyc_output/', 19 | 'lib/', 20 | 'build/', 21 | '**/*.d.ts', 22 | '**/*.js.map', 23 | '*.config.js', 24 | '*.config.ts', 25 | 'bin/run.js', 26 | 'bin/dev.js', 27 | '**/*.test.ts', 28 | '**/*.test.js', 29 | '**/__fixtures__/**', 30 | '**/fixtures/**', 31 | '**/*.spec.ts', 32 | '**/*.spec.js' 33 | ] 34 | }, 35 | { 36 | files: ['src/**/*.ts'], 37 | ignores: [ 38 | '**/*.test.ts', 39 | '**/*.spec.ts', 40 | '**/__fixtures__/**', 41 | '**/fixtures/**', 42 | '**/*.d.ts' 43 | ] 44 | }, 45 | ...oclif, 46 | { 47 | rules: { 48 | 'no-useless-constructor': 'off', 49 | 'indent': 'off', // Disable base indent rule to avoid conflicts with @stylistic/indent 50 | 'lines-between-class-members': 'off', 51 | 'comma-dangle': 'off', 52 | '@typescript-eslint/camelcase': 'off' 53 | } 54 | } 55 | ]; -------------------------------------------------------------------------------- /src/commands/discover.ts: -------------------------------------------------------------------------------- 1 | import Command, {ipFlag} from '../base-command'; 2 | import {discoverModemLocation, DiscoveryOptions, ModemDiscovery} from '../modem/discovery'; 3 | 4 | export default class Discover extends Command { 5 | static description 6 | = 'Try to discover a cable modem in the network'; 7 | static examples = [ 8 | '$ vodafone-station-cli discover', 9 | '$ vodafone-station-cli discover --ip 192.168.100.1', 10 | ]; 11 | static flags = { 12 | ip: ipFlag(), 13 | } 14 | 15 | async discoverModem(): Promise { 16 | try { 17 | const {flags} = await this.parse(Discover) 18 | const discoveryOptions: DiscoveryOptions = { 19 | ip: flags.ip, 20 | } 21 | 22 | const modemLocation = await discoverModemLocation(discoveryOptions); 23 | this.log(`Possibly found modem under the following location: ${modemLocation.protocol}://${modemLocation.ipAddress}`); 24 | const modem = new ModemDiscovery(modemLocation, this.logger); 25 | const discoveredModem = await modem.discover(); 26 | this.log(`Discovered modem: ${JSON.stringify(discoveredModem)}`); 27 | } catch (error) { 28 | this.log('Something went wrong.', error); 29 | } 30 | } 31 | 32 | async run(): Promise { 33 | await this.discoverModem(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [main, feat/*] 6 | tags: 7 | - 'v*.*.*' 8 | pull_request: 9 | branches: [main] 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: '20' 19 | - run: yarn install --frozen-lockfile 20 | - run: yarn lint 21 | - run: yarn test 22 | - name: Build tarballs 23 | if: startsWith(github.ref, 'refs/tags/') 24 | run: yarn build:standalone 25 | - name: Release 26 | uses: softprops/action-gh-release@v1 27 | if: startsWith(github.ref, 'refs/tags/') 28 | with: 29 | files: dist/**/*.tar.gz 30 | draft: true 31 | body_path: CHANGELOG.md 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | # Setup .npmrc file to publish to npm 35 | - uses: actions/setup-node@v4 36 | if: startsWith(github.ref, 'refs/tags/') 37 | with: 38 | node-version: '20.x' 39 | registry-url: 'https://registry.npmjs.org' 40 | - name: Publish to npm 41 | if: startsWith(github.ref, 'refs/tags/') 42 | run: npm publish 43 | env: 44 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 45 | -------------------------------------------------------------------------------- /src/modem/host-exposure.ts: -------------------------------------------------------------------------------- 1 | import {Log} from '../logger' 2 | import {discoverModemLocation, DiscoveryOptions, ModemDiscovery} from './discovery' 3 | import {colorize} from './docsis-diagnose' 4 | import {modemFactory} from './factory' 5 | 6 | export async function toggleHostExposureEntries(toggle: boolean, entries: string[], password: string, logger: Log, discoveryOptions?: DiscoveryOptions): Promise { 7 | const modemLocation = await discoverModemLocation(discoveryOptions) 8 | const discoveredModem = await new ModemDiscovery(modemLocation, logger).discover() 9 | const modem = modemFactory(discoveredModem, logger) 10 | try { 11 | await modem.login(password) 12 | const settings = await modem.getHostExposure() 13 | 14 | let names = entries 15 | if (names.length === 0) { 16 | names = settings.hosts.map(host => host.serviceName) 17 | } 18 | 19 | for (const name of names) { 20 | const index = settings.hosts.findIndex(host => host.serviceName === name) 21 | if (index === -1) { 22 | logger.log(colorize('yellow', `Entry with the name '${name}' does not exist.`)) 23 | } else { 24 | settings.hosts[index].enabled = toggle 25 | } 26 | } 27 | 28 | await modem.setHostExposure(settings) 29 | } catch (error) { 30 | logger.error('Could not change host exposure settings on modem.', error) 31 | throw error 32 | } finally { 33 | await modem.logout() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_normalized_minimal.json: -------------------------------------------------------------------------------- 1 | { 2 | "downstream": [ 3 | { 4 | "channelId": "6", 5 | "channelType": "SC-QAM", 6 | "modulation": "256QAM", 7 | "powerLevel": 5.3, 8 | "lockStatus": "Locked", 9 | "snr": 39, 10 | "frequency": 618 11 | }, 12 | { 13 | "channelId": "27", 14 | "channelType": "SC-QAM", 15 | "modulation": "64QAM", 16 | "powerLevel": 5, 17 | "lockStatus": "Locked", 18 | "snr": 34, 19 | "frequency": 794 20 | } 21 | ], 22 | "downstreamOfdm": [ 23 | { 24 | "channelId": "33", 25 | "channelType": "OFDM", 26 | "modulation": "1024QAM", 27 | "powerLevel": 10, 28 | "lockStatus": "Locked", 29 | "snr": 39, 30 | "frequencyStart": 151, 31 | "frequencyEnd": 324 32 | } 33 | ], 34 | "upstream": [ 35 | { 36 | "channelId": "1", 37 | "channelType": "SC-QAM", 38 | "modulation": "64QAM", 39 | "powerLevel": 45, 40 | "lockStatus": "Locked", 41 | "snr": 0, 42 | "frequency": 51 43 | } 44 | ], 45 | "upstreamOfdma": [ 46 | { 47 | "channelId": "9", 48 | "channelType": "OFDMA", 49 | "modulation": "16_QAM", 50 | "powerLevel": 45, 51 | "lockStatus": "SUCCESS", 52 | "snr": 0, 53 | "frequencyStart": 29.8, 54 | "frequencyEnd": 64.8 55 | } 56 | ], 57 | "time": "2022-01-03T10:59:05.635Z" 58 | } -------------------------------------------------------------------------------- /src/commands/host-exposure/enable.ts: -------------------------------------------------------------------------------- 1 | import {Flags} from '@oclif/core' 2 | 3 | import Command, {ipFlag} from '../../base-command' 4 | import {DiscoveryOptions} from '../../modem/discovery' 5 | import {toggleHostExposureEntries} from '../../modem/host-exposure' 6 | 7 | export default class EnableHostExposureEntries extends Command { 8 | static description = 'Enable a set of host exposure entries' 9 | static examples = [ 10 | '$ vodafone-station-cli host-exposure:enable -p PASSWORD [ENTRY NAME | [ENTRY NAME...]]', 11 | '$ vodafone-station-cli host-exposure:enable -p PASSWORD --ip 192.168.100.1 [ENTRY NAME | [ENTRY NAME...]]', 12 | ] 13 | static flags = { 14 | ip: ipFlag(), 15 | password: Flags.string({ 16 | char: 'p', 17 | description: 'router/modem password', 18 | }), 19 | } 20 | static strict = false 21 | 22 | async run(): Promise { 23 | const {argv, flags} = await this.parse(EnableHostExposureEntries) 24 | 25 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 26 | if (!password || password === '') { 27 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 28 | return 29 | } 30 | 31 | const discoveryOptions: DiscoveryOptions = { 32 | ip: flags.ip, 33 | } 34 | 35 | try { 36 | await toggleHostExposureEntries(true, argv as string[], password!, this.logger, discoveryOptions) 37 | } catch (error) { 38 | this.error(error as Error, {message: 'Something went wrong.'}) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface Log { 3 | debug(...args: unknown[]): void; 4 | error(input: Error | string, ...options: unknown[]): void; 5 | log(message?: string, ...args: unknown[]): void; 6 | warn(input: Error | string): void; 7 | } 8 | 9 | export class OclifLogger implements Log { 10 | constructor( 11 | private delegateLog: (message?: string, ...args: unknown[]) => void, 12 | private delegateWarn: (input: Error | string) => Error | string, 13 | private delegateDebug: (...args: unknown[]) => void, 14 | private delegateError: (input: Error | string, ...options: unknown[]) => void, 15 | ) {} 16 | 17 | debug(...args: unknown[]): void { 18 | this.delegateDebug(args) 19 | } 20 | 21 | error(input: Error | string, options: {code?: string | undefined; exit: false}): void { 22 | this.delegateError(input, options) 23 | } 24 | 25 | jsonEnabled() { 26 | return true; 27 | } 28 | 29 | log(message?: string, ...args: unknown[]): void { 30 | this.delegateLog(message, args) 31 | } 32 | 33 | warn(input: Error | string): void { 34 | this.delegateWarn(input) 35 | } 36 | } 37 | 38 | export class ConsoleLogger implements Log { 39 | debug(...args: unknown[]): void { 40 | console.debug(args) 41 | } 42 | 43 | error(input: Error | string, options: {code?: string | undefined; exit: false}): void { 44 | console.log(input, options) 45 | } 46 | 47 | log(message?: string, ...args: unknown[]): void { 48 | console.log(message, args) 49 | } 50 | 51 | warn(input: Error | string): void { 52 | console.warn(input) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/modem/arris-modem.test.ts: -------------------------------------------------------------------------------- 1 | import {ConsoleLogger} from '../logger' 2 | import {Arris, ArrisDocsisStatus, normalizeDocsisStatus} from './arris-modem' 3 | import {CryptoVars} from './tools/html-parser' 4 | import fixtureDocsisStatus from './__fixtures__/docsisStatus_arris.json' 5 | import fixtureDocsisStatusOfdma from './__fixtures__/docsisStatus_ofdm_arris.json' 6 | 7 | describe('Arris', () => { 8 | test('should encrypt', () => { 9 | const expected = { 10 | EncryptData: 11 | '8059e124da83bf88c89ae02ab0c7a0335a272a2045e5c3fb075dcfba42788b5436483e01f37b5f25c50b5d9e6366734eb9eb33919d892e97bb025c63e35b5a76e5ffe53a292e8e8ebc99c923ea2977803b', 12 | Name: 'admin', 13 | AuthData: 'loginPassword', 14 | } 15 | const given: CryptoVars = { 16 | iv: 'c68af53914949158', 17 | salt: 'c4d0a0c70c3fcac4', 18 | sessionId: '01a91cedd129fd8c6f18e3a1b58d096f', 19 | nonce: 'WslSZgE7NuQr+1BMqiYEOBMzQlo=', 20 | } 21 | const arrisModem = new Arris("0.0.0.0", "http", new ConsoleLogger()); 22 | expect(arrisModem.encryptPassword('test', given)).toEqual(expected) 23 | }) 24 | 25 | describe('normalizeDocsisStatus', () => { 26 | test('should work with ofdm in download', () => { 27 | const status = normalizeDocsisStatus(fixtureDocsisStatus as ArrisDocsisStatus) 28 | expect(status).toMatchSnapshot() 29 | }) 30 | test('should work with ofdm in download and in upload', () => { 31 | const status = normalizeDocsisStatus(fixtureDocsisStatusOfdma as ArrisDocsisStatus) 32 | expect(status).toMatchSnapshot() 33 | }) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /src/commands/restart.ts: -------------------------------------------------------------------------------- 1 | import {Flags} from '@oclif/core'; 2 | 3 | import Command, {ipFlag} from '../base-command'; 4 | import {discoverModemLocation, DiscoveryOptions, ModemDiscovery} from '../modem/discovery'; 5 | import {modemFactory} from '../modem/factory'; 6 | 7 | export default class Restart extends Command { 8 | static description = 'restart the modem/router'; 9 | static examples = [ 10 | '$ vodafone-station-cli restart', 11 | '$ vodafone-station-cli restart --ip 192.168.100.1', 12 | ]; 13 | static flags = { 14 | ip: ipFlag(), 15 | password: Flags.string({ 16 | char: 'p', 17 | description: 'router/modem password', 18 | }), 19 | } 20 | 21 | async run(): Promise { 22 | const {flags} = await this.parse(Restart) 23 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 24 | if (!password || password === '') { 25 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 26 | return 27 | } 28 | 29 | const discoveryOptions: DiscoveryOptions = { 30 | ip: flags.ip, 31 | } 32 | 33 | const modemLocation = await discoverModemLocation(discoveryOptions) 34 | const discoveredModem = await new ModemDiscovery(modemLocation, this.logger).discover() 35 | const modem = modemFactory(discoveredModem, this.logger) 36 | try { 37 | await modem.login(password!) 38 | await modem.restart() 39 | this.log('The modem has been restarted.') 40 | } catch (error) { 41 | this.log('Something went wrong.', error) 42 | } finally { 43 | await modem.logout() 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/modem/tools/html-parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import { 4 | extractCredentialString, 5 | extractCryptoVars, 6 | extractDocsisStatus, 7 | extractFirmwareVersion, 8 | } from './html-parser' 9 | 10 | describe('htmlParser', () => { 11 | const fixtureIndex = fs.readFileSync( 12 | path.join(__dirname, './__fixtures__/index.php.html'), 13 | 'utf8' 14 | ) 15 | test('extractCryptoVars', () => { 16 | const expected = { 17 | nonce: 'cyCCZrSU0MXlXGhDso44BGA+MmA=', 18 | iv: 'da571578b4f51e21', 19 | salt: '02355f4a986c6900', 20 | sessionId: '343fe70eb4a25c54c34ce1c43d8359f4', 21 | } 22 | const extractedVars = extractCryptoVars(fixtureIndex) 23 | expect(extractedVars).toBeTruthy() 24 | expect(extractedVars).toEqual(expected) 25 | }) 26 | 27 | test('extractDocsisStatus', () => { 28 | const fixtureDocsisData = fs.readFileSync( 29 | path.join(__dirname, './__fixtures__/status_docsis_data.php.html'), 30 | 'utf8' 31 | ) 32 | expect( 33 | extractDocsisStatus( 34 | fixtureDocsisData, 35 | new Date('2021-02-26T09:19:56.042Z') 36 | ) 37 | ).toMatchSnapshot() 38 | }) 39 | test('extractCredentialString', () => { 40 | const fixture = fs.readFileSync( 41 | path.join(__dirname, './__fixtures__/base_95x.js'), 42 | 'utf8' 43 | ) 44 | expect(extractCredentialString(fixture)).toEqual( 45 | 'someRandomCatchyHash37f1f79255b66b5c02348e3dc6ff5fcd559654e2' 46 | ) 47 | }) 48 | 49 | test('extractFirmwareVersion', () => { 50 | expect(extractFirmwareVersion(fixtureIndex)).toBe('01.02.068.11.EURO.PC20') 51 | }) 52 | }) 53 | -------------------------------------------------------------------------------- /src/modem/tools/crypto.test.ts: -------------------------------------------------------------------------------- 1 | import {decrypt, deriveKey, deriveKeyTechnicolor, encrypt} from './crypto' 2 | import {CryptoVars} from './html-parser' 3 | 4 | describe('crypto', () => { 5 | const cryptoVars: CryptoVars = { 6 | iv: 'c68af53914949158', 7 | salt: 'c4d0a0c70c3fcac4', 8 | sessionId: '01a91cedd129fd8c6f18e3a1b58d096f', 9 | nonce: 'WslSZgE7NuQr+1BMqiYEOBMzQlo=', 10 | } 11 | const testPasswordAsKey = '203c8c0da0606debbdd581d1a1cdb2c8' 12 | 13 | test('deriveKey', () => { 14 | expect(deriveKey('test', cryptoVars.salt)).toEqual(testPasswordAsKey) 15 | }) 16 | 17 | test('deriveKey from technicolor', () => { 18 | const password = 'as' 19 | const salt = 'HSts76GJOAB' 20 | const expected = 'bcdf6051836bf84744229389ccc96896' 21 | expect(deriveKeyTechnicolor(password, salt)).toBe(expected) 22 | }) 23 | 24 | test('deriveKey from technicolor 2times with saltwebui', () => { 25 | const password = 'test' 26 | const salt = 'HSts76GJOAB' 27 | const saltwebui = 'KoD4Sga9fw1K' 28 | const expected = 'd1f11af69dddb4e66ca029ccba4571d4' 29 | expect(deriveKeyTechnicolor(deriveKeyTechnicolor(password, salt), saltwebui)).toBe(expected) 30 | }) 31 | 32 | test('encrypt', () => { 33 | expect( 34 | encrypt(testPasswordAsKey, 'textToEncrypt', cryptoVars.iv, 'authData') 35 | ).toEqual('8f1ec931fd9f8d89d98cbb60e4a021320e988b9c6ab97b97208639aa72') 36 | }) 37 | 38 | test('decrypt', () => { 39 | expect( 40 | decrypt( 41 | testPasswordAsKey, 42 | '8f1ec931fd9f8d89d98cbb60e4a021320e988b9c6ab97b97208639aa72', 43 | cryptoVars.iv, 44 | 'authData' 45 | ) 46 | ).toEqual('textToEncrypt') 47 | }) 48 | }) 49 | -------------------------------------------------------------------------------- /src/commands/host-exposure/disable.ts: -------------------------------------------------------------------------------- 1 | import {Args, Flags} from '@oclif/core' 2 | 3 | import Command, {ipFlag} from '../../base-command' 4 | import {DiscoveryOptions} from '../../modem/discovery' 5 | import {toggleHostExposureEntries} from '../../modem/host-exposure' 6 | 7 | export default class DisableHostExposureEntries extends Command { 8 | static args = { 9 | entries: Args.string({ 10 | description: 'Host exposure entries to disable. Pass no names to disable every existing entry.', 11 | required: false, 12 | }), 13 | } 14 | static description = 'Disable a set of host exposure entries' 15 | static examples = [ 16 | '$ vodafone-station-cli host-exposure:disable -p PASSWORD [ENTRY NAME | [ENTRY NAME...]]', 17 | '$ vodafone-station-cli host-exposure:disable -p PASSWORD --ip 192.168.100.1 [ENTRY NAME | [ENTRY NAME...]]', 18 | ] 19 | static flags = { 20 | ip: ipFlag(), 21 | password: Flags.string({ 22 | char: 'p', 23 | description: 'router/modem password', 24 | }), 25 | } 26 | static strict = false 27 | 28 | async run(): Promise { 29 | const {argv, flags} = await this.parse(DisableHostExposureEntries) 30 | 31 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 32 | if (!password || password === '') { 33 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 34 | return 35 | } 36 | 37 | const discoveryOptions: DiscoveryOptions = { 38 | ip: flags.ip, 39 | } 40 | 41 | try { 42 | await toggleHostExposureEntries(false, argv as string[], password!, this.logger, discoveryOptions) 43 | } catch (error) { 44 | this.error(error as Error, {message: 'Something went wrong.'}) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Bower dependency directory (https://bower.io/) 2 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 3 | # Compiled binary addons (https://nodejs.org/api/addons.html) 4 | # Coverage directory used by tools like istanbul 5 | # Dependency directories 6 | # Diagnostic reports (https://nodejs.org/api/report.html) 7 | # Directory for instrumented libs generated by jscoverage/JSCover 8 | # DynamoDB Local files 9 | # FuseBox cache 10 | # Gatsby files 11 | # Generated reports 12 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 13 | # Logs 14 | # Microbundle cache 15 | # Next.js build output 16 | # Nuxt.js build / generate output 17 | # Optional REPL history 18 | # Optional eslint cache 19 | # Optional npm cache directory 20 | # Output of 'npm pack' 21 | # Runtime data 22 | # Serverless directories 23 | # TernJS port file 24 | # TypeScript cache 25 | # TypeScript v1 declaration files 26 | # Yarn Integrity file 27 | # dotenv environment variables file 28 | # https://nextjs.org/blog/next-9-1#public-directory-support 29 | # node-waf configuration 30 | # nyc test coverage 31 | # parcel-bundler cache (https://parceljs.org/) 32 | # public 33 | # vuepress build output 34 | *-debug.log 35 | *-error.log 36 | *.lcov 37 | *.log 38 | *.pid 39 | *.pid.lock 40 | *.seed 41 | *.tgz 42 | *.tsbuildinfo 43 | .cache 44 | .cache/ 45 | .dynamodb/ 46 | .env 47 | .env.test 48 | .eslintcache 49 | .fusebox/ 50 | .grunt 51 | .lock-wscript 52 | .next 53 | .node_repl_history 54 | .npm 55 | .nuxt 56 | .nyc_output 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | .serverless/ 62 | .tern-port 63 | .vuepress/dist 64 | .yarn-integrity 65 | /.nyc_output 66 | /dist 67 | /lib 68 | /package-lock.json 69 | /tmp 70 | bower_components 71 | build/Release 72 | coverage 73 | dist 74 | jspm_packages/ 75 | lerna-debug.log* 76 | lib-cov 77 | logs 78 | node_modules 79 | node_modules/ 80 | npm-debug.log* 81 | pids 82 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 83 | reports/ 84 | typings/ 85 | yarn-debug.log* 86 | yarn-error.log* 87 | oclif.manifest.json 88 | 89 | # Generated reports 90 | reports/ -------------------------------------------------------------------------------- /src/modem/tools/crypto.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable new-cap */ 2 | import sjcl from 'sjcl' 3 | const SJCL_ITERATIONS = 1000 4 | const SJCL_KEYSIZEBITS = 128 5 | const SJCL_TAGLENGTH = 128 6 | 7 | export interface BitParams { 8 | authData: sjcl.BitArray; 9 | iv: sjcl.BitArray; 10 | prf: sjcl.SjclCipher; 11 | } 12 | 13 | export function prepareParameters( 14 | derivedKey: string, 15 | ivHex: string, 16 | authData: string, 17 | ): BitParams { 18 | return { 19 | authData: sjcl.codec.utf8String.toBits(authData), 20 | iv: sjcl.codec.hex.toBits(ivHex), 21 | prf: new sjcl.cipher.aes(sjcl.codec.hex.toBits(derivedKey)), 22 | } 23 | } 24 | 25 | export function deriveKey(password: string, salt: string): string { 26 | const derivedKeyBits = sjcl.misc.pbkdf2( 27 | password, 28 | sjcl.codec.hex.toBits(salt), 29 | SJCL_ITERATIONS, 30 | SJCL_KEYSIZEBITS, 31 | ) 32 | 33 | return sjcl.codec.hex.fromBits(derivedKeyBits) 34 | } 35 | 36 | export function deriveKeyTechnicolor(password: string, salt: string): string { 37 | const derivedKeyBits = sjcl.misc.pbkdf2(password, salt, SJCL_ITERATIONS, SJCL_KEYSIZEBITS) 38 | return sjcl.codec.hex.fromBits(derivedKeyBits) 39 | } 40 | 41 | export function encrypt( 42 | derivedKey: string, 43 | plainText: string, 44 | ivHex: string, 45 | authData: string, 46 | ): string { 47 | const bitParams = prepareParameters(derivedKey, ivHex, authData) 48 | const encryptedBits = sjcl.mode.ccm.encrypt( 49 | bitParams.prf, 50 | sjcl.codec.utf8String.toBits(plainText), 51 | bitParams.iv, 52 | bitParams.authData, 53 | SJCL_TAGLENGTH, 54 | ) 55 | return sjcl.codec.hex.fromBits(encryptedBits) 56 | } 57 | 58 | export function decrypt( 59 | derivedKey: string, 60 | cipherTextHex: string, 61 | ivHex: string, 62 | authData: string, 63 | ): string { 64 | const bitParams = prepareParameters(derivedKey, ivHex, authData) 65 | const decryptedBits = sjcl.mode.ccm.decrypt( 66 | bitParams.prf, 67 | sjcl.codec.hex.toBits(cipherTextHex), 68 | bitParams.iv, 69 | bitParams.authData, 70 | SJCL_TAGLENGTH, 71 | ) 72 | return sjcl.codec.utf8String.fromBits(decryptedBits) 73 | } 74 | 75 | -------------------------------------------------------------------------------- /src/commands/diagnose.ts: -------------------------------------------------------------------------------- 1 | import {Flags} from '@oclif/core'; 2 | 3 | import Command, {ipFlag} from '../base-command'; 4 | import {DiscoveryOptions} from '../modem/discovery'; 5 | import DocsisDiagnose, {colorize} from '../modem/docsis-diagnose'; 6 | import {TablePrinter} from '../modem/printer'; 7 | import {webDiagnoseLink} from '../modem/web-diagnose'; 8 | import {getDocsisStatus} from './docsis'; 9 | 10 | export default class Diagnose extends Command { 11 | static description 12 | = 'Diagnose the quality of the docsis connection.'; 13 | static examples = [ 14 | '$ vodafone-station-cli diagnose', 15 | '$ vodafone-station-cli diagnose --ip 192.168.100.1', 16 | ]; 17 | static flags = { 18 | ip: ipFlag(), 19 | password: Flags.string({ 20 | char: 'p', 21 | description: 'router/modem password', 22 | }), 23 | web: Flags.boolean({ 24 | char: 'w', 25 | description: 'review the docsis values in a webapp', 26 | }), 27 | }; 28 | 29 | async run(): Promise { 30 | const {flags} = await this.parse(Diagnose) 31 | 32 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 33 | if (!password || password === '') { 34 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 35 | return 36 | } 37 | 38 | const discoveryOptions: DiscoveryOptions = { 39 | ip: flags.ip, 40 | } 41 | 42 | try { 43 | const docsisStatus = await getDocsisStatus(password!, this.logger, discoveryOptions) 44 | const diagnoser = new DocsisDiagnose(docsisStatus) 45 | const tablePrinter = new TablePrinter(docsisStatus); 46 | this.log(tablePrinter.print()) 47 | 48 | if (diagnoser.hasDeviations()) { 49 | this.log(colorize('yellow', 'Warning: Docsis connection quality deviation found!')); 50 | } 51 | 52 | if (flags.web) { 53 | this.log(`Review your docsis state online -> ${webDiagnoseLink(docsisStatus)}`) 54 | } 55 | 56 | this.log(diagnoser.printDeviationsConsole()) 57 | } catch (error) { 58 | this.error(error as Error, {message: 'Something went wrong'}) 59 | } 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /src/commands/host-exposure/get.ts: -------------------------------------------------------------------------------- 1 | import {Flags} from '@oclif/core' 2 | 3 | import Command, {ipFlag} from '../../base-command' 4 | import {Log} from '../../logger' 5 | import {discoverModemLocation, DiscoveryOptions, ModemDiscovery} from '../../modem/discovery' 6 | import {modemFactory} from '../../modem/factory' 7 | import {HostExposureSettings} from '../../modem/modem' 8 | 9 | export async function getHostExposureSettings(password: string, logger: Log, discoveryOptions?: DiscoveryOptions): Promise { 10 | const modemLocation = await discoverModemLocation(discoveryOptions) 11 | const discoveredModem = await new ModemDiscovery(modemLocation, logger).discover() 12 | const modem = modemFactory(discoveredModem, logger) 13 | try { 14 | await modem.login(password) 15 | const settings = await modem.getHostExposure() 16 | return settings 17 | } catch (error) { 18 | console.error('Could not get host exposure settings from modem.', error) 19 | throw error 20 | } finally { 21 | await modem.logout() 22 | } 23 | } 24 | 25 | export default class GetHostExposure extends Command { 26 | static description = 'Get the current IPV6 host exposure settings'; 27 | static examples = [ 28 | `$ vodafone-station-cli host-exposure:get -p PASSWORD 29 | {JSON data} 30 | `, 31 | `$ vodafone-station-cli host-exposure:get -p PASSWORD --ip 192.168.100.1 32 | {JSON data} 33 | `, 34 | ]; 35 | static flags = { 36 | ip: ipFlag(), 37 | password: Flags.string({ 38 | char: 'p', 39 | description: 'router/modem password', 40 | }), 41 | }; 42 | 43 | async run(): Promise { 44 | const {flags} = await this.parse(GetHostExposure) 45 | 46 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 47 | if (!password || password === '') { 48 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 49 | return 50 | } 51 | 52 | const discoveryOptions: DiscoveryOptions = { 53 | ip: flags.ip, 54 | } 55 | 56 | try { 57 | const settings = await getHostExposureSettings(password!, this.logger, discoveryOptions) 58 | const settingsJSON = JSON.stringify(settings, undefined, 4) 59 | this.log(settingsJSON) 60 | } catch (error) { 61 | this.error(error as Error, {message: 'Something went wrong.'}) 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/modem/tools/html-parser.ts: -------------------------------------------------------------------------------- 1 | import type {ArrisDocsisChannelStatus, ArrisDocsisStatus} from '../arris-modem' 2 | 3 | const nonceMatcher = /var csp_nonce = "(?.*?)";/gm 4 | const ivMatcher = /var myIv = ["|'](?.*?)["|'];/gm 5 | const saltMatcher = /var mySalt = ["|'](?.*?)["|'];/gm 6 | const sessionIdMatcher = /var currentSessionId = ["|'](?.*?)["|'];/gm 7 | const swVersionMatcher = /_ga.swVersion = ["|'](?.*?)["|'];/gm 8 | 9 | export interface CryptoVars { 10 | iv: string; 11 | nonce: string; 12 | salt: string; 13 | sessionId: string; 14 | } 15 | 16 | export function extractCryptoVars(html: string): CryptoVars { 17 | const nonce = nonceMatcher.exec(html)?.groups?.nonce 18 | const iv = ivMatcher.exec(html)?.groups?.iv 19 | const salt = saltMatcher.exec(html)?.groups?.salt 20 | const sessionId = sessionIdMatcher.exec(html)?.groups?.sessionId 21 | return { 22 | iv, nonce, salt, sessionId, 23 | } as CryptoVars 24 | } 25 | 26 | export function extractFirmwareVersion(html: string): string | undefined { 27 | return swVersionMatcher.exec(html)?.groups?.swVersion 28 | } 29 | 30 | export function extractDocsisStatus( 31 | html: string, 32 | date: Date = new Date(), 33 | ): ArrisDocsisStatus { 34 | const docsisMatcher = { 35 | dsChannels: /js_dsNums = ["|'](?.*?)["|'];/gm, 36 | dsData: /json_dsData = (?.*?);/gm, 37 | ofdmChannels: /js_ofdmNums = ["|'](?.*?)["|'];/gm, 38 | usChannels: /js_usNums = ["|'](?.*?)["|'];/gm, 39 | usData: /json_usData = (?.*?);/gm, 40 | } 41 | 42 | const downstream = docsisMatcher.dsData.exec(html)?.groups?.dsData ?? '[]' 43 | const upstream = docsisMatcher.usData.exec(html)?.groups?.usData ?? '[]' 44 | const downstreamChannels 45 | = docsisMatcher.dsChannels.exec(html)?.groups?.dsChannels ?? '0' 46 | const upstreamChannels 47 | = docsisMatcher.usChannels.exec(html)?.groups?.usChannels ?? '0' 48 | const ofdmChannels 49 | = docsisMatcher.ofdmChannels.exec(html)?.groups?.ofdmChannels ?? '0' 50 | return { 51 | downstream: JSON.parse(downstream) as ArrisDocsisChannelStatus[], 52 | downstreamChannels: Number.parseInt(downstreamChannels, 10), 53 | ofdmChannels: Number.parseInt(ofdmChannels, 10), 54 | time: date.toISOString(), 55 | upstream: JSON.parse(upstream) as ArrisDocsisChannelStatus[], 56 | upstreamChannels: Number.parseInt(upstreamChannels, 10), 57 | } 58 | } 59 | 60 | export function extractCredentialString(html: string): string { 61 | const matcher = /createCookie\([\n]*\s*"credential"\s*,[\n]*\s*["|'](?.*?)["|']\s*/gims 62 | return matcher.exec(html)?.groups?.credential ?? '' 63 | } 64 | -------------------------------------------------------------------------------- /src/commands/host-exposure/set.ts: -------------------------------------------------------------------------------- 1 | import {Args, Flags} from '@oclif/core' 2 | import {readFile} from 'node:fs/promises' 3 | 4 | import Command, {ipFlag} from '../../base-command' 5 | import {Log} from '../../logger' 6 | import {discoverModemLocation, DiscoveryOptions, ModemDiscovery} from '../../modem/discovery' 7 | import {modemFactory} from '../../modem/factory' 8 | import {HostExposureSettings} from '../../modem/modem' 9 | 10 | export async function setHostExposureSettings( 11 | settings: HostExposureSettings, 12 | password: string, 13 | logger: Log, 14 | discoveryOptions?: DiscoveryOptions, 15 | ): Promise { 16 | const modemLocation = await discoverModemLocation(discoveryOptions) 17 | const discoveredModem = await new ModemDiscovery(modemLocation, logger).discover() 18 | const modem = modemFactory(discoveredModem, logger) 19 | try { 20 | await modem.login(password) 21 | await modem.setHostExposure(settings) 22 | return settings 23 | } catch (error) { 24 | logger.error('Could not get host exposure settings from modem.', error) 25 | throw error 26 | } finally { 27 | await modem.logout() 28 | } 29 | } 30 | 31 | export default class SetHostExposure extends Command { 32 | static args = { 33 | file: Args.string({ 34 | description: 'input JSON file', 35 | required: true, 36 | }), 37 | } 38 | static description = 'Set the current IPV6 host exposure settings from a JSON file' 39 | static examples = [ 40 | '$ vodafone-station-cli host-exposure:set -p PASSWORD ', 41 | '$ vodafone-station-cli host-exposure:set -p PASSWORD --ip 192.168.100.1 ', 42 | ] 43 | static flags = { 44 | ip: ipFlag(), 45 | password: Flags.string({ 46 | char: 'p', 47 | description: 'router/modem password', 48 | }), 49 | } 50 | 51 | async run(): Promise { 52 | const {args, flags} = await this.parse(SetHostExposure) 53 | 54 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 55 | if (!password || password === '') { 56 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 57 | return 58 | } 59 | 60 | const discoveryOptions: DiscoveryOptions = { 61 | ip: flags.ip, 62 | } 63 | 64 | try { 65 | const newSettingsJSON = await readFile(args.file, {encoding: 'utf8'}) 66 | const newSettings = JSON.parse(newSettingsJSON) as HostExposureSettings 67 | await setHostExposureSettings(newSettings, password!, this.logger, discoveryOptions) 68 | this.log('New host exposure settings set.') 69 | } catch (error) { 70 | this.error(error as Error, {message: 'Something went wrong.'}) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vodafone-station-cli", 3 | "description": "Access your Vodafone Station from the comfort of the command line.", 4 | "version": "1.5.3", 5 | "author": "Dobroslav Totev", 6 | "bin": { 7 | "vodafone-station-cli": "./bin/run" 8 | }, 9 | "homepage": "https://github.com/totev/vodafone-station-cli", 10 | "license": "MIT", 11 | "main": "dist/index.js", 12 | "bugs": "https://github.com/totev/vodafone-station-cli/issues", 13 | "repository": "totev/vodafone-station-cli", 14 | "files": [ 15 | "/bin", 16 | "/dist", 17 | "!/dist/**/__fixtures__/", 18 | "!/dist/**/*.test.*", 19 | "/npm-shrinkwrap.json", 20 | "/oclif.manifest.json", 21 | "/yarn.lock" 22 | ], 23 | "keywords": [ 24 | "vodafone", 25 | "station", 26 | "cli", 27 | "docsis", 28 | "cable-modem" 29 | ], 30 | "dependencies": { 31 | "@oclif/core": "^4.4.0", 32 | "@oclif/plugin-help": "^6.2.29", 33 | "@oclif/plugin-plugins": "^5.4.40", 34 | "axios": "^1.9.0", 35 | "axios-cookiejar-support": "^6.0.2", 36 | "brotli": "^1.3.3", 37 | "dotenv": "^16.6.1", 38 | "http-cookie-agent": "^7.0.1", 39 | "sjcl": "1.0.8", 40 | "tough-cookie": "^5.1.2", 41 | "tslib": "^2.8.1" 42 | }, 43 | "devDependencies": { 44 | "@oclif/prettier-config": "^0.2.1", 45 | "@oclif/test": "^4.1.13", 46 | "@types/jest": "^29.5.14", 47 | "@types/node": "^22.19.1", 48 | "@types/sjcl": "^1.0.34", 49 | "@types/tough-cookie": "^4.0.5", 50 | "@typescript-eslint/eslint-plugin": "^8.33.1", 51 | "eslint": "^9.31.0", 52 | "eslint-config-oclif": "^6.0.68", 53 | "eslint-config-prettier": "^10.1.8", 54 | "globby": "^14.1.0", 55 | "jest": "^30.0.0", 56 | "oclif": "^4.18.1", 57 | "shx": "^0.4.0", 58 | "ts-jest": "^29.4.5", 59 | "ts-node": "^10.9.2", 60 | "typescript": "^5.8.3", 61 | "typescript-eslint": "^8.34.0" 62 | }, 63 | "oclif": { 64 | "bin": "vodafone-station-cli", 65 | "dirname": "vodafone-station-cli", 66 | "commands": "./dist/commands", 67 | "plugins": [ 68 | "@oclif/plugin-help", 69 | "@oclif/plugin-plugins" 70 | ], 71 | "topicSeparator": " ", 72 | "topics": { 73 | "host-exposure": { 74 | "description": "Manage IPv6 host exposure settings." 75 | } 76 | } 77 | }, 78 | "scripts": { 79 | "build": "shx rm -rf dist && tsc -b", 80 | "build:standalone": "oclif pack tarballs", 81 | "lint": "eslint . --ext .ts", 82 | "postpack": "shx rm -f oclif.manifest.json", 83 | "prepack": "yarn build && oclif manifest && oclif readme", 84 | "test": "jest", 85 | "version": "oclif readme && git add README.md" 86 | }, 87 | "engines": { 88 | "node": ">=20.0.0" 89 | }, 90 | "types": "dist/index.d.ts" 91 | } -------------------------------------------------------------------------------- /src/modem/printer.ts: -------------------------------------------------------------------------------- 1 | import type {DocsisStatus, HumanizedDocsis31ChannelStatus, HumanizedDocsisChannelStatus} from './modem'; 2 | 3 | export class TablePrinter { 4 | head = [ 5 | 'ID', 6 | 'Ch. Type', 7 | 'Modulation', 8 | 'Power', 9 | 'Frequency', 10 | ' Lock status ', 11 | 'SNR ', 12 | ] 13 | 14 | constructor(private docsisStatus: DocsisStatus) {} 15 | 16 | get lineSeparator(): string { 17 | const dash = '-'; 18 | const plus = '+' 19 | const dashes = this.spacedHead.map(word => dash.repeat(word.length)) 20 | return ['', ...dashes, ''].join(plus) 21 | } 22 | 23 | get spacedHead(): string[] { 24 | return this.head.map(header => ` ${header} `) 25 | } 26 | 27 | docsis31StatusToRow(rowObjects: HumanizedDocsis31ChannelStatus[]): string { 28 | return rowObjects?.map(channelStatus => [ 29 | channelStatus.channelId, 30 | channelStatus.channelType, 31 | channelStatus.modulation, 32 | channelStatus.powerLevel, 33 | `${channelStatus.frequencyStart}-${channelStatus.frequencyEnd}`, 34 | channelStatus.lockStatus, 35 | channelStatus.snr, 36 | ]) 37 | .map(rowValues => this.tableRow(...rowValues)) 38 | .join('\n') ?? '' 39 | } 40 | 41 | docsisStatusToRow(rowObjects: HumanizedDocsisChannelStatus[]): string { 42 | return rowObjects?.map(channelStatus => [ 43 | channelStatus.channelId, 44 | channelStatus.channelType, 45 | channelStatus.modulation, 46 | channelStatus.powerLevel, 47 | channelStatus.frequency, 48 | channelStatus.lockStatus, 49 | channelStatus.snr, 50 | ]) 51 | .map(rowValues => this.tableRow(...rowValues)) 52 | .join('\n') ?? '' 53 | } 54 | 55 | lineText(...words: Array): string { 56 | const paddedWords = words.map((word, index) => { 57 | const headerWord = this.head[index]; 58 | return String(word).padEnd(headerWord.length, ' ') 59 | }) 60 | return ['', ...paddedWords, ''].join(' | ').trim() 61 | } 62 | 63 | print(): string { 64 | const header = this.tableHeader(); 65 | const downstream = this.docsisStatusToRow(this.docsisStatus.downstream) 66 | const downstreamOfdm = this.docsis31StatusToRow(this.docsisStatus.downstreamOfdm) 67 | const upstream = this.docsisStatusToRow(this.docsisStatus.upstream) 68 | const upstreamOfdma = this.docsis31StatusToRow(this.docsisStatus.upstreamOfdma) 69 | 70 | return ` 71 | Downstream\n${header}\n${downstream}\n 72 | Downstream OFDM\n${header}\n${downstreamOfdm}\n 73 | Upstream\n${header}\n${upstream}\n 74 | Upstream OFDMA\n${header}\n${upstreamOfdma} 75 | ` 76 | } 77 | 78 | tableHeader(): string { 79 | return `${this.lineSeparator}\n${this.lineText(...this.head)}\n${this.lineSeparator}` 80 | } 81 | 82 | tableRow(...words: Array): string { 83 | return `${this.lineText(...words)}\n${this.lineSeparator}` 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/commands/docsis.ts: -------------------------------------------------------------------------------- 1 | import {Flags} from '@oclif/core' 2 | import {promises as fsp} from 'node:fs' 3 | 4 | import Command, {ipFlag} from '../base-command' 5 | import {Log} from '../logger' 6 | import {discoverModemLocation, DiscoveryOptions, ModemDiscovery} from '../modem/discovery' 7 | import {colorize} from '../modem/docsis-diagnose' 8 | import {modemFactory} from '../modem/factory' 9 | import {DocsisStatus} from '../modem/modem' 10 | import {webDiagnoseLink} from '../modem/web-diagnose' 11 | 12 | export async function getDocsisStatus(password: string, logger: Log, discoveryOptions?: DiscoveryOptions): Promise { 13 | const modemLocation = await discoverModemLocation(discoveryOptions) 14 | const discoveredModem = await new ModemDiscovery(modemLocation, logger).discover() 15 | const modem = modemFactory(discoveredModem, logger) 16 | try { 17 | await modem.login(password) 18 | const docsisData = await modem.docsis() 19 | return docsisData 20 | } catch (error) { 21 | logger.log(colorize('red', 'Could not fetch docsis status from modem.')) 22 | logger.error(error as Error) 23 | throw error 24 | } finally { 25 | await modem.logout() 26 | } 27 | } 28 | 29 | export default class Docsis extends Command { 30 | static description = 'Get the current docsis status as reported by the modem in a JSON format.' 31 | static examples = [ 32 | `$ vodafone-station-cli docsis -p PASSWORD 33 | {JSON data} 34 | `, 35 | `$ vodafone-station-cli docsis -p PASSWORD --ip 192.168.100.1 36 | {JSON data} 37 | `, 38 | ] 39 | static flags = { 40 | file: Flags.boolean({ 41 | char: 'f', 42 | description: 'write out a report file under ./reports/{CURRENT_UNIX_TIMESTAMP}_docsisStatus.json', 43 | }), 44 | ip: ipFlag(), 45 | password: Flags.string({ 46 | char: 'p', 47 | description: 'router/modem password', 48 | }), 49 | web: Flags.boolean({ 50 | char: 'w', 51 | description: 'review the docsis values in a webapp', 52 | }), 53 | } 54 | 55 | async run(): Promise { 56 | const {flags} = await this.parse(Docsis) 57 | const password = flags.password ?? process.env.VODAFONE_ROUTER_PASSWORD 58 | if (!password || password === '') { 59 | this.log('You must provide a password either using -p or by setting the environment variable VODAFONE_ROUTER_PASSWORD') 60 | return 61 | } 62 | 63 | const discoveryOptions: DiscoveryOptions = { 64 | ip: flags.ip, 65 | } 66 | 67 | try { 68 | const docsisStatus = await getDocsisStatus(password!, this.logger, discoveryOptions) 69 | const docsisStatusJSON = JSON.stringify(docsisStatus, undefined, 4) 70 | 71 | if (flags.file) { 72 | await this.writeDocsisStatus(docsisStatusJSON) 73 | } else { 74 | this.log(docsisStatusJSON) 75 | } 76 | 77 | if (flags.web) { 78 | this.log(`Review your docsis state online -> ${webDiagnoseLink(docsisStatus)}`) 79 | } 80 | } catch (error) { 81 | this.error(error as Error, {message: 'Something went wrong'}) 82 | } 83 | } 84 | 85 | async writeDocsisStatus(docsisStatusJson: string): Promise { 86 | const reportFile = `./reports/${Date.now()}_docsisStatus.json` 87 | this.log('Writing docsis report as json to file: ', reportFile) 88 | const data = new Uint8Array(Buffer.from(docsisStatusJson)) 89 | return fsp.writeFile(reportFile, data) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/modem/printer.test.ts: -------------------------------------------------------------------------------- 1 | import { DocsisStatus, HumanizedDocsisChannelStatus } from "./modem"; 2 | import { TablePrinter } from "./printer"; 3 | import fixtureDocsisStatus from './__fixtures__/docsisStatus_normalized.json'; 4 | 5 | 6 | describe('TablePrinter', () => { 7 | const printer = new TablePrinter(fixtureDocsisStatus as any) 8 | 9 | test('spacedHead spaces with one space before and after string', () => { 10 | expect(printer.spacedHead).toHaveLength(printer.head.length) 11 | expect(printer.spacedHead.map(head => head.length)).toEqual(printer.head.map(head => head.length + 2)) 12 | }); 13 | 14 | test('lineSeparator', () => { 15 | const expected = "+----+----------+------------+-------+-----------+---------------+------+" 16 | expect(printer.lineSeparator).toEqual(expected) 17 | }); 18 | 19 | test('lineText single word', () => { 20 | expect(printer.lineText('Test')).toEqual('| Test |') 21 | }); 22 | 23 | test('lineText multi words', () => { 24 | expect(printer.lineText('Test', "Test2")).toEqual('| Test | Test2 |') 25 | }); 26 | 27 | test('tableHeader', () => { 28 | const expeced = ` 29 | +----+----------+------------+-------+-----------+---------------+------+ 30 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 31 | +----+----------+------------+-------+-----------+---------------+------+`.trim() 32 | expect(printer.tableHeader()).toEqual(expeced) 33 | }); 34 | 35 | test('tableRow', () => { 36 | const expeced = ` 37 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 38 | +----+----------+------------+-------+-----------+---------------+------+`.trim() 39 | expect(printer.tableRow(...printer.head)).toEqual(expeced) 40 | }); 41 | 42 | test('print', () => { 43 | expect(printer.print()).toMatchSnapshot() 44 | }); 45 | 46 | }); 47 | 48 | test('print with uncompleted lock ', () => { 49 | const expected = ` 50 | Downstream 51 | +----+----------+------------+-------+-----------+---------------+------+ 52 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 53 | +----+----------+------------+-------+-----------+---------------+------+ 54 | 55 | 56 | Downstream OFDM 57 | +----+----------+------------+-------+-----------+---------------+------+ 58 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 59 | +----+----------+------------+-------+-----------+---------------+------+ 60 | 61 | 62 | Upstream 63 | +----+----------+------------+-------+-----------+---------------+------+ 64 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 65 | +----+----------+------------+-------+-----------+---------------+------+ 66 | 67 | 68 | Upstream OFDMA 69 | +----+----------+------------+-------+-----------+---------------+------+ 70 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 71 | +----+----------+------------+-------+-----------+---------------+------+ 72 | | 9 | OFDMA | 16QAM | NaN | 29.8-NaN | Not Completed | 0 | 73 | +----+----------+------------+-------+-----------+---------------+------+ 74 | ` 75 | const printer = new TablePrinter({ 76 | upstreamOfdma: [{ 77 | channelId: "9", 78 | channelType: "OFDMA", 79 | modulation: "16QAM", 80 | powerLevel: NaN, 81 | frequencyStart: 29.8, 82 | frequencyEnd: NaN, 83 | lockStatus: "Not Completed", 84 | snr: 0 85 | 86 | }] 87 | } as DocsisStatus) 88 | 89 | expect(printer.print()).toStrictEqual(expected) 90 | }); -------------------------------------------------------------------------------- /src/modem/technicolor-modem.test.ts: -------------------------------------------------------------------------------- 1 | import fixtureDocsis31Status from './__fixtures__/docsisStatus_ofdma_technicolor.json' 2 | import fixtureDocsisStatus from './__fixtures__/docsisStatus_technicolor.json' 3 | import { normalizeChannelStatus, normalizeDocsisStatus, normalizeOfdmChannelStatus, normalizeUpstreamChannelStatus, normalizeUpstreamOfdmaChannelStatus, TechnicolorDocsisStatus } from './technicolor-modem' 4 | 5 | 6 | test('normalizeChannelStatus with SC-QAM channel', () => { 7 | const nativeStatus = 8 | { 9 | __id: '1', 10 | channelid: '5', 11 | CentralFrequency: '602 MHz', 12 | power: '-4.0 dBmV', 13 | SNR: '38.8 dB', 14 | FFT: '256 QAM', 15 | locked: 'Locked', 16 | ChannelType: 'SC-QAM' 17 | } as const 18 | const status = normalizeChannelStatus(nativeStatus) 19 | expect(status).toEqual( 20 | { 21 | channelId: '5', 22 | channelType: 'SC-QAM', 23 | modulation: '256QAM', 24 | powerLevel: -4, 25 | lockStatus: 'Locked', 26 | snr: 38.8, 27 | frequency: 602 28 | }, 29 | ) 30 | }) 31 | 32 | test('normalizeOfdmChannelStatus with OFDM channel', () => { 33 | const nativeStatus = 34 | { 35 | __id: '1', 36 | channelid_ofdm: '33', 37 | start_frequency: '151 MHz', 38 | end_frequency: '324 MHz', 39 | CentralFrequency_ofdm: '288 MHz', 40 | bandwidth: '171 MHz', 41 | power_ofdm: '-3.2 dBmV', 42 | SNR_ofdm: '39.55 dB', 43 | FFT_ofdm: 'qam256/qam1024', 44 | locked_ofdm: 'Locked', 45 | ChannelType: 'OFDM' 46 | } as const 47 | 48 | const status = normalizeOfdmChannelStatus(nativeStatus) 49 | expect(status).toEqual( 50 | { 51 | channelId: '33', 52 | channelType: 'OFDM', 53 | modulation: '256QAM', 54 | powerLevel: -3.2, 55 | lockStatus: 'Locked', 56 | snr: 39.55, 57 | frequencyStart: 151, 58 | frequencyEnd: 324 59 | } 60 | ) 61 | }) 62 | 63 | test('normalizeUpstreamChannelStatus', () => { 64 | const nativeStatus = { 65 | __id: '1', 66 | channelidup: '1', 67 | CentralFrequency: '51.0 MHz', 68 | power: '49.8 dBmV', 69 | ChannelType: 'SC-QAM', 70 | FFT: '64qam', 71 | RangingStatus: 'Completed' 72 | } as const 73 | expect(normalizeUpstreamChannelStatus(nativeStatus)).toEqual( 74 | { 75 | channelId: '1', 76 | channelType: 'SC-QAM', 77 | frequency: 51, 78 | lockStatus: 'Completed', 79 | modulation: '64QAM', 80 | powerLevel: 49.8, 81 | snr: 0, 82 | }, 83 | ) 84 | }) 85 | 86 | test('normalizeUpstreamOfdmaChannelStatus', () => { 87 | const nativeStatus = { 88 | __id: '1', 89 | channelidup: '9', 90 | start_frequency: '29.800000 MHz', 91 | end_frequency: '64.750000 MHz', 92 | power: '44.0 dBmV', 93 | CentralFrequency: '46 MHz', 94 | bandwidth: '35 MHz', 95 | FFT: 'qpsk', 96 | ChannelType: 'OFDMA', 97 | RangingStatus: 'Completed' 98 | } as const 99 | expect(normalizeUpstreamOfdmaChannelStatus(nativeStatus)).toEqual( 100 | { 101 | channelId: '9', 102 | channelType: 'OFDMA', 103 | frequencyEnd: 64.75, 104 | frequencyStart: 29.8, 105 | lockStatus: 'Completed', 106 | modulation: 'QPSK', 107 | powerLevel: 44, 108 | snr: 0, 109 | }, 110 | ) 111 | }) 112 | 113 | describe('normalizeDocsisStatus', () => { 114 | test('should work with ofdm in download', () => { 115 | const {time, ...status} = normalizeDocsisStatus(fixtureDocsisStatus as TechnicolorDocsisStatus) 116 | expect(status).toMatchSnapshot() 117 | }) 118 | test('should work with ofdm in download and ofdam in upload', () => { 119 | const {time, ...status} = normalizeDocsisStatus(fixtureDocsis31Status as TechnicolorDocsisStatus) 120 | expect(status).toMatchSnapshot() 121 | }) 122 | }) 123 | -------------------------------------------------------------------------------- /src/modem/modem.test.ts: -------------------------------------------------------------------------------- 1 | import { Log } from "../logger"; 2 | import { normalizeModulation } from "./modem"; 3 | import { Technicolor } from "./technicolor-modem"; 4 | 5 | // Mock the cookie agents 6 | jest.mock('http-cookie-agent/http', () => ({ 7 | HttpCookieAgent: jest.fn().mockImplementation(() => ({})), 8 | HttpsCookieAgent: jest.fn().mockImplementation(() => ({})), 9 | })); 10 | 11 | jest.mock('axios', () => ({ 12 | create: jest.fn().mockReturnValue({}), 13 | default: { 14 | create: jest.fn().mockReturnValue({}), 15 | }, 16 | })); 17 | 18 | import axios from 'axios'; 19 | import { HttpCookieAgent, HttpsCookieAgent } from 'http-cookie-agent/http'; 20 | 21 | const mockLogger = { 22 | debug: jest.fn(), 23 | warn: jest.fn(), 24 | error: jest.fn(), 25 | } as unknown as Log; 26 | 27 | describe('Modem axios configuration', () => { 28 | beforeEach(() => { 29 | jest.clearAllMocks(); 30 | }); 31 | 32 | test('should use HttpCookieAgent for HTTP protocol', () => { 33 | new Technicolor('192.168.1.1', 'http', mockLogger); 34 | 35 | expect(HttpCookieAgent).toHaveBeenCalledWith({ 36 | cookies: { jar: expect.any(Object) }, 37 | keepAlive: true, 38 | }); 39 | 40 | expect(HttpsCookieAgent).not.toHaveBeenCalled(); 41 | 42 | expect(axios.create).toHaveBeenCalledWith( 43 | expect.objectContaining({ 44 | httpAgent: expect.any(Object), 45 | }) 46 | ); 47 | 48 | // Verify that httpsAgent is not set 49 | const configArg = (axios.create as jest.Mock).mock.calls[0][0]; 50 | expect(configArg).not.toHaveProperty('httpsAgent'); 51 | }); 52 | 53 | test('should use HttpsCookieAgent for HTTPS protocol', () => { 54 | new Technicolor('192.168.1.1', 'https', mockLogger); 55 | 56 | expect(HttpsCookieAgent).toHaveBeenCalledWith({ 57 | cookies: { jar: expect.any(Object) }, 58 | keepAlive: true, 59 | rejectUnauthorized: false, 60 | }); 61 | 62 | expect(HttpCookieAgent).not.toHaveBeenCalled(); 63 | 64 | expect(axios.create).toHaveBeenCalledWith( 65 | expect.objectContaining({ 66 | httpsAgent: expect.any(Object), 67 | }) 68 | ); 69 | 70 | // Verify that httpAgent is not set 71 | const configArg = (axios.create as jest.Mock).mock.calls[0][0]; 72 | expect(configArg).not.toHaveProperty('httpAgent'); 73 | }); 74 | 75 | test('should configure axios with correct base settings', () => { 76 | new Technicolor('192.168.1.1', 'http', mockLogger); 77 | 78 | expect(axios.create).toHaveBeenCalledWith( 79 | expect.objectContaining({ 80 | baseURL: 'http://192.168.1.1', 81 | headers: { 82 | 'X-Requested-With': 'XMLHttpRequest', 83 | }, 84 | timeout: 45_000, 85 | withCredentials: true, 86 | }) 87 | ); 88 | }); 89 | }); 90 | 91 | test.each` 92 | input | expected 93 | ${"64QAM"} | ${"64QAM"} 94 | ${"64-QAM"} | ${"64QAM"} 95 | ${"64qam"} | ${"64QAM"} 96 | ${"64 qam"} | ${"64QAM"} 97 | ${"1024-qam"} | ${"1024QAM"} 98 | ${"Unknown"} | ${"Unknown"} 99 | `('normalizeModulation($input)', ({ input, expected }) => { 100 | expect(normalizeModulation(input)).toBe(expected); 101 | }); 102 | 103 | test.each` 104 | input | expected 105 | ${"256QAM/1024QAM"} | ${"256QAM"} 106 | ${"64 QAM/1024QAM"} | ${"64QAM"} 107 | ${"1024-qam/1024QAM"} | ${"1024QAM"} 108 | `('normalizeModulation($input)', ({ input, expected }) => { 109 | expect(normalizeModulation(input)).toBe(expected); 110 | }); 111 | 112 | test('normalizeModulation should handle empty input', () => { 113 | expect(() => normalizeModulation('')).toThrow('Empty modulation value received: ""'); 114 | }); 115 | 116 | test('normalizeModulation should handle unknown input', () => { 117 | expect(() => normalizeModulation('invalid')).toThrow('Unknown modulation "invalid" (normalized: "INVALID")'); 118 | }); 119 | -------------------------------------------------------------------------------- /src/modem/discovery.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosResponse} from 'axios'; 2 | import {debug} from 'node:console'; 3 | 4 | import {Log} from '../logger'; 5 | import {TechnicolorConfiguration} from './technicolor-modem'; 6 | import {extractFirmwareVersion} from './tools/html-parser'; 7 | 8 | // Default IP addresses - can be overridden via CLI flags or environment variables 9 | const DEFAULT_BRIDGED_MODEM_IP = '192.168.100.1'; 10 | const DEFAULT_ROUTER_IP = '192.168.0.1'; 11 | 12 | axios.defaults.timeout = 10_000; 13 | 14 | interface ModemLocation { 15 | ipAddress: string; 16 | protocol: Protocol; 17 | } 18 | 19 | export interface DiscoveryOptions { 20 | ip?: string; 21 | } 22 | 23 | export async function discoverModemLocation(options: DiscoveryOptions = {}): Promise { 24 | let defaultIps = [DEFAULT_BRIDGED_MODEM_IP, DEFAULT_ROUTER_IP]; 25 | // If specific IP is provided, only try that IP 26 | if (options.ip) { 27 | defaultIps = [options.ip]; 28 | } 29 | 30 | debug(`Probing for modem at IPs: ${defaultIps.join(', ')}`); 31 | 32 | try { 33 | const headRequests = []; 34 | for (const ip of defaultIps) { 35 | headRequests.push( 36 | axios.head(`http://${ip}`), 37 | axios.head(`https://${ip}`), 38 | ); 39 | } 40 | 41 | const results = await Promise.allSettled(headRequests); 42 | const maybeResult = results.find(result => result.status === 'fulfilled') as undefined | {value: AxiosResponse}; 43 | if (maybeResult?.value.request?.host) { 44 | return { 45 | ipAddress: maybeResult?.value.request?.host, 46 | protocol: maybeResult?.value.request?.protocol.replace(':', '') as Protocol, 47 | }; 48 | } 49 | 50 | throw new Error('Could not find a router/modem under the known addresses.'); 51 | } catch (error) { 52 | console.error('Could not find a router/modem under the known addresses.'); 53 | throw error; 54 | } 55 | } 56 | 57 | export type Protocol = 'http' | 'https'; 58 | 59 | export interface ModemInformation { 60 | deviceType: 'Arris' | 'Technicolor'; 61 | firmwareVersion: string; 62 | ipAddress: string; 63 | protocol: Protocol; 64 | } 65 | 66 | export class ModemDiscovery { 67 | constructor( 68 | private readonly modemLocation: ModemLocation, 69 | private readonly logger: Log, 70 | ) {} 71 | 72 | async discover(): Promise { 73 | try { 74 | const maybeModem = await Promise.any([ 75 | this.tryArris(), 76 | this.tryTechnicolor(), 77 | ]); 78 | if (!maybeModem) { 79 | throw new Error('Modem discovery was unsuccessful'); 80 | } 81 | 82 | return maybeModem; 83 | } catch (error) { 84 | this.logger.log('yellow', 'Could not find a router/modem under the known addresses'); 85 | throw error; 86 | } 87 | } 88 | 89 | async tryArris(): Promise { 90 | const {ipAddress, protocol} = this.modemLocation; 91 | 92 | const {data} = await axios.get(`${protocol}://${ipAddress}/index.php`, { 93 | headers: { 94 | Accept: 'text/html,application/xhtml+xml,application/xml', 95 | }, 96 | }); 97 | const firmwareVersion = extractFirmwareVersion(data as string); 98 | if (!firmwareVersion) { 99 | throw new Error('Unable to parse firmware version.'); 100 | } 101 | 102 | return { 103 | deviceType: 'Arris', 104 | firmwareVersion, 105 | ipAddress, 106 | protocol, 107 | }; 108 | } 109 | 110 | async tryTechnicolor(): Promise { 111 | const {ipAddress, protocol} = this.modemLocation; 112 | const {data} = await axios.get(`${protocol}://${ipAddress}/api/v1/login_conf`); 113 | this.logger.debug(`Technicolor login configuration: ${JSON.stringify(data)}`); 114 | if (data.error === 'ok' && data.data?.firmwareversion) { 115 | return { 116 | deviceType: 'Technicolor', 117 | firmwareVersion: data.data.firmwareversion, 118 | ipAddress, 119 | protocol, 120 | }; 121 | } 122 | 123 | throw new Error('Could not determine modem type'); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/modem/tools/__fixtures__/index.php.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 15 | 16 | 50 | 51 | 81 | 82 | 83 | 84 | 85 | asda asdasd as 86 | 87 | 88 | 89 |

Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium.

90 | 91 |

Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Etiam rhoncus. Maecenas tempus, tellus eget condimentum rhoncus, sem quam semper libero, sit amet adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et ante tincidunt tempus. Donec vitae sapien ut libero venenatis faucibus. Nullam quis ante. Etiam sit amet orci eget eros faucibus tincidunt. Duis leo. Sed fringilla mauris sit amet nibh. Donec sodales sagittis magna. Sed consequat, leo eget bibendum sodales, augue velit cursus nunc,

92 | 93 | 94 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | v1.5.3 2 | --- 3 | - **🔧 Fix**: Fixed discover command throwing unnecessary ExitError by removing explicit `this.exit()` call 4 | 5 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.5.2...v1.5.3 6 | 7 | 8 | v1.5.2 9 | --- 10 | - **🚨 Critical Fix**: Fixed "Cannot find module 'tslib'" runtime error by moving `tslib` from devDependencies to dependencies 11 | 12 | 13 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.5.1...v1.5.2 14 | 15 | v1.5.1 16 | --- 17 | - **Documentation updates**: Updated README.md with latest dependency versions 18 | 19 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.5.0...v1.5.1 20 | 21 | v1.5.0 22 | --- 23 | - **IP Flag Support**: Added IP flag support across all commands for flexible modem/router address specification 24 | - **Enhanced Discovery**: Improved modem discovery with support for custom IP addresses 25 | - **Documentation**: Updated help text and examples for IP flag usage 26 | 27 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.4.0...v1.5.0 28 | 29 | v1.4.0 30 | --- 31 | - **🔐 HTTPS Support**: Added comprehensive HTTPS support for secure modem communication 32 | - **Automatic Protocol Detection**: Smart discovery automatically tries both HTTP and HTTPS endpoints 33 | - **HTTPS Cookie Management**: Integrated `HttpsCookieAgent` for secure cookie handling in HTTPS sessions 34 | - **Protocol-Aware URLs**: Dynamic URL construction based on detected protocol support 35 | - **Secure Authentication**: Login credentials transmitted over encrypted connections when HTTPS is available 36 | - **Graceful Fallback**: Automatically falls back to HTTP when HTTPS is not supported 37 | **New Dependencies**: 38 | - Added `http-cookie-agent` for HTTPS cookie management 39 | 40 | **Security Benefits**: 41 | - Encrypted API communication when HTTPS is available 42 | - Secure session management with encrypted cookies 43 | 44 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.3.0...v1.4.0 45 | 46 | v1.3.0 47 | --- 48 | - **Major dependency updates**: Updated all dependencies to their latest versions 49 | - **ESLint 9 migration**: Migrated from legacy `.eslintrc` to modern `eslint.config.js` flat config format 50 | - **Enhanced ES module support**: Updated Jest configuration to properly handle ES modules from updated dependencies 51 | - **Security improvements**: Fixed security vulnerabilities in dependencies with `npm audit fix` 52 | - **Removed deprecated packages**: Replaced deprecated `eslint-config-oclif-typescript` with modern alternatives 53 | - **Improved linting**: Enhanced ESLint configuration to only lint source code, excluding tests and build artifacts 54 | - **Build system modernization**: Updated build tools and configurations for better compatibility 55 | 56 | **Breaking Changes**: 57 | - **Node.js 20+ required**: Updated minimum Node.js requirement from 18.x to 20.x due to updated dependencies 58 | - Updated CI to use Node.js 20 59 | 60 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.11...v1.3.0 61 | 62 | v1.2.8 63 | --- 64 | - Updated dependencies 65 | 66 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.7...v1.2.8 67 | 68 | v1.2.7 69 | --- 70 | - Third party dependencies updated 71 | - Fixed npm package contents 72 | 73 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.6...v1.2.7 74 | 75 | v1.2.6 76 | --- 77 | - Added a new pretty printer for console output 78 | 79 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.5...v1.2.6 80 | 81 | v1.2.5 82 | --- 83 | - Updated dependencies 84 | - Fixed technicolor restart issues 85 | 86 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.4...v1.2.5 87 | 88 | v1.2.4 89 | --- 90 | - Fix modulation value normalization on technicolor modems #23 91 | 92 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.3...v1.2.4 93 | 94 | v1.2.3 95 | --- 96 | - Add docsis values sharing via https://docsis-diagnose.totev.dev 97 | 98 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.2...v1.2.3 99 | 100 | v1.2.2 101 | --- 102 | - Respect log level 103 | 104 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.1...v1.2.2 105 | 106 | v1.2.1 107 | --- 108 | - Updated to the newest oclif version 109 | - Cleaned up logging outputs 110 | 111 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.2.0...v1.2.1 112 | 113 | v1.2.0 114 | --- 115 | - Added basic diagnosing functionality based on the docsis values from [vodafonekabelforum.de](https://www.vodafonekabelforum.de/viewtopic.php?t=32353) 116 | 117 | **Full Changelog**: https://github.com/totev/vodafone-station-cli/compare/v1.1.5...v1.1.5 118 | 119 | v1.1.5 120 | --- 121 | - Fixed the powerLevel parsing for arris modems - was dB(μV) but should have been dB(mV) 122 | 123 | v1.1.4 124 | --- 125 | - Add HTTP Referrer for Technicolor CGA4322DE Firmware: 2.0.17-IMS-KDG 126 | 127 | v1.1.3 128 | --- 129 | - Added support for Technicolor CGA6444VF 130 | - Minor bugfixes 131 | 132 | v1.1.2 133 | --- 134 | - Bugfixes related to parallel promise resolution 135 | - Updated telegraf configuration 136 | 137 | v1.1.1 138 | --- 139 | - Added support for Technicolor modems 140 | - Abstracted all of the modem logic to support multiple brands as adapters 141 | - Fixed timeout issues when modems take too long to response 142 | - Updated dependencies 143 | - Added automatic releases 144 | -------------------------------------------------------------------------------- /src/modem/tools/__fixtures__/status_docsis_data.php.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | -------------------------------------------------------------------------------- /src/modem/modem.ts: -------------------------------------------------------------------------------- 1 | import axios, {AxiosInstance, AxiosRequestConfig} from 'axios' 2 | import {HttpCookieAgent, HttpsCookieAgent} from 'http-cookie-agent/http' 3 | import {CookieJar} from 'tough-cookie' 4 | 5 | import type {Protocol as HttpProtocol} from './discovery' 6 | 7 | import {Log} from '../logger' 8 | 9 | export type DocsisChannelType = 'OFDM' | 'OFDMA' | 'SC-QAM' 10 | 11 | export type Modulation = '16QAM' | '32QAM' | '64QAM' | '256QAM' | '1024QAM' | '2048QAM' | '4096QAM' | 'QPSK' | 'Unknown' 12 | 13 | export type Protocol = 'TCP' | 'UDP' 14 | 15 | export interface HumanizedDocsisChannelStatus { 16 | channelId: string 17 | channelType: DocsisChannelType 18 | frequency: number // MHz 19 | lockStatus: string 20 | modulation: Modulation 21 | powerLevel: number // dBmV 22 | snr: number // dB 23 | } 24 | 25 | export interface DiagnosedDocsisChannelStatus 26 | extends HumanizedDocsisChannelStatus { 27 | diagnose: Diagnose 28 | } 29 | export interface DiagnosedDocsis31ChannelStatus 30 | extends HumanizedDocsis31ChannelStatus { 31 | diagnose: Diagnose 32 | } 33 | 34 | export interface Diagnose { 35 | color: 'green' | 'red' | 'yellow' 36 | description: string 37 | deviation: boolean 38 | } 39 | 40 | export interface HumanizedDocsis31ChannelStatus extends Omit { 41 | frequencyEnd: number // MHz 42 | frequencyStart: number // MHz 43 | } 44 | 45 | export interface DocsisStatus { 46 | downstream: HumanizedDocsisChannelStatus[] 47 | downstreamOfdm: HumanizedDocsis31ChannelStatus[] 48 | time: string 49 | upstream: HumanizedDocsisChannelStatus[] 50 | upstreamOfdma: HumanizedDocsis31ChannelStatus[] 51 | } 52 | 53 | export interface DiagnosedDocsisStatus { 54 | downstream: DiagnosedDocsisChannelStatus[] 55 | downstreamOfdm: DiagnosedDocsis31ChannelStatus[] 56 | time: string 57 | upstream: DiagnosedDocsisChannelStatus[] 58 | upstreamOfdma: DiagnosedDocsis31ChannelStatus[] 59 | } 60 | 61 | export interface ExposedHostSettings { 62 | enabled: boolean 63 | endPort: number 64 | index: number 65 | mac: string 66 | protocol: Protocol 67 | serviceName: string 68 | startPort: number 69 | } 70 | 71 | export interface HostExposureSettings { 72 | hosts: ExposedHostSettings[] 73 | } 74 | 75 | export interface GenericModem { 76 | docsis(): Promise 77 | getHostExposure(): Promise 78 | login(password: string): Promise 79 | logout(): Promise 80 | restart(): Promise 81 | } 82 | 83 | export abstract class Modem implements GenericModem { 84 | static USERNAME = 'admin' 85 | protected readonly cookieJar: CookieJar 86 | protected readonly httpClient: AxiosInstance 87 | 88 | constructor( 89 | protected readonly modemIp: string, 90 | protected readonly protocol: HttpProtocol, 91 | protected readonly logger: Log, 92 | ) { 93 | this.cookieJar = new CookieJar() 94 | this.httpClient = this.initAxios() 95 | } 96 | 97 | get baseUrl(): string { 98 | return `${this.protocol}://${this.modemIp}` 99 | } 100 | 101 | docsis(): Promise { 102 | throw new Error('Method not implemented.') 103 | } 104 | 105 | getHostExposure(): Promise { 106 | throw new Error('Method not implemented.') 107 | } 108 | 109 | login(_password: string): Promise { 110 | throw new Error('Method not implemented.') 111 | } 112 | 113 | logout(): Promise { 114 | throw new Error('Method not implemented.') 115 | } 116 | 117 | restart(): Promise { 118 | throw new Error('Method not implemented.') 119 | } 120 | 121 | setHostExposure(_: HostExposureSettings): Promise { 122 | throw new Error('Method not implemented.') 123 | } 124 | 125 | private initAxios(): AxiosInstance { 126 | const config: AxiosRequestConfig = { 127 | baseURL: this.baseUrl, 128 | headers: { 129 | 'X-Requested-With': 'XMLHttpRequest', 130 | }, 131 | timeout: 45_000, 132 | withCredentials: true, 133 | } 134 | 135 | if (this.protocol === 'https') { 136 | config.httpsAgent = new HttpsCookieAgent({ 137 | cookies: {jar: this.cookieJar}, 138 | keepAlive: true, 139 | rejectUnauthorized: false, // the modems have a self signed ssl certificate 140 | }) 141 | } else { 142 | config.httpAgent = new HttpCookieAgent({ 143 | cookies: {jar: this.cookieJar}, 144 | keepAlive: true, 145 | }) 146 | } 147 | 148 | return axios.create(config) 149 | } 150 | } 151 | 152 | export function normalizeModulation(modulation: string): Modulation { 153 | let normalizedModulation = modulation 154 | 155 | // Handle empty or undefined modulation 156 | if (!modulation || modulation.trim() === '') { 157 | throw new Error(`Empty modulation value received: "${modulation}"`) 158 | } 159 | 160 | // Handle slash-separated values by taking the first one 161 | if (modulation.includes('/')) { 162 | return normalizeModulation(modulation.split('/')[0]) 163 | } 164 | 165 | // Remove dashes and spaces 166 | if (modulation.includes('-')) { 167 | normalizedModulation = modulation.split('-').join('') 168 | } 169 | 170 | if (modulation.includes(' ')) { 171 | normalizedModulation = modulation.split(' ').join('') 172 | } 173 | 174 | // Convert to uppercase 175 | normalizedModulation = normalizedModulation.toUpperCase() 176 | 177 | // Handle "Unknown" case 178 | if (normalizedModulation === 'UNKNOWN') { 179 | return 'Unknown' 180 | } 181 | 182 | // Handle formats like "QAM256" -> "256QAM" 183 | if (normalizedModulation.startsWith('QAM') && normalizedModulation.length > 3) { 184 | const number = normalizedModulation.slice(3) 185 | normalizedModulation = `${number}QAM` 186 | } 187 | 188 | // Validate against known modulations 189 | const validModulations: Modulation[] = ['16QAM', '32QAM', '64QAM', '256QAM', '1024QAM', '2048QAM', '4096QAM', 'QPSK', 'Unknown'] 190 | 191 | if (validModulations.includes(normalizedModulation as Modulation)) { 192 | return normalizedModulation as Modulation 193 | } 194 | 195 | throw new Error(`Unknown modulation "${modulation}" (normalized: "${normalizedModulation}")`) 196 | } 197 | -------------------------------------------------------------------------------- /src/commands/docsis.test.ts: -------------------------------------------------------------------------------- 1 | import { discoverModemLocation, ModemDiscovery } from '../modem/discovery'; 2 | import { modemFactory } from '../modem/factory'; 3 | import { getDocsisStatus } from './docsis'; 4 | 5 | // Mock all dependencies 6 | jest.mock('../modem/discovery'); 7 | jest.mock('../modem/factory'); 8 | 9 | const mockLogger = { 10 | debug: jest.fn(), 11 | warn: jest.fn(), 12 | error: jest.fn(), 13 | log: jest.fn(), 14 | }; 15 | 16 | const mockModem = { 17 | login: jest.fn(), 18 | logout: jest.fn(), 19 | docsis: jest.fn(), 20 | }; 21 | 22 | describe('Docsis Command', () => { 23 | beforeEach(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | describe('getDocsisStatus', () => { 28 | it('should pass IP parameter to discovery', async () => { 29 | const mockModemLocation = { ipAddress: '192.168.1.100', protocol: 'http' as const }; 30 | const mockModemInfo = { 31 | deviceType: 'Technicolor' as const, 32 | firmwareVersion: '1.0', 33 | ipAddress: '192.168.1.100', 34 | protocol: 'http' as const 35 | }; 36 | const mockDocsisData = { 37 | downstream: [], 38 | upstream: [], 39 | uptime: '1 day' 40 | }; 41 | 42 | (discoverModemLocation as jest.Mock).mockResolvedValue(mockModemLocation); 43 | (ModemDiscovery.prototype.discover as jest.Mock).mockResolvedValue(mockModemInfo); 44 | (modemFactory as jest.Mock).mockReturnValue(mockModem); 45 | mockModem.login.mockResolvedValue(undefined); 46 | mockModem.docsis.mockResolvedValue(mockDocsisData); 47 | 48 | const result = await getDocsisStatus('password', mockLogger as any, { ip: '192.168.1.100' }); 49 | 50 | expect(discoverModemLocation).toHaveBeenCalledWith({ ip: '192.168.1.100' }); 51 | expect(result).toBe(mockDocsisData); 52 | }); 53 | 54 | it('should work without IP parameter (use defaults)', async () => { 55 | const mockModemLocation = { ipAddress: '192.168.100.1', protocol: 'http' as const }; 56 | const mockModemInfo = { 57 | deviceType: 'Technicolor' as const, 58 | firmwareVersion: '1.0', 59 | ipAddress: '192.168.100.1', 60 | protocol: 'http' as const 61 | }; 62 | const mockDocsisData = { 63 | downstream: [], 64 | upstream: [], 65 | uptime: '1 day' 66 | }; 67 | 68 | (discoverModemLocation as jest.Mock).mockResolvedValue(mockModemLocation); 69 | (ModemDiscovery.prototype.discover as jest.Mock).mockResolvedValue(mockModemInfo); 70 | (modemFactory as jest.Mock).mockReturnValue(mockModem); 71 | mockModem.login.mockResolvedValue(undefined); 72 | mockModem.docsis.mockResolvedValue(mockDocsisData); 73 | 74 | const result = await getDocsisStatus('password', mockLogger as any); 75 | 76 | expect(discoverModemLocation).toHaveBeenCalledWith(undefined); 77 | expect(result).toBe(mockDocsisData); 78 | }); 79 | 80 | it('should handle modem login failure', async () => { 81 | const mockModemLocation = { ipAddress: '192.168.1.100', protocol: 'http' as const }; 82 | const mockModemInfo = { 83 | deviceType: 'Technicolor' as const, 84 | firmwareVersion: '1.0', 85 | ipAddress: '192.168.1.100', 86 | protocol: 'http' as const 87 | }; 88 | 89 | (discoverModemLocation as jest.Mock).mockResolvedValue(mockModemLocation); 90 | (ModemDiscovery.prototype.discover as jest.Mock).mockResolvedValue(mockModemInfo); 91 | (modemFactory as jest.Mock).mockReturnValue(mockModem); 92 | mockModem.login.mockRejectedValue(new Error('Login failed')); 93 | 94 | await expect(getDocsisStatus('password', mockLogger as any, { ip: '192.168.1.100' })) 95 | .rejects.toThrow('Login failed'); 96 | 97 | expect(mockModem.logout).toHaveBeenCalled(); 98 | }); 99 | 100 | it('should handle discovery failure', async () => { 101 | (discoverModemLocation as jest.Mock).mockRejectedValue(new Error('Discovery failed')); 102 | 103 | await expect(getDocsisStatus('password', mockLogger as any, { ip: '192.168.1.100' })) 104 | .rejects.toThrow('Discovery failed'); 105 | }); 106 | 107 | it('should handle docsis fetch failure', async () => { 108 | const mockModemLocation = { ipAddress: '192.168.1.100', protocol: 'http' as const }; 109 | const mockModemInfo = { 110 | deviceType: 'Technicolor' as const, 111 | firmwareVersion: '1.0', 112 | ipAddress: '192.168.1.100', 113 | protocol: 'http' as const 114 | }; 115 | 116 | (discoverModemLocation as jest.Mock).mockResolvedValue(mockModemLocation); 117 | (ModemDiscovery.prototype.discover as jest.Mock).mockResolvedValue(mockModemInfo); 118 | (modemFactory as jest.Mock).mockReturnValue(mockModem); 119 | mockModem.login.mockResolvedValue(undefined); 120 | mockModem.docsis.mockRejectedValue(new Error('Docsis fetch failed')); 121 | 122 | await expect(getDocsisStatus('password', mockLogger as any, { ip: '192.168.1.100' })) 123 | .rejects.toThrow('Docsis fetch failed'); 124 | 125 | expect(mockModem.logout).toHaveBeenCalled(); 126 | }); 127 | 128 | it('should always call logout even on success', async () => { 129 | const mockModemLocation = { ipAddress: '192.168.1.100', protocol: 'http' as const }; 130 | const mockModemInfo = { 131 | deviceType: 'Technicolor' as const, 132 | firmwareVersion: '1.0', 133 | ipAddress: '192.168.1.100', 134 | protocol: 'http' as const 135 | }; 136 | const mockDocsisData = { 137 | downstream: [], 138 | upstream: [], 139 | uptime: '1 day' 140 | }; 141 | 142 | (discoverModemLocation as jest.Mock).mockResolvedValue(mockModemLocation); 143 | (ModemDiscovery.prototype.discover as jest.Mock).mockResolvedValue(mockModemInfo); 144 | (modemFactory as jest.Mock).mockReturnValue(mockModem); 145 | mockModem.login.mockResolvedValue(undefined); 146 | mockModem.docsis.mockResolvedValue(mockDocsisData); 147 | 148 | await getDocsisStatus('password', mockLogger as any, { ip: '192.168.1.100' }); 149 | 150 | expect(mockModem.login).toHaveBeenCalledWith('password'); 151 | expect(mockModem.docsis).toHaveBeenCalled(); 152 | expect(mockModem.logout).toHaveBeenCalled(); 153 | }); 154 | }); 155 | }); -------------------------------------------------------------------------------- /src/modem/__snapshots__/printer.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TablePrinter print 1`] = ` 4 | " 5 | Downstream 6 | +----+----------+------------+-------+-----------+---------------+------+ 7 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 8 | +----+----------+------------+-------+-----------+---------------+------+ 9 | | 1 | SC-QAM | 256QAM | 55.1 | 114 | Locked | 35 | 10 | +----+----------+------------+-------+-----------+---------------+------+ 11 | | 2 | SC-QAM | 256QAM | 54.7 | 130 | Locked | 35 | 12 | +----+----------+------------+-------+-----------+---------------+------+ 13 | | 3 | SC-QAM | 256QAM | 54.8 | 138 | Locked | 36 | 14 | +----+----------+------------+-------+-----------+---------------+------+ 15 | | 4 | SC-QAM | 256QAM | 54.6 | 146 | Locked | 36 | 16 | +----+----------+------------+-------+-----------+---------------+------+ 17 | | 5 | SC-QAM | 256QAM | 57 | 602 | Locked | 38 | 18 | +----+----------+------------+-------+-----------+---------------+------+ 19 | | 6 | SC-QAM | 256QAM | 57.3 | 618 | Locked | 39 | 20 | +----+----------+------------+-------+-----------+---------------+------+ 21 | | 7 | SC-QAM | 256QAM | 57.7 | 626 | Locked | 38 | 22 | +----+----------+------------+-------+-----------+---------------+------+ 23 | | 8 | SC-QAM | 256QAM | 58.5 | 642 | Locked | 39 | 24 | +----+----------+------------+-------+-----------+---------------+------+ 25 | | 9 | SC-QAM | 256QAM | 58.3 | 650 | Locked | 39 | 26 | +----+----------+------------+-------+-----------+---------------+------+ 27 | | 10 | SC-QAM | 256QAM | 58.3 | 658 | Locked | 39 | 28 | +----+----------+------------+-------+-----------+---------------+------+ 29 | | 11 | SC-QAM | 256QAM | 58.1 | 666 | Locked | 38 | 30 | +----+----------+------------+-------+-----------+---------------+------+ 31 | | 12 | SC-QAM | 256QAM | 58.8 | 674 | Locked | 39 | 32 | +----+----------+------------+-------+-----------+---------------+------+ 33 | | 13 | SC-QAM | 256QAM | 58.8 | 682 | Locked | 39 | 34 | +----+----------+------------+-------+-----------+---------------+------+ 35 | | 14 | SC-QAM | 256QAM | 59.4 | 690 | Locked | 39 | 36 | +----+----------+------------+-------+-----------+---------------+------+ 37 | | 15 | SC-QAM | 64QAM | 53 | 698 | Locked | 35 | 38 | +----+----------+------------+-------+-----------+---------------+------+ 39 | | 16 | SC-QAM | 64QAM | 54.1 | 706 | Locked | 35 | 40 | +----+----------+------------+-------+-----------+---------------+------+ 41 | | 17 | SC-QAM | 64QAM | 54.2 | 714 | Locked | 34 | 42 | +----+----------+------------+-------+-----------+---------------+------+ 43 | | 18 | SC-QAM | 64QAM | 53.8 | 722 | Locked | 35 | 44 | +----+----------+------------+-------+-----------+---------------+------+ 45 | | 19 | SC-QAM | 64QAM | 53.9 | 730 | Locked | 35 | 46 | +----+----------+------------+-------+-----------+---------------+------+ 47 | | 20 | SC-QAM | 64QAM | 54.9 | 738 | Locked | 35 | 48 | +----+----------+------------+-------+-----------+---------------+------+ 49 | | 21 | SC-QAM | 64QAM | 55.3 | 746 | Locked | 35 | 50 | +----+----------+------------+-------+-----------+---------------+------+ 51 | | 22 | SC-QAM | 64QAM | 54.5 | 754 | Locked | 35 | 52 | +----+----------+------------+-------+-----------+---------------+------+ 53 | | 23 | SC-QAM | 64QAM | 54.5 | 762 | Locked | 35 | 54 | +----+----------+------------+-------+-----------+---------------+------+ 55 | | 24 | SC-QAM | 64QAM | 54.5 | 770 | Locked | 35 | 56 | +----+----------+------------+-------+-----------+---------------+------+ 57 | | 25 | SC-QAM | 64QAM | 55 | 778 | Locked | 35 | 58 | +----+----------+------------+-------+-----------+---------------+------+ 59 | | 26 | SC-QAM | 64QAM | 55 | 786 | Locked | 35 | 60 | +----+----------+------------+-------+-----------+---------------+------+ 61 | | 27 | SC-QAM | 64QAM | 54.9 | 794 | Locked | 34 | 62 | +----+----------+------------+-------+-----------+---------------+------+ 63 | | 28 | SC-QAM | 64QAM | 54.4 | 802 | Locked | 35 | 64 | +----+----------+------------+-------+-----------+---------------+------+ 65 | | 29 | SC-QAM | 64QAM | 54.1 | 810 | Locked | 35 | 66 | +----+----------+------------+-------+-----------+---------------+------+ 67 | | 30 | SC-QAM | 64QAM | 54.5 | 818 | Locked | 35 | 68 | +----+----------+------------+-------+-----------+---------------+------+ 69 | | 31 | SC-QAM | 64QAM | 54.5 | 826 | Locked | 35 | 70 | +----+----------+------------+-------+-----------+---------------+------+ 71 | | 32 | SC-QAM | 64QAM | 53.7 | 834 | Locked | 34 | 72 | +----+----------+------------+-------+-----------+---------------+------+ 73 | 74 | Downstream OFDM 75 | +----+----------+------------+-------+-----------+---------------+------+ 76 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 77 | +----+----------+------------+-------+-----------+---------------+------+ 78 | | 33 | OFDM | 1024QAM | 56.1 | 151-324 | Locked | 39 | 79 | +----+----------+------------+-------+-----------+---------------+------+ 80 | 81 | Upstream 82 | +----+----------+------------+-------+-----------+---------------+------+ 83 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 84 | +----+----------+------------+-------+-----------+---------------+------+ 85 | | 3 | SC-QAM | 64QAM | 110.3 | 37 | Locked | 0 | 86 | +----+----------+------------+-------+-----------+---------------+------+ 87 | | 4 | SC-QAM | 32QAM | 110.3 | 31 | Locked | 0 | 88 | +----+----------+------------+-------+-----------+---------------+------+ 89 | | 1 | SC-QAM | 64QAM | 110.3 | 51 | Locked | 0 | 90 | +----+----------+------------+-------+-----------+---------------+------+ 91 | | 2 | SC-QAM | 64QAM | 110.3 | 45 | Locked | 0 | 92 | +----+----------+------------+-------+-----------+---------------+------+ 93 | 94 | Upstream OFDMA 95 | +----+----------+------------+-------+-----------+---------------+------+ 96 | | ID | Ch. Type | Modulation | Power | Frequency | Lock status | SNR | 97 | +----+----------+------------+-------+-----------+---------------+------+ 98 | | 9 | OFDMA | 16_QAM | 106.2 | 29.8-64.8 | SUCCESS | 0 | 99 | +----+----------+------------+-------+-----------+---------------+------+ 100 | " 101 | `; 102 | -------------------------------------------------------------------------------- /src/modem/arris-modem.ts: -------------------------------------------------------------------------------- 1 | import {Log} from '../logger'; 2 | import {Protocol} from './discovery'; 3 | import { 4 | DocsisChannelType, DocsisStatus, HumanizedDocsis31ChannelStatus, HumanizedDocsisChannelStatus, Modem, 5 | } from './modem'; 6 | import {decrypt, deriveKey, encrypt} from './tools/crypto'; 7 | import { 8 | CryptoVars, extractCredentialString, extractCryptoVars, extractDocsisStatus, 9 | } from './tools/html-parser'; 10 | 11 | export interface ArrisDocsisStatus { 12 | downstream: ArrisDocsisChannelStatus[]; 13 | downstreamChannels: number; 14 | ofdmChannels: number; 15 | time: string; 16 | upstream: ArrisDocsisChannelStatus[]; 17 | upstreamChannels: number; 18 | } 19 | 20 | export interface ArrisDocsisChannelStatus { 21 | ChannelID: string; 22 | ChannelType: DocsisChannelType; 23 | Frequency: number | string; 24 | LockStatus: string; 25 | Modulation: string; 26 | PowerLevel: string; 27 | SNRLevel?: number | string; 28 | } 29 | 30 | export interface SetPasswordRequest { 31 | AuthData: string; 32 | EncryptData: string; 33 | Name: string; 34 | } 35 | 36 | export interface SetPasswordResponse { 37 | encryptData: string; 38 | p_status: string; 39 | p_waitTime?: number; 40 | } 41 | 42 | export function normalizeChannelStatus(channelStatus: ArrisDocsisChannelStatus): HumanizedDocsis31ChannelStatus | HumanizedDocsisChannelStatus { 43 | const frequency: Record = {} 44 | if (channelStatus.ChannelType === 'SC-QAM') { 45 | frequency.frequency = channelStatus.Frequency as number 46 | } 47 | 48 | if (['OFDM', 'OFDMA'].includes(channelStatus.ChannelType)) { 49 | const ofdmaFrequency = String(channelStatus.Frequency).split('~') 50 | frequency.frequencyStart = Number(ofdmaFrequency[0]) 51 | frequency.frequencyEnd = Number(ofdmaFrequency[1]) 52 | } 53 | 54 | return { 55 | channelId: channelStatus.ChannelID, 56 | channelType: channelStatus.ChannelType, 57 | lockStatus: channelStatus.LockStatus, 58 | modulation: channelStatus.Modulation, 59 | powerLevel: Number.parseFloat(channelStatus.PowerLevel.split('/')[0]), 60 | snr: Number.parseInt(`${channelStatus.SNRLevel ?? 0}`, 10), 61 | ...frequency, 62 | } as HumanizedDocsis31ChannelStatus | HumanizedDocsisChannelStatus 63 | } 64 | 65 | export function normalizeDocsisStatus(arrisDocsisStatus: ArrisDocsisStatus): DocsisStatus { 66 | const result: DocsisStatus = { 67 | downstream: [], 68 | downstreamOfdm: [], 69 | time: arrisDocsisStatus.time, 70 | upstream: [], 71 | upstreamOfdma: [], 72 | } 73 | result.downstream = arrisDocsisStatus.downstream 74 | .filter(downstream => downstream.ChannelType === 'SC-QAM') 75 | .map(channel => normalizeChannelStatus(channel)) as HumanizedDocsisChannelStatus[] 76 | 77 | result.downstreamOfdm = arrisDocsisStatus.downstream 78 | .filter(downstream => downstream.ChannelType === 'OFDM') 79 | .map(channel => normalizeChannelStatus(channel)) as HumanizedDocsis31ChannelStatus[] 80 | 81 | result.upstream = arrisDocsisStatus.upstream 82 | .filter(upstream => upstream.ChannelType === 'SC-QAM') 83 | .map(channel => normalizeChannelStatus(channel)) as HumanizedDocsisChannelStatus[] 84 | 85 | result.upstreamOfdma = arrisDocsisStatus.upstream 86 | .filter(upstream => upstream.ChannelType === 'OFDMA') 87 | .map(channel => normalizeChannelStatus(channel)) as HumanizedDocsis31ChannelStatus[] 88 | return result 89 | } 90 | 91 | export class Arris extends Modem { 92 | private csrfNonce = ''; 93 | 94 | constructor( 95 | readonly modemIp: string, 96 | readonly protocol: Protocol, 97 | readonly logger: Log, 98 | ) { 99 | super(modemIp, protocol, logger); 100 | } 101 | 102 | async addCredentialToCookie(): Promise { 103 | const credential = await this.fetchCredential(); 104 | this.logger.debug('Credential: ', credential); 105 | // set obligatory static cookie 106 | this.cookieJar.setCookie(`credential= ${credential}`, this.baseUrl); 107 | } 108 | 109 | async createServerRecord(setPasswordRequest: SetPasswordRequest): Promise { 110 | try { 111 | const {data} = await this.httpClient.post( 112 | '/php/ajaxSet_Password.php', 113 | setPasswordRequest, 114 | ); 115 | // TODO handle wrong password case 116 | // { p_status: 'Lockout', p_waitTime: 1 } 117 | if (data.p_status === 'Lockout') { 118 | throw new Error(`Remote user locked out for: ${data.p_waitTime}s`); 119 | } 120 | 121 | return data; 122 | } catch (error) { 123 | this.logger.error('Could not pass password on remote router.', error); 124 | throw error; 125 | } 126 | } 127 | 128 | async docsis(): Promise { 129 | if (!this.csrfNonce) { 130 | throw new Error('A valid csrfNonce is required in order to query the modem.'); 131 | } 132 | 133 | try { 134 | const {data} = await this.httpClient.get( 135 | '/php/status_docsis_data.php', 136 | { 137 | headers: { 138 | Connection: 'keep-alive', 139 | csrfNonce: this.csrfNonce, 140 | Referer: `${this.baseUrl}/?status_docsis&mid=StatusDocsis`, 141 | }, 142 | }, 143 | ); 144 | return normalizeDocsisStatus(extractDocsisStatus(data as string)); 145 | } catch (error) { 146 | this.logger.error('Could not fetch remote docsis status', error); 147 | throw error; 148 | } 149 | } 150 | 151 | encryptPassword( 152 | password: string, 153 | cryptoVars: CryptoVars, 154 | ): SetPasswordRequest { 155 | const jsData = `{"Password": "${password}", "Nonce": "${cryptoVars.sessionId}"}`; 156 | const key = deriveKey(password, cryptoVars.salt); 157 | const authData = 'loginPassword'; 158 | const encryptData = encrypt(key, jsData, cryptoVars.iv, authData); 159 | 160 | return { 161 | AuthData: authData, 162 | EncryptData: encryptData, 163 | Name: Modem.USERNAME, 164 | }; 165 | } 166 | 167 | async fetchCredential(): Promise { 168 | try { 169 | const {data} = await this.httpClient.get('/base_95x.js'); 170 | return extractCredentialString(data as string); 171 | } catch (error) { 172 | this.logger.error('Could not fetch credential.', error); 173 | throw error; 174 | } 175 | } 176 | 177 | async getCurrentCryptoVars(): Promise { 178 | try { 179 | const {data} = await this.httpClient.get('/', { 180 | headers: {Accept: 'text/html,application/xhtml+xml,application/xml'}, 181 | }); 182 | const cryptoVars = extractCryptoVars(data as string); 183 | this.logger.debug('Parsed crypto vars: ', cryptoVars); 184 | return cryptoVars; 185 | } catch (error) { 186 | this.logger.error('Could not get the index page from the router', error); 187 | throw error; 188 | } 189 | } 190 | 191 | async login(password: string): Promise { 192 | const cryptoVars = await this.getCurrentCryptoVars(); 193 | const encPw = this.encryptPassword(password, cryptoVars); 194 | this.logger.debug('Encrypted password: ', encPw); 195 | const serverSetPassword = await this.createServerRecord(encPw); 196 | this.logger.debug('ServerSetPassword: ', serverSetPassword); 197 | 198 | const csrfNonce = this.loginPasswordCheck( 199 | serverSetPassword.encryptData, 200 | cryptoVars, 201 | deriveKey(password, cryptoVars.salt), 202 | ); 203 | this.logger.debug('Csrf nonce: ', csrfNonce); 204 | 205 | await this.addCredentialToCookie(); 206 | this.csrfNonce = csrfNonce; 207 | } 208 | 209 | loginPasswordCheck( 210 | encryptedData: string, 211 | cryptoVars: CryptoVars, 212 | key: string, 213 | ): string { 214 | const csrfNonce = decrypt(key, encryptedData, cryptoVars.iv, 'nonce'); 215 | return csrfNonce; 216 | } 217 | 218 | async logout(): Promise { 219 | try { 220 | this.logger.log('Logging out...'); 221 | return this.httpClient.post('/php/logout.php'); 222 | } catch (error) { 223 | this.logger.error('Could not do a full session logout', error); 224 | throw error; 225 | } 226 | } 227 | 228 | async restart(): Promise { 229 | try { 230 | const {data} = await this.httpClient.post( 231 | 'php/ajaxSet_status_restart.php', 232 | { 233 | RestartReset: 'Restart', 234 | }, 235 | { 236 | headers: { 237 | Connection: 'keep-alive', 238 | csrfNonce: this.csrfNonce, 239 | Referer: `${this.baseUrl}/?status_docsis&mid=StatusDocsis`, 240 | }, 241 | }, 242 | ); 243 | this.logger.log('Router is restarting'); 244 | return data; 245 | } catch (error) { 246 | this.logger.error('Could not restart router.', error); 247 | throw error; 248 | } 249 | } 250 | } 251 | 252 | -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_arris_normalized.json: -------------------------------------------------------------------------------- 1 | { 2 | "downstream": [ 3 | { 4 | "channelId": "2", 5 | "channelType": "SC-QAM", 6 | "modulation": "256QAM", 7 | "powerLevel": -5.6, 8 | "lockStatus": "Locked", 9 | "snr": 35, 10 | "frequency": 130 11 | }, 12 | { 13 | "channelId": "3", 14 | "channelType": "SC-QAM", 15 | "modulation": "256QAM", 16 | "powerLevel": -5.4, 17 | "lockStatus": "Locked", 18 | "snr": 35, 19 | "frequency": 138 20 | }, 21 | { 22 | "channelId": "4", 23 | "channelType": "SC-QAM", 24 | "modulation": "256QAM", 25 | "powerLevel": -5.8, 26 | "lockStatus": "Locked", 27 | "snr": 36, 28 | "frequency": 146 29 | }, 30 | { 31 | "channelId": "5", 32 | "channelType": "SC-QAM", 33 | "modulation": "256QAM", 34 | "powerLevel": -3.2, 35 | "lockStatus": "Locked", 36 | "snr": 38, 37 | "frequency": 602 38 | }, 39 | { 40 | "channelId": "6", 41 | "channelType": "SC-QAM", 42 | "modulation": "256QAM", 43 | "powerLevel": -3.3, 44 | "lockStatus": "Locked", 45 | "snr": 39, 46 | "frequency": 618 47 | }, 48 | { 49 | "channelId": "7", 50 | "channelType": "SC-QAM", 51 | "modulation": "256QAM", 52 | "powerLevel": -2.9, 53 | "lockStatus": "Locked", 54 | "snr": 38, 55 | "frequency": 626 56 | }, 57 | { 58 | "channelId": "8", 59 | "channelType": "SC-QAM", 60 | "modulation": "256QAM", 61 | "powerLevel": -2.3, 62 | "lockStatus": "Locked", 63 | "snr": 38, 64 | "frequency": 642 65 | }, 66 | { 67 | "channelId": "9", 68 | "channelType": "SC-QAM", 69 | "modulation": "256QAM", 70 | "powerLevel": -2.4, 71 | "lockStatus": "Locked", 72 | "snr": 39, 73 | "frequency": 650 74 | }, 75 | { 76 | "channelId": "10", 77 | "channelType": "SC-QAM", 78 | "modulation": "256QAM", 79 | "powerLevel": -2.1, 80 | "lockStatus": "Locked", 81 | "snr": 39, 82 | "frequency": 658 83 | }, 84 | { 85 | "channelId": "11", 86 | "channelType": "SC-QAM", 87 | "modulation": "256QAM", 88 | "powerLevel": -2.5, 89 | "lockStatus": "Locked", 90 | "snr": 38, 91 | "frequency": 666 92 | }, 93 | { 94 | "channelId": "12", 95 | "channelType": "SC-QAM", 96 | "modulation": "256QAM", 97 | "powerLevel": -1.8, 98 | "lockStatus": "Locked", 99 | "snr": 39, 100 | "frequency": 674 101 | }, 102 | { 103 | "channelId": "13", 104 | "channelType": "SC-QAM", 105 | "modulation": "256QAM", 106 | "powerLevel": -1.8, 107 | "lockStatus": "Locked", 108 | "snr": 38, 109 | "frequency": 682 110 | }, 111 | { 112 | "channelId": "14", 113 | "channelType": "SC-QAM", 114 | "modulation": "256QAM", 115 | "powerLevel": -1.2, 116 | "lockStatus": "Locked", 117 | "snr": 39, 118 | "frequency": 690 119 | }, 120 | { 121 | "channelId": "15", 122 | "channelType": "SC-QAM", 123 | "modulation": "64QAM", 124 | "powerLevel": -7.6, 125 | "lockStatus": "Locked", 126 | "snr": 34, 127 | "frequency": 698 128 | }, 129 | { 130 | "channelId": "16", 131 | "channelType": "SC-QAM", 132 | "modulation": "64QAM", 133 | "powerLevel": -6.5, 134 | "lockStatus": "Locked", 135 | "snr": 35, 136 | "frequency": 706 137 | }, 138 | { 139 | "channelId": "17", 140 | "channelType": "SC-QAM", 141 | "modulation": "64QAM", 142 | "powerLevel": -6.6, 143 | "lockStatus": "Locked", 144 | "snr": 35, 145 | "frequency": 714 146 | }, 147 | { 148 | "channelId": "18", 149 | "channelType": "SC-QAM", 150 | "modulation": "64QAM", 151 | "powerLevel": -6.7, 152 | "lockStatus": "Locked", 153 | "snr": 35, 154 | "frequency": 722 155 | }, 156 | { 157 | "channelId": "19", 158 | "channelType": "SC-QAM", 159 | "modulation": "64QAM", 160 | "powerLevel": -6.8, 161 | "lockStatus": "Locked", 162 | "snr": 35, 163 | "frequency": 730 164 | }, 165 | { 166 | "channelId": "20", 167 | "channelType": "SC-QAM", 168 | "modulation": "64QAM", 169 | "powerLevel": -5.7, 170 | "lockStatus": "Locked", 171 | "snr": 35, 172 | "frequency": 738 173 | }, 174 | { 175 | "channelId": "21", 176 | "channelType": "SC-QAM", 177 | "modulation": "64QAM", 178 | "powerLevel": -5.1, 179 | "lockStatus": "Locked", 180 | "snr": 35, 181 | "frequency": 746 182 | }, 183 | { 184 | "channelId": "22", 185 | "channelType": "SC-QAM", 186 | "modulation": "64QAM", 187 | "powerLevel": -6.1, 188 | "lockStatus": "Locked", 189 | "snr": 35, 190 | "frequency": 754 191 | }, 192 | { 193 | "channelId": "23", 194 | "channelType": "SC-QAM", 195 | "modulation": "64QAM", 196 | "powerLevel": -5.7, 197 | "lockStatus": "Locked", 198 | "snr": 35, 199 | "frequency": 762 200 | }, 201 | { 202 | "channelId": "24", 203 | "channelType": "SC-QAM", 204 | "modulation": "64QAM", 205 | "powerLevel": -5.9, 206 | "lockStatus": "Locked", 207 | "snr": 35, 208 | "frequency": 770 209 | }, 210 | { 211 | "channelId": "25", 212 | "channelType": "SC-QAM", 213 | "modulation": "64QAM", 214 | "powerLevel": -5.5, 215 | "lockStatus": "Locked", 216 | "snr": 35, 217 | "frequency": 778 218 | }, 219 | { 220 | "channelId": "26", 221 | "channelType": "SC-QAM", 222 | "modulation": "64QAM", 223 | "powerLevel": -5.3, 224 | "lockStatus": "Locked", 225 | "snr": 35, 226 | "frequency": 786 227 | }, 228 | { 229 | "channelId": "27", 230 | "channelType": "SC-QAM", 231 | "modulation": "64QAM", 232 | "powerLevel": -5.4, 233 | "lockStatus": "Locked", 234 | "snr": 34, 235 | "frequency": 794 236 | }, 237 | { 238 | "channelId": "28", 239 | "channelType": "SC-QAM", 240 | "modulation": "64QAM", 241 | "powerLevel": -5.7, 242 | "lockStatus": "Locked", 243 | "snr": 35, 244 | "frequency": 802 245 | }, 246 | { 247 | "channelId": "29", 248 | "channelType": "SC-QAM", 249 | "modulation": "64QAM", 250 | "powerLevel": -6, 251 | "lockStatus": "Locked", 252 | "snr": 35, 253 | "frequency": 810 254 | }, 255 | { 256 | "channelId": "30", 257 | "channelType": "SC-QAM", 258 | "modulation": "64QAM", 259 | "powerLevel": -5.7, 260 | "lockStatus": "Locked", 261 | "snr": 35, 262 | "frequency": 818 263 | }, 264 | { 265 | "channelId": "31", 266 | "channelType": "SC-QAM", 267 | "modulation": "64QAM", 268 | "powerLevel": -5.9, 269 | "lockStatus": "Locked", 270 | "snr": 35, 271 | "frequency": 826 272 | }, 273 | { 274 | "channelId": "32", 275 | "channelType": "SC-QAM", 276 | "modulation": "64QAM", 277 | "powerLevel": -6.7, 278 | "lockStatus": "Locked", 279 | "snr": 35, 280 | "frequency": 834 281 | }, 282 | { 283 | "channelId": "1", 284 | "channelType": "SC-QAM", 285 | "modulation": "256QAM", 286 | "powerLevel": -5.3, 287 | "lockStatus": "Locked", 288 | "snr": 35, 289 | "frequency": 114 290 | } 291 | ], 292 | "downstreamOfdm": [ 293 | { 294 | "channelId": "33", 295 | "channelType": "OFDM", 296 | "modulation": "1024QAM", 297 | "powerLevel": -4, 298 | "lockStatus": "Locked", 299 | "snr": 39, 300 | "frequencyStart": 151, 301 | "frequencyEnd": 324 302 | } 303 | ], 304 | "upstream": [ 305 | { 306 | "channelId": "3", 307 | "channelType": "SC-QAM", 308 | "modulation": "64QAM", 309 | "powerLevel": 51, 310 | "lockStatus": "Locked", 311 | "snr": 0, 312 | "frequency": 37 313 | }, 314 | { 315 | "channelId": "4", 316 | "channelType": "SC-QAM", 317 | "modulation": "64QAM", 318 | "powerLevel": 51, 319 | "lockStatus": "Locked", 320 | "snr": 0, 321 | "frequency": 31 322 | }, 323 | { 324 | "channelId": "1", 325 | "channelType": "SC-QAM", 326 | "modulation": "64QAM", 327 | "powerLevel": 51, 328 | "lockStatus": "Locked", 329 | "snr": 0, 330 | "frequency": 51 331 | }, 332 | { 333 | "channelId": "2", 334 | "channelType": "SC-QAM", 335 | "modulation": "64QAM", 336 | "powerLevel": 51, 337 | "lockStatus": "Locked", 338 | "snr": 0, 339 | "frequency": 45 340 | } 341 | ], 342 | "upstreamOfdma": [ 343 | { 344 | "channelId": "9", 345 | "channelType": "OFDMA", 346 | "modulation": "16_QAM", 347 | "powerLevel": 47, 348 | "lockStatus": "SUCCESS", 349 | "snr": 0, 350 | "frequencyStart": 29.8, 351 | "frequencyEnd": 64.8 352 | } 353 | ], 354 | "time": "2022-02-19T13:47:14.772Z" 355 | } -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_normalized.json: -------------------------------------------------------------------------------- 1 | { 2 | "downstream": [ 3 | { 4 | "channelId": "1", 5 | "channelType": "SC-QAM", 6 | "modulation": "256QAM", 7 | "powerLevel": 55.1, 8 | "lockStatus": "Locked", 9 | "snr": 35, 10 | "frequency": 114 11 | }, 12 | { 13 | "channelId": "2", 14 | "channelType": "SC-QAM", 15 | "modulation": "256QAM", 16 | "powerLevel": 54.7, 17 | "lockStatus": "Locked", 18 | "snr": 35, 19 | "frequency": 130 20 | }, 21 | { 22 | "channelId": "3", 23 | "channelType": "SC-QAM", 24 | "modulation": "256QAM", 25 | "powerLevel": 54.8, 26 | "lockStatus": "Locked", 27 | "snr": 36, 28 | "frequency": 138 29 | }, 30 | { 31 | "channelId": "4", 32 | "channelType": "SC-QAM", 33 | "modulation": "256QAM", 34 | "powerLevel": 54.6, 35 | "lockStatus": "Locked", 36 | "snr": 36, 37 | "frequency": 146 38 | }, 39 | { 40 | "channelId": "5", 41 | "channelType": "SC-QAM", 42 | "modulation": "256QAM", 43 | "powerLevel": 57, 44 | "lockStatus": "Locked", 45 | "snr": 38, 46 | "frequency": 602 47 | }, 48 | { 49 | "channelId": "6", 50 | "channelType": "SC-QAM", 51 | "modulation": "256QAM", 52 | "powerLevel": 57.3, 53 | "lockStatus": "Locked", 54 | "snr": 39, 55 | "frequency": 618 56 | }, 57 | { 58 | "channelId": "7", 59 | "channelType": "SC-QAM", 60 | "modulation": "256QAM", 61 | "powerLevel": 57.7, 62 | "lockStatus": "Locked", 63 | "snr": 38, 64 | "frequency": 626 65 | }, 66 | { 67 | "channelId": "8", 68 | "channelType": "SC-QAM", 69 | "modulation": "256QAM", 70 | "powerLevel": 58.5, 71 | "lockStatus": "Locked", 72 | "snr": 39, 73 | "frequency": 642 74 | }, 75 | { 76 | "channelId": "9", 77 | "channelType": "SC-QAM", 78 | "modulation": "256QAM", 79 | "powerLevel": 58.3, 80 | "lockStatus": "Locked", 81 | "snr": 39, 82 | "frequency": 650 83 | }, 84 | { 85 | "channelId": "10", 86 | "channelType": "SC-QAM", 87 | "modulation": "256QAM", 88 | "powerLevel": 58.3, 89 | "lockStatus": "Locked", 90 | "snr": 39, 91 | "frequency": 658 92 | }, 93 | { 94 | "channelId": "11", 95 | "channelType": "SC-QAM", 96 | "modulation": "256QAM", 97 | "powerLevel": 58.1, 98 | "lockStatus": "Locked", 99 | "snr": 38, 100 | "frequency": 666 101 | }, 102 | { 103 | "channelId": "12", 104 | "channelType": "SC-QAM", 105 | "modulation": "256QAM", 106 | "powerLevel": 58.8, 107 | "lockStatus": "Locked", 108 | "snr": 39, 109 | "frequency": 674 110 | }, 111 | { 112 | "channelId": "13", 113 | "channelType": "SC-QAM", 114 | "modulation": "256QAM", 115 | "powerLevel": 58.8, 116 | "lockStatus": "Locked", 117 | "snr": 39, 118 | "frequency": 682 119 | }, 120 | { 121 | "channelId": "14", 122 | "channelType": "SC-QAM", 123 | "modulation": "256QAM", 124 | "powerLevel": 59.4, 125 | "lockStatus": "Locked", 126 | "snr": 39, 127 | "frequency": 690 128 | }, 129 | { 130 | "channelId": "15", 131 | "channelType": "SC-QAM", 132 | "modulation": "64QAM", 133 | "powerLevel": 53, 134 | "lockStatus": "Locked", 135 | "snr": 35, 136 | "frequency": 698 137 | }, 138 | { 139 | "channelId": "16", 140 | "channelType": "SC-QAM", 141 | "modulation": "64QAM", 142 | "powerLevel": 54.1, 143 | "lockStatus": "Locked", 144 | "snr": 35, 145 | "frequency": 706 146 | }, 147 | { 148 | "channelId": "17", 149 | "channelType": "SC-QAM", 150 | "modulation": "64QAM", 151 | "powerLevel": 54.2, 152 | "lockStatus": "Locked", 153 | "snr": 34, 154 | "frequency": 714 155 | }, 156 | { 157 | "channelId": "18", 158 | "channelType": "SC-QAM", 159 | "modulation": "64QAM", 160 | "powerLevel": 53.8, 161 | "lockStatus": "Locked", 162 | "snr": 35, 163 | "frequency": 722 164 | }, 165 | { 166 | "channelId": "19", 167 | "channelType": "SC-QAM", 168 | "modulation": "64QAM", 169 | "powerLevel": 53.9, 170 | "lockStatus": "Locked", 171 | "snr": 35, 172 | "frequency": 730 173 | }, 174 | { 175 | "channelId": "20", 176 | "channelType": "SC-QAM", 177 | "modulation": "64QAM", 178 | "powerLevel": 54.9, 179 | "lockStatus": "Locked", 180 | "snr": 35, 181 | "frequency": 738 182 | }, 183 | { 184 | "channelId": "21", 185 | "channelType": "SC-QAM", 186 | "modulation": "64QAM", 187 | "powerLevel": 55.3, 188 | "lockStatus": "Locked", 189 | "snr": 35, 190 | "frequency": 746 191 | }, 192 | { 193 | "channelId": "22", 194 | "channelType": "SC-QAM", 195 | "modulation": "64QAM", 196 | "powerLevel": 54.5, 197 | "lockStatus": "Locked", 198 | "snr": 35, 199 | "frequency": 754 200 | }, 201 | { 202 | "channelId": "23", 203 | "channelType": "SC-QAM", 204 | "modulation": "64QAM", 205 | "powerLevel": 54.5, 206 | "lockStatus": "Locked", 207 | "snr": 35, 208 | "frequency": 762 209 | }, 210 | { 211 | "channelId": "24", 212 | "channelType": "SC-QAM", 213 | "modulation": "64QAM", 214 | "powerLevel": 54.5, 215 | "lockStatus": "Locked", 216 | "snr": 35, 217 | "frequency": 770 218 | }, 219 | { 220 | "channelId": "25", 221 | "channelType": "SC-QAM", 222 | "modulation": "64QAM", 223 | "powerLevel": 55, 224 | "lockStatus": "Locked", 225 | "snr": 35, 226 | "frequency": 778 227 | }, 228 | { 229 | "channelId": "26", 230 | "channelType": "SC-QAM", 231 | "modulation": "64QAM", 232 | "powerLevel": 55, 233 | "lockStatus": "Locked", 234 | "snr": 35, 235 | "frequency": 786 236 | }, 237 | { 238 | "channelId": "27", 239 | "channelType": "SC-QAM", 240 | "modulation": "64QAM", 241 | "powerLevel": 54.9, 242 | "lockStatus": "Locked", 243 | "snr": 34, 244 | "frequency": 794 245 | }, 246 | { 247 | "channelId": "28", 248 | "channelType": "SC-QAM", 249 | "modulation": "64QAM", 250 | "powerLevel": 54.4, 251 | "lockStatus": "Locked", 252 | "snr": 35, 253 | "frequency": 802 254 | }, 255 | { 256 | "channelId": "29", 257 | "channelType": "SC-QAM", 258 | "modulation": "64QAM", 259 | "powerLevel": 54.1, 260 | "lockStatus": "Locked", 261 | "snr": 35, 262 | "frequency": 810 263 | }, 264 | { 265 | "channelId": "30", 266 | "channelType": "SC-QAM", 267 | "modulation": "64QAM", 268 | "powerLevel": 54.5, 269 | "lockStatus": "Locked", 270 | "snr": 35, 271 | "frequency": 818 272 | }, 273 | { 274 | "channelId": "31", 275 | "channelType": "SC-QAM", 276 | "modulation": "64QAM", 277 | "powerLevel": 54.5, 278 | "lockStatus": "Locked", 279 | "snr": 35, 280 | "frequency": 826 281 | }, 282 | { 283 | "channelId": "32", 284 | "channelType": "SC-QAM", 285 | "modulation": "64QAM", 286 | "powerLevel": 53.7, 287 | "lockStatus": "Locked", 288 | "snr": 34, 289 | "frequency": 834 290 | } 291 | ], 292 | "downstreamOfdm": [ 293 | { 294 | "channelId": "33", 295 | "channelType": "OFDM", 296 | "modulation": "1024QAM", 297 | "powerLevel": 56.1, 298 | "lockStatus": "Locked", 299 | "snr": 39, 300 | "frequencyStart": 151, 301 | "frequencyEnd": 324 302 | } 303 | ], 304 | "upstream": [ 305 | { 306 | "channelId": "3", 307 | "channelType": "SC-QAM", 308 | "modulation": "64QAM", 309 | "powerLevel": 110.3, 310 | "lockStatus": "Locked", 311 | "snr": 0, 312 | "frequency": 37 313 | }, 314 | { 315 | "channelId": "4", 316 | "channelType": "SC-QAM", 317 | "modulation": "32QAM", 318 | "powerLevel": 110.3, 319 | "lockStatus": "Locked", 320 | "snr": 0, 321 | "frequency": 31 322 | }, 323 | { 324 | "channelId": "1", 325 | "channelType": "SC-QAM", 326 | "modulation": "64QAM", 327 | "powerLevel": 110.3, 328 | "lockStatus": "Locked", 329 | "snr": 0, 330 | "frequency": 51 331 | }, 332 | { 333 | "channelId": "2", 334 | "channelType": "SC-QAM", 335 | "modulation": "64QAM", 336 | "powerLevel": 110.3, 337 | "lockStatus": "Locked", 338 | "snr": 0, 339 | "frequency": 45 340 | } 341 | ], 342 | "upstreamOfdma": [ 343 | { 344 | "channelId": "9", 345 | "channelType": "OFDMA", 346 | "modulation": "16_QAM", 347 | "powerLevel": 106.2, 348 | "lockStatus": "SUCCESS", 349 | "snr": 0, 350 | "frequencyStart": 29.8, 351 | "frequencyEnd": 64.8 352 | } 353 | ], 354 | "time": "2022-01-03T10:59:05.635Z" 355 | } -------------------------------------------------------------------------------- /src/modem/tools/__snapshots__/html-parser.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`htmlParser extractDocsisStatus 1`] = ` 4 | { 5 | "downstream": [ 6 | { 7 | "ChannelID": "33", 8 | "ChannelType": "OFDM", 9 | "Frequency": "151~324", 10 | "LockStatus": "Locked", 11 | "Modulation": "1024QAM", 12 | "PowerLevel": "-3.5/56.5", 13 | "SNRLevel": "40", 14 | }, 15 | { 16 | "ChannelID": "2", 17 | "ChannelType": "SC-QAM", 18 | "Frequency": 130, 19 | "LockStatus": "Locked", 20 | "Modulation": "256QAM", 21 | "PowerLevel": "-5.2/54.8", 22 | "SNRLevel": 36.4, 23 | }, 24 | { 25 | "ChannelID": "3", 26 | "ChannelType": "SC-QAM", 27 | "Frequency": 138, 28 | "LockStatus": "Locked", 29 | "Modulation": "256QAM", 30 | "PowerLevel": "-5/55", 31 | "SNRLevel": 36.6, 32 | }, 33 | { 34 | "ChannelID": "4", 35 | "ChannelType": "SC-QAM", 36 | "Frequency": 146, 37 | "LockStatus": "Locked", 38 | "Modulation": "256QAM", 39 | "PowerLevel": "-5.4/54.6", 40 | "SNRLevel": 36.6, 41 | }, 42 | { 43 | "ChannelID": "5", 44 | "ChannelType": "SC-QAM", 45 | "Frequency": 602, 46 | "LockStatus": "Locked", 47 | "Modulation": "256QAM", 48 | "PowerLevel": "-3/57", 49 | "SNRLevel": 38.6, 50 | }, 51 | { 52 | "ChannelID": "6", 53 | "ChannelType": "SC-QAM", 54 | "Frequency": 618, 55 | "LockStatus": "Locked", 56 | "Modulation": "256QAM", 57 | "PowerLevel": "-2.8/57.2", 58 | "SNRLevel": 38.6, 59 | }, 60 | { 61 | "ChannelID": "7", 62 | "ChannelType": "SC-QAM", 63 | "Frequency": 626, 64 | "LockStatus": "Locked", 65 | "Modulation": "256QAM", 66 | "PowerLevel": "-2.3/57.7", 67 | "SNRLevel": 39, 68 | }, 69 | { 70 | "ChannelID": "8", 71 | "ChannelType": "SC-QAM", 72 | "Frequency": 642, 73 | "LockStatus": "Locked", 74 | "Modulation": "256QAM", 75 | "PowerLevel": "-1.7/58.3", 76 | "SNRLevel": 39, 77 | }, 78 | { 79 | "ChannelID": "9", 80 | "ChannelType": "SC-QAM", 81 | "Frequency": 650, 82 | "LockStatus": "Locked", 83 | "Modulation": "256QAM", 84 | "PowerLevel": "-1.7/58.3", 85 | "SNRLevel": 40.4, 86 | }, 87 | { 88 | "ChannelID": "10", 89 | "ChannelType": "SC-QAM", 90 | "Frequency": 658, 91 | "LockStatus": "Locked", 92 | "Modulation": "256QAM", 93 | "PowerLevel": "-1.5/58.5", 94 | "SNRLevel": 40.4, 95 | }, 96 | { 97 | "ChannelID": "11", 98 | "ChannelType": "SC-QAM", 99 | "Frequency": 666, 100 | "LockStatus": "Locked", 101 | "Modulation": "256QAM", 102 | "PowerLevel": "-2/58", 103 | "SNRLevel": 39, 104 | }, 105 | { 106 | "ChannelID": "12", 107 | "ChannelType": "SC-QAM", 108 | "Frequency": 674, 109 | "LockStatus": "Locked", 110 | "Modulation": "256QAM", 111 | "PowerLevel": "-1.2/58.8", 112 | "SNRLevel": 39, 113 | }, 114 | { 115 | "ChannelID": "13", 116 | "ChannelType": "SC-QAM", 117 | "Frequency": 682, 118 | "LockStatus": "Locked", 119 | "Modulation": "256QAM", 120 | "PowerLevel": "-1.2/58.8", 121 | "SNRLevel": 39, 122 | }, 123 | { 124 | "ChannelID": "14", 125 | "ChannelType": "SC-QAM", 126 | "Frequency": 690, 127 | "LockStatus": "Locked", 128 | "Modulation": "256QAM", 129 | "PowerLevel": "-0.8/59.2", 130 | "SNRLevel": 40.4, 131 | }, 132 | { 133 | "ChannelID": "15", 134 | "ChannelType": "SC-QAM", 135 | "Frequency": 698, 136 | "LockStatus": "Locked", 137 | "Modulation": "64QAM", 138 | "PowerLevel": "-7.1/52.9", 139 | "SNRLevel": 35, 140 | }, 141 | { 142 | "ChannelID": "16", 143 | "ChannelType": "SC-QAM", 144 | "Frequency": 706, 145 | "LockStatus": "Locked", 146 | "Modulation": "64QAM", 147 | "PowerLevel": "-6.1/53.9", 148 | "SNRLevel": 35.7, 149 | }, 150 | { 151 | "ChannelID": "17", 152 | "ChannelType": "SC-QAM", 153 | "Frequency": 714, 154 | "LockStatus": "Locked", 155 | "Modulation": "64QAM", 156 | "PowerLevel": "-6.1/53.9", 157 | "SNRLevel": 35, 158 | }, 159 | { 160 | "ChannelID": "18", 161 | "ChannelType": "SC-QAM", 162 | "Frequency": 722, 163 | "LockStatus": "Locked", 164 | "Modulation": "64QAM", 165 | "PowerLevel": "-6.3/53.7", 166 | "SNRLevel": 35.5, 167 | }, 168 | { 169 | "ChannelID": "19", 170 | "ChannelType": "SC-QAM", 171 | "Frequency": 730, 172 | "LockStatus": "Locked", 173 | "Modulation": "64QAM", 174 | "PowerLevel": "-6.5/53.5", 175 | "SNRLevel": 35, 176 | }, 177 | { 178 | "ChannelID": "20", 179 | "ChannelType": "SC-QAM", 180 | "Frequency": 738, 181 | "LockStatus": "Locked", 182 | "Modulation": "64QAM", 183 | "PowerLevel": "-5.5/54.5", 184 | "SNRLevel": 35.7, 185 | }, 186 | { 187 | "ChannelID": "21", 188 | "ChannelType": "SC-QAM", 189 | "Frequency": 746, 190 | "LockStatus": "Locked", 191 | "Modulation": "64QAM", 192 | "PowerLevel": "-5/55", 193 | "SNRLevel": 35.7, 194 | }, 195 | { 196 | "ChannelID": "22", 197 | "ChannelType": "SC-QAM", 198 | "Frequency": 754, 199 | "LockStatus": "Locked", 200 | "Modulation": "64QAM", 201 | "PowerLevel": "-5.7/54.3", 202 | "SNRLevel": 35, 203 | }, 204 | { 205 | "ChannelID": "23", 206 | "ChannelType": "SC-QAM", 207 | "Frequency": 762, 208 | "LockStatus": "Locked", 209 | "Modulation": "64QAM", 210 | "PowerLevel": "-5.5/54.5", 211 | "SNRLevel": 34.9, 212 | }, 213 | { 214 | "ChannelID": "24", 215 | "ChannelType": "SC-QAM", 216 | "Frequency": 770, 217 | "LockStatus": "Locked", 218 | "Modulation": "64QAM", 219 | "PowerLevel": "-5.6/54.4", 220 | "SNRLevel": 34.9, 221 | }, 222 | { 223 | "ChannelID": "25", 224 | "ChannelType": "SC-QAM", 225 | "Frequency": 778, 226 | "LockStatus": "Locked", 227 | "Modulation": "64QAM", 228 | "PowerLevel": "-5.2/54.8", 229 | "SNRLevel": 35.7, 230 | }, 231 | { 232 | "ChannelID": "26", 233 | "ChannelType": "SC-QAM", 234 | "Frequency": 786, 235 | "LockStatus": "Locked", 236 | "Modulation": "64QAM", 237 | "PowerLevel": "-5.1/54.9", 238 | "SNRLevel": 35.7, 239 | }, 240 | { 241 | "ChannelID": "27", 242 | "ChannelType": "SC-QAM", 243 | "Frequency": 794, 244 | "LockStatus": "Locked", 245 | "Modulation": "64QAM", 246 | "PowerLevel": "-5.2/54.8", 247 | "SNRLevel": 35, 248 | }, 249 | { 250 | "ChannelID": "28", 251 | "ChannelType": "SC-QAM", 252 | "Frequency": 802, 253 | "LockStatus": "Locked", 254 | "Modulation": "64QAM", 255 | "PowerLevel": "-5.5/54.5", 256 | "SNRLevel": 35.5, 257 | }, 258 | { 259 | "ChannelID": "29", 260 | "ChannelType": "SC-QAM", 261 | "Frequency": 810, 262 | "LockStatus": "Locked", 263 | "Modulation": "64QAM", 264 | "PowerLevel": "-5.5/54.5", 265 | "SNRLevel": 35.5, 266 | }, 267 | { 268 | "ChannelID": "30", 269 | "ChannelType": "SC-QAM", 270 | "Frequency": 818, 271 | "LockStatus": "Locked", 272 | "Modulation": "64QAM", 273 | "PowerLevel": "-5.3/54.7", 274 | "SNRLevel": 35.5, 275 | }, 276 | { 277 | "ChannelID": "31", 278 | "ChannelType": "SC-QAM", 279 | "Frequency": 826, 280 | "LockStatus": "Locked", 281 | "Modulation": "64QAM", 282 | "PowerLevel": "-5.6/54.4", 283 | "SNRLevel": 35.7, 284 | }, 285 | { 286 | "ChannelID": "32", 287 | "ChannelType": "SC-QAM", 288 | "Frequency": 834, 289 | "LockStatus": "Locked", 290 | "Modulation": "64QAM", 291 | "PowerLevel": "-6.5/53.5", 292 | "SNRLevel": 34.9, 293 | }, 294 | { 295 | "ChannelID": "1", 296 | "ChannelType": "SC-QAM", 297 | "Frequency": 114, 298 | "LockStatus": "Locked", 299 | "Modulation": "256QAM", 300 | "PowerLevel": "-4.8/55.2", 301 | "SNRLevel": 35.6, 302 | }, 303 | ], 304 | "downstreamChannels": 33, 305 | "ofdmChannels": 1, 306 | "time": "2021-02-26T09:19:56.042Z", 307 | "upstream": [ 308 | { 309 | "ChannelID": "4", 310 | "ChannelType": "SC-QAM", 311 | "Frequency": 36, 312 | "LockStatus": "ACTIVE", 313 | "Modulation": "64QAM", 314 | "PowerLevel": "49/109", 315 | }, 316 | { 317 | "ChannelID": "1", 318 | "ChannelType": "SC-QAM", 319 | "Frequency": 59, 320 | "LockStatus": "ACTIVE", 321 | "Modulation": "32QAM", 322 | "PowerLevel": "47/107", 323 | }, 324 | { 325 | "ChannelID": "3", 326 | "ChannelType": "SC-QAM", 327 | "Frequency": 46, 328 | "LockStatus": "ACTIVE", 329 | "Modulation": "64QAM", 330 | "PowerLevel": "49/109", 331 | }, 332 | { 333 | "ChannelID": "2", 334 | "ChannelType": "SC-QAM", 335 | "Frequency": 52, 336 | "LockStatus": "ACTIVE", 337 | "Modulation": "64QAM", 338 | "PowerLevel": "47/107", 339 | }, 340 | ], 341 | "upstreamChannels": 4, 342 | } 343 | `; 344 | -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_ofdm_arris.json: -------------------------------------------------------------------------------- 1 | { 2 | "downstream": [ 3 | { 4 | "ChannelType": "OFDM", 5 | "Modulation": "1024QAM", 6 | "Frequency": "151~324", 7 | "ChannelID": "33", 8 | "PowerLevel": "-3.8/56.2", 9 | "SNRLevel": "40", 10 | "LockStatus": "Locked" 11 | }, 12 | { 13 | "ChannelType": "SC-QAM", 14 | "Frequency": 602, 15 | "PowerLevel": "-3.4/56.6", 16 | "SNRLevel": 38.6, 17 | "Modulation": "256QAM", 18 | "ChannelID": "5", 19 | "LockStatus": "Locked" 20 | }, 21 | { 22 | "ChannelType": "SC-QAM", 23 | "Frequency": 114, 24 | "PowerLevel": "-4.7/55.3", 25 | "SNRLevel": 35.8, 26 | "Modulation": "256QAM", 27 | "ChannelID": "1", 28 | "LockStatus": "Locked" 29 | }, 30 | { 31 | "ChannelType": "SC-QAM", 32 | "Frequency": 130, 33 | "PowerLevel": "-5/55", 34 | "SNRLevel": 36.4, 35 | "Modulation": "256QAM", 36 | "ChannelID": "2", 37 | "LockStatus": "Locked" 38 | }, 39 | { 40 | "ChannelType": "SC-QAM", 41 | "Frequency": 138, 42 | "PowerLevel": "-4.9/55.1", 43 | "SNRLevel": 36.4, 44 | "Modulation": "256QAM", 45 | "ChannelID": "3", 46 | "LockStatus": "Locked" 47 | }, 48 | { 49 | "ChannelType": "SC-QAM", 50 | "Frequency": 826, 51 | "PowerLevel": "-6.2/53.8", 52 | "SNRLevel": 35, 53 | "Modulation": "64QAM", 54 | "ChannelID": "31", 55 | "LockStatus": "Locked" 56 | }, 57 | { 58 | "ChannelType": "SC-QAM", 59 | "Frequency": 834, 60 | "PowerLevel": "-7.2/52.8", 61 | "SNRLevel": 34.4, 62 | "Modulation": "64QAM", 63 | "ChannelID": "32", 64 | "LockStatus": "Locked" 65 | }, 66 | { 67 | "ChannelType": "SC-QAM", 68 | "Frequency": 818, 69 | "PowerLevel": "-5.9/54.1", 70 | "SNRLevel": 35.5, 71 | "Modulation": "64QAM", 72 | "ChannelID": "30", 73 | "LockStatus": "Locked" 74 | }, 75 | { 76 | "ChannelType": "SC-QAM", 77 | "Frequency": 810, 78 | "PowerLevel": "-6.4/53.6", 79 | "SNRLevel": 35, 80 | "Modulation": "64QAM", 81 | "ChannelID": "29", 82 | "LockStatus": "Locked" 83 | }, 84 | { 85 | "ChannelType": "SC-QAM", 86 | "Frequency": 626, 87 | "PowerLevel": "-2.9/57.1", 88 | "SNRLevel": 38.6, 89 | "Modulation": "256QAM", 90 | "ChannelID": "7", 91 | "LockStatus": "Locked" 92 | }, 93 | { 94 | "ChannelType": "SC-QAM", 95 | "Frequency": 618, 96 | "PowerLevel": "-3.4/56.6", 97 | "SNRLevel": 39, 98 | "Modulation": "256QAM", 99 | "ChannelID": "6", 100 | "LockStatus": "Locked" 101 | }, 102 | { 103 | "ChannelType": "SC-QAM", 104 | "Frequency": 786, 105 | "PowerLevel": "-5.6/54.4", 106 | "SNRLevel": 34.9, 107 | "Modulation": "64QAM", 108 | "ChannelID": "26", 109 | "LockStatus": "Locked" 110 | }, 111 | { 112 | "ChannelType": "SC-QAM", 113 | "Frequency": 794, 114 | "PowerLevel": "-5.6/54.4", 115 | "SNRLevel": 35.5, 116 | "Modulation": "64QAM", 117 | "ChannelID": "27", 118 | "LockStatus": "Locked" 119 | }, 120 | { 121 | "ChannelType": "SC-QAM", 122 | "Frequency": 802, 123 | "PowerLevel": "-6/54", 124 | "SNRLevel": 34.9, 125 | "Modulation": "64QAM", 126 | "ChannelID": "28", 127 | "LockStatus": "Locked" 128 | }, 129 | { 130 | "ChannelType": "SC-QAM", 131 | "Frequency": 146, 132 | "PowerLevel": "-5.2/54.8", 133 | "SNRLevel": 36.4, 134 | "Modulation": "256QAM", 135 | "ChannelID": "4", 136 | "LockStatus": "Locked" 137 | }, 138 | { 139 | "ChannelType": "SC-QAM", 140 | "Frequency": 778, 141 | "PowerLevel": "-5.7/54.3", 142 | "SNRLevel": 35.5, 143 | "Modulation": "64QAM", 144 | "ChannelID": "25", 145 | "LockStatus": "Locked" 146 | }, 147 | { 148 | "ChannelType": "SC-QAM", 149 | "Frequency": 650, 150 | "PowerLevel": "-2.3/57.7", 151 | "SNRLevel": 39, 152 | "Modulation": "256QAM", 153 | "ChannelID": "9", 154 | "LockStatus": "Locked" 155 | }, 156 | { 157 | "ChannelType": "SC-QAM", 158 | "Frequency": 658, 159 | "PowerLevel": "-2.4/57.6", 160 | "SNRLevel": 39, 161 | "Modulation": "256QAM", 162 | "ChannelID": "10", 163 | "LockStatus": "Locked" 164 | }, 165 | { 166 | "ChannelType": "SC-QAM", 167 | "Frequency": 666, 168 | "PowerLevel": "-2.7/57.3", 169 | "SNRLevel": 38.6, 170 | "Modulation": "256QAM", 171 | "ChannelID": "11", 172 | "LockStatus": "Locked" 173 | }, 174 | { 175 | "ChannelType": "SC-QAM", 176 | "Frequency": 754, 177 | "PowerLevel": "-6.4/53.6", 178 | "SNRLevel": 35, 179 | "Modulation": "64QAM", 180 | "ChannelID": "22", 181 | "LockStatus": "Locked" 182 | }, 183 | { 184 | "ChannelType": "SC-QAM", 185 | "Frequency": 762, 186 | "PowerLevel": "-6.2/53.8", 187 | "SNRLevel": 35, 188 | "Modulation": "64QAM", 189 | "ChannelID": "23", 190 | "LockStatus": "Locked" 191 | }, 192 | { 193 | "ChannelType": "SC-QAM", 194 | "Frequency": 770, 195 | "PowerLevel": "-6.3/53.7", 196 | "SNRLevel": 35, 197 | "Modulation": "64QAM", 198 | "ChannelID": "24", 199 | "LockStatus": "Locked" 200 | }, 201 | { 202 | "ChannelType": "SC-QAM", 203 | "Frequency": 642, 204 | "PowerLevel": "-2.4/57.6", 205 | "SNRLevel": 39, 206 | "Modulation": "256QAM", 207 | "ChannelID": "8", 208 | "LockStatus": "Locked" 209 | }, 210 | { 211 | "ChannelType": "SC-QAM", 212 | "Frequency": 746, 213 | "PowerLevel": "-5.4/54.6", 214 | "SNRLevel": 35.7, 215 | "Modulation": "64QAM", 216 | "ChannelID": "21", 217 | "LockStatus": "Locked" 218 | }, 219 | { 220 | "ChannelType": "SC-QAM", 221 | "Frequency": 674, 222 | "PowerLevel": "-2/58", 223 | "SNRLevel": 39, 224 | "Modulation": "256QAM", 225 | "ChannelID": "12", 226 | "LockStatus": "Locked" 227 | }, 228 | { 229 | "ChannelType": "SC-QAM", 230 | "Frequency": 682, 231 | "PowerLevel": "-1.9/58.1", 232 | "SNRLevel": 39, 233 | "Modulation": "256QAM", 234 | "ChannelID": "13", 235 | "LockStatus": "Locked" 236 | }, 237 | { 238 | "ChannelType": "SC-QAM", 239 | "Frequency": 698, 240 | "PowerLevel": "-7.8/52.2", 241 | "SNRLevel": 34.9, 242 | "Modulation": "64QAM", 243 | "ChannelID": "15", 244 | "LockStatus": "Locked" 245 | }, 246 | { 247 | "ChannelType": "SC-QAM", 248 | "Frequency": 706, 249 | "PowerLevel": "-6.8/53.2", 250 | "SNRLevel": 35.5, 251 | "Modulation": "64QAM", 252 | "ChannelID": "16", 253 | "LockStatus": "Locked" 254 | }, 255 | { 256 | "ChannelType": "SC-QAM", 257 | "Frequency": 722, 258 | "PowerLevel": "-7/53", 259 | "SNRLevel": 35, 260 | "Modulation": "64QAM", 261 | "ChannelID": "18", 262 | "LockStatus": "Locked" 263 | }, 264 | { 265 | "ChannelType": "SC-QAM", 266 | "Frequency": 690, 267 | "PowerLevel": "-1.5/58.5", 268 | "SNRLevel": 39, 269 | "Modulation": "256QAM", 270 | "ChannelID": "14", 271 | "LockStatus": "Locked" 272 | }, 273 | { 274 | "ChannelType": "SC-QAM", 275 | "Frequency": 714, 276 | "PowerLevel": "-6.8/53.2", 277 | "SNRLevel": 35, 278 | "Modulation": "64QAM", 279 | "ChannelID": "17", 280 | "LockStatus": "Locked" 281 | }, 282 | { 283 | "ChannelType": "SC-QAM", 284 | "Frequency": 730, 285 | "PowerLevel": "-6.9/53.1", 286 | "SNRLevel": 34.9, 287 | "Modulation": "64QAM", 288 | "ChannelID": "19", 289 | "LockStatus": "Locked" 290 | }, 291 | { 292 | "ChannelType": "SC-QAM", 293 | "Frequency": 738, 294 | "PowerLevel": "-6/54", 295 | "SNRLevel": 35.7, 296 | "Modulation": "64QAM", 297 | "ChannelID": "20", 298 | "LockStatus": "Locked" 299 | } 300 | ], 301 | "upstream": [ 302 | { 303 | "ChannelType": "OFDMA", 304 | "Frequency": "29.8~64.8", 305 | "ChannelID": "9", 306 | "PowerLevel": "47/107", 307 | "Modulation": "16_QAM", 308 | "LockStatus": "SUCCESS" 309 | }, 310 | { 311 | "Frequency": 37, 312 | "PowerLevel": "51/111", 313 | "ChannelType": "SC-QAM", 314 | "ChannelID": "3", 315 | "Modulation": "32QAM", 316 | "LockStatus": "ACTIVE" 317 | }, 318 | { 319 | "Frequency": 51, 320 | "PowerLevel": "49/109", 321 | "ChannelType": "SC-QAM", 322 | "ChannelID": "1", 323 | "Modulation": "32QAM", 324 | "LockStatus": "ACTIVE" 325 | }, 326 | { 327 | "Frequency": 31, 328 | "PowerLevel": "51/111", 329 | "ChannelType": "SC-QAM", 330 | "ChannelID": "4", 331 | "Modulation": "32QAM", 332 | "LockStatus": "ACTIVE" 333 | }, 334 | { 335 | "Frequency": 45, 336 | "PowerLevel": "51/111", 337 | "ChannelType": "SC-QAM", 338 | "ChannelID": "2", 339 | "Modulation": "32QAM", 340 | "LockStatus": "ACTIVE" 341 | } 342 | ], 343 | "downstreamChannels": 33, 344 | "upstreamChannels": 5, 345 | "ofdmChannels": 1, 346 | "time": "2021-10-15T22:49:45.348Z" 347 | } -------------------------------------------------------------------------------- /src/modem/technicolor-modem.ts: -------------------------------------------------------------------------------- 1 | import {Log} from '../logger'; 2 | import {Protocol} from './discovery'; 3 | import { 4 | DocsisChannelType, DocsisStatus, HumanizedDocsis31ChannelStatus, HumanizedDocsisChannelStatus, Modem, normalizeModulation, 5 | } from './modem'; 6 | import {deriveKeyTechnicolor} from './tools/crypto'; 7 | 8 | export interface TechnicolorBaseResponse { 9 | data?: {[key: string]: unknown}; 10 | error: 'error' | 'ok' | string; 11 | message: string; 12 | } 13 | 14 | export interface TechnicolorConfiguration extends TechnicolorBaseResponse { 15 | data: { 16 | AFTR: string; 17 | DeviceMode: 'Ipv4' | 'Ipv4'; 18 | firmwareversion: string; 19 | internetipv4: string; 20 | IPAddressRT: string[]; 21 | LanMode: 'modem' | 'router'; 22 | }; 23 | } 24 | 25 | export interface TechnicolorSaltResponse extends TechnicolorBaseResponse { 26 | salt: string; 27 | saltwebui: string; 28 | } 29 | 30 | export interface TechnicolorTokenResponse extends TechnicolorBaseResponse { 31 | token: string; 32 | } 33 | 34 | export interface TechnicolorDocsisStatus { 35 | data: { 36 | downstream: TechnicolorDocsisChannelStatus[]; 37 | ofdm_downstream: TechnicolorDocsis31ChannelStatus[]; 38 | ofdma_upstream: TechnicolorDocsis31UpstreamChannelStatus[]; 39 | upstream: TechnicolorDocsisUpstreamChannelStatus[]; 40 | }; 41 | error: string; 42 | message: string; 43 | } 44 | 45 | export interface TechnicolorDocsisUpstreamChannelStatus extends Omit { 46 | CentralFrequency: string; 47 | channelidup: string; 48 | RangingStatus: string; 49 | } 50 | 51 | export interface TechnicolorDocsis31UpstreamChannelStatus { 52 | __id: string; // '1', 53 | bandwidth: string; // '35 MHz', 54 | CentralFrequency: string; // '46 MHz', 55 | channelidup: string; // '9', 56 | ChannelType: string; // 'OFDMA', 57 | end_frequency: string; // '64.750000 MHz', 58 | FFT: string; // 'qpsk', 59 | power: string; // '44.0 dBmV', 60 | RangingStatus: string;// 'Completed' 61 | start_frequency: string; // '29.800000 MHz', 62 | } 63 | 64 | export interface TechnicolorDocsisChannelStatus { 65 | __id: string; 66 | CentralFrequency: string; // MHz 67 | channelid: string; 68 | ChannelType: DocsisChannelType; 69 | FFT: string; // modulation 70 | locked: string; 71 | power: string; // dBmV 72 | SNR: string; // dB 73 | } 74 | 75 | export interface TechnicolorDocsis31ChannelStatus { 76 | __id: string; 77 | bandwidth: string; // MHz 78 | CentralFrequency_ofdm: string; // MHz 79 | channelid_ofdm: string; 80 | ChannelType: DocsisChannelType; 81 | end_frequency: string; // MHz 82 | FFT_ofdm: string; // 'qam256/qam1024' 83 | locked_ofdm: string; 84 | power_ofdm: string; // dBmV 85 | SNR_ofdm: string; // dB 86 | start_frequency: string; // MHz 87 | } 88 | 89 | export function normalizeChannelStatus(channelStatus: TechnicolorDocsisChannelStatus): HumanizedDocsisChannelStatus { 90 | return { 91 | channelId: channelStatus.channelid, 92 | channelType: channelStatus.ChannelType, 93 | frequency: Number.parseInt(`${channelStatus.CentralFrequency ?? 0}`, 10), 94 | lockStatus: channelStatus.locked, 95 | modulation: normalizeModulation(channelStatus.FFT), 96 | powerLevel: Number.parseFloat(channelStatus.power), 97 | snr: Number.parseFloat(`${channelStatus.SNR ?? 0}`), 98 | } 99 | } 100 | 101 | export function normalizeUpstreamChannelStatus(channelStatus: TechnicolorDocsisUpstreamChannelStatus): HumanizedDocsisChannelStatus { 102 | return { 103 | channelId: channelStatus.channelidup, 104 | channelType: channelStatus.ChannelType, 105 | frequency: Number.parseInt(`${channelStatus.CentralFrequency ?? 0}`, 10), 106 | lockStatus: channelStatus.RangingStatus, 107 | modulation: normalizeModulation(channelStatus.FFT), 108 | powerLevel: Number.parseFloat(channelStatus.power), 109 | snr: 0, 110 | } 111 | } 112 | 113 | export function normalizeUpstreamOfdmaChannelStatus(channelStatus: TechnicolorDocsis31UpstreamChannelStatus): HumanizedDocsis31ChannelStatus { 114 | return { 115 | channelId: channelStatus.channelidup, 116 | channelType: channelStatus.ChannelType as DocsisChannelType, 117 | frequencyEnd: Number.parseFloat(channelStatus.end_frequency), 118 | frequencyStart: Number.parseFloat(channelStatus.start_frequency), 119 | lockStatus: channelStatus.RangingStatus, 120 | modulation: normalizeModulation(channelStatus.FFT), 121 | powerLevel: Number.parseFloat(channelStatus.power), 122 | snr: 0, 123 | } 124 | } 125 | 126 | export function normalizeOfdmChannelStatus(channelStatus: TechnicolorDocsis31ChannelStatus): HumanizedDocsis31ChannelStatus { 127 | return { 128 | channelId: channelStatus.channelid_ofdm, 129 | channelType: channelStatus.ChannelType, 130 | frequencyEnd: Number.parseInt(channelStatus.end_frequency, 10), 131 | frequencyStart: Number.parseInt(channelStatus.start_frequency, 10), 132 | lockStatus: channelStatus.locked_ofdm, 133 | modulation: normalizeModulation(channelStatus.FFT_ofdm), 134 | powerLevel: Number.parseFloat(channelStatus.power_ofdm), 135 | snr: Number.parseFloat(channelStatus.SNR_ofdm), 136 | } 137 | } 138 | 139 | export function normalizeDocsisStatus(channelStatus: TechnicolorDocsisStatus): DocsisStatus { 140 | return { 141 | downstream: channelStatus.data.downstream.map(channel => normalizeChannelStatus(channel)), 142 | downstreamOfdm: channelStatus.data.ofdm_downstream.map(channel => normalizeOfdmChannelStatus(channel)), 143 | time: new Date().toISOString(), 144 | upstream: channelStatus.data.upstream.map(channel => normalizeUpstreamChannelStatus(channel)), 145 | upstreamOfdma: channelStatus.data.ofdma_upstream.map(channel => normalizeUpstreamOfdmaChannelStatus(channel)), 146 | } 147 | } 148 | 149 | export class Technicolor extends Modem { 150 | constructor( 151 | readonly modemIp: string, 152 | readonly protocol: Protocol, 153 | readonly logger: Log, 154 | ) { 155 | super(modemIp, protocol, logger); 156 | } 157 | 158 | async docsis(): Promise { 159 | const {data: docsisStatus} = await this.httpClient.get( 160 | '/api/v1/sta_docsis_status', 161 | { 162 | headers: { 163 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 164 | Referer: this.baseUrl, 165 | }, 166 | }, 167 | ); 168 | return normalizeDocsisStatus(docsisStatus as TechnicolorDocsisStatus); 169 | } 170 | 171 | async login(password: string): Promise { 172 | try { 173 | const {data: salt} 174 | = await this.httpClient.post( 175 | '/api/v1/session/login', 176 | `username=${Modem.USERNAME}&password=seeksalthash`, 177 | { 178 | headers: { 179 | 'Content-Type': 180 | 'application/x-www-form-urlencoded; charset=UTF-8', 181 | Referer: this.baseUrl, 182 | }, 183 | }, 184 | ); 185 | this.logger.debug('Salt', salt); 186 | 187 | if (salt.message && salt.message === 'MSG_LOGIN_150') { 188 | throw new Error('A user is already logged in'); 189 | } 190 | 191 | const derivedKey = deriveKeyTechnicolor( 192 | deriveKeyTechnicolor(password, salt.salt), 193 | salt.saltwebui, 194 | ); 195 | this.logger.debug('Derived key', derivedKey); 196 | const {data: loginResponse} 197 | = await this.httpClient.post( 198 | '/api/v1/session/login', 199 | `username=${Modem.USERNAME}&password=${derivedKey}`, 200 | { 201 | headers: { 202 | 'Content-Type': 203 | 'application/x-www-form-urlencoded; charset=UTF-8', 204 | Referer: this.baseUrl, 205 | }, 206 | }, 207 | ); 208 | this.logger.debug('Login status', loginResponse); 209 | const {data: messageResponse} 210 | = await this.httpClient.get( 211 | '/api/v1/session/menu', 212 | { 213 | headers: { 214 | 'Content-Type': 215 | 'application/x-www-form-urlencoded; charset=UTF-8', 216 | Referer: this.baseUrl, 217 | }, 218 | }, 219 | ); 220 | this.logger.debug('Message status', messageResponse); 221 | } catch (error) { 222 | this.logger.warn(`Something went wrong with the login ${error}`); 223 | } 224 | } 225 | 226 | async logout(): Promise { 227 | this.logger.debug('Logging out...'); 228 | return this.httpClient.post('api/v1/session/logout'); 229 | } 230 | 231 | async restart(): Promise { 232 | const {data: tokenResponse} 233 | = await this.httpClient.get( 234 | 'api/v1/session/init_page', 235 | { 236 | headers: { 237 | Referer: this.baseUrl, 238 | }, 239 | }, 240 | ); 241 | 242 | this.logger.debug('Token response: ', tokenResponse); 243 | const {data: restartResponse} 244 | = await this.httpClient.post( 245 | 'api/v1/sta_restart', 246 | 'restart=Router%2CWifi%2CVoIP%2CDect%2CMoCA&ui_access=reboot_device', 247 | { 248 | headers: { 249 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 250 | Referer: this.baseUrl, 251 | 'X-CSRF-TOKEN': tokenResponse.token, 252 | }, 253 | }, 254 | ); 255 | 256 | if (restartResponse?.error === 'error') { 257 | this.logger.debug(restartResponse); 258 | throw new Error(`Could not restart router: ${restartResponse.message}`); 259 | } 260 | 261 | return restartResponse; 262 | } 263 | } 264 | 265 | -------------------------------------------------------------------------------- /src/modem/discovery.test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { discoverModemLocation, ModemDiscovery } from './discovery'; 3 | import { extractFirmwareVersion } from './tools/html-parser'; 4 | 5 | // Mock axios 6 | jest.mock('axios'); 7 | const mockedAxios = axios as jest.Mocked; 8 | 9 | // Mock html-parser 10 | jest.mock('./tools/html-parser'); 11 | const mockedExtractFirmwareVersion = extractFirmwareVersion as jest.MockedFunction; 12 | 13 | describe('Discovery', () => { 14 | beforeEach(() => { 15 | jest.clearAllMocks(); 16 | delete process.env.VODAFONE_ROUTER_IP; 17 | }); 18 | 19 | describe('discoverModemLocation', () => { 20 | describe('with IP parameter', () => { 21 | it('should use provided IP address and return modem location', async () => { 22 | const mockResponse = { 23 | status: 200, 24 | request: { 25 | host: '192.168.1.100', 26 | protocol: 'http:' 27 | } 28 | }; 29 | mockedAxios.head = jest.fn().mockResolvedValue(mockResponse); 30 | 31 | const result = await discoverModemLocation({ ip: '192.168.1.100' }); 32 | 33 | expect(mockedAxios.head).toHaveBeenCalledWith('http://192.168.1.100'); 34 | expect(result).toEqual({ 35 | ipAddress: '192.168.1.100', 36 | protocol: 'http', 37 | }); 38 | }); 39 | 40 | it('should try both HTTP and HTTPS for provided IP', async () => { 41 | const mockResponse = { 42 | status: 200, 43 | request: { 44 | host: '192.168.1.100', 45 | protocol: 'https:' 46 | } 47 | }; 48 | mockedAxios.head = jest.fn() 49 | .mockRejectedValueOnce(new Error('HTTP failed')) 50 | .mockResolvedValueOnce(mockResponse); 51 | 52 | const result = await discoverModemLocation({ ip: '192.168.1.100' }); 53 | 54 | expect(mockedAxios.head).toHaveBeenCalledTimes(2); 55 | expect(mockedAxios.head).toHaveBeenNthCalledWith(1, 'http://192.168.1.100'); 56 | expect(mockedAxios.head).toHaveBeenNthCalledWith(2, 'https://192.168.1.100'); 57 | expect(result).toEqual({ 58 | ipAddress: '192.168.1.100', 59 | protocol: 'https', 60 | }); 61 | }); 62 | 63 | it('should throw error if both HTTP and HTTPS fail with provided IP', async () => { 64 | mockedAxios.head = jest.fn().mockRejectedValue(new Error('Connection failed')); 65 | 66 | await expect(discoverModemLocation({ ip: '192.168.1.100' })) 67 | .rejects.toThrow('Could not find a router/modem under the known addresses.'); 68 | }); 69 | }); 70 | 71 | describe('without IP parameter (default behavior)', () => { 72 | it('should try default IPs when no IP provided', async () => { 73 | const mockResponse = { 74 | status: 200, 75 | request: { 76 | host: '192.168.100.1', 77 | protocol: 'http:' 78 | } 79 | }; 80 | mockedAxios.head = jest.fn().mockResolvedValue(mockResponse); 81 | 82 | const result = await discoverModemLocation(); 83 | 84 | // Should try first default IP 85 | expect(mockedAxios.head).toHaveBeenCalledWith('http://192.168.100.1'); 86 | expect(result.ipAddress).toBe('192.168.100.1'); 87 | }); 88 | 89 | it('should try multiple default IPs if first fails', async () => { 90 | const mockResponse = { 91 | status: 200, 92 | request: { 93 | host: '192.168.0.1', 94 | protocol: 'https:' 95 | } 96 | }; 97 | 98 | mockedAxios.head = jest.fn() 99 | .mockRejectedValueOnce(new Error('Failed')) // 192.168.100.1 HTTP 100 | .mockRejectedValueOnce(new Error('Failed')) // 192.168.100.1 HTTPS 101 | .mockRejectedValueOnce(new Error('Failed')) // 192.168.0.1 HTTP 102 | .mockResolvedValueOnce(mockResponse); // 192.168.0.1 HTTPS 103 | 104 | const result = await discoverModemLocation(); 105 | 106 | expect(mockedAxios.head).toHaveBeenCalledTimes(4); 107 | expect(mockedAxios.head).toHaveBeenNthCalledWith(1, 'http://192.168.100.1'); 108 | expect(mockedAxios.head).toHaveBeenNthCalledWith(2, 'https://192.168.100.1'); 109 | expect(mockedAxios.head).toHaveBeenNthCalledWith(3, 'http://192.168.0.1'); 110 | expect(mockedAxios.head).toHaveBeenNthCalledWith(4, 'https://192.168.0.1'); 111 | expect(result.ipAddress).toBe('192.168.0.1'); 112 | expect(result.protocol).toBe('https'); 113 | }); 114 | }); 115 | 116 | describe('protocol detection', () => { 117 | it('should correctly extract protocol without colon', async () => { 118 | const mockResponse = { 119 | status: 200, 120 | request: { 121 | host: '192.168.1.100', 122 | protocol: 'https:' // Protocol with colon as returned by axios 123 | } 124 | }; 125 | mockedAxios.head = jest.fn().mockResolvedValue(mockResponse); 126 | 127 | const result = await discoverModemLocation({ ip: '192.168.1.100' }); 128 | 129 | expect(result.protocol).toBe('https'); // Should be without colon 130 | }); 131 | }); 132 | }); 133 | 134 | describe('ModemDiscovery', () => { 135 | const mockLogger = { 136 | debug: jest.fn(), 137 | warn: jest.fn(), 138 | error: jest.fn(), 139 | }; 140 | 141 | const mockModemLocation = { 142 | ipAddress: '192.168.1.1', 143 | protocol: 'http' as const, 144 | }; 145 | 146 | beforeEach(() => { 147 | jest.clearAllMocks(); 148 | }); 149 | 150 | describe('discover', () => { 151 | it('should successfully discover Technicolor modem', async () => { 152 | const mockTechnicolorResponse = { 153 | data: { 154 | error: 'ok', 155 | data: { 156 | firmwareversion: '20.3.c.0317', 157 | }, 158 | }, 159 | }; 160 | mockedAxios.get = jest.fn() 161 | .mockRejectedValueOnce(new Error('Arris failed')) // tryArris fails 162 | .mockResolvedValueOnce(mockTechnicolorResponse); // tryTechnicolor succeeds 163 | 164 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 165 | const result = await discovery.discover(); 166 | 167 | expect(result).toEqual({ 168 | deviceType: 'Technicolor', 169 | firmwareVersion: '20.3.c.0317', 170 | ipAddress: '192.168.1.1', 171 | protocol: 'http', 172 | }); 173 | }); 174 | 175 | it('should successfully discover Arris modem', async () => { 176 | const mockArrisResponse = { 177 | data: 'some firmware version data', 178 | }; 179 | 180 | mockedExtractFirmwareVersion.mockReturnValue('AR01.03.045.12_042321_711.PC20.1'); 181 | 182 | mockedAxios.get = jest.fn() 183 | .mockResolvedValueOnce(mockArrisResponse) // tryArris succeeds 184 | .mockRejectedValueOnce(new Error('Technicolor failed')); // tryTechnicolor fails 185 | 186 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 187 | const result = await discovery.discover(); 188 | 189 | expect(result).toEqual({ 190 | deviceType: 'Arris', 191 | firmwareVersion: 'AR01.03.045.12_042321_711.PC20.1', 192 | ipAddress: '192.168.1.1', 193 | protocol: 'http', 194 | }); 195 | }); 196 | 197 | it('should throw error when both modem types fail', async () => { 198 | mockedAxios.get = jest.fn().mockRejectedValue(new Error('Connection failed')); 199 | 200 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 201 | 202 | await expect(discovery.discover()).rejects.toThrow(); 203 | }); 204 | }); 205 | 206 | describe('tryTechnicolor', () => { 207 | it('should make correct API call for Technicolor', async () => { 208 | const mockResponse = { 209 | data: { 210 | error: 'ok', 211 | data: { 212 | firmwareversion: '20.3.c.0317', 213 | }, 214 | }, 215 | }; 216 | mockedAxios.get = jest.fn().mockResolvedValue(mockResponse); 217 | 218 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 219 | const result = await discovery.tryTechnicolor(); 220 | 221 | expect(mockedAxios.get).toHaveBeenCalledWith('http://192.168.1.1/api/v1/login_conf'); 222 | expect(result.deviceType).toBe('Technicolor'); 223 | expect(result.firmwareVersion).toBe('20.3.c.0317'); 224 | }); 225 | 226 | it('should throw error for invalid Technicolor response', async () => { 227 | const mockResponse = { 228 | data: { 229 | error: 'failed', 230 | }, 231 | }; 232 | mockedAxios.get = jest.fn().mockResolvedValue(mockResponse); 233 | 234 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 235 | 236 | await expect(discovery.tryTechnicolor()).rejects.toThrow('Could not determine modem type'); 237 | }); 238 | }); 239 | 240 | describe('tryArris', () => { 241 | it('should make correct API call for Arris', async () => { 242 | const mockResponse = { 243 | data: 'firmware data', 244 | }; 245 | 246 | mockedExtractFirmwareVersion.mockReturnValue('AR01.03.045.12_042321_711.PC20.1'); 247 | 248 | mockedAxios.get = jest.fn().mockResolvedValue(mockResponse); 249 | 250 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 251 | const result = await discovery.tryArris(); 252 | 253 | expect(mockedAxios.get).toHaveBeenCalledWith('http://192.168.1.1/index.php', { 254 | headers: { 255 | Accept: 'text/html,application/xhtml+xml,application/xml', 256 | }, 257 | }); 258 | expect(result.deviceType).toBe('Arris'); 259 | }); 260 | 261 | it('should throw error when firmware version cannot be extracted', async () => { 262 | const mockResponse = { 263 | data: 'no firmware data', 264 | }; 265 | 266 | mockedExtractFirmwareVersion.mockReturnValue(undefined); 267 | 268 | mockedAxios.get = jest.fn().mockResolvedValue(mockResponse); 269 | 270 | const discovery = new ModemDiscovery(mockModemLocation, mockLogger as any); 271 | 272 | await expect(discovery.tryArris()).rejects.toThrow('Unable to parse firmware version.'); 273 | }); 274 | }); 275 | }); 276 | }); -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_arris.json: -------------------------------------------------------------------------------- 1 | { 2 | "downstream": [ 3 | { 4 | "ChannelType": "OFDM", 5 | "Modulation": "1024QAM", 6 | "Frequency": "151~324", 7 | "ChannelID": "33", 8 | "PowerLevel": "-4.1/55.9", 9 | "SNRLevel": "39", 10 | "LockStatus": "Locked" 11 | }, 12 | { 13 | "ChannelType": "SC-QAM", 14 | "Frequency": 130, 15 | "PowerLevel": "-6.7/53.3", 16 | "SNRLevel": 35.1, 17 | "Modulation": "256QAM", 18 | "ChannelID": "2", 19 | "LockStatus": "Locked" 20 | }, 21 | { 22 | "ChannelType": "SC-QAM", 23 | "Frequency": 138, 24 | "PowerLevel": "-6.4/53.6", 25 | "SNRLevel": 35.1, 26 | "Modulation": "256QAM", 27 | "ChannelID": "3", 28 | "LockStatus": "Locked" 29 | }, 30 | { 31 | "ChannelType": "SC-QAM", 32 | "Frequency": 146, 33 | "PowerLevel": "-6.7/53.3", 34 | "SNRLevel": 35.8, 35 | "Modulation": "256QAM", 36 | "ChannelID": "4", 37 | "LockStatus": "Locked" 38 | }, 39 | { 40 | "ChannelType": "SC-QAM", 41 | "Frequency": 602, 42 | "PowerLevel": "-3.7/56.3", 43 | "SNRLevel": 38.6, 44 | "Modulation": "256QAM", 45 | "ChannelID": "5", 46 | "LockStatus": "Locked" 47 | }, 48 | { 49 | "ChannelType": "SC-QAM", 50 | "Frequency": 618, 51 | "PowerLevel": "-3.8/56.2", 52 | "SNRLevel": 38.6, 53 | "Modulation": "256QAM", 54 | "ChannelID": "6", 55 | "LockStatus": "Locked" 56 | }, 57 | { 58 | "ChannelType": "SC-QAM", 59 | "Frequency": 626, 60 | "PowerLevel": "-3.6/56.4", 61 | "SNRLevel": 38.6, 62 | "Modulation": "256QAM", 63 | "ChannelID": "7", 64 | "LockStatus": "Locked" 65 | }, 66 | { 67 | "ChannelType": "SC-QAM", 68 | "Frequency": 642, 69 | "PowerLevel": "-4.2/55.8", 70 | "SNRLevel": 38.6, 71 | "Modulation": "256QAM", 72 | "ChannelID": "8", 73 | "LockStatus": "Locked" 74 | }, 75 | { 76 | "ChannelType": "SC-QAM", 77 | "Frequency": 650, 78 | "PowerLevel": "-4.8/55.2", 79 | "SNRLevel": 38.6, 80 | "Modulation": "256QAM", 81 | "ChannelID": "9", 82 | "LockStatus": "Locked" 83 | }, 84 | { 85 | "ChannelType": "SC-QAM", 86 | "Frequency": 658, 87 | "PowerLevel": "-4.6/55.4", 88 | "SNRLevel": 39, 89 | "Modulation": "256QAM", 90 | "ChannelID": "10", 91 | "LockStatus": "Locked" 92 | }, 93 | { 94 | "ChannelType": "SC-QAM", 95 | "Frequency": 666, 96 | "PowerLevel": "-4.4/55.6", 97 | "SNRLevel": 37.6, 98 | "Modulation": "256QAM", 99 | "ChannelID": "11", 100 | "LockStatus": "Locked" 101 | }, 102 | { 103 | "ChannelType": "SC-QAM", 104 | "Frequency": 674, 105 | "PowerLevel": "-3.1/56.9", 106 | "SNRLevel": 38.6, 107 | "Modulation": "256QAM", 108 | "ChannelID": "12", 109 | "LockStatus": "Locked" 110 | }, 111 | { 112 | "ChannelType": "SC-QAM", 113 | "Frequency": 682, 114 | "PowerLevel": "-2.7/57.3", 115 | "SNRLevel": 39, 116 | "Modulation": "256QAM", 117 | "ChannelID": "13", 118 | "LockStatus": "Locked" 119 | }, 120 | { 121 | "ChannelType": "SC-QAM", 122 | "Frequency": 690, 123 | "PowerLevel": "-2/58", 124 | "SNRLevel": 39, 125 | "Modulation": "256QAM", 126 | "ChannelID": "14", 127 | "LockStatus": "Locked" 128 | }, 129 | { 130 | "ChannelType": "SC-QAM", 131 | "Frequency": 698, 132 | "PowerLevel": "-8/52", 133 | "SNRLevel": 34.9, 134 | "Modulation": "64QAM", 135 | "ChannelID": "15", 136 | "LockStatus": "Locked" 137 | }, 138 | { 139 | "ChannelType": "SC-QAM", 140 | "Frequency": 706, 141 | "PowerLevel": "-7/53", 142 | "SNRLevel": 35.5, 143 | "Modulation": "64QAM", 144 | "ChannelID": "16", 145 | "LockStatus": "Locked" 146 | }, 147 | { 148 | "ChannelType": "SC-QAM", 149 | "Frequency": 714, 150 | "PowerLevel": "-6.8/53.2", 151 | "SNRLevel": 35, 152 | "Modulation": "64QAM", 153 | "ChannelID": "17", 154 | "LockStatus": "Locked" 155 | }, 156 | { 157 | "ChannelType": "SC-QAM", 158 | "Frequency": 722, 159 | "PowerLevel": "-7.1/52.9", 160 | "SNRLevel": 34.9, 161 | "Modulation": "64QAM", 162 | "ChannelID": "18", 163 | "LockStatus": "Locked" 164 | }, 165 | { 166 | "ChannelType": "SC-QAM", 167 | "Frequency": 730, 168 | "PowerLevel": "-7.1/52.9", 169 | "SNRLevel": 35, 170 | "Modulation": "64QAM", 171 | "ChannelID": "19", 172 | "LockStatus": "Locked" 173 | }, 174 | { 175 | "ChannelType": "SC-QAM", 176 | "Frequency": 738, 177 | "PowerLevel": "-6.1/53.9", 178 | "SNRLevel": 35.7, 179 | "Modulation": "64QAM", 180 | "ChannelID": "20", 181 | "LockStatus": "Locked" 182 | }, 183 | { 184 | "ChannelType": "SC-QAM", 185 | "Frequency": 746, 186 | "PowerLevel": "-5.6/54.4", 187 | "SNRLevel": 36.3, 188 | "Modulation": "64QAM", 189 | "ChannelID": "21", 190 | "LockStatus": "Locked" 191 | }, 192 | { 193 | "ChannelType": "SC-QAM", 194 | "Frequency": 754, 195 | "PowerLevel": "-6.2/53.8", 196 | "SNRLevel": 35.5, 197 | "Modulation": "64QAM", 198 | "ChannelID": "22", 199 | "LockStatus": "Locked" 200 | }, 201 | { 202 | "ChannelType": "SC-QAM", 203 | "Frequency": 762, 204 | "PowerLevel": "-5.9/54.1", 205 | "SNRLevel": 35.5, 206 | "Modulation": "64QAM", 207 | "ChannelID": "23", 208 | "LockStatus": "Locked" 209 | }, 210 | { 211 | "ChannelType": "SC-QAM", 212 | "Frequency": 770, 213 | "PowerLevel": "-6.2/53.8", 214 | "SNRLevel": 35, 215 | "Modulation": "64QAM", 216 | "ChannelID": "24", 217 | "LockStatus": "Locked" 218 | }, 219 | { 220 | "ChannelType": "SC-QAM", 221 | "Frequency": 778, 222 | "PowerLevel": "-5.9/54.1", 223 | "SNRLevel": 36.3, 224 | "Modulation": "64QAM", 225 | "ChannelID": "25", 226 | "LockStatus": "Locked" 227 | }, 228 | { 229 | "ChannelType": "SC-QAM", 230 | "Frequency": 786, 231 | "PowerLevel": "-5.7/54.3", 232 | "SNRLevel": 35.5, 233 | "Modulation": "64QAM", 234 | "ChannelID": "26", 235 | "LockStatus": "Locked" 236 | }, 237 | { 238 | "ChannelType": "SC-QAM", 239 | "Frequency": 794, 240 | "PowerLevel": "-5.9/54.1", 241 | "SNRLevel": 35.5, 242 | "Modulation": "64QAM", 243 | "ChannelID": "27", 244 | "LockStatus": "Locked" 245 | }, 246 | { 247 | "ChannelType": "SC-QAM", 248 | "Frequency": 802, 249 | "PowerLevel": "-6.4/53.6", 250 | "SNRLevel": 35.5, 251 | "Modulation": "64QAM", 252 | "ChannelID": "28", 253 | "LockStatus": "Locked" 254 | }, 255 | { 256 | "ChannelType": "SC-QAM", 257 | "Frequency": 810, 258 | "PowerLevel": "-6.5/53.5", 259 | "SNRLevel": 35.7, 260 | "Modulation": "64QAM", 261 | "ChannelID": "29", 262 | "LockStatus": "Locked" 263 | }, 264 | { 265 | "ChannelType": "SC-QAM", 266 | "Frequency": 818, 267 | "PowerLevel": "-6.3/53.7", 268 | "SNRLevel": 35, 269 | "Modulation": "64QAM", 270 | "ChannelID": "30", 271 | "LockStatus": "Locked" 272 | }, 273 | { 274 | "ChannelType": "SC-QAM", 275 | "Frequency": 826, 276 | "PowerLevel": "-6.8/53.2", 277 | "SNRLevel": 35.5, 278 | "Modulation": "64QAM", 279 | "ChannelID": "31", 280 | "LockStatus": "Locked" 281 | }, 282 | { 283 | "ChannelType": "SC-QAM", 284 | "Frequency": 834, 285 | "PowerLevel": "-7.9/52.1", 286 | "SNRLevel": 34.9, 287 | "Modulation": "64QAM", 288 | "ChannelID": "32", 289 | "LockStatus": "Locked" 290 | }, 291 | { 292 | "ChannelType": "SC-QAM", 293 | "Frequency": 114, 294 | "PowerLevel": "-6.5/53.5", 295 | "SNRLevel": 34.3, 296 | "Modulation": "256QAM", 297 | "ChannelID": "1", 298 | "LockStatus": "Locked" 299 | } 300 | ], 301 | "upstream": [ 302 | { 303 | "Frequency": 31, 304 | "PowerLevel": "52.3/112.3", 305 | "ChannelType": "SC-QAM", 306 | "ChannelID": "4", 307 | "Modulation": "64QAM", 308 | "LockStatus": "ACTIVE" 309 | }, 310 | { 311 | "Frequency": 51, 312 | "PowerLevel": "52.3/112.3", 313 | "ChannelType": "SC-QAM", 314 | "ChannelID": "1", 315 | "Modulation": "64QAM", 316 | "LockStatus": "ACTIVE" 317 | }, 318 | { 319 | "Frequency": 37, 320 | "PowerLevel": "52.3/112.3", 321 | "ChannelType": "SC-QAM", 322 | "ChannelID": "3", 323 | "Modulation": "32QAM", 324 | "LockStatus": "ACTIVE" 325 | }, 326 | { 327 | "Frequency": 45, 328 | "PowerLevel": "52.3/112.3", 329 | "ChannelType": "SC-QAM", 330 | "ChannelID": "2", 331 | "Modulation": "64QAM", 332 | "LockStatus": "ACTIVE" 333 | } 334 | ], 335 | "downstreamChannels": 33, 336 | "upstreamChannels": 4, 337 | "ofdmChannels": 1, 338 | "time": "2021-04-11T13:38:36.163Z" 339 | } -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_ofdma_technicolor.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "ok", 3 | "message": "all values retrieved", 4 | "data": { 5 | "ofdm_downstream": [ 6 | { 7 | "__id": "1", 8 | "channelid_ofdm": "33", 9 | "start_frequency": "151 MHz", 10 | "end_frequency": "324 MHz", 11 | "CentralFrequency_ofdm": "288 MHz", 12 | "bandwidth": "171 MHz", 13 | "power_ofdm": "-3.6 dBmV", 14 | "SNR_ofdm": "39.39 dB", 15 | "FFT_ofdm": "qam256/qam1024", 16 | "locked_ofdm": "Locked", 17 | "ChannelType": "OFDM" 18 | } 19 | ], 20 | "downstream": [ 21 | { 22 | "__id": "1", 23 | "channelid": "5", 24 | "CentralFrequency": "602 MHz", 25 | "power": "-4.3 dBmV", 26 | "SNR": "38.9 dB", 27 | "FFT": "256 QAM", 28 | "locked": "Locked", 29 | "ChannelType": "SC-QAM" 30 | }, 31 | { 32 | "__id": "2", 33 | "channelid": "1", 34 | "CentralFrequency": "114 MHz", 35 | "power": "-5.8 dBmV", 36 | "SNR": "36.4 dB", 37 | "FFT": "256 QAM", 38 | "locked": "Locked", 39 | "ChannelType": "SC-QAM" 40 | }, 41 | { 42 | "__id": "3", 43 | "channelid": "2", 44 | "CentralFrequency": "130 MHz", 45 | "power": "-5.8 dBmV", 46 | "SNR": "36.8 dB", 47 | "FFT": "256 QAM", 48 | "locked": "Locked", 49 | "ChannelType": "SC-QAM" 50 | }, 51 | { 52 | "__id": "4", 53 | "channelid": "3", 54 | "CentralFrequency": "138 MHz", 55 | "power": "-5.9 dBmV", 56 | "SNR": "36.9 dB", 57 | "FFT": "256 QAM", 58 | "locked": "Locked", 59 | "ChannelType": "SC-QAM" 60 | }, 61 | { 62 | "__id": "5", 63 | "channelid": "4", 64 | "CentralFrequency": "146 MHz", 65 | "power": "-5.6 dBmV", 66 | "SNR": "37.2 dB", 67 | "FFT": "256 QAM", 68 | "locked": "Locked", 69 | "ChannelType": "SC-QAM" 70 | }, 71 | { 72 | "__id": "6", 73 | "channelid": "6", 74 | "CentralFrequency": "618 MHz", 75 | "power": "-3.7 dBmV", 76 | "SNR": "39.1 dB", 77 | "FFT": "256 QAM", 78 | "locked": "Locked", 79 | "ChannelType": "SC-QAM" 80 | }, 81 | { 82 | "__id": "7", 83 | "channelid": "7", 84 | "CentralFrequency": "626 MHz", 85 | "power": "-3.4 dBmV", 86 | "SNR": "39.5 dB", 87 | "FFT": "256 QAM", 88 | "locked": "Locked", 89 | "ChannelType": "SC-QAM" 90 | }, 91 | { 92 | "__id": "8", 93 | "channelid": "8", 94 | "CentralFrequency": "642 MHz", 95 | "power": "-3.1 dBmV", 96 | "SNR": "39.8 dB", 97 | "FFT": "256 QAM", 98 | "locked": "Locked", 99 | "ChannelType": "SC-QAM" 100 | }, 101 | { 102 | "__id": "9", 103 | "channelid": "9", 104 | "CentralFrequency": "650 MHz", 105 | "power": "-2.7 dBmV", 106 | "SNR": "40.0 dB", 107 | "FFT": "256 QAM", 108 | "locked": "Locked", 109 | "ChannelType": "SC-QAM" 110 | }, 111 | { 112 | "__id": "10", 113 | "channelid": "10", 114 | "CentralFrequency": "658 MHz", 115 | "power": "-2.5 dBmV", 116 | "SNR": "40.0 dB", 117 | "FFT": "256 QAM", 118 | "locked": "Locked", 119 | "ChannelType": "SC-QAM" 120 | }, 121 | { 122 | "__id": "11", 123 | "channelid": "11", 124 | "CentralFrequency": "666 MHz", 125 | "power": "-2.9 dBmV", 126 | "SNR": "39.6 dB", 127 | "FFT": "256 QAM", 128 | "locked": "Locked", 129 | "ChannelType": "SC-QAM" 130 | }, 131 | { 132 | "__id": "12", 133 | "channelid": "12", 134 | "CentralFrequency": "674 MHz", 135 | "power": "-2.6 dBmV", 136 | "SNR": "39.6 dB", 137 | "FFT": "256 QAM", 138 | "locked": "Locked", 139 | "ChannelType": "SC-QAM" 140 | }, 141 | { 142 | "__id": "13", 143 | "channelid": "13", 144 | "CentralFrequency": "682 MHz", 145 | "power": "-2.6 dBmV", 146 | "SNR": "40.1 dB", 147 | "FFT": "256 QAM", 148 | "locked": "Locked", 149 | "ChannelType": "SC-QAM" 150 | }, 151 | { 152 | "__id": "14", 153 | "channelid": "14", 154 | "CentralFrequency": "690 MHz", 155 | "power": "-1.7 dBmV", 156 | "SNR": "40.6 dB", 157 | "FFT": "256 QAM", 158 | "locked": "Locked", 159 | "ChannelType": "SC-QAM" 160 | }, 161 | { 162 | "__id": "15", 163 | "channelid": "15", 164 | "CentralFrequency": "698 MHz", 165 | "power": "-7.9 dBmV", 166 | "SNR": "34.7 dB", 167 | "FFT": "64 QAM", 168 | "locked": "Locked", 169 | "ChannelType": "SC-QAM" 170 | }, 171 | { 172 | "__id": "16", 173 | "channelid": "16", 174 | "CentralFrequency": "706 MHz", 175 | "power": "-7.1 dBmV", 176 | "SNR": "35.4 dB", 177 | "FFT": "64 QAM", 178 | "locked": "Locked", 179 | "ChannelType": "SC-QAM" 180 | }, 181 | { 182 | "__id": "17", 183 | "channelid": "17", 184 | "CentralFrequency": "714 MHz", 185 | "power": "-7.5 dBmV", 186 | "SNR": "34.8 dB", 187 | "FFT": "64 QAM", 188 | "locked": "Locked", 189 | "ChannelType": "SC-QAM" 190 | }, 191 | { 192 | "__id": "18", 193 | "channelid": "18", 194 | "CentralFrequency": "722 MHz", 195 | "power": "-7.6 dBmV", 196 | "SNR": "35.0 dB", 197 | "FFT": "64 QAM", 198 | "locked": "Locked", 199 | "ChannelType": "SC-QAM" 200 | }, 201 | { 202 | "__id": "19", 203 | "channelid": "19", 204 | "CentralFrequency": "730 MHz", 205 | "power": "-7.2 dBmV", 206 | "SNR": "35.1 dB", 207 | "FFT": "64 QAM", 208 | "locked": "Locked", 209 | "ChannelType": "SC-QAM" 210 | }, 211 | { 212 | "__id": "20", 213 | "channelid": "20", 214 | "CentralFrequency": "738 MHz", 215 | "power": "-6.3 dBmV", 216 | "SNR": "35.8 dB", 217 | "FFT": "64 QAM", 218 | "locked": "Locked", 219 | "ChannelType": "SC-QAM" 220 | }, 221 | { 222 | "__id": "21", 223 | "channelid": "21", 224 | "CentralFrequency": "746 MHz", 225 | "power": "-6.2 dBmV", 226 | "SNR": "35.7 dB", 227 | "FFT": "64 QAM", 228 | "locked": "Locked", 229 | "ChannelType": "SC-QAM" 230 | }, 231 | { 232 | "__id": "22", 233 | "channelid": "22", 234 | "CentralFrequency": "754 MHz", 235 | "power": "-7.2 dBmV", 236 | "SNR": "35.0 dB", 237 | "FFT": "64 QAM", 238 | "locked": "Locked", 239 | "ChannelType": "SC-QAM" 240 | }, 241 | { 242 | "__id": "23", 243 | "channelid": "23", 244 | "CentralFrequency": "762 MHz", 245 | "power": "-7.0 dBmV", 246 | "SNR": "35.0 dB", 247 | "FFT": "64 QAM", 248 | "locked": "Locked", 249 | "ChannelType": "SC-QAM" 250 | }, 251 | { 252 | "__id": "24", 253 | "channelid": "24", 254 | "CentralFrequency": "770 MHz", 255 | "power": "-6.7 dBmV", 256 | "SNR": "35.0 dB", 257 | "FFT": "64 QAM", 258 | "locked": "Locked", 259 | "ChannelType": "SC-QAM" 260 | }, 261 | { 262 | "__id": "25", 263 | "channelid": "25", 264 | "CentralFrequency": "778 MHz", 265 | "power": "-6.3 dBmV", 266 | "SNR": "35.8 dB", 267 | "FFT": "64 QAM", 268 | "locked": "Locked", 269 | "ChannelType": "SC-QAM" 270 | }, 271 | { 272 | "__id": "26", 273 | "channelid": "26", 274 | "CentralFrequency": "786 MHz", 275 | "power": "-6.4 dBmV", 276 | "SNR": "35.3 dB", 277 | "FFT": "64 QAM", 278 | "locked": "Locked", 279 | "ChannelType": "SC-QAM" 280 | }, 281 | { 282 | "__id": "27", 283 | "channelid": "27", 284 | "CentralFrequency": "794 MHz", 285 | "power": "-6.5 dBmV", 286 | "SNR": "35.3 dB", 287 | "FFT": "64 QAM", 288 | "locked": "Locked", 289 | "ChannelType": "SC-QAM" 290 | }, 291 | { 292 | "__id": "28", 293 | "channelid": "28", 294 | "CentralFrequency": "802 MHz", 295 | "power": "-6.8 dBmV", 296 | "SNR": "35.2 dB", 297 | "FFT": "64 QAM", 298 | "locked": "Locked", 299 | "ChannelType": "SC-QAM" 300 | }, 301 | { 302 | "__id": "29", 303 | "channelid": "29", 304 | "CentralFrequency": "810 MHz", 305 | "power": "-7.0 dBmV", 306 | "SNR": "35.0 dB", 307 | "FFT": "64 QAM", 308 | "locked": "Locked", 309 | "ChannelType": "SC-QAM" 310 | }, 311 | { 312 | "__id": "30", 313 | "channelid": "30", 314 | "CentralFrequency": "818 MHz", 315 | "power": "-6.4 dBmV", 316 | "SNR": "35.3 dB", 317 | "FFT": "64 QAM", 318 | "locked": "Locked", 319 | "ChannelType": "SC-QAM" 320 | }, 321 | { 322 | "__id": "31", 323 | "channelid": "31", 324 | "CentralFrequency": "826 MHz", 325 | "power": "-7.0 dBmV", 326 | "SNR": "35.2 dB", 327 | "FFT": "64 QAM", 328 | "locked": "Locked", 329 | "ChannelType": "SC-QAM" 330 | }, 331 | { 332 | "__id": "32", 333 | "channelid": "32", 334 | "CentralFrequency": "834 MHz", 335 | "power": "-7.9 dBmV", 336 | "SNR": "34.4 dB", 337 | "FFT": "64 QAM", 338 | "locked": "Locked", 339 | "ChannelType": "SC-QAM" 340 | } 341 | ], 342 | "ofdma_upstream": [ 343 | { 344 | "__id": "1", 345 | "channelidup": "9", 346 | "start_frequency": "29.800000 MHz", 347 | "end_frequency": "64.750000 MHz", 348 | "power": "44.0 dBmV", 349 | "CentralFrequency": "46 MHz", 350 | "bandwidth": "35 MHz", 351 | "FFT": "qpsk", 352 | "ChannelType": "OFDMA", 353 | "RangingStatus": "Completed" 354 | } 355 | ], 356 | "upstream": [ 357 | { 358 | "__id": "1", 359 | "channelidup": "1", 360 | "CentralFrequency": "51.0 MHz", 361 | "power": "51.8 dBmV", 362 | "ChannelType": "SC-QAM", 363 | "FFT": "qam64", 364 | "RangingStatus": "Completed" 365 | }, 366 | { 367 | "__id": "2", 368 | "channelidup": "2", 369 | "CentralFrequency": "44.6 MHz", 370 | "power": "51.8 dBmV", 371 | "ChannelType": "SC-QAM", 372 | "FFT": "qam64", 373 | "RangingStatus": "Completed" 374 | }, 375 | { 376 | "__id": "3", 377 | "channelidup": "3", 378 | "CentralFrequency": "37.2 MHz", 379 | "power": "51.8 dBmV", 380 | "ChannelType": "SC-QAM", 381 | "FFT": "qam64", 382 | "RangingStatus": "Completed" 383 | }, 384 | { 385 | "__id": "4", 386 | "channelidup": "4", 387 | "CentralFrequency": "30.8 MHz", 388 | "power": "51.8 dBmV", 389 | "ChannelType": "SC-QAM", 390 | "FFT": "qam32", 391 | "RangingStatus": "Completed" 392 | } 393 | ] 394 | } 395 | } -------------------------------------------------------------------------------- /src/modem/docsis-diagnose.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Diagnose, DiagnosedDocsis31ChannelStatus, DiagnosedDocsisChannelStatus, DiagnosedDocsisStatus, DocsisChannelType, DocsisStatus, Modulation, 3 | } from './modem'; 4 | 5 | import {BAD_MODEM_POWER_LEVEL} from './constants'; 6 | 7 | export interface Deviation { 8 | channelType?: DocsisChannelType 9 | check(powerLevel: number):Diagnose; 10 | modulation: 'Unknown' | Modulation; 11 | } 12 | 13 | // based on https://www.vodafonekabelforum.de/viewtopic.php?t=32353 14 | export default class DocsisDiagnose { 15 | constructor(private docsisStatus:DocsisStatus) {} 16 | 17 | get diagnose(): DiagnosedDocsisStatus { 18 | return { 19 | downstream: this.checkDownstream(), 20 | downstreamOfdm: this.checkOfdmDownstream(), 21 | time: this.docsisStatus.time, 22 | upstream: this.checkUpstream(), 23 | upstreamOfdma: this.checkOfdmaUpstream(), 24 | } 25 | } 26 | 27 | checkDownstream(): DiagnosedDocsisChannelStatus[] { 28 | return this.docsisStatus.downstream 29 | .map(channel => ({...channel, diagnose: downstreamDeviation(channel)})) 30 | } 31 | 32 | checkDownstreamSNR(): DiagnosedDocsisChannelStatus[] { 33 | return this.docsisStatus.downstream 34 | .map(channel => ({...channel, diagnose: checkSignalToNoise(channel)})) 35 | } 36 | 37 | checkOfdmaUpstream(): DiagnosedDocsis31ChannelStatus[] { 38 | return this.docsisStatus.upstreamOfdma 39 | .map(channel => ({...channel, diagnose: upstreamDeviation(channel)})) 40 | } 41 | 42 | checkOfdmDownstream(): DiagnosedDocsis31ChannelStatus[] { 43 | return this.docsisStatus.downstreamOfdm 44 | .map(channel => ({...channel, diagnose: downstreamDeviation(channel)})) 45 | } 46 | 47 | checkOfdmDownstreamSNR(): DiagnosedDocsis31ChannelStatus[] { 48 | return this.docsisStatus.downstreamOfdm 49 | .map(channel => ({...channel, diagnose: checkSignalToNoise(channel)})) 50 | } 51 | 52 | checkUpstream(): DiagnosedDocsisChannelStatus[] { 53 | return this.docsisStatus.upstream 54 | .map(channel => ({...channel, diagnose: upstreamDeviation(channel)})) 55 | } 56 | 57 | hasDeviations(): boolean { 58 | return [ 59 | this.checkDownstream(), 60 | this.checkDownstreamSNR(), 61 | this.checkOfdmDownstream(), 62 | this.checkOfdmDownstreamSNR(), 63 | this.checkUpstream(), 64 | this.checkOfdmaUpstream(), 65 | ] 66 | .flat() 67 | .some(({diagnose}) => diagnose.deviation) 68 | } 69 | 70 | printDeviationsConsole(): string { 71 | if (this.hasDeviations() === false) { 72 | return colorize('green', 'Hooray no deviations found!') 73 | } 74 | 75 | const down 76 | = [...this.checkDownstream(), ...this.checkOfdmDownstream()] 77 | .filter(downstream => downstream.diagnose.deviation) 78 | .map(down => 79 | colorize(down.diagnose.color, `ch${down.channelId}pl`)) 80 | const downSnr 81 | = [...this.checkDownstreamSNR(), 82 | ...this.checkOfdmDownstreamSNR()] 83 | .filter(downstream => downstream.diagnose.deviation) 84 | .map(down => 85 | colorize(down.diagnose.color, `ch${down.channelId}snr`)) 86 | const up 87 | = [...this.diagnose.upstream, ...this.diagnose.upstreamOfdma] 88 | .filter(upstream => upstream.diagnose.deviation) 89 | .map(upstream => 90 | colorize(upstream.diagnose.color, `ch${upstream.channelId}pl`)).join(', ') 91 | 92 | return [ 93 | 'Legend: pl = power level | snr = signal to noise ration', 94 | `Colors: ${colorize(FixWithinOneMonth.color, FixWithinOneMonth.description)} | ${colorize(FixImmediately.color, FixImmediately.description)}`, 95 | `DOWN: ${[...down, ...downSnr].join(', ')}`, 96 | `UP: ${up}`, 97 | ].join('\n'); 98 | } 99 | } 100 | 101 | export const SEVERITY_COLORS 102 | = { 103 | green: '\u001B[32m', 104 | red: '\u001B[31m', 105 | yellow: '\u001B[33m', 106 | } 107 | 108 | export function colorize(severity: 'green' | 'red' | 'yellow', message: string): string { 109 | const color = SEVERITY_COLORS[severity] ?? SEVERITY_COLORS.green 110 | const colorStop = '\u001B[0m' 111 | return `${color}${message}${colorStop}`; 112 | } 113 | 114 | export class UpstreamDeviationSCQAM implements Deviation { 115 | channelType = 'SC-QAM' as const 116 | modulation = '64QAM' as const 117 | 118 | check(powerLevel: number): Diagnose { 119 | if (powerLevel <= 35) 120 | return FixImmediately 121 | if (powerLevel > 35 && powerLevel <= 37) 122 | return FixWithinOneMonth; 123 | if (powerLevel > 37 && powerLevel <= 41) 124 | return ToleratedDeviation; 125 | if (powerLevel > 41 && powerLevel <= 47) 126 | return CompliesToSpecifications; 127 | if (powerLevel > 47 && powerLevel <= 51) 128 | return ToleratedDeviation; 129 | if (powerLevel > 51 && powerLevel <= 53) 130 | return FixWithinOneMonth; 131 | if (powerLevel > 53) 132 | return FixImmediately 133 | 134 | throw new Error(`PowerLevel is not within supported range. PowerLevel: ${powerLevel}`); 135 | } 136 | } 137 | 138 | export class UpstreamDeviationOFDMA implements Deviation { 139 | channelType = 'OFDMA' as const 140 | modulation = '64QAM' as const 141 | 142 | check(powerLevel: number): Diagnose { 143 | if (powerLevel <= 38) 144 | return FixImmediately 145 | if (powerLevel > 38 && powerLevel <= 40) 146 | return FixWithinOneMonth; 147 | if (powerLevel > 40 && powerLevel <= 44) 148 | return ToleratedDeviation; 149 | if (powerLevel > 44 && powerLevel <= 47) 150 | return CompliesToSpecifications; 151 | if (powerLevel > 47 && powerLevel <= 48) 152 | return ToleratedDeviation; 153 | if (powerLevel > 48 && powerLevel <= 50) 154 | return FixWithinOneMonth; 155 | if (powerLevel > 50) 156 | return FixImmediately 157 | 158 | if (powerLevel === BAD_MODEM_POWER_LEVEL) 159 | return FixImmediately 160 | 161 | throw new Error(`PowerLevel is not within supported range. PowerLevel: ${powerLevel}`); 162 | } 163 | } 164 | 165 | export function downstreamDeviation({modulation, powerLevel}:{modulation: Modulation, powerLevel: number}): Diagnose { 166 | const deviation = downstreamDeviationFactory(modulation); 167 | return deviation.check(powerLevel); 168 | } 169 | 170 | export class DownstreamDeviation64QAM implements Deviation { 171 | modulation = '64QAM' as const 172 | 173 | check(powerLevel: number): Diagnose { 174 | if (powerLevel >= -60 && powerLevel <= -14) 175 | return FixImmediately 176 | if (powerLevel > -14 && powerLevel <= -12) 177 | return FixWithinOneMonth; 178 | if (powerLevel > -12 && powerLevel <= -10) 179 | return ToleratedDeviation; 180 | if (powerLevel > -10 && powerLevel <= 7) 181 | return CompliesToSpecifications; 182 | if (powerLevel > 7 && powerLevel <= 12) 183 | return ToleratedDeviation; 184 | if (powerLevel > 12 && powerLevel <= 14) 185 | return FixWithinOneMonth; 186 | if (powerLevel >= 14.1) 187 | return FixImmediately 188 | 189 | throw new Error(`PowerLevel is not within supported range. PowerLevel: ${powerLevel}`); 190 | } 191 | } 192 | 193 | export class DownstreamDeviation256QAM implements Deviation { 194 | delegate = new DownstreamDeviation64QAM() 195 | modulation = '256QAM' as const 196 | 197 | check(powerLevel: number): Diagnose { 198 | const adjustedPowerLevel = powerLevel - 6 <= -60 ? powerLevel : powerLevel - 6; 199 | return this.delegate.check(adjustedPowerLevel) 200 | } 201 | } 202 | 203 | export class DownstreamDeviation1024QAM implements Deviation { 204 | delegate = new DownstreamDeviation64QAM() 205 | modulation = '1024QAM' as const 206 | 207 | check(powerLevel: number): Diagnose { 208 | const adjustedPowerLevel = powerLevel - 8 <= -60 ? powerLevel : powerLevel - 8; 209 | return this.delegate.check(adjustedPowerLevel) 210 | } 211 | } 212 | export class DownstreamDeviation2048QAM implements Deviation { 213 | delegate = new DownstreamDeviation64QAM() 214 | modulation = '2048QAM' as const 215 | 216 | check(powerLevel: number): Diagnose { 217 | const adjustedPowerLevel = powerLevel - 10 <= -60 ? powerLevel : powerLevel - 10; 218 | return this.delegate.check(adjustedPowerLevel) 219 | } 220 | } 221 | export class DownstreamDeviation4096QAM implements Deviation { 222 | delegate = new DownstreamDeviation64QAM() 223 | modulation = '4096QAM' as const 224 | 225 | check(powerLevel: number): Diagnose { 226 | const adjustedPowerLevel = powerLevel - 12 <= -60 ? powerLevel : powerLevel - 12; 227 | return this.delegate.check(adjustedPowerLevel) 228 | } 229 | } 230 | 231 | export class DownstreamDeviationUnknown implements Deviation { 232 | modulation = 'Unknown' as const 233 | 234 | check(_powerLevel: number): Diagnose { 235 | return FixImmediately; 236 | } 237 | } 238 | 239 | export const FixImmediately: Diagnose = { 240 | color: 'red', 241 | description: 'Fix immediately', 242 | deviation: true, 243 | } 244 | export const CompliesToSpecifications: Diagnose = { 245 | color: 'green', 246 | description: 'Complies to specifications', 247 | deviation: false, 248 | } 249 | export const ToleratedDeviation: Diagnose = { 250 | color: 'green', 251 | description: 'Tolerated deviation', 252 | deviation: false, 253 | } 254 | export const FixWithinOneMonth: Diagnose = { 255 | color: 'yellow', 256 | description: 'Fix within one Month', 257 | deviation: true, 258 | } 259 | 260 | export function downstreamDeviationFactory(modulation: 'Unknown' | Modulation): Deviation { 261 | switch (modulation) { 262 | case '64QAM': { 263 | return new DownstreamDeviation64QAM(); 264 | } 265 | 266 | case '256QAM': { 267 | return new DownstreamDeviation256QAM(); 268 | } 269 | 270 | case '1024QAM': { 271 | return new DownstreamDeviation1024QAM(); 272 | } 273 | 274 | case '2048QAM': { 275 | return new DownstreamDeviation2048QAM(); 276 | } 277 | 278 | case '4096QAM': { 279 | return new DownstreamDeviation4096QAM(); 280 | } 281 | 282 | case 'Unknown': { 283 | return new DownstreamDeviationUnknown(); 284 | } 285 | 286 | default: { 287 | throw new Error(`Unsupported modulation ${modulation}`) 288 | } 289 | } 290 | } 291 | 292 | export function upstreamDeviationFactory(channelType: DocsisChannelType): Deviation { 293 | switch (channelType) { 294 | case 'OFDMA': { 295 | return new UpstreamDeviationOFDMA() 296 | } 297 | 298 | case 'SC-QAM': { 299 | return new UpstreamDeviationSCQAM(); 300 | } 301 | 302 | default: { 303 | throw new Error(`Unsupported channel type ${channelType}`) 304 | } 305 | } 306 | } 307 | 308 | export function upstreamDeviation({channelType, powerLevel}:{channelType: DocsisChannelType, powerLevel: number}): Diagnose { 309 | const deviation = upstreamDeviationFactory(channelType); 310 | return deviation.check(powerLevel); 311 | } 312 | 313 | export function checkSignalToNoise({modulation, snr}:{modulation: Modulation; snr: number,}): Diagnose { 314 | let snrOffsetForModulation; 315 | switch (modulation) { 316 | case '64QAM': { 317 | snrOffsetForModulation = 0; 318 | break; 319 | } 320 | 321 | case '256QAM': { 322 | snrOffsetForModulation = 6; 323 | break; 324 | } 325 | 326 | case '1024QAM': { 327 | snrOffsetForModulation = 12; 328 | break; 329 | } 330 | 331 | case '2048QAM': { 332 | snrOffsetForModulation = 15; 333 | break; 334 | } 335 | 336 | case '4096QAM': { 337 | snrOffsetForModulation = 18; 338 | break; 339 | } 340 | 341 | case 'Unknown': { 342 | // For unknown modulation, we can't determine proper SNR deviation 343 | // so we return FixImmediately to indicate a problem 344 | return FixImmediately; 345 | } 346 | 347 | default: { 348 | throw new Error(`Unsupported modulation ${modulation}`) 349 | } 350 | } 351 | 352 | const adjustedSNR = snr - snrOffsetForModulation; 353 | 354 | if (adjustedSNR <= 24) 355 | return FixImmediately 356 | if (adjustedSNR > 24 && adjustedSNR <= 26) 357 | return FixWithinOneMonth; 358 | if (adjustedSNR > 26 && adjustedSNR <= 27) 359 | return ToleratedDeviation; 360 | if (adjustedSNR > 27) 361 | return CompliesToSpecifications; 362 | 363 | throw new Error(`SignalToNoiseRation is not within supported range. SNR: ${snr}`); 364 | } 365 | -------------------------------------------------------------------------------- /src/modem/__fixtures__/docsisStatus_technicolor.json: -------------------------------------------------------------------------------- 1 | { 2 | "error": "ok", 3 | "message": "all values retrieved", 4 | "data": { 5 | "ofdm_downstream": [ 6 | { 7 | "__id": "1", 8 | "channelid_ofdm": "33", 9 | "start_frequency": "151 MHz", 10 | "end_frequency": "324 MHz", 11 | "CentralFrequency_ofdm": "288 MHz", 12 | "bandwidth": "171 MHz", 13 | "power_ofdm": "-3.0 dBmV", 14 | "SNR_ofdm": "39.55 dB", 15 | "FFT_ofdm": "qam256/qam1024", 16 | "locked_ofdm": "Locked", 17 | "ChannelType": "OFDM" 18 | } 19 | ], 20 | "downstream": [ 21 | { 22 | "__id": "1", 23 | "channelid": "5", 24 | "CentralFrequency": "602 MHz", 25 | "power": "-4.0 dBmV", 26 | "SNR": "38.8 dB", 27 | "FFT": "256 QAM", 28 | "locked": "Locked", 29 | "ChannelType": "SC-QAM" 30 | }, 31 | { 32 | "__id": "2", 33 | "channelid": "1", 34 | "CentralFrequency": "114 MHz", 35 | "power": "-5.4 dBmV", 36 | "SNR": "36.5 dB", 37 | "FFT": "256 QAM", 38 | "locked": "Locked", 39 | "ChannelType": "SC-QAM" 40 | }, 41 | { 42 | "__id": "3", 43 | "channelid": "2", 44 | "CentralFrequency": "130 MHz", 45 | "power": "-5.4 dBmV", 46 | "SNR": "36.8 dB", 47 | "FFT": "256 QAM", 48 | "locked": "Locked", 49 | "ChannelType": "SC-QAM" 50 | }, 51 | { 52 | "__id": "4", 53 | "channelid": "3", 54 | "CentralFrequency": "138 MHz", 55 | "power": "-5.5 dBmV", 56 | "SNR": "36.9 dB", 57 | "FFT": "256 QAM", 58 | "locked": "Locked", 59 | "ChannelType": "SC-QAM" 60 | }, 61 | { 62 | "__id": "5", 63 | "channelid": "4", 64 | "CentralFrequency": "146 MHz", 65 | "power": "-5.2 dBmV", 66 | "SNR": "37.2 dB", 67 | "FFT": "256 QAM", 68 | "locked": "Locked", 69 | "ChannelType": "SC-QAM" 70 | }, 71 | { 72 | "__id": "6", 73 | "channelid": "6", 74 | "CentralFrequency": "618 MHz", 75 | "power": "-3.8 dBmV", 76 | "SNR": "38.9 dB", 77 | "FFT": "256 QAM", 78 | "locked": "Locked", 79 | "ChannelType": "SC-QAM" 80 | }, 81 | { 82 | "__id": "7", 83 | "channelid": "7", 84 | "CentralFrequency": "626 MHz", 85 | "power": "-3.9 dBmV", 86 | "SNR": "38.9 dB", 87 | "FFT": "256 QAM", 88 | "locked": "Locked", 89 | "ChannelType": "SC-QAM" 90 | }, 91 | { 92 | "__id": "8", 93 | "channelid": "8", 94 | "CentralFrequency": "642 MHz", 95 | "power": "-4.9 dBmV", 96 | "SNR": "38.5 dB", 97 | "FFT": "256 QAM", 98 | "locked": "Locked", 99 | "ChannelType": "SC-QAM" 100 | }, 101 | { 102 | "__id": "9", 103 | "channelid": "9", 104 | "CentralFrequency": "650 MHz", 105 | "power": "-4.8 dBmV", 106 | "SNR": "38.4 dB", 107 | "FFT": "256 QAM", 108 | "locked": "Locked", 109 | "ChannelType": "SC-QAM" 110 | }, 111 | { 112 | "__id": "10", 113 | "channelid": "10", 114 | "CentralFrequency": "658 MHz", 115 | "power": "-4.1 dBmV", 116 | "SNR": "39.1 dB", 117 | "FFT": "256 QAM", 118 | "locked": "Locked", 119 | "ChannelType": "SC-QAM" 120 | }, 121 | { 122 | "__id": "11", 123 | "channelid": "11", 124 | "CentralFrequency": "666 MHz", 125 | "power": "-4.1 dBmV", 126 | "SNR": "38.7 dB", 127 | "FFT": "256 QAM", 128 | "locked": "Locked", 129 | "ChannelType": "SC-QAM" 130 | }, 131 | { 132 | "__id": "12", 133 | "channelid": "12", 134 | "CentralFrequency": "674 MHz", 135 | "power": "-3.1 dBmV", 136 | "SNR": "39.0 dB", 137 | "FFT": "256 QAM", 138 | "locked": "Locked", 139 | "ChannelType": "SC-QAM" 140 | }, 141 | { 142 | "__id": "13", 143 | "channelid": "13", 144 | "CentralFrequency": "682 MHz", 145 | "power": "-2.8 dBmV", 146 | "SNR": "39.7 dB", 147 | "FFT": "256 QAM", 148 | "locked": "Locked", 149 | "ChannelType": "SC-QAM" 150 | }, 151 | { 152 | "__id": "14", 153 | "channelid": "14", 154 | "CentralFrequency": "690 MHz", 155 | "power": "-1.8 dBmV", 156 | "SNR": "40.4 dB", 157 | "FFT": "256 QAM", 158 | "locked": "Locked", 159 | "ChannelType": "SC-QAM" 160 | }, 161 | { 162 | "__id": "15", 163 | "channelid": "15", 164 | "CentralFrequency": "698 MHz", 165 | "power": "-7.7 dBmV", 166 | "SNR": "34.6 dB", 167 | "FFT": "64 QAM", 168 | "locked": "Locked", 169 | "ChannelType": "SC-QAM" 170 | }, 171 | { 172 | "__id": "16", 173 | "channelid": "16", 174 | "CentralFrequency": "706 MHz", 175 | "power": "-6.8 dBmV", 176 | "SNR": "35.4 dB", 177 | "FFT": "64 QAM", 178 | "locked": "Locked", 179 | "ChannelType": "SC-QAM" 180 | }, 181 | { 182 | "__id": "17", 183 | "channelid": "17", 184 | "CentralFrequency": "714 MHz", 185 | "power": "-7.1 dBmV", 186 | "SNR": "35.0 dB", 187 | "FFT": "64 QAM", 188 | "locked": "Locked", 189 | "ChannelType": "SC-QAM" 190 | }, 191 | { 192 | "__id": "18", 193 | "channelid": "18", 194 | "CentralFrequency": "722 MHz", 195 | "power": "-7.2 dBmV", 196 | "SNR": "35.0 dB", 197 | "FFT": "64 QAM", 198 | "locked": "Locked", 199 | "ChannelType": "SC-QAM" 200 | }, 201 | { 202 | "__id": "19", 203 | "channelid": "19", 204 | "CentralFrequency": "730 MHz", 205 | "power": "-6.9 dBmV", 206 | "SNR": "35.1 dB", 207 | "FFT": "64 QAM", 208 | "locked": "Locked", 209 | "ChannelType": "SC-QAM" 210 | }, 211 | { 212 | "__id": "20", 213 | "channelid": "20", 214 | "CentralFrequency": "738 MHz", 215 | "power": "-5.9 dBmV", 216 | "SNR": "35.7 dB", 217 | "FFT": "64 QAM", 218 | "locked": "Locked", 219 | "ChannelType": "SC-QAM" 220 | }, 221 | { 222 | "__id": "21", 223 | "channelid": "21", 224 | "CentralFrequency": "746 MHz", 225 | "power": "-5.6 dBmV", 226 | "SNR": "35.8 dB", 227 | "FFT": "64 QAM", 228 | "locked": "Locked", 229 | "ChannelType": "SC-QAM" 230 | }, 231 | { 232 | "__id": "22", 233 | "channelid": "22", 234 | "CentralFrequency": "754 MHz", 235 | "power": "-6.7 dBmV", 236 | "SNR": "35.0 dB", 237 | "FFT": "64 QAM", 238 | "locked": "Locked", 239 | "ChannelType": "SC-QAM" 240 | }, 241 | { 242 | "__id": "23", 243 | "channelid": "23", 244 | "CentralFrequency": "762 MHz", 245 | "power": "-6.4 dBmV", 246 | "SNR": "35.1 dB", 247 | "FFT": "64 QAM", 248 | "locked": "Locked", 249 | "ChannelType": "SC-QAM" 250 | }, 251 | { 252 | "__id": "24", 253 | "channelid": "24", 254 | "CentralFrequency": "770 MHz", 255 | "power": "-6.3 dBmV", 256 | "SNR": "35.1 dB", 257 | "FFT": "64 QAM", 258 | "locked": "Locked", 259 | "ChannelType": "SC-QAM" 260 | }, 261 | { 262 | "__id": "25", 263 | "channelid": "25", 264 | "CentralFrequency": "778 MHz", 265 | "power": "-6.0 dBmV", 266 | "SNR": "35.7 dB", 267 | "FFT": "64 QAM", 268 | "locked": "Locked", 269 | "ChannelType": "SC-QAM" 270 | }, 271 | { 272 | "__id": "26", 273 | "channelid": "26", 274 | "CentralFrequency": "786 MHz", 275 | "power": "-6.2 dBmV", 276 | "SNR": "35.2 dB", 277 | "FFT": "64 QAM", 278 | "locked": "Locked", 279 | "ChannelType": "SC-QAM" 280 | }, 281 | { 282 | "__id": "27", 283 | "channelid": "27", 284 | "CentralFrequency": "794 MHz", 285 | "power": "-6.5 dBmV", 286 | "SNR": "34.8 dB", 287 | "FFT": "64 QAM", 288 | "locked": "Locked", 289 | "ChannelType": "SC-QAM" 290 | }, 291 | { 292 | "__id": "28", 293 | "channelid": "28", 294 | "CentralFrequency": "802 MHz", 295 | "power": "-7.1 dBmV", 296 | "SNR": "34.7 dB", 297 | "FFT": "64 QAM", 298 | "locked": "Locked", 299 | "ChannelType": "SC-QAM" 300 | }, 301 | { 302 | "__id": "29", 303 | "channelid": "29", 304 | "CentralFrequency": "810 MHz", 305 | "power": "-6.9 dBmV", 306 | "SNR": "34.9 dB", 307 | "FFT": "64 QAM", 308 | "locked": "Locked", 309 | "ChannelType": "SC-QAM" 310 | }, 311 | { 312 | "__id": "30", 313 | "channelid": "30", 314 | "CentralFrequency": "818 MHz", 315 | "power": "-6.7 dBmV", 316 | "SNR": "35.0 dB", 317 | "FFT": "64 QAM", 318 | "locked": "Locked", 319 | "ChannelType": "SC-QAM" 320 | }, 321 | { 322 | "__id": "31", 323 | "channelid": "31", 324 | "CentralFrequency": "826 MHz", 325 | "power": "-7.2 dBmV", 326 | "SNR": "35.0 dB", 327 | "FFT": "64 QAM", 328 | "locked": "Locked", 329 | "ChannelType": "SC-QAM" 330 | }, 331 | { 332 | "__id": "32", 333 | "channelid": "32", 334 | "CentralFrequency": "834 MHz", 335 | "power": "-7.8 dBmV", 336 | "SNR": "34.2 dB", 337 | "FFT": "64 QAM", 338 | "locked": "Locked", 339 | "ChannelType": "SC-QAM" 340 | } 341 | ], 342 | "ofdma_upstream": [], 343 | "upstream": [ 344 | { 345 | "__id": "1", 346 | "channelidup": "1", 347 | "CentralFrequency": "51.0 MHz", 348 | "power": "49.8 dBmV", 349 | "ChannelType": "SC-QAM", 350 | "FFT": "qam64", 351 | "RangingStatus": "Completed" 352 | }, 353 | { 354 | "__id": "2", 355 | "channelidup": "2", 356 | "CentralFrequency": "44.6 MHz", 357 | "power": "51.8 dBmV", 358 | "ChannelType": "SC-QAM", 359 | "FFT": "qam64", 360 | "RangingStatus": "Completed" 361 | }, 362 | { 363 | "__id": "3", 364 | "channelidup": "3", 365 | "CentralFrequency": "37.2 MHz", 366 | "power": "51.8 dBmV", 367 | "ChannelType": "SC-QAM", 368 | "FFT": "qam64", 369 | "RangingStatus": "Completed" 370 | }, 371 | { 372 | "__id": "4", 373 | "channelidup": "4", 374 | "CentralFrequency": "30.8 MHz", 375 | "power": "51.8 dBmV", 376 | "ChannelType": "SC-QAM", 377 | "FFT": "qam32", 378 | "RangingStatus": "Completed" 379 | } 380 | ] 381 | } 382 | } --------------------------------------------------------------------------------