Compare commits

...

45 commits
v6.4.3 ... main

Author SHA1 Message Date
Chrystian Huot e813924647 fix: simplify conditional logic in ParseDSDPlusMeta function 2026-05-17 08:57:35 -04:00
Chrystian Huot 7edf0df1ac fix: streamline error handling in migrateCalls function 2026-05-17 08:57:24 -04:00
Chrystian Huot b7975763cf fix: refactor audio conversion mode handling in FFMpeg 2026-05-17 08:57:02 -04:00
Chrystian Huot 3c4da551c9 fix: remove unnecessary error variable in command URL validation 2026-05-17 08:56:49 -04:00
Chrystian Huot 7d97763f8f fix: remove unnecessary error handling in LogsHandler 2026-05-17 08:56:39 -04:00
Chrystian Huot 45151574ae linted 2026-05-16 12:04:38 -04:00
Chrystian Huot f18df8a98f linted 2026-05-16 12:04:11 -04:00
Chrystian Huot c4827ac78b chore: update Go module dependencies and version to 1.26 2026-05-16 12:00:10 -04:00
Chrystian Huot 76ce9dbd13 fix: update SDRTrunk compatibility status in README 2026-05-16 11:17:32 -04:00
Chrystian Huot 58cd89f816 npm audit fix 2026-05-16 11:16:13 -04:00
Chrystian Huot 7c90168160 feat: update API access policy and add contributor agreement 2026-05-16 11:07:10 -04:00
Chrystian Huot 5f527eb27d Merge branch 'v7-wip' 2026-05-16 10:30:52 -04:00
Chrystian Huot 3b6246c39f feat: add Makefile for build and distribution processes 2026-05-16 10:06:34 -04:00
Chrystian Huot e0ff30a43c fix: correct markdown formatting for email link in API Access Policy 2026-01-03 09:27:36 -05:00
Chrystian Huot a49040eaaa Fix email formatting in WebSocket API access policy 2026-01-03 09:27:07 -05:00
Chrystian Huot a34971064f fix: correct email formatting in API Access Policy 2026-01-03 09:25:26 -05:00
Chrystian Huot 2fe0758c22 Add API Access Policy and update copyright information
- Introduced a new document, API_ACCESS_POLICY.md, outlining the terms of use for the WebSocket API, including license, scope of restriction, authorized access, authentication, prohibited use, enforcement, and contact information.
- Updated README.md to highlight the restricted nature of the WebSocket API and link to the new access policy.
- Changed copyright information in multiple server files to reflect the new email address for Chrystian Huot.
- Added comments in server files to indicate the WebSocket API access policy and its restrictions.
2026-01-03 09:24:21 -05:00
Chrystian Huot 36fe8e80a6 Add WebSocket API access policy and update README
- Introduced API_ACCESS_POLICY.md detailing terms of use for the WebSocket API.
- Updated README.md to highlight the restricted access to the WebSocket API for Saubeo Solutions and its applications.
- Added access policy comments in relevant server files to enforce unauthorized access prohibition.
2026-01-03 09:17:34 -05:00
Chrystian Huot 0d0023c827
Merge pull request #517 from chuot/main
Update copyright year to 2026 in all relevant files
2026-01-03 08:55:53 -05:00
Chrystian Huot a97f43252a Update copyright year to 2026 in all relevant files 2026-01-03 08:51:29 -05:00
Chrystian Huot 53a426bea9 feat: update to angular 21 2025-12-21 14:21:02 -05:00
Chrystian Huot c9cc7f0a3c Update copyright year to 2026 in all relevant files 2025-12-21 09:14:31 -05:00
Chrystian Huot 93b18b87f5
Merge pull request #482 from chuot/doc/updated-readme
Doc/updated readme
2025-03-30 12:08:48 -04:00
Chrystian Huot 94c0d70c97 removed commercial support 2025-03-30 11:51:59 -04:00
Chrystian Huot ccc2cfab2c added discord community server 2025-03-30 11:51:27 -04:00
Chrystian Huot 5e104046f5 added reference to gh wiki 2025-03-30 11:37:59 -04:00
Chrystian Huot a57b784e97 modified reference to gh discussions 2025-03-30 11:32:57 -04:00
Chrystian Huot 61c67ef652 removed twitter link 2025-03-30 11:24:27 -04:00
Chrystian Huot d39578e9b5 rebooted 2024-09-14 13:21:48 -04:00
Chrystian Huot c9c4ea4530 Version 6.6.3 2022-11-25 07:32:27 -05:00
Chrystian Huot afbd174f5f Version 6.6.2 2022-10-17 11:10:12 -04:00
Chrystian Huot 12a0d0a3d6 Version 6.6.1 2022-10-07 08:28:08 -04:00
Chrystian Huot 6356c3b780 Version 6.6.0 2022-10-04 07:03:55 -04:00
Chrystian Huot af41604322 Version 6.5.6 2022-09-19 07:35:40 -04:00
Chrystian Huot 2e772c0e1e Version 6.5.5 2022-09-12 07:54:20 -04:00
Chrystian Huot d03f0b1fef Version 6.5.4 2022-09-09 11:52:20 -04:00
Chrystian Huot bf75f92f47 detailed Docker image instructions 2022-08-16 09:57:39 -04:00
Chrystian Huot f0ada59456 Version 6.5.3 2022-08-10 08:17:28 -04:00
Chrystian Huot 7e9679ef5a Version 6.5.2 2022-07-11 08:35:15 -04:00
Chrystian Huot f92355592a Add NGINX and Apache HTTP reverse proxy examples 2022-06-29 11:33:27 -04:00
Chrystian Huot bd560fe57d Fix typo 2022-06-29 11:32:24 -04:00
Chrystian Huot 2dae2606b9 Version 6.5.1 2022-06-29 09:50:53 -04:00
Chrystian Huot 0e088ad534 Version 6.5.0 2022-06-27 11:19:18 -04:00
Chrystian Huot 05747a0428 Version 6.4.5 2022-06-14 14:35:06 -04:00
Chrystian Huot f8122b06c9 Version 6.4.4 2022-06-14 08:37:38 -04:00
191 changed files with 28119 additions and 26820 deletions

76
API_ACCESS_POLICY.md Normal file
View file

@ -0,0 +1,76 @@
# WebSocket API Terms of Use
**Effective Date:** January 3rd, 2026
**Project:** Rdio Scanner open source project
**Sponsor:** Saubeo Solutions
---
### 1. License
This software is distributed under the GNU General Public License (GPL). You are free to use, modify, and distribute the source code under the terms of the GPL.
**Exception:** The WebSocket API implementation is proprietary and subject to the terms outlined in this document.
---
### 2. Scope of Restriction
The HTTP REST API and all other features are fully available under GPL terms.
The **WebSocket API** is a proprietary real-time communication channel provided exclusively by Saubeo Solutions. This distinction supports the commercial model that funds ongoing project development.
---
### 3. Authorized Access
**Official Saubeo Solutions Service:**
- The WebSocket API is exclusively available through Saubeo Solutions' hosted service for its native applications and the integrated web application.
**Self-Hosted Deployments:**
- Self-hosted instances can use the HTTP REST API without restriction.
- Access to the WebSocket API in self-hosted instances requires a prior written license agreement with Saubeo Solutions.
- Contact [rdio-scanner@saubeo.solutions](mailto:rdio-scanner@saubeo.solutions) to inquire about licensing.
---
### 4. Authentication
Authorized clients (including self-hosted licensees) must:
- Use valid authentication credentials provided by Saubeo Solutions.
- Ensure secure handling of credentials. Sharing credentials with unauthorized parties is strictly prohibited.
---
### 5. Prohibited Use
You may not:
- Attempt to use the WebSocket API without explicit authorization (either through the official hosted service or a valid license agreement).
- Use the WebSocket API in commercial applications or services without a license agreement.
- Reverse-engineer, replicate, or redistribute the WebSocket API service.
- Bypass or disable WebSocket API restrictions through code modification.
### 6. HTTP REST API
The HTTP REST API provided by Rdio Scanner is **not restricted** and fully available under GPL terms for all uses, including self-hosted deployments and third-party applications.
---
### 7. Enforcement
Violation of these terms may result in:
- Immediate revocation of WebSocket API access.
- Termination of any license agreement.
- Legal action for unauthorized use.
---
### 8. Commercial Licensing
For WebSocket API access in self-hosted or commercial deployments, contact:
[rdio-scanner@saubeo.solutions](mailto:rdio-scanner@saubeo.solutions)

View file

@ -1,10 +1,106 @@
# Change log
## Version 7.0
**Make sure to backup your config and your database before updating to Version 7.0.**
- New database schema now compatible with SQLite, MariaDB, MySQL and PostgreSQL.
- New Delayed feature which allows to delay ingested audio broadcasting for a specified amount of minutes.
- New alert sounds that can be assigned to groups, tags, systems and talkgroups.
- New system and talkgroup types to help identify the radio system.
- Talkgroups can now be assigned to more than one group.
- LED colors can now be assigned to groups, tags, systems and talkgroups.
- Better call duplicates detection, thanks to the new database schema.
- Tags toggle removed in favor of multi groups assignment for talkgroups.
- AFS systems option remove and replace by system/talkgroup type provoice.
- Newer API while retaining backward compatility.
- Integrated web app migrated to Angular 15.
- Simplified talkgroup importation to a specific system.
- New /reset url path that allow reseting the user access code and talkgroups selection.
- New #UNITLBL metatag for dirwatch.
- TODO: Hold TG SYS persistent in local storage
- TODO: Ingest Site ID and API Site ID
- TODO: Ingest call DBFS and API call DBFS
- TODO: search by uid
- TODO: Display site # or label on main screen
## Version 6.6
- From now on precompiled versions of macOS will be named as such instead of darwin.
- Better example for rtlsdr-airband that leverage the new #TGHZ, #TGKHZ and #TGMHZ meta tags for dirwatch.
- Fixed dirwatch definition not always showing mask field when type is default.
- Fixed truncated source ids with SDRTrunk parser (issue #265).
- Fixed admin logs not updating if no results are found.
- New parameter http://host:port/?id=xyz added to URL that allows multiple client instances with different talkgroup selections to be retained accross sessions.
_v6.6.1_
- Fixed search issue (issue #267).
_v6.6.2_
- Fixed authentication endless loop if wrong access code is entered.
_v6.6.3_
- Fixed dirwatch validation for type trunk-recorder (issue #280).
## Version 6.5
- Fixed API looping on malformed or invalid multipart content (issue #181, #212).
- Source code updated to GO 1.18 with `interface{}` replaced by `any`.
- Removed ingest mutex for performance reasons.
- Replaced all `path.Base()` by `filepath.Base()` to fix an issue with audio filenames on Windows.
- New `Branding Label` and `Email Support` options to show on main screen (issue #220).
- New temporary avoid feature (discussion #218).
- Fixed remote address regexp (issue #225).
- Added the `ident` to `new listener` log message (discussion #226).
- New populated talkgroups won't be activated on the client if its group (or tag) is turned off (issue #227).
- Removed the duplicated webapp section from the PDF document.
_v6.5.1_
- Fixed broken functionality for `HOLD SYS` and `HOLD TG` (issue #228).
_v6.5.2_
- Fixed erratic listeners count.
- Show call date on main screen when call is older than one day (issue #229).
- Fixed dirwatch #DATE, #TIME and #ZTIME regexp to accomodate filenames like 20220711082833 (issue #235).
_v6.5.3_
- Return of the -admin_password option to reset the administrator password in case of forgetting.
- New `<iframe>` wrapper in `docs/examples/iframe` for those who want to give more information to their users.
- Fixed systems.write constraint failed (issue #241).
- Add filename to dirwatch error messages (issue #248).
- Dirwatch.type=default now defaults to the current date and time if none are provided by the metatags (discussion #250).
_v6.5.4_
- Fixed some warnings when linting server code.
- New dirwatch type `DSDPlus Fast Lane` (discussion #244).
- Added new error catches on dirwatch (issue 254).
- Fixed search by inaccurate time (issue #258).
- Reverted sync.Map to regular map with sync.Mutex.
_v6.5.5_
- Fixed concurrent map read and map write on dirwatch.
_v6.5.6_
- Fixed Clients lockup by removing mutex on some unecessary Clients methods.
- Better `DSDPlus Fast Lane` parser. Tested with `ConP(BS)`, `DMR(BS)`, `NEXEDGE48(CS)`, `NEXEDGE48(CB)`, `NEXEDGE48(TB)`, `NEXEDGE96(CB)`, `NEXEDGE96(CS)`, `NEXEDGE96(TB)`, `P25(BS)` and `P25`.
- Fixed unit aliases not displaying on the main screen under certain circumstances.
- New incremental debouncer for emitting listeners count.
## Version 6.4
- New `-cmd` command line options to allow advanced administrative tasks.
- New playback mode goes live options which is not enabled by default (issue #175).
- Fix logs retrieval from administrative dashboard (issue #193).
- Fixed logs retrieval from administrative dashboard (issue #193).
- Improved field conversions when retrieving calls from a mysql/mariadb database (issue #194, #198).
- Highlight replayed call on the history list (issue #196).
@ -14,8 +110,8 @@ _v6.4.1_
- New audio conversion options which replace the disable audio conversion option.
- Keep database connections open and don't close them when idle.
- Log the origin of listeners.
- Fix timestamp format when checking for call duplicates.
- Fix http timeouts on call ingestions or admin config save when dowstream takes too long (issue #197).
- Fixed timestamp format when checking for call duplicates.
- Fixed http timeouts on call ingestions or admin config save when dowstream takes too long (issue #197).
_v6.4.2_
@ -26,9 +122,18 @@ _v6.4.3_
- Add a note on the dirwatch admin screen about sdr-trunk.
- Starts client read/write pumps before registering with the controller (issue #181, #212).
_v6.4.4_
- Don't emit calls to listeners in separate GO routine to stay in sync with call ingestion.
_v6.4.5_
- SQL idle connections now expiring after 1 minute.
- Reverted defer rows.Close() to simple rows.Close().
## Version 6.3
- Change scroll speed when drag droping talkgroups or units in a system (discussion #170).
- Changed scroll speed when drag droping talkgroups or units in a system (discussion #170).
- System Ids listed in the `Config / Options / AFS Systems` will have their talkgroup Ids displayed in AFS format (issue #163).
- New dirwatch meta tags #GROUP #SYSLBL #TAG #TGAFS and #UNIT for better ProScan compatibility (issue #164).
- Playback mode will now catch up to live (issue #175).
@ -42,70 +147,70 @@ _v6.3.1_
_v6.3.2_
- New #TGLBL metatag for dirwatch for ProScan (%C) or alike.
- Fix `semacquire` lockup in Clients (issue #177, #181, #182).
- Fixed `semacquire` lockup in Clients (issue #177, #181, #182).
- Replay button now replays from history if pressed multiple times quickly (issue #186).
_v6.3.3_
- Fix concurrent map writes fatal error in dirwatch (issue #187).
- Fixed concurrent map writes fatal error in dirwatch (issue #187).
- Brighter LED colors and new orange color.
- Fix call id when retrieved from a MySQL database.
- Fixed call id when retrieved from a MySQL database.
- Add loudnorm audio filter to the ffmpeg audio conversion.
- Show the real IP address in the logs taking into account if behind a proxy.
- Fix panic when emitting a call to clients.
- Fixed panic when emitting a call to clients.
_v6.3.4_
- Fix ffmpeg audio filter not available on older version (issue #189).
- Fixed ffmpeg audio filter not available on older version (issue #189).
- Improved logging when run as a service, Windows users can now see these logs in the events viewer.
- Dirwatch now catches panic errors and logs them.
_v6.3.5_
- Replace standard map with sync.map in dirwatch.
- Fix the ffmpeg version test.
- Fix led color type, orage -> orange.
- Fix incorrect options when reading from a mysql database (issue #190).
- Fixed the ffmpeg version test.
- Fixed led color type, orage -> orange.
- Fixed incorrect options when reading from a mysql database (issue #190).
_v6.3.6_
- Fix systems order properties not sent to clients.
- Fix side panels not scrolling to top when opened.
- Fixed systems order properties not sent to clients.
- Fixed side panels not scrolling to top when opened.
## Version 6.2
- New max clients options which is 200 by default.
- New show listeners count options which is disabled by default (issue #125).
- Fix panic: concurrent write to websocket connection on goroutine.
- Fix units import from SDR Trunk (issue #150).
- Fixed panic: concurrent write to websocket connection on goroutine.
- Fixed units import from SDR Trunk (issue #150).
_v6.2.1_
- Fix SIGSEGV error in Units.Merge (issue #151).
- Fixed SIGSEGV error in Units.Merge (issue #151).
_v6.2.2_
- Fix another SIGSEGV error in Units.Merge (issue #151).
- Fixed another SIGSEGV error in Units.Merge (issue #151).
_v6.2.3_
- New random UUID in the JSON-Web Token payload.
- Fix dirwatch not properly shutting down when a new configuration is applied.
- Fix dashboard logout not sending HTTP 200 OK status.
- Fixed dirwatch not properly shutting down when a new configuration is applied.
- Fixed dashboard logout not sending HTTP 200 OK status.
- Clear the active dirwatch list when stopped.
- Pauses calls ingestion before database pruning.
- Fix regex for units in driwatch type SDRTrunk (discussion #155).
- Fixed regex for units in driwatch type SDRTrunk (discussion #155).
- Update SQLite driver.
_v6.2.4_
- Fix call frequencies table not being transmitted to downstream.
- Fixed call frequencies table not being transmitted to downstream.
- Avoid using setInterval and setTimeout in the webapp.
- Fix talkgroup search filter upon new configuration (issue #158).
- Fixed talkgroup search filter upon new configuration (issue #158).
_v6.2.5_
- Fix unnecessary auto populate of unit id/label (issue #160).
- Fixed unnecessary auto populate of unit id/label (issue #160).
## Version 6.1
@ -113,55 +218,55 @@ _v6.2.5_
- New search patched talkgroups option which is disabled by default.
- Talkgroups and units are now stored in their own database table.
- New units CSV importer.
- Fix blacklisted talkgroups being created anyway when autopopulate is enabled.
- Fix compatibility with mysql/mariadb (default sqlite is still recommended).
- Fixed blacklisted talkgroups being created anyway when autopopulate is enabled.
- Fixed compatibility with mysql/mariadb (default sqlite is still recommended).
_v6.1.1_
- Fix `unknown datetime format sql.NullString` error.
- Fixed `unknown datetime format sql.NullString` error.
_v6.1.2_
- Fix image links in webapp.md (issue #76).
- Fix SIGSEGV when trying to autopopulate (issue #77).
- Fix parsing SDRTrunk meta data.
- Fixed image links in webapp.md (issue #76).
- Fixed SIGSEGV when trying to autopopulate (issue #77).
- Fixed parsing SDRTrunk meta data.
- Dirwatch type trunk-recorder now deletes json files without audio (when deleteAfter is set).
- Add a new `docs/update-from-v5.md` document.
_v6.1.3_
- Fix concurrent config write when autopopulate is enabled (issue #77).
- Fix API in regards to audio filename and audio type (issue #78).
- Fix migration error on mysql database (issue #86).
- Fix some calls not playing on the native app (issue #87).
- Fix admin password not read from mysql.
- Fixed concurrent config write when autopopulate is enabled (issue #77).
- Fixed API in regards to audio filename and audio type (issue #78).
- Fixed migration error on mysql database (issue #86).
- Fixed some calls not playing on the native app (issue #87).
- Fixed admin password not read from mysql.
_v6.1.4_
- Talkgroup label now syncs with the talkgroup_tag from the API or dirwatch (issue #80).
- Fix more migration errors on mysql database (issue #86).
- Fix config export not working with non latin-1 characters (issue #89).
- Fix talkgroup label from dirwatch type sdrtrunk (discussion #98).
- Fix SIGSEGV (issue #100).
- Fixed more migration errors on mysql database (issue #86).
- Fixed config export not working with non latin-1 characters (issue #89).
- Fixed talkgroup label from dirwatch type sdrtrunk (discussion #98).
- Fixed SIGSEGV (issue #100).
- New `patch` indicator for patched talkgroups.
_v6.1.5_
- Fix trunk-recorder API (issue #104).
- Fix for avoid/patch flags on main display not beaving as expected.
- Fix downstream not sending sources data.
- Fix dirwatch crashing when config is updated.
- Fixed trunk-recorder API (issue #104).
- Fixed for avoid/patch flags on main display not beaving as expected.
- Fixed downstream not sending sources data.
- Fixed dirwatch crashing when config is updated.
_v6.1.6_
- Fix webapp not reporting the correct version.
- Fixed webapp not reporting the correct version.
_v6.1.7_
- More concurrency mutexes to resolve SQL_BUSY errors.
- Better internal management of dirwatches.
- Fix SDRTrunk files not being ingested (discussion #108).
- Fix Trunk Recorder talkgroup_tag assign to the wrong property (issue #115).
- Fixed SDRTrunk files not being ingested (discussion #108).
- Fixed Trunk Recorder talkgroup_tag assign to the wrong property (issue #115).
- Improved the way the talkgroup label and name are autopopulated. If Trunk Recorder sends a talkgroup_tag with an empty value or with a single `-`, it will not overwrite the talkgroup label.
_v6.1.8_
@ -170,8 +275,8 @@ _v6.1.8_
_v6.1.9_
- Fix talkgroup sorting issue when importing from a CSV file (issue #119).
- Fix SIGSEGV (issue #120).
- Fixed talkgroup sorting issue when importing from a CSV file (issue #119).
- Fixed SIGSEGV (issue #120).
_v6.1.10_
@ -179,36 +284,36 @@ _v6.1.10_
_v6.1.11_
- Fix connection errors when behind a reverse-proxy.
- Fix disappearing talkgroups (issue #127).
- Fixed connection errors when behind a reverse-proxy.
- Fixed disappearing talkgroups (issue #127).
_v6.1.12_
- Fix too many open files (issue #129).
- Fixed too many open files (issue #129).
- Cosmetic: AVOID and PATCH flags now only appear when needed.
_v6.1.13_
- Better handling of dead client connections.
- Fix too many open files (issue #129).
- Fixed too many open files (issue #129).
- Remove net.http error messages from the output (issue #131).
_v6.1.14_
- Fix FAQ section not being added to the PDF documents.
- Fixed FAQ section not being added to the PDF documents.
- Bump delay before killing unauthenticated clients from 10 seconds to 60 seconds.
- Remove the gitter.im support forum from the documentation and prefer github discussions.
_v6.1.15_
- Fix access and downstreams order not retained.
- Fixed access and downstreams order not retained.
- Remove the self-signed certificate generator (-ssl create) as it was causing more problems than solutions.
- Client handling and call ingestion now run on 2 different threads (issue #135).
- Fix downstream talkgroup select keeps reverting to all talkgroups (issue #136).
- Fixed downstream talkgroup select keeps reverting to all talkgroups (issue #136).
_v6.1.16_
- Fix concurrent map access for clients.
- Fixed concurrent map access for clients.
- Some tweaks to websocket management.
## Version 6.0
@ -233,24 +338,24 @@ The backend server has been completely rewritten in GO language. Therefore, all
_v6.0.1_
- Fix button sound on select panel for TG (beep state inverted)
- Fixed button sound on select panel for TG (beep state inverted)
- Auto populate system units (issue #66)
_v6.0.2_
- Try to fix the SQL_BUSY error (issue #67).
- Fix `-service stop` timing out before exiting.
- Fixed `-service stop` timing out before exiting.
- Drop the ApiKey uniqueness of the downstreams database table.
- Fix auto-populating the database with empty units tag.
- Fixed auto-populating the database with empty units tag.
_v6.0.3_
- Fix strconv.Atoi: invalid syntax for dirwatch type sdrtrunk.
- Fix the new version available dialog opening more than once.
- Fixed strconv.Atoi: invalid syntax for dirwatch type sdrtrunk.
- Fixed the new version available dialog opening more than once.
_v6.0.4_
- Fix wrong time calculation in prune scheduler.
- Fixed wrong time calculation in prune scheduler.
- More fix on the SQL_BUSY error (issue #67).
- Support files (certs, db, ini) are now created in the same folder as the executable, if the folder is writable, or under a `Rdio Scanner` folder in the user's home folder.
- Some code refactoring.
@ -263,26 +368,26 @@ _v6.0.5_
_v6.0.6_
- Fix an issue with not closing the database when restarting the host platform (issue #71).
- Fixed an issue with not closing the database when restarting the host platform (issue #71).
- Fixed SDRTunk parser when artist tag contains CTCSS tones.
- Platforms linux/amd64, linux/arm and linux/arm64 are now available for the Docker container.
_v6.0.7_
- Fix dropped connections when going through a proxy.
- Fixed dropped connections when going through a proxy.
## Version 5.2
- Change to how the server reports version.
- Fix cmd.js exiting on inexistant session token keystore.
- Fix issue with iframe.
- Fixed cmd.js exiting on inexistant session token keystore.
- Fixed issue with iframe.
- Node modules updated for security fixes.
_v5.2.1_
- Fix talkgroup header on the search panel (issue #47).
- Fixed talkgroup header on the search panel (issue #47).
- Update dirwatch meta tags #DATE, #TIME and #ZTIME for SDRSharp compatibility (issue #48).
- Fix dirwath date and time parsing bug.
- Fixed dirwath date and time parsing bug.
- Configurable call duplicate detection time frame.
_v5.2.2_
@ -293,13 +398,13 @@ _v5.2.2_
_v5.2.3_
- Change history columns padding from 1px to 6px on the main screen.
- Fix a bug in the admin api where the server crash when saving new config from the admin dashboard.
- Fixed a bug in the admin api where the server crash when saving new config from the admin dashboard.
_v5.2.4_
- Updated to Angular 12.2.
- New update prompt for clients when server is updated.
- Fix unaligned back arrow on the search panel.
- Fixed unaligned back arrow on the search panel.
_v5.2.5_
@ -310,20 +415,20 @@ _v5.2.5_
_v5.2.6_
- Fix crash when when options.pruneDays = 0.
- Fixed crash when when options.pruneDays = 0.
_v5.2.7_
- Fix handling of JSON datatypes on MySQL/MariaDB database backend.
- Fix listeners count.
- Fixed handling of JSON datatypes on MySQL/MariaDB database backend.
- Fixed listeners count.
_V5.2.8_
- Fix SQLite does not support TEXT with options.
- Fixed SQLite does not support TEXT with options.
_V5.2.9_
- Fix bad code for server options parsing.
- Fixed bad code for server options parsing.
- Increase dirwatch polling interval from 1000ms to 2500ms.
## Version 5.1
@ -346,15 +451,15 @@ This one is a big one... **Be sure to backup your config.json and your database.
_v5.1.1_
- Fix database migration script to version 5.1 to filter out duplicate property values on unique fields.
- Fix payload too large error message when saving configuration from the administrative dashboard.
- Fixed database migration script to version 5.1 to filter out duplicate property values on unique fields.
- Fixed payload too large error message when saving configuration from the administrative dashboard.
- Bring back the load-rrdb, load-tr and random uuid command line tools.
_v5.1.2_
- Fix config class not returning proper id properties when new records are added.
- Fix database migration script to version 5.1 when on mysql.
- Fix bad logic in apiKey validation.
- Fixed config class not returning proper id properties when new records are added.
- Fixed database migration script to version 5.1 when on mysql.
- Fixed bad logic in apiKey validation.
- Remove the autoJsonMap from the sequelize dialectOptions.
- Client updated to angular 12.
@ -368,15 +473,15 @@ _v5.1.2_
_v5.0.1_
- Remove the EBU R128 loudness normalization as it's not working as intended.
- Fix the API key validation when using the complex syntax.
- Fixed the API key validation when using the complex syntax.
_v5.0.2_
- Fix rdioScanner.options.disableAudioConversion which was ignored when true.
- Fixed rdioScanner.options.disableAudioConversion which was ignored when true.
_v5.0.3_
- Fix error with docker builds where sequelize can't find the sqlite database.
- Fixed error with docker builds where sequelize can't find the sqlite database.
_v5.0.4_
@ -403,7 +508,7 @@ _v4.9.1_
- Add EBU R128 loudness normalization.
- dirWatch.type="trunk-recorder" now deletes the JSON file in case the audio file is missing.
- Fix downstream sending wrong talkgroup id.
- Fixed downstream sending wrong talkgroup id.
_v4.9.2_
@ -413,8 +518,8 @@ _v4.9.2_
- Add downstream.system.id_as property to allow export system with a different id.
- Add system.order for system list ordering on the client side.
- Fix client main screen unscrollable overflow while in landscape.
- Fix issue 26 - date in documentation for mask isn't clear.
- Fixed client main screen unscrollable overflow while in landscape.
- Fixed issue 26 - date in documentation for mask isn't clear.
- The skip button now also allows you to skip the one second delay between calls.
- Node modules update.
@ -425,26 +530,26 @@ _v4.8.1_
_v4.8.2_
- Fix dirWatch.type='sdr-trunk' metatag artist as source is now optional.
- Fix dirWatch.type='sdr-trunk' metatag title as talkgroup.id.
- Fixed dirWatch.type='sdr-trunk' metatag artist as source is now optional.
- Fixed dirWatch.type='sdr-trunk' metatag title as talkgroup.id.
- Web app now running with Angular 11.
- Node modules update.
_v4.8.3_
- Add the ability to overwrite the default dirWatch extension for type sdr-trunk and trunk-recorder.
- Fix dirWatch.disabled being ignored.
- Fixed dirWatch.disabled being ignored.
- Node modules update.
_v4.8.4_
- Fix the timezone issue when on mariadb.
- Fix downstream sending wrong talkgroup id.
- Fixed the timezone issue when on mariadb.
- Fixed downstream sending wrong talkgroup id.
- Node modules security update.
_v4.8.5_
- Fix broken dirwatch.delay.
- Fixed broken dirwatch.delay.
- Node modules update.
## Version 4.7
@ -458,37 +563,37 @@ _v4.8.5_
_v4.7.1_
- Fix crash on client when access to talkgroups is restricted with a password.
- Fixed crash on client when access to talkgroups is restricted with a password.
_v4.7.2_
- Fix Keypad beeps not working on iOS.
- Fix pause not going off due to the above bug.
- Fixed Keypad beeps not working on iOS.
- Fixed pause not going off due to the above bug.
_v4.7.3_
- Fix websocket not connection on ssl.
- Fixed websocket not connection on ssl.
_v4.7.4_
- Fix display width too wide when long talkgroup name.
- Fixed display width too wide when long talkgroup name.
_v4.7.5_
- Fix playback mode getting mixed up if clicking too fast on play.
- Fix side panels background color inheritance.
- Fixed playback mode getting mixed up if clicking too fast on play.
- Fixed side panels background color inheritance.
- Node modules update.
_v4.7.6_
- Fix search results not going back to page 1 when search filters are modified.
- Fixed search results not going back to page 1 when search filters are modified.
- Skip next button no longer emit a denied beeps sequence when pushed while there's no audio playing.
- Node modules update.
## Version 4.6
- Fix documentation in regards to load-rrd in install-github.md.
- Fix database absolute path in config.json.
- Fixed documentation in regards to load-rrd in install-github.md.
- Fixed database absolute path in config.json.
- Remove config.options.useLed.
- Rename Config.options.keyBeep to Config.options.keypadBeeps.
- Config.options.keypadBeeps now with presets instead of full pattern declaration.
@ -504,7 +609,7 @@ _v4.7.6_
- Config.systems.talkgroups.patches to group many talkgroups (patches) into one talkgroup.id.
- Config.options now groups allowDownloads, disableAudioConversion, pruneDays, useDimmer, useGroup and useLed options instead of having them spread all over the config file.
- Client will always display talkgroup id on the right side instead of 0 when call is analog.
- Fix annoying bug when next call queued to play is still played even though offline continuous play mode is turned off.
- Fixed annoying bug when next call queued to play is still played even though offline continuous play mode is turned off.
- Talkgroup ID is displayed no matter what and unit ID is displayed only if known.
## Version 4.3
@ -513,15 +618,15 @@ _v4.7.6_
- Automatic database migration on startup.
- Client now on Angular 10 in strict mode.
- Dockerized.
- Fix downstream not being triggered when a new call imported.
- Fix dirWatch mask parser and new mask metatags.
- Fix stop button on the search panel when in offline play mode.
- Fix SSL certificate handling.
- Fixed downstream not being triggered when a new call imported.
- Fixed dirWatch mask parser and new mask metatags.
- Fixed stop button on the search panel when in offline play mode.
- Fixed SSL certificate handling.
- Rewritten documentation.
## Version 4.2
- Fix possible race conditions....
- Fixed possible race conditions....
- Added websocket keepalive which helps mobile clients when switching from/to wifi/wan.
- Better playback offline mode animations and queue count.
- New dirWatch.mask option to simplify meta data import.

View file

@ -1,6 +1,6 @@
# How to compile
> RDIO SCANNER REPRESENTS A LOT OF INVESTMENT IN TIME, SUPPORT, TESTS AND MATERIALS. BY INTENTIONALLY MODIFYING ITS CODE TO REMOVE ITS IDENTITY, YOU HARM THE OPEN SOURCE PROJECT AND ITS AUTHOR. PLEASE BE RESPECTFUL, SPONSOR THIS PROJECT IF YOU CAN, USE NATIVE APPS IF POSSIBLE.
> RDIO SCANNER REPRESENTS A LOT OF INVESTMENT IN TIME, SUPPORT, TESTS AND MATERIALS. BY INTENTIONALLY MODIFYING ITS CODE TO REMOVE ITS IDENTITY, YOU HARM THE OPEN SOURCE PROJECT AND ITS AUTHOR. PLEASE BE RESPECTFUL, SPONSOR THIS PROJECT, USE NATIVE APPS IF POSSIBLE.
The official version of [Rdio Scanner](https://github.com/chuot/rdio-scanner) is compiled on a PC using the current version of Fedora Workstation. You should have no problem building on another platform as long as the prerequisites are available and installed.
@ -27,13 +27,13 @@ Clone the official repository on your computer and start the build process.
When finished, you will find the precompiled versions for various platforms in the `dist` folder.
rdio-scanner-darwin-amd64-v6.4.3.zip
rdio-scanner-darwin-arm64-v6.4.3.zip
rdio-scanner-freebsd-amd64-v6.4.3.zip
rdio-scanner-linux-386-v6.4.3.zip
rdio-scanner-linux-amd64-v6.4.3.zip
rdio-scanner-linux-arm64-v6.4.3.zip
rdio-scanner-linux-arm-v6.4.3.zip
rdio-scanner-windows-amd64-v6.4.3.zip
rdio-scanner-darwin-amd64-v7.0.0.zip
rdio-scanner-darwin-arm64-v7.0.0.zip
rdio-scanner-freebsd-amd64-v7.0.0.zip
rdio-scanner-linux-386-v7.0.0.zip
rdio-scanner-linux-amd64-v7.0.0.zip
rdio-scanner-linux-arm64-v7.0.0.zip
rdio-scanner-linux-arm-v7.0.0.zip
rdio-scanner-windows-amd64-v7.0.0.zip
**Happy Rdio scanning !**

View file

@ -18,6 +18,10 @@ For **feature requests**, describe the reason and use case.
Most of the time, the issue will be assigned to the [owner and project manager](https://github.com/chuot) and then resolved in a timely manner. In some cases, the issue will be identified as _help needed_ and then assigned to a volonteer contributor. Again, pull requests will be rejected if they are not related to a previously opened and assigned issue.
## Contributor Agreement
By submitting a contribution to this repository, you agree to the terms of the [Individual Contributor Assignment Agreement](./ICAA.md).
## Random Pull Requests
To keep the project stable and well organized, random pull requests will be rejected if no issue have been previously opened and assigned first by the [owner and project manager](https://github.com/chuot).

102
ICAA.md Normal file
View file

@ -0,0 +1,102 @@
# Individual Contributor Assignment Agreement (ICAA)
_For contributions to the rdio-scanner project (<https://github.com/chuot/rdio-scanner>)_
Thank you for your interest in contributing to **rdio-scanner**, an open-source project owned and maintained by **Saubeo Solutions** (<https://saubeo.solutions>).
This Contributor Agreement explains the rights you transfer to Saubeo Solutions when you submit contributions.
By submitting a pull request, patch, or contribution to this Project, **you accept and agree to the terms of this Agreement**. Maintainers may also require explicit sign-off for some or all contributions.
---
## 1. Definitions
- **"You"** means the individual submitting a Contribution.
- **"Contribution"** means any code, documentation, design, data, or other material submitted to the rdio-scanner project in any form.
- **"Project"** means the rdio-scanner repository and all related materials maintained by Saubeo Solutions.
- **"Saubeo Solutions"** means the legal owner of the Project and its copyrights.
---
## 2. Assignment of Copyright and Related Rights
You hereby irrevocably assign to Saubeo Solutions, and agree to assign upon creation to the fullest extent permitted by law, all right, title, and interest in and to your Contribution, including all copyrights and related rights, worldwide and for the full duration of those rights.
This assignment includes the right for Saubeo Solutions to use, reproduce, modify, adapt, publish, distribute, sublicense, relicense, and otherwise commercialize your Contribution as part of the Project or any derivative work, including under open-source or commercial terms.
To the extent any right cannot be assigned under applicable law, you grant Saubeo Solutions an exclusive, perpetual, worldwide, royalty-free, irrevocable, sublicensable license to that right.
---
## 3. Further Assurances
You agree to execute and deliver any additional documents reasonably requested by Saubeo Solutions to confirm, register, perfect, or enforce the rights transferred under this Agreement.
---
## 4. Patent License
You grant Saubeo Solutions a **perpetual, worldwide, non-exclusive, royalty-free, irrevocable patent license** to:
- make, have made, use, offer to sell, sell, import, and otherwise transfer
- any Contribution you submit,
- including any patent claims you own or control that would be infringed by your Contribution.
If you initiate patent litigation against Saubeo Solutions over your Contribution, this patent license terminates immediately.
---
## 5. Moral Rights
To the extent permitted by law, you waive any **moral rights** you may have in your Contribution, such as rights of attribution or integrity.
Where such waiver is not legally permitted, you agree not to assert those rights against Saubeo Solutions or downstream recipients to the extent necessary to exercise the rights granted under this Agreement.
Saubeo Solutions will make reasonable efforts to credit contributors in accordance with open-source norms.
---
## 6. Your Representations
By submitting a Contribution, you confirm that:
1. **You wrote the Contribution yourself**, or you otherwise have sufficient rights to submit it under this Agreement.
2. **You are legally entitled to transfer the rights in this Agreement**.
3. If your employer or another entity has rights in your Contribution, **you have obtained all necessary permissions** to submit and transfer rights under this Agreement.
4. **Your Contribution does not knowingly violate any third-party rights**, including copyrights, patents, or trade secrets.
5. If your Contribution includes third-party code or content, **you will clearly identify it and its license terms**, and you represent that such inclusion is permitted and license-compatible with the Project.
---
## 7. No Warranty
Your Contribution is provided **"as is"**, without warranties or conditions of any kind.
---
## 8. Governing Law and Dispute Resolution
This Agreement is intended for worldwide use.
Any dispute arising from this Agreement will first be addressed through good-faith negotiation.
If no resolution is reached, the dispute will be finally settled by binding arbitration in a mutually agreed venue and language.
If arbitration is not legally available, disputes will be resolved by a court of competent jurisdiction.
---
## 9. Limitation of Liability
To the maximum extent permitted by law, neither party will be liable to the other for any indirect, incidental, special, exemplary, or consequential damages arising out of this Agreement.
---
## 10. Severability and Survival
If any provision of this Agreement is held unenforceable, the remaining provisions will remain in full force and effect.
Sections 2, 3, 4, 5, 6, 7, 9, and 10 survive termination of this Agreement.
---
## 11. Acceptance
By submitting a Contribution to the Project (including via GitHub pull request), **you agree to the terms of this Contributor Agreement**.

View file

@ -1,5 +1,5 @@
################################################################################
## Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
## Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
##
## This program is free software: you can redistribute it and/or modify
## it under the terms of the GNU General Public License as published by
@ -16,20 +16,20 @@
################################################################################
app := rdio-scanner
date := 2022/06/14
ver := 6.4.3
date := 2026/05/16
ver := 7.0.0-dev
client := $(wildcard client/*.json client/*.ts)
server := $(wildcard server/*.go)
build = @cd server && GOOS=$(1) GOARCH=$(2) go build -o ../dist/$(1)-$(2)/$(3)
pandoc = @test -d dist/$(1)-$(2) || mkdir -p dist/$(1)-$(2) && pandoc -f markdown -o dist/$(1)-$(2)/$(3) --resource-path docs:docs/platforms $(4) docs/webapp.md docs/webapp.md docs/faq.md CHANGELOG.md
build = @cd server && GOOS=$(1) GOARCH=$(3) go build -o ../dist/$(2)-$(3)/$(4)
pandoc = @test -d dist/$(1)-$(2) || mkdir -p dist/$(1)-$(2) && pandoc -f markdown -o dist/$(1)-$(2)/$(3) --resource-path docs:docs/platforms $(4) docs/webapp.md docs/faq.md CHANGELOG.md
zip = @cd dist/$(1)-$(2) && zip -q ../$(app)-$(1)-$(2)-v$(ver).zip * && cd ..
.PHONY: all clean container dist sed
.PHONY: darwin darwin-amd64 darwin-arm64
.PHONY: freebsd freebsd-amd64
.PHONY: linux linux-386 linux-amd64 linux-arm linux-arm64
.PHONY: macos macos-amd64 macos-arm64
.PHONY: windows windows-amd64
all: clean dist
@ -43,13 +43,13 @@ container: webapp linux-amd64
@podman build --platform linux/amd64,linux/arm,linux/arm64 --pull --manifest rdio-scanner:latest .
@podman manifest push --format v2s2 localhost/rdio-scanner:latest docker://docker.io/chuot/rdio-scanner:latest
dist: darwin freebsd linux windows
dist: freebsd linux macos windows
sed:
@sed -i -re "s|^(\s*\"version\":).*$$|\1 \"$(ver)\"|" client/package.json
@sed -i -re "s|^(const\s+Version\s+=).*$$|\1 \"$(ver)\"|" server/version.go
@sed -i -re "s|v[0-9]+\.[0-9]+\.[0-9]+|v$(ver)|" COMPILING.md docs/platforms/*.md
@sed -i -re "s|[0-9]{4}/[0-9]{2}/[0-9]{2}|$(date)|" docs/platforms/*.md
@sed -i -re "s|v[0-9]+\.[0-9]+\.[0-9]+|v$(ver)|" COMPILING.md README.md docs/docker/README.md docs/platforms/*.md
@sed -i -re "s|[0-9]{4}/[0-9]{2}/[0-9]{2}|$(date)|" docs/docker/README.md docs/platforms/*.md
webapp: server/webapp/index.html
@ -57,26 +57,12 @@ server/webapp/index.html: $(client)
@cd client && test -d node_modules || npm ci --loglevel=error --no-progress
@cd client && npm run build
darwin: darwin-amd64 darwin-arm64
darwin-amd64: webapp dist/$(app)-darwin-amd64-v$(ver).zip
darwin-arm64: webapp dist/$(app)-darwin-arm64-v$(ver).zip
dist/$(app)-darwin-amd64-v$(ver).zip: $(server)
$(call pandoc,darwin,amd64,rdio-scanner.pdf,docs/platforms/darwin.md)
$(call build,darwin,amd64,$(app))
$(call zip,darwin,amd64,$(app))
dist/$(app)-darwin-arm64-v$(ver).zip: $(server)
$(call pandoc,darwin,arm64,rdio-scanner.pdf,docs/platforms/darwin.md)
$(call build,darwin,arm64,$(app))
$(call zip,darwin,arm64,$(app))
freebsd: freebsd-amd64
freebsd-amd64: webapp dist/$(app)-freebsd-amd64-v$(ver).zip
dist/$(app)-freebsd-amd64-v$(ver).zip: $(server)
$(call pandoc,freebsd,amd64,rdio-scanner.pdf,docs/platforms/freebsd.md)
$(call build,freebsd,amd64,$(app))
$(call build,freebsd,freebsd,amd64,$(app))
$(call zip,freebsd,amd64,$(app))
linux: linux-386 linux-amd64 linux-arm linux-arm64
@ -87,28 +73,42 @@ linux-arm64: webapp dist/$(app)-linux-arm64-v$(ver).zip
dist/$(app)-linux-386-v$(ver).zip: $(server)
$(call pandoc,linux,386,rdio-scanner.pdf,docs/platforms/linux.md)
$(call build,linux,386,$(app))
$(call build,linux,linux,386,$(app))
$(call zip,linux,386,$(app))
dist/$(app)-linux-amd64-v$(ver).zip: $(server)
$(call pandoc,linux,amd64,rdio-scanner.pdf,docs/platforms/linux.md)
$(call build,linux,amd64,$(app))
$(call build,linux,linux,amd64,$(app))
$(call zip,linux,amd64,$(app))
dist/$(app)-linux-arm-v$(ver).zip: $(server)
$(call pandoc,linux,arm,rdio-scanner.pdf,docs/platforms/linux.md)
$(call build,linux,arm,$(app))
$(call build,linux,linux,arm,$(app))
$(call zip,linux,arm,$(app))
dist/$(app)-linux-arm64-v$(ver).zip: $(server)
$(call pandoc,linux,arm64,rdio-scanner.pdf,docs/platforms/linux.md)
$(call build,linux,arm64,$(app))
$(call build,linux,linux,arm64,$(app))
$(call zip,linux,arm64,$(app))
macos: macos-amd64 macos-arm64
macos-amd64: webapp dist/$(app)-macos-amd64-v$(ver).zip
macos-arm64: webapp dist/$(app)-macos-arm64-v$(ver).zip
dist/$(app)-macos-amd64-v$(ver).zip: $(server)
$(call pandoc,macos,amd64,rdio-scanner.pdf,docs/platforms/macos.md)
$(call build,darwin,macos,amd64,$(app))
$(call zip,macos,amd64,$(app))
dist/$(app)-macos-arm64-v$(ver).zip: $(server)
$(call pandoc,macos,arm64,rdio-scanner.pdf,docs/platforms/macos.md)
$(call build,darwin,macos,arm64,$(app))
$(call zip,macos,arm64,$(app))
windows: windows-amd64
windows-amd64: webapp dist/$(app)-windows-amd64-v$(ver).zip
dist/$(app)-windows-amd64-v$(ver).zip: $(server)
$(call pandoc,windows,amd64,rdio-scanner.pdf,docs/platforms/windows.md)
$(call build,windows,amd64,$(app).exe)
$(call build,windows,windows,amd64,$(app).exe)
$(call zip,windows,amd64,$(app))

View file

@ -1,12 +1,12 @@
![Rdio Scanner](./docs/images/rdio-scanner.png?raw=true)
# What is it ?
## What is it ?
[Rdio Scanner](https://github.com/chuot/rdio-scanner) is an open source software that ingest and distribute audio files generated by various software-defined radio recorders. Its interface tries to reproduce the user experience of a real police scanner, while adding its own touch.
You can listen to [Rdio Scanner](https://github.com/chuot/rdio-scanner) on any modern browsers using the integrated web app.
# Recorders compatibility
## Recorders compatibility
[Rdio Scanner](https://github.com/chuot/rdio-scanner) works with any radio recorder, as long as they can create audio files separated by conversations or transmissions.
@ -16,44 +16,73 @@ Here is a list of recorders known to work with [Rdio Scanner](https://github.com
| -------------------------------------------------------------- | --- | -------- |
| [Trunk Recorder](https://github.com/robotastic/trunk-recorder) | X | X |
| [RTLSDR-Airband](https://github.com/szpajder/RTLSDR-Airband) | | X |
| [sdrtrunk](https://github.com/DSheirer/sdrtrunk) | | X |
| [SDRTrunk](https://github.com/DSheirer/sdrtrunk) | X | |
| [voxcall](https://github.com/aaknitt/voxcall) | X | |
| [ProScan](https://www.proscan.org/) | | X |
| [DSDPlus Fast Lane](https://https://www.dsdplus.com/) | | X |
# Quick start
## Quick start
ALWAYS DOWNLOAD THE LATEST VERSION OF [RDIO SCANNER](https://github.com/chuot/rdio-scanner) FROM ITS OFFICIAL REPOSITORY AT **[HTTPS://GITHUB.COM/CHUOT/RDIO-SCANNER](https://github.com/chuot/rdio-scanner)**.
1. Download the the latest precompiled version of [Rdio Scanner](https://github.com/chuot/rdio-scanner) from the [releases tab](https://github.com/chuot/rdio-scanner/releases).
1. Download the latest precompiled version of [Rdio Scanner](https://github.com/chuot/rdio-scanner) from the [releases tab](https://github.com/chuot/rdio-scanner/releases).
| Operating system | Architecture | Use package |
| ---------------- | ------------ | ------------------------------------- |
| FreeBSD | amd64 | rdio-scanner-freebsd-amd64-v7.0.0.zip |
| Linux | 386 | rdio-scanner-linux-386-v7.0.0.zip |
| Linux | amd64 | rdio-scanner-linux-amd64-v7.0.0.zip |
| Linux | arm | rdio-scanner-linux-arm-v7.0.0.zip |
| Linux | arm64 | rdio-scanner-linux-arm64-v7.0.0.zip |
| macOS | amd64 | rdio-scanner-macos-amd64-v7.0.0.zip |
| macOS | arm64 | rdio-scanner-macos-arm64-v7.0.0.zip |
| Windows | amd64 | rdio-scanner-macos-amd64-v7.0.0.zip |
2. Extract the contents of the archive somewhere on your computer.
3. Run the [Rdio Scanner](https://github.com/chuot/rdio-scanner) executable.
4. Access the administrative dashboard to finalize the configuration.
More detailed instructions are available in the `rdio-scanner.pdf` file provided in the precompiled archives.
If you are updating from version 5, please refer to [docs/update-from-v5.md](docs/update-from-v5.md) before proceeding.
## Docker
# Need help ?
As a courtesy to Docker users, [Rdio Scanner](https://github.com/chuot/rdio-scanner) is also distributed as a Docker image where a new version is generated with each new release. More information available at **[https://hub.docker.com/repository/docker/chuot/rdio-scanner](https://hub.docker.com/repository/docker/chuot/rdio-scanner)**.
You can ask your questions or post your comments on the [Rdio Scanner Discussions](https://github.com/chuot/rdio-scanner/discussions) at **[https://github.com/chuot/rdio-scanner/discussions](https://github.com/chuot/rdio-scanner/discussions)**.
## Need help ?
# Show your appreciation, support the author
### GitHub Wiki
Your question might already be addressed on the [Rdio Scanner GitHub Wiki](https://github.com/chuot/rdio-scanner/wiki), be sure to check it out at **[https://github.com/chuot/rdio-scanner/wiki](https://github.com/chuot/rdio-scanner/wiki)**.
### GitHub Discussions
Feel free to ask your questions or share your comments on the [Rdio Scanner GitHub discussions](https://github.com/chuot/rdio-scanner/discussions) at **[https://github.com/chuot/rdio-scanner/discussions](https://github.com/chuot/rdio-scanner/discussions)**.
## Show your appreciation, support the author
If you like [Rdio Scanner](https://github.com/chuot/rdio-scanner), **[consider starring the GitHub repository](https://github.com/chuot/rdio-scanner/stargazers)** to show you appreciation to the author for his hard work. It cost nothing but is really appreciated.
If you use [Rdio Scanner](https://github.com/chuot/rdio-scanner) for commercial purposes or derive income from it, **[sponsor the project](https://github.com/sponsors/chuot)** to help support continued development.
[![Follow us on Twitter](./docs/images/twitter-badge.png?raw=true)](https://twitter.com/RdioScanner)
# Improve your experience on the go
## Improve your experience on the go
You can enjoy your [Rdio Scanner](https://github.com/chuot/rdio-scanner) on the go on your mobile device with the native app.
[![Available on the App Store](./docs/images/app-store-badge.png?raw=true)](https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone)
[![Get it on Google Play](./docs/images/google-play-badge.png?raw=true)](https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner)
# Commercial support
## Contributing
Commercial support is available at **[https://saubeo.solutions/](https://saubeo.solutions)**.
By submitting a contribution to this repository, you agree to the terms of the [Individual Contributor Assignment Agreement](./ICAA.md).
## WebSocket API - Commercial Use
This project is licensed under the GNU GPL. The **HTTP REST API** is fully available for all uses.
However, the **WebSocket API** is a proprietary service provided exclusively by Saubeo Solutions for its native applications and the integrated web application. This restriction supports the commercial model that funds this project's development.
Self-hosted deployments can use the HTTP REST API without limitation. Access to the WebSocket API in self-hosted instances requires a license agreement. For details, see [API_ACCESS_POLICY.md](API_ACCESS_POLICY.md).
---
**Happy Rdio scanning !**

55
SECURITY.md Normal file
View file

@ -0,0 +1,55 @@
# Security Policy
## Supported Versions
Security updates are provided for the latest version of **rdio-scanner**.
Older versions may not receive patches unless the issue is critical.
## Reporting a Vulnerability
If you discover a security vulnerability, we appreciate your help in responsibly disclosing it.
**Please do NOT open a public GitHub issue.**
Instead, contact us privately at: **<rdio-scanner@saubeo.solutions>**
Include as much detail as possible:
- Steps to reproduce
- Impact assessment
- Affected versions
- Any proof-of-concept code (if applicable)
We will acknowledge your report within **72 hours**.
## Disclosure Process
1. We investigate the report and confirm the vulnerability.
2. We work with you (if needed) to understand the issue fully.
3. We prepare a fix and coordinate a release.
4. We publish a security advisory once the fix is available.
We aim to resolve validated security issues within **14 days**, depending on severity and complexity.
## Scope
This policy applies to:
- The rdio-scanner codebase
- Documentation
- Any related scripts or tooling included in the repository
It does **not** cover:
- Third-party dependencies
- External services or infrastructure not maintained by Saubeo Solutions
## Responsible Disclosure
We ask that you:
- Give us reasonable time to fix the issue before public disclosure
- Avoid actions that could harm users or data
- Follow ethical security research practices
We greatly appreciate your contribution to the safety and reliability of rdio-scanner.

View file

@ -16,14 +16,15 @@
"root": "",
"sourceRoot": "src",
"prefix": "rdio-scanner",
"architect": {
"targets": {
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"outputPath": "../server/webapp",
"index": "src/index.html",
"main": "src/main.ts",
"polyfills": "src/polyfills.ts",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
@ -36,15 +37,16 @@
],
"scripts": [],
"serviceWorker": true,
"ngswConfigPath": "ngsw-config.json"
"ngswConfigPath": "ngsw-config.json",
"main": "src/main.ts"
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "1500kb",
"maximumError": "1500kb"
"maximumWarning": "2mb",
"maximumError": "2mb"
},
{
"type": "anyComponentStyle",
@ -61,9 +63,7 @@
"outputHashing": "all"
},
"development": {
"buildOptimizer": false,
"optimization": false,
"vendorChunk": true,
"extractLicenses": false,
"sourceMap": true,
"namedChunks": true
@ -75,11 +75,11 @@
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"browserTarget": "rdio-scanner:build:production"
"buildTarget": "rdio-scanner:build:production"
},
"development": {
"browserTarget": "rdio-scanner:build:development",
"proxyConfig": "src/proxy.conf.js"
"proxyConfig": "src/proxy.conf.js",
"buildTarget": "rdio-scanner:build:development"
}
},
"defaultConfiguration": "development"
@ -87,14 +87,16 @@
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n",
"options": {
"browserTarget": "rdio-scanner:build"
"buildTarget": "rdio-scanner:build"
}
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"main": "src/test.ts",
"polyfills": "src/polyfills.ts",
"polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss",
@ -112,5 +114,33 @@
}
}
},
"defaultProject": "rdio-scanner"
"cli": {
"analytics": false
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}

36057
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,44 @@
{
"author": "Chrystian Huot <chrystian.huot@saubeo.solutions>",
"dependencies": {
"@angular/animations": "^13.3.10",
"@angular/cdk": "^13.3.8",
"@angular/common": "~13.3.10",
"@angular/compiler": "~13.3.10",
"@angular/core": "~13.3.10",
"@angular/forms": "~13.3.10",
"@angular/material": "^13.3.8",
"@angular/platform-browser": "~13.3.10",
"@angular/platform-browser-dynamic": "~13.3.10",
"@angular/router": "^13.3.10",
"@angular/service-worker": "^13.3.10",
"rxjs": "~7.5.5",
"tslib": "^2.4.0",
"zone.js": "~0.11.5"
},
"devDependencies": {
"@angular-devkit/build-angular": "^13.3.7",
"@angular/cli": "^13.3.7",
"@angular/compiler-cli": "~13.3.10",
"@types/node": "^17.0.40",
"@typescript-eslint/eslint-plugin": "^5.27.1",
"@typescript-eslint/parser": "^5.27.1",
"ajv-keywords": "^5.1.0",
"eslint": "^8.17.0",
"ts-node": "~10.8.1",
"typescript": "~4.6.4"
},
"engines": {
"node": ">=16.0"
},
"license": "LICENSE",
"name": "rdio-scanner",
"private": true,
"scripts": {
"build": "ng build --base-href ./ --configuration production",
"start": "ng serve"
},
"version": "6.4.3"
"author": "Chrystian Huot <chrystian@huot.qc.ca>",
"dependencies": {
"@angular/animations": "^21.0.6",
"@angular/cdk": "^21.0.5",
"@angular/common": "^21.0.6",
"@angular/compiler": "^21.0.6",
"@angular/core": "^21.0.6",
"@angular/forms": "^21.0.6",
"@angular/material": "^21.0.5",
"@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "^21.0.6",
"@angular/router": "^21.0.6",
"@angular/service-worker": "^21.0.6",
"material-icons": "^1.13.14",
"rxjs": "~7.8.2",
"tslib": "^2.8.1",
"zone.js": "~0.16.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^21.0.4",
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.6",
"@types/node": "^25.0.3",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"ajv-keywords": "^5.1.0",
"eslint": "^9.39.2",
"ts-node": "~10.9.2",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=16.0"
},
"license": "LICENSE",
"name": "rdio-scanner",
"private": true,
"scripts": {
"build": "ng build --base-href ./ --configuration production",
"serve": "ng serve"
},
"version": "7.0.0"
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,5 +23,6 @@ import { Component } from '@angular/core';
selector: 'app-root',
styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html',
standalone: false
})
export class AppComponent { }

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -1,43 +1,43 @@
<ng-container *ngIf="!authenticated">
<rdio-scanner-admin-login></rdio-scanner-admin-login>
</ng-container>
<ng-container *ngIf="authenticated">
<rdio-scanner-admin-todos></rdio-scanner-admin-todos>
<mat-accordion displayMode="flat">
<mat-expansion-panel #configPanel (afterCollapse)="configComponent?.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>settings</mat-icon>
Config
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config>
</mat-expansion-panel>
<mat-expansion-panel (afterExpand)="logsComponent.reload()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>article</mat-icon>
Logs
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="toolsComponent?.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>build</mat-icon>
Tools
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools>
</mat-expansion-panel>
<mat-expansion-panel hideToggle (click)="logout()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>logout</mat-icon>
Logout
</mat-panel-title>
</mat-expansion-panel-header>
</mat-expansion-panel>
</mat-accordion>
</ng-container>
@if (!authenticated) {
<rdio-scanner-admin-login></rdio-scanner-admin-login>
}
@if (authenticated) {
<rdio-scanner-admin-todos></rdio-scanner-admin-todos>
<mat-accordion displayMode="flat">
<mat-expansion-panel #configPanel (afterCollapse)="configComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>settings</mat-icon>
Config
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-config #configComponent></rdio-scanner-admin-config>
</mat-expansion-panel>
<mat-expansion-panel (afterExpand)="logsComponent.reload()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>article</mat-icon>
Logs
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="toolsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>build</mat-icon>
Tools
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-tools #toolsComponent (config)="configComponent.reset($event, { dirty: true }); configPanel.open()"></rdio-scanner-admin-tools>
</mat-expansion-panel>
<mat-expansion-panel hideToggle (click)="logout()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>logout</mat-icon>
Logout
</mat-panel-title>
</mat-expansion-panel-header>
</mat-expansion-panel>
</mat-accordion>
}

View file

@ -1,4 +1,7 @@
rdio-scanner-admin {
display: block;
padding: 1rem;
.mat-expansion-panel-header {
user-select: none;
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -25,21 +25,24 @@ import { AdminEvent, RdioScannerAdminService, Group, Tag } from './admin.service
selector: 'rdio-scanner-admin',
styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html',
standalone: false
})
export class RdioScannerAdminComponent implements OnDestroy {
authenticated = this.adminService.authenticated;
authenticated = true;
groups: Group[] = [];
tags: Tag[] = [];
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('authenticated' in event) {
this.authenticated = event.authenticated || false;
}
});
private eventSubscription;
constructor(private adminService: RdioScannerAdminService) { }
constructor(private adminService: RdioScannerAdminService) {
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('authenticated' in event) {
this.authenticated = event.authenticated || false;
}
});
}
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,18 +17,19 @@
* ****************************************************************************
*/
import { HttpClientModule } from '@angular/common/http';
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { AppSharedModule } from '../../../shared/shared.module';
import { RdioScannerAdminComponent } from './admin.component';
import { RdioScannerAdminService } from './admin.service';
import { RdioScannerAdminConfigComponent } from './config/config.component';
import { RdioScannerAdminAccessComponent } from './config/access/access.component';
import { RdioScannerAdminApiKeysComponent } from './config/api-keys/api-keys.component';
import { RdioScannerAdminDirWatchComponent } from './config/dir-watch/dir-watch.component';
import { RdioScannerAdminApikeysComponent } from './config/apikeys/apikeys.component';
import { RdioScannerAdminDirwatchComponent } from './config/dirwatch/dirwatch.component';
import { RdioScannerAdminDownstreamsComponent } from './config/downstreams/downstreams.component';
import { RdioScannerAdminGroupsComponent } from './config/groups/groups.component';
import { RdioScannerAdminOptionsComponent } from './config/options/options.component';
import { RdioScannerAdminSiteComponent } from './config/systems/site/site.component';
import { RdioScannerAdminSystemsSelectComponent } from './config/systems/select/select.component';
import { RdioScannerAdminSystemComponent } from './config/systems/system/system.component';
import { RdioScannerAdminSystemsComponent } from './config/systems/systems.component';
@ -44,13 +45,12 @@ import { RdioScannerAdminImportTalkgroupsComponent } from './tools/import-talkgr
import { RdioScannerAdminImportUnitsComponent } from './tools/import-units/import-units.component';
import { RdioScannerAdminPasswordComponent } from './tools/password/password.component';
@NgModule({
declarations: [
@NgModule({ declarations: [
RdioScannerAdminComponent,
RdioScannerAdminConfigComponent,
RdioScannerAdminAccessComponent,
RdioScannerAdminApiKeysComponent,
RdioScannerAdminDirWatchComponent,
RdioScannerAdminApikeysComponent,
RdioScannerAdminDirwatchComponent,
RdioScannerAdminDownstreamsComponent,
RdioScannerAdminGroupsComponent,
RdioScannerAdminImportExportConfigComponent,
@ -60,6 +60,7 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
RdioScannerAdminLogsComponent,
RdioScannerAdminOptionsComponent,
RdioScannerAdminPasswordComponent,
RdioScannerAdminSiteComponent,
RdioScannerAdminSystemComponent,
RdioScannerAdminSystemsComponent,
RdioScannerAdminSystemsSelectComponent,
@ -69,9 +70,5 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
RdioScannerAdminToolsComponent,
RdioScannerAdminUnitComponent,
],
entryComponents: [RdioScannerAdminSystemsSelectComponent],
exports: [RdioScannerAdminComponent],
imports: [AppSharedModule, HttpClientModule],
providers: [RdioScannerAdminService],
})
exports: [RdioScannerAdminComponent], imports: [AppSharedModule], providers: [RdioScannerAdminService, provideHttpClient(withInterceptorsFromDi())] })
export class RdioScannerAdminModule { }

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -25,7 +25,7 @@ import { firstValueFrom, timer } from 'rxjs';
import { AppUpdateService } from '../../../shared/update/update.service';
export interface Access {
_id?: string;
id?: string;
code?: string;
expiration?: Date;
ident?: string;
@ -39,6 +39,17 @@ export interface Access {
}[] | number[] | '*';
}
export interface Alert {
begin: number;
end: number;
frequency: number;
type: OscillatorType;
}
export interface Alerts {
[key: string]: Alert[];
}
export interface AdminEvent {
authenticated?: boolean;
config?: Config;
@ -46,8 +57,8 @@ export interface AdminEvent {
passwordNeedChange?: boolean;
}
export interface ApiKey {
_id?: string;
export interface Apikey {
id?: string;
disabled?: boolean;
ident?: string;
key?: string;
@ -60,17 +71,18 @@ export interface ApiKey {
export interface Config {
access?: Access[];
apiKeys?: ApiKey[];
dirWatch?: DirWatch[];
apikeys?: Apikey[];
dirwatch?: Dirwatch[];
downstreams?: Downstream[];
groups?: Group[];
options?: Options;
systems?: System[];
tags?: Tag[];
version?: string;
}
export interface DirWatch {
_id?: string;
export interface Dirwatch {
id?: string;
delay?: number;
deleteAfter?: boolean;
directory?: string;
@ -79,14 +91,15 @@ export interface DirWatch {
frequency?: number;
mask?: string;
order?: number;
siteId?: number;
systemId?: number;
talkgroupId?: number;
type?: string;
}
export interface Downstream {
_id?: string;
apiKey?: string;
id?: string;
apikey?: string;
disabled?: boolean;
order?: number;
systems?: {
@ -101,12 +114,15 @@ export interface Downstream {
}
export interface Group {
_id?: number;
id?: number;
alert?: string;
label?: string;
led?: string;
order?: number;
}
export interface Log {
_id: number;
id?: number;
dateTime: Date;
level: number;
message: string;
@ -129,58 +145,79 @@ export interface LogsQueryOptions {
}
export interface Options {
afsSystems?: string;
audioConversion?: 0 | 1 | 2 | 3;
autoPopulate?: boolean;
branding?: string;
dimmerDelay?: number;
disableDuplicateDetection?: boolean;
duplicateDetectionTimeFrame?: number;
email?: string;
keypadBeeps?: string;
maxClients?: number;
playbackGoesLive?: boolean;
pruneDays?: number;
searchPatchedTalkgroups?: boolean;
showListenersCount?: boolean;
sortTalkgroups?: boolean;
tagsToggle?: boolean;
time12hFormat?: boolean;
}
export interface Site {
id?: number | null;
label?: string;
order?: number;
siteRef?: number;
}
export interface System {
_id?: number;
id?: number | null;
alert?: string;
autoPopulate?: boolean;
blacklists?: string;
id?: number;
delay?: number;
label?: string;
led?: string | null;
order?: number | null;
sites?: Site[];
systemRef?: number;
talkgroups?: Talkgroup[];
type?: string;
units?: Unit[];
}
export interface Tag {
_id?: number;
id?: number;
alert?: string;
label?: string;
led?: string;
order?: number;
}
export interface Talkgroup {
id?: number | null;
alert?: string;
delay?: number;
frequency?: number | null;
groupId?: number;
id?: number;
groupIds?: number[];
label?: string;
led?: string | null;
name?: string;
order?: number;
tagId?: number;
talkgroupRef?: number;
type?: string;
}
export interface Unit {
id?: number | null;
label?: string;
order?: number;
unitRef?: number;
unitFrom?: number;
unitTo?: number;
}
enum url {
alerts = 'alerts',
config = 'config',
login = 'login',
logout = 'logout',
@ -190,8 +227,16 @@ enum url {
const SESSION_STORAGE_KEY = 'rdio-scanner-admin-token';
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
@Injectable()
export class RdioScannerAdminService implements OnDestroy {
Alerts: Alerts | undefined;
event = new EventEmitter<AdminEvent>();
get authenticated() {
@ -206,6 +251,8 @@ export class RdioScannerAdminService implements OnDestroy {
return this._passwordNeedChange;
}
private audioContext: AudioContext | undefined;
private configWebSocket: WebSocket | undefined;
private _docker = false;
@ -308,6 +355,19 @@ export class RdioScannerAdminService implements OnDestroy {
}
}
async loadAlerts(): Promise<void> {
try {
this.Alerts = await firstValueFrom(this.ngHttpClient.get<Alerts>(
this.getUrl(url.alerts),
{ headers: this.getHeaders(), responseType: 'json' },
));
} catch (error) {
this.errorHandler(error);
}
}
async login(password: string): Promise<boolean> {
try {
const res = await firstValueFrom(this.ngHttpClient.post<{
@ -362,6 +422,47 @@ export class RdioScannerAdminService implements OnDestroy {
}
}
async playAlert(name: string): Promise<void> {
return new Promise((resolve) => {
if (this.audioContext === undefined) {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
}
if (this.Alerts !== undefined && name in this.Alerts) {
const ctx = this.audioContext;
const seq = this.Alerts[name];
const gn = ctx.createGain();
gn.gain.value = .1;
gn.connect(ctx.destination);
seq.forEach((beep, index) => {
const osc = ctx.createOscillator();
osc.connect(gn);
osc.frequency.value = beep.frequency;
osc.type = beep.type;
if (index === seq.length - 1) {
osc.onended = () => resolve();
}
osc.start(ctx.currentTime + beep.begin);
osc.stop(ctx.currentTime + beep.end);
});
} else {
resolve();
}
});
}
async saveConfig(config: Config): Promise<Config> {
try {
const res = await firstValueFrom(this.ngHttpClient.put<{ config: Config }>(
@ -381,134 +482,161 @@ export class RdioScannerAdminService implements OnDestroy {
newAccessForm(access?: Access): FormGroup {
return this.ngFormBuilder.group({
_id: [access?._id],
code: [access?.code, [Validators.required, this.validateAccessCode()]],
expiration: [access?.expiration],
ident: [access?.ident, Validators.required],
limit: [access?.limit],
order: [access?.order],
systems: [access?.systems, Validators.required],
id: this.ngFormBuilder.nonNullable.control(access?.id),
code: this.ngFormBuilder.nonNullable.control(access?.code, [Validators.required, this.validateAccessCode()]),
expiration: this.ngFormBuilder.nonNullable.control(access?.expiration),
ident: this.ngFormBuilder.nonNullable.control(access?.ident, Validators.required),
limit: this.ngFormBuilder.nonNullable.control(access?.limit),
order: this.ngFormBuilder.nonNullable.control(access?.order),
systems: this.ngFormBuilder.nonNullable.control(access?.systems),
});
}
newApiKeyForm(apiKey?: ApiKey): FormGroup {
newApikeyForm(apikey?: Apikey): FormGroup {
return this.ngFormBuilder.group({
_id: [apiKey?._id],
disabled: [apiKey?.disabled],
ident: [apiKey?.ident, Validators.required],
key: [apiKey?.key, [Validators.required, this.validateApiKey()]],
order: [apiKey?.order],
systems: [apiKey?.systems, Validators.required],
id: this.ngFormBuilder.control(apikey?.id),
disabled: this.ngFormBuilder.control(apikey?.disabled),
ident: this.ngFormBuilder.control(apikey?.ident, Validators.required),
key: this.ngFormBuilder.control(apikey?.key, [Validators.required, this.validateApikey()]),
order: this.ngFormBuilder.control(apikey?.order),
systems: this.ngFormBuilder.control(apikey?.systems, Validators.required),
});
}
newConfigForm(config?: Config): FormGroup {
return this.ngFormBuilder.group({
access: this.ngFormBuilder.array(config?.access?.map((access) => this.newAccessForm(access)) || []),
apiKeys: this.ngFormBuilder.array(config?.apiKeys?.map((apiKey) => this.newApiKeyForm(apiKey)) || []),
dirWatch: this.ngFormBuilder.array(config?.dirWatch?.map((dirWatch) => this.newDirWatchForm(dirWatch)) || []),
apikeys: this.ngFormBuilder.array(config?.apikeys?.map((apikey) => this.newApikeyForm(apikey)) || []),
dirwatch: this.ngFormBuilder.array(config?.dirwatch?.map((dirwatch) => this.newDirwatchForm(dirwatch)) || []),
downstreams: this.ngFormBuilder.array(config?.downstreams?.map((downstream) => this.newDownstreamForm(downstream)) || []),
groups: this.ngFormBuilder.array(config?.groups?.map((group) => this.newGroupForm(group)) || []),
options: this.newOptionsForm(config?.options),
systems: this.ngFormBuilder.array(config?.systems?.map((system) => this.newSystemForm(system)) || []),
tags: this.ngFormBuilder.array(config?.tags?.map((tag) => this.newTagForm(tag)) || []),
version: this.ngFormBuilder.control(config?.version),
});
}
newDirWatchForm(dirWatch?: DirWatch): FormGroup {
newDirwatchForm(dirwatch?: Dirwatch): FormGroup {
return this.ngFormBuilder.group({
_id: [dirWatch?._id],
delay: [typeof dirWatch?.delay === 'number' ? Math.max(2000, dirWatch?.delay) : 2000],
deleteAfter: [dirWatch?.deleteAfter],
directory: [dirWatch?.directory, [Validators.required, this.validateDirectory()]],
disabled: [dirWatch?.disabled],
extension: [dirWatch?.extension, this.validateExtension()],
frequency: [dirWatch?.frequency, Validators.min(0)],
mask: [dirWatch?.mask, this.validateMask()],
order: [dirWatch?.order],
systemId: [dirWatch?.systemId, this.validateDirwatchSystemId()],
talkgroupId: [dirWatch?.talkgroupId, this.validateDirwatchTalkgroupId()],
type: [dirWatch?.type],
id: this.ngFormBuilder.control(dirwatch?.id),
delay: this.ngFormBuilder.control(typeof dirwatch?.delay === 'number' ? Math.max(2000, dirwatch?.delay) : 2000),
deleteAfter: this.ngFormBuilder.control(dirwatch?.deleteAfter),
directory: this.ngFormBuilder.control(dirwatch?.directory, [Validators.required, this.validateDirectory()]),
disabled: this.ngFormBuilder.control(dirwatch?.disabled),
extension: this.ngFormBuilder.control(dirwatch?.extension, this.validateExtension()),
frequency: this.ngFormBuilder.control(dirwatch?.frequency, Validators.min(1)),
mask: this.ngFormBuilder.control(dirwatch?.mask, this.validateMask()),
order: this.ngFormBuilder.control(dirwatch?.order),
siteId: this.ngFormBuilder.control(dirwatch?.siteId),
systemId: this.ngFormBuilder.control(dirwatch?.systemId, this.validateDirwatchSystemId()),
talkgroupId: this.ngFormBuilder.control(dirwatch?.talkgroupId, this.validateDirwatchTalkgroupId()),
type: this.ngFormBuilder.control(dirwatch?.type ?? 'default'),
});
}
newDownstreamForm(downstream?: Downstream): FormGroup {
return this.ngFormBuilder.group({
_id: [downstream?._id],
apiKey: [downstream?.apiKey, [Validators.required, this.validateApiKey()]],
disabled: [downstream?.disabled],
order: [downstream?.order],
systems: [downstream?.systems, Validators.required],
url: [downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]],
id: this.ngFormBuilder.control(downstream?.id),
apikey: this.ngFormBuilder.control(downstream?.apikey, [Validators.required, this.validateApikey()]),
disabled: this.ngFormBuilder.control(downstream?.disabled),
order: this.ngFormBuilder.control(downstream?.order),
systems: this.ngFormBuilder.control(downstream?.systems, Validators.required),
url: this.ngFormBuilder.control(downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]),
});
}
newGroupForm(group?: Group): FormGroup {
return this.ngFormBuilder.group({
_id: [group?._id],
label: [group?.label, Validators.required],
});
}
newTagForm(tag?: Tag): FormGroup {
return this.ngFormBuilder.group({
_id: [tag?._id],
label: [tag?.label, Validators.required],
});
}
newSystemForm(system?: System): FormGroup {
return this.ngFormBuilder.group({
_id: [system?._id],
autoPopulate: [system?.autoPopulate],
blacklists: [system?.blacklists, this.validateBlacklists()],
id: [system?.id, [Validators.required, Validators.min(1), this.validateId()]],
label: [system?.label, Validators.required],
led: [system?.led],
order: [system?.order],
talkgroups: this.ngFormBuilder.array(system?.talkgroups?.map((talkgroup) => this.newTalkgroupForm(talkgroup)) || []),
units: this.ngFormBuilder.array(system?.units?.map((unit) => this.newUnitForm(unit)) || []),
});
}
newTalkgroupForm(talkgroup?: Talkgroup): FormGroup {
return this.ngFormBuilder.group({
frequency: [talkgroup?.frequency, Validators.min(0)],
groupId: [talkgroup?.groupId, [Validators.required, this.validateGroup()]],
id: [talkgroup?.id, [Validators.required, Validators.min(1), this.validateId()]],
label: [talkgroup?.label, Validators.required],
led: [talkgroup?.led],
name: [talkgroup?.name, Validators.required],
order: [talkgroup?.order],
tagId: [talkgroup?.tagId, [Validators.required, this.validateTag()]],
});
}
newUnitForm(unit?: Unit): FormGroup {
return this.ngFormBuilder.group({
id: [unit?.id, [Validators.required, Validators.min(0), this.validateId()]],
label: [unit?.label, Validators.required],
order: [unit?.order],
id: this.ngFormBuilder.control(group?.id),
alert: this.ngFormBuilder.control(group?.alert || ''),
label: this.ngFormBuilder.control(group?.label, Validators.required),
led: this.ngFormBuilder.control(group?.led || ''),
order: this.ngFormBuilder.control(group?.order),
});
}
newOptionsForm(options?: Options): FormGroup {
return this.ngFormBuilder.group({
afsSystems: [options?.afsSystems, this.validateAfsSystems()],
audioConversion: [options?.audioConversion],
autoPopulate: [options?.autoPopulate],
dimmerDelay: [options?.dimmerDelay, [Validators.required, Validators.min(0)]],
disableDuplicateDetection: [options?.disableDuplicateDetection],
duplicateDetectionTimeFrame: [options?.duplicateDetectionTimeFrame, [Validators.required, Validators.min(0)]],
keypadBeeps: [options?.keypadBeeps, Validators.required],
maxClients: [options?.maxClients, [Validators.required, Validators.min(1)]],
playbackGoesLive: [options?.playbackGoesLive],
pruneDays: [options?.pruneDays, [Validators.required, Validators.min(0)]],
searchPatchedTalkgroups: [options?.searchPatchedTalkgroups],
showListenersCount: [options?.showListenersCount],
sortTalkgroups: [options?.sortTalkgroups],
tagsToggle: [options?.tagsToggle],
time12hFormat: [options?.time12hFormat],
audioConversion: this.ngFormBuilder.control(options?.audioConversion),
autoPopulate: this.ngFormBuilder.control(options?.autoPopulate),
branding: this.ngFormBuilder.control(options?.branding),
dimmerDelay: this.ngFormBuilder.control(options?.dimmerDelay, [Validators.required, Validators.min(0)]),
disableDuplicateDetection: this.ngFormBuilder.control(options?.disableDuplicateDetection),
duplicateDetectionTimeFrame: this.ngFormBuilder.control(options?.duplicateDetectionTimeFrame, [Validators.required, Validators.min(0)]),
email: this.ngFormBuilder.control(options?.email),
keypadBeeps: this.ngFormBuilder.control(options?.keypadBeeps, Validators.required),
maxClients: this.ngFormBuilder.control(options?.maxClients, [Validators.required, Validators.min(1)]),
playbackGoesLive: this.ngFormBuilder.control(options?.playbackGoesLive),
pruneDays: this.ngFormBuilder.control(options?.pruneDays, [Validators.required, Validators.min(0)]),
showListenersCount: this.ngFormBuilder.control(options?.showListenersCount),
sortTalkgroups: this.ngFormBuilder.control(options?.sortTalkgroups),
time12hFormat: this.ngFormBuilder.control(options?.time12hFormat),
});
}
newSiteForm(site?: Site): FormGroup {
return this.ngFormBuilder.group({
id: this.ngFormBuilder.control(site?.id),
label: this.ngFormBuilder.control(site?.label, Validators.required),
order: this.ngFormBuilder.control(site?.order),
siteRef: this.ngFormBuilder.control(site?.siteRef, [Validators.required, Validators.min(1), this.validateSiteRef()]),
});
}
newSystemForm(system?: System): FormGroup {
return this.ngFormBuilder.group({
id: this.ngFormBuilder.control(system?.id),
alert: this.ngFormBuilder.control(system?.alert),
autoPopulate: this.ngFormBuilder.control(system?.autoPopulate),
blacklists: this.ngFormBuilder.control(system?.blacklists, this.validateBlacklists()),
delay: this.ngFormBuilder.control(system?.delay),
label: this.ngFormBuilder.control(system?.label, Validators.required),
led: this.ngFormBuilder.control(system?.led || ''),
order: this.ngFormBuilder.control(system?.order),
sites: this.ngFormBuilder.array(system?.sites?.map((site) => this.newSiteForm(site)) || []),
systemRef: this.ngFormBuilder.control(system?.systemRef, [Validators.required, Validators.min(1), this.validateSystemRef()]),
talkgroups: this.ngFormBuilder.array(system?.talkgroups?.map((talkgroup) => this.newTalkgroupForm(talkgroup)) || []),
type: this.ngFormBuilder.control(system?.type || ''),
units: this.ngFormBuilder.array(system?.units?.map((unit) => this.newUnitForm(unit)) || []),
});
}
newTagForm(tag?: Tag): FormGroup {
return this.ngFormBuilder.group({
id: this.ngFormBuilder.control(tag?.id),
alert: this.ngFormBuilder.control(tag?.alert || ''),
label: this.ngFormBuilder.control(tag?.label, Validators.required),
led: this.ngFormBuilder.control(tag?.led || ''),
order: this.ngFormBuilder.control(tag?.order),
});
}
newTalkgroupForm(talkgroup?: Talkgroup): FormGroup {
return this.ngFormBuilder.group({
id: this.ngFormBuilder.control(talkgroup?.id),
alert: this.ngFormBuilder.control(talkgroup?.alert),
delay: this.ngFormBuilder.control(talkgroup?.delay),
frequency: this.ngFormBuilder.control(talkgroup?.frequency, Validators.min(0)),
groupIds: this.ngFormBuilder.control(talkgroup?.groupIds, [Validators.required, this.validateGroup()]),
label: this.ngFormBuilder.control(talkgroup?.label, Validators.required),
led: this.ngFormBuilder.control(talkgroup?.led || ''),
name: this.ngFormBuilder.control(talkgroup?.name, Validators.required),
order: this.ngFormBuilder.control(talkgroup?.order),
tagId: this.ngFormBuilder.control(talkgroup?.tagId, [Validators.required, this.validateTag()]),
talkgroupRef: this.ngFormBuilder.control(talkgroup?.talkgroupRef, [Validators.required, Validators.min(1), this.validateTalkgroupRef()]),
type: this.ngFormBuilder.control(talkgroup?.type || ''),
});
}
newUnitForm(unit?: Unit): FormGroup {
return this.ngFormBuilder.group({
id: this.ngFormBuilder.control(unit?.id),
label: this.ngFormBuilder.control(unit?.label, Validators.required),
order: this.ngFormBuilder.control(unit?.order),
unitRef: this.ngFormBuilder.control(unit?.unitRef, [Validators.min(1), this.validateUnitRef()]),
unitFrom: this.ngFormBuilder.control(unit?.unitFrom, [Validators.min(1), this.validateUnitFrom()]),
unitTo: this.ngFormBuilder.control(unit?.unitTo, [Validators.min(1), this.validateUnitTo()])
});
}
@ -601,21 +729,15 @@ export class RdioScannerAdminService implements OnDestroy {
};
}
private validateAfsSystems(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return typeof control.value === 'string' && control.value.length ? /^[0-9]+(,[0-9]+)*$/.test(control.value) ? null : { invalid: true } : null;
};
}
private validateApiKey(): ValidatorFn {
private validateApikey(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {
return null;
}
const apiKeys: ApiKey[] = control.parent?.parent?.getRawValue() || [];
const apikeys: Apikey[] = control.parent?.parent?.getRawValue() || [];
const count = apiKeys.reduce((c, a) => c += a.key === control.value ? 1 : 0, 0);
const count = apikeys.reduce((c, a) => c += a.key === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
@ -633,9 +755,13 @@ export class RdioScannerAdminService implements OnDestroy {
return null;
}
const dirWatch: DirWatch[] = control.parent?.parent?.getRawValue() || [];
if (control.value.startsWith('\\')) {
return { network: true }
}
const count = dirWatch.reduce((c, a) => c += a.directory === control.value ? 1 : 0, 0);
const dirwatch: Dirwatch[] = control.parent?.parent?.getRawValue() || [];
const count = dirwatch.reduce((c, a) => c += a.directory === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
@ -649,7 +775,7 @@ export class RdioScannerAdminService implements OnDestroy {
const type = dirwatch.type;
return ['sdr-trunk'].includes(type) || control.value !== null || /#SYS/.test(mask) ? null : { required: true };
return ['dsdplus', 'sdr-trunk', 'trunk-recorder'].includes(type) || control.value !== null || /#SYS/.test(mask) ? null : { required: true };
};
}
@ -661,7 +787,7 @@ export class RdioScannerAdminService implements OnDestroy {
const type = dirwatch.type;
return ['trunk-recorder', 'sdr-trunk'].includes(type) || control.value !== null || /#TG/.test(mask) ? null : { required: true };
return ['dsdplus', 'sdr-trunk', 'trunk-recorder'].includes(type) || control.value !== null || /#TG/.test(mask) ? null : { required: true };
};
}
@ -695,35 +821,21 @@ export class RdioScannerAdminService implements OnDestroy {
return null;
}
const groupIds = control.root.get('groups')?.value.map((group: Group) => group._id);
const groupIds = control.root.get('groups')?.value.map((group: Group) => group.id);
return groupIds ? groupIds.includes(control.value) ? null : { required: true } : null;
};
}
private validateId(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === null || typeof control.value !== 'number') {
return null;
}
const systems: System[] = control.parent?.parent?.getRawValue() || [];
const count = systems.reduce((c, s) => c += s.id === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateMask(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string') {
return null;
}
const masks = ['#DATE', '#GROUP', '#HZ', '#KHZ', '#MHZ', '#SYS', '#SYSLBL', '#TAG', '#TG', '#TGAFS', '#TGHZ', '#TGKHZ', '#TGLBL', '#TGMHZ', '#TIME', '#UNIT', '#ZTIME'];
const masks = ['#DATE', '#GROUP', '#HZ', '#KHZ', '#MHZ', '#SITE', '#SITELBL', '#SYS', '#SYSLBL', '#TAG', '#TG', '#TGAFS', '#TGHZ', '#TGKHZ', '#TGLBL', '#TGMHZ', '#TIME', '#UNIT', '#UNITLBL', '#ZTIME'];
const metas = control.value.match(/(#[A-Z]+)/g) || [];
const metas = control.value.match(/(#[A-Z]+)/g) || [] as string[];
const count = metas.reduce((c, m) => {
if (masks.includes(m)) {
@ -737,18 +849,118 @@ export class RdioScannerAdminService implements OnDestroy {
};
}
private validateSiteRef(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === null || typeof control.value !== 'number') {
return null;
}
const sites: Site[] = control.parent?.parent?.getRawValue() || [];
const count = sites.reduce((c, s) => c += s.siteRef === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateSystemRef(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === null || typeof control.value !== 'number') {
return null;
}
const systems: System[] = control.parent?.parent?.getRawValue() || [];
const count = systems.reduce((c, s) => c += control.value !== null && control.value > 0 && s.systemRef === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateTag(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'number') {
return null;
}
const tagIds = control.root.get('tags')?.value.map((tag: Tag) => tag._id);
const tagIds = control.root.get('tags')?.value.map((tag: Tag) => tag.id);
return tagIds ? tagIds.includes(control.value) ? null : { required: true } : null;
};
}
private validateTalkgroupRef(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (control.value === null || typeof control.value !== 'number') {
return null;
}
const talkgroups: Talkgroup[] = control.parent?.parent?.getRawValue() || [];
const count = talkgroups.reduce((c, t) => c += t.talkgroupRef === control.value ? 1 : 0, 0);
return count > 1 ? { duplicate: true } : null;
};
}
private validateUnitRef(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const unitRef = control.parent?.get('unitRef')?.value;
const unitFrom = control.parent?.get('unitFrom')?.value;
const unitTo = control.parent?.get('unitTo')?.value;
const units: Unit[] = control.parent?.parent?.getRawValue() || [];
const count = units.reduce((c, u) => c += u.unitRef === unitRef ? 1 : 0, 0);
return unitRef === null && (unitFrom === null || unitTo === null)
? { required: true }
: count > 1
? { duplicate: true }
: null;
};
}
private validateUnitFrom(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const unitFrom = control.value;
const unitTo = control.parent?.get('unitTo')?.value;
if (typeof unitFrom === 'number' && typeof unitTo === 'number' && unitFrom >= unitTo) {
return { range: true };
}
setTimeout(() => {
control.parent?.get('unitRef')?.updateValueAndValidity();
control.parent?.get('unitTo')?.updateValueAndValidity();
});
return null;
}
}
private validateUnitTo(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const unitFrom = control.parent?.get('unitFrom')?.value;
const unitTo = control.value;
if (typeof unitFrom === 'number' && typeof unitTo === 'number' && unitFrom >= unitTo) {
return { range: true };
}
setTimeout(() => {
control.parent?.get('unitRef')?.updateValueAndValidity();
control.parent?.get('unitFrom')?.updateValueAndValidity();
});
return null;
}
}
private validateUrl(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) {

View file

@ -1,95 +1,111 @@
<div class="row top">
<p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with
specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored
internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button>
<p class="mat-body">You can decide to leave your instance unlocked and accessible to everyone, or set passwords with
specific systems/talkgroups. When used, the user will be prompted once for their password. It is then stored
internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button>
</div>
<p *ngIf="!accesses.length" class="mat-small text-center">No access is defined, your instance is open and accessible to
all</p>
@if (!accesses.length) {
<p class="mat-small text-center">No access is defined, your instance is open and accessible to
all</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let access of accesses; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ access.value.ident || 'NewAccess' }}
<mat-icon *ngIf="access.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="access">
<div class="row">
<p>
<span class="mat-body">Code</span><br>
<span class="mat-caption">The access code to unlock the client.</span>
</p>
<mat-form-field floatLabel="never">
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
<mat-error *ngIf="access.get('code')?.hasError('duplicate')">
Code is already defined
</mat-error>
<mat-error *ngIf="access.get('code')?.hasError('required')">
Code is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
whom.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="access.get('ident')?.hasError('required')">
Ident is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Expiration</span><br>
<span class="mat-caption">Expiration date for this access code.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
<mat-datepicker #expirationDate></mat-datepicker>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Limit</span><br>
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
with this access code exceeds the defined limit.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
The access code allows access to <u>
<ng-container *ngIf="access.value.systems === '*'">all</ng-container>
<ng-container *ngIf="access.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete access
</button>
</div>
</ng-container>
</mat-expansion-panel>
@for (access of accesses; track access; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ access.value.ident || 'NewAccess' }}
@if (access.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="access">
<div class="row">
<p>
<span class="mat-body">Code</span><br>
<span class="mat-caption">The access code to unlock the client.</span>
</p>
<mat-form-field floatLabel="auto">
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</button>
@if (access.get('code')?.hasError('duplicate')) {
<mat-error>
Code is already defined
</mat-error>
}
@if (access.get('code')?.hasError('required')) {
<mat-error>
Code is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for
whom.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident">
@if (access.get('ident')?.hasError('required')) {
<mat-error>
Ident is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Expiration</span><br>
<span class="mat-caption">Expiration date for this access code.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="expiration" placeholder="Expiration date"
[matDatepicker]="expirationDate" (click)="expirationDate.open()">
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
<mat-datepicker #expirationDate></mat-datepicker>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Limit</span><br>
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
with this access code exceeds the defined limit.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
The access code allows access to <u>
@if (access.value.systems === '*') {
all
}
@if (access.value.systems !== '*') {
some
}
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="access.disabled" (click)="select(access)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete access
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({
selector: 'rdio-scanner-admin-access',
templateUrl: './access.component.html',
standalone: false
})
export class RdioScannerAdminAccessComponent {
@Input() form: FormArray | undefined;

View file

@ -33,10 +33,10 @@
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon>
</button>
<mat-error *ngIf="apiKey.get('key')?.hasError('duplicate')">
<mat-error *ngIf="apiKey.get('key').hasError('duplicate')">
Key is already defined
</mat-error>
<mat-error *ngIf="apiKey.get('key')?.hasError('required')">
<mat-error *ngIf="apiKey.get('key').hasError('required')">
Key is required
</mat-error>
</mat-form-field>
@ -49,7 +49,7 @@
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="apiKey.get('ident')?.hasError('required')">
<mat-error *ngIf="apiKey.get('ident').hasError('required')">
Ident is required
</mat-error>
</mat-form-field>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -0,0 +1,96 @@
<div class="row top">
<p class="mat-body">API keys are used for recorder upload scripts and downstream instances. Each of them must
authenticate with an API key to exchange audio files.</p>
<button type="button" mat-button color="accent" (click)="add()">New API key</button>
</div>
@if (!apikeys.length) {
<p class="mat-small text-center">No defined API keys</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="apikeys" (cdkDropListDropped)="drop($event)">
@for (apikey of apikeys; track apikey; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ apikey.value.ident || 'NewApikey' }}
@if (apikey.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="apikey">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the API key.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Key</span><br>
<span class="mat-caption">Api key.</span>
</p>
<mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="key" placeholder="Key">
<button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon>
</button>
@if (apikey.get('key')!.hasError('duplicate')) {
<mat-error>
Key is already defined
</mat-error>
}
@if (apikey.get('key')!.hasError('required')) {
<mat-error>
Key is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Ident</span><br>
<span class="mat-caption">Dummy identifier which serves only to identify this API key is for
whom.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident">
@if (apikey.get('ident')!.hasError('required')) {
<mat-error>
Ident is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This API key allows access to <u>
@if (apikey.value.systems === '*') {
all
}
@if (apikey.value.systems !== '*') {
some
}
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="apikey.disabled" (click)="select(apikey)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete API key
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion>

View file

@ -0,0 +1,105 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2026 Chrystian Huot <chrystian@huot.qc.ca>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select.component';
@Component({
selector: 'rdio-scanner-admin-apikeys',
templateUrl: './apikeys.component.html',
standalone: false
})
export class RdioScannerAdminApikeysComponent {
@Input() form: FormArray | undefined;
get apikeys(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) { }
add(): void {
const apikey = this.adminService.newApikeyForm({
key: this.uuid(),
systems: '*',
});
apikey.markAllAsTouched();
this.form?.insert(0, apikey);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
copy(inputElement: HTMLInputElement): void {
navigator.clipboard.writeText(inputElement.value)
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
select(access: FormGroup): void {
const matDialogRef = this.matDialog.open(RdioScannerAdminSystemsSelectComponent, { data: access });
matDialogRef.afterClosed().subscribe((data) => {
if (data) {
access.get('systems')?.setValue(data);
access.markAsDirty();
}
});
}
private uuid() {
let dt = new Date().getTime();
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (dt + Math.random() * 16) % 16 | 0;
dt = Math.floor(dt / 16);
return (c == 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
}

View file

@ -1,90 +1,90 @@
<form *ngIf="form" autocomplete="off" [formGroup]="form" (ngSubmit)="save()">
<mat-accordion displayMode="flat">
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>manage_accounts</mat-icon>
Access
<mat-icon *ngIf="form?.get('access')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="apiKeyComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>vpn_key</mat-icon>
ApiKey
<mat-icon *ngIf="form?.get('apiKey')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-api-keys #apiKeyComponent [form]="apiKeys"></rdio-scanner-admin-api-keys>
</mat-expansion-panel>
<mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirWatchComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>folder</mat-icon>
Dirwatch
<mat-icon *ngIf="form?.get('dirWatch')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-dir-watch #dirWatchComponent [form]="dirWatch"></rdio-scanner-admin-dir-watch>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>share</mat-icon>
Downstreams
<mat-icon *ngIf="form?.get('downstreams')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>workspaces</mat-icon>
Groups
<mat-icon *ngIf="form?.get('groups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>tune</mat-icon>
Options
<mat-icon *ngIf="form?.get('options')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>podcasts</mat-icon>
Systems
<mat-icon *ngIf="form?.get('systems')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>sell</mat-icon>
Tags
<mat-icon *ngIf="form?.get('tags')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-raised-button [disabled]="form?.disabled || form?.pristine"
(click)="reset()">Reset</button>
<button type="submit" mat-raised-button color="primary"
[disabled]="form?.disabled || form?.pristine || !form?.valid">Save</button>
</div>
<form *ngIf="form as f" autocomplete="off" [formGroup]="f" (ngSubmit)="save()">
<mat-accordion displayMode="flat">
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>manage_accounts</mat-icon>
Access
<mat-icon *ngIf="f.get('access')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="apikeyComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>vpn_key</mat-icon>
ApiKey
<mat-icon *ngIf="f.get('apikey')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-apikeys #apikeyComponent [form]="apikeys"></rdio-scanner-admin-apikeys>
</mat-expansion-panel>
<mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirwatchComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>folder</mat-icon>
Dirwatch
<mat-icon *ngIf="f.get('dirwatch')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-dirwatch #dirwatchComponent [form]="dirwatch"></rdio-scanner-admin-dirwatch>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>share</mat-icon>
Downstreams
<mat-icon *ngIf="f.get('downstreams')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>workspaces</mat-icon>
Groups
<mat-icon *ngIf="f.get('groups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>tune</mat-icon>
Options
<mat-icon *ngIf="f.get('options')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
</mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="systemsComponent.closeAll()">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>podcasts</mat-icon>
Systems
<mat-icon *ngIf="f.get('systems')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
</mat-expansion-panel>
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon>sell</mat-icon>
Tags
<mat-icon *ngIf="f.get('tags')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-raised-button [disabled]="f.disabled || f.pristine"
(click)="reset()">Reset</button>
<button type="submit" mat-raised-button color="primary"
[disabled]="f.disabled || f.pristine || !f.valid">Save</button>
</div>
</form>

View file

@ -25,7 +25,7 @@ rdio-scanner-admin-config {
padding-left: 1rem;
}
.mat-button:last-child {
.mat-mdc-button:last-child {
margin-left: auto;
}
}
@ -52,7 +52,7 @@ rdio-scanner-admin-config {
}
@media (max-width: 719px) {
rdio-scanner-admin-config .mat-form-field {
rdio-scanner-admin-config .mat-mdc-form-field {
flex: 100%;
}
}
@ -60,13 +60,13 @@ rdio-scanner-admin-config {
@media (min-width: 720px) {
.row {
> p ~ div,
.mat-form-field {
.mat-mdc-form-field {
min-width: 260px;
}
> p ~ div > {
.mat-button,
.mat-slide-toggle {
.mat-mdc-button,
.mat-mdc-slide-toggle {
float: right;
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -21,6 +21,8 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnIni
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -28,9 +30,10 @@ import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
selector: 'rdio-scanner-admin-config',
styleUrls: ['./config.component.scss'],
templateUrl: './config.component.html',
standalone: false
})
export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
docker: boolean = false;
docker = false;
form: FormGroup | undefined;
@ -38,12 +41,12 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
return this.form?.get('access') as FormArray;
}
get apiKeys(): FormArray {
return this.form?.get('apiKeys') as FormArray;
get apikeys(): FormArray {
return this.form?.get('apikeys') as FormArray;
}
get dirWatch(): FormArray {
return this.form?.get('dirWatch') as FormArray;
get dirwatch(): FormArray {
return this.form?.get('dirwatch') as FormArray;
}
get downstreams(): FormArray {
@ -68,38 +71,42 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
private config: Config | undefined;
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('authenticated' in event && event.authenticated === true) {
this.config = await this.adminService.getConfig();
this.reset();
}
if ('config' in event) {
this.config = event.config;
if (this.form?.pristine) {
this.reset();
}
}
if ('docker' in event) {
this.docker = event.docker!;
}
});
private readonly destroy$ = new Subject<void>();
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(
private adminService: RdioScannerAdminService,
private ngChangeDetectorRef: ChangeDetectorRef,
) { }
) {
this.adminService.event.pipe(takeUntil(this.destroy$)).subscribe(async (event: AdminEvent) => {
if ('authenticated' in event && event.authenticated === true) {
this.config = await this.adminService.getConfig();
this.reset();
}
if ('config' in event) {
this.config = event.config;
if (this.form?.pristine) {
this.reset();
}
}
if ('docker' in event) {
this.docker = event.docker ?? false;
}
});
}
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();
this.destroy$.next();
this.destroy$.complete();
}
async ngOnInit(): Promise<void> {
await this.adminService.loadAlerts();
this.config = await this.adminService.getConfig();
this.reset();
@ -112,28 +119,34 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
reset(config = this.config, options?: { dirty?: boolean }): void {
this.form = this.adminService.newConfigForm(config);
this.form.statusChanges.subscribe(() => {
this.form.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.ngChangeDetectorRef.markForCheck();
});
this.groups.valueChanges.subscribe(() => {
this.systems.controls.forEach((system) => {
const systemsArray = this.systems;
const groupsArray = this.groups;
const tagsArray = this.tags;
groupsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (!systemsArray) { return; }
systemsArray.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => {
const groupId = talkgroup.get('groupId') as FormControl;
const groupIds = talkgroup.get('groupIds') as FormArray;
groupId.updateValueAndValidity({ onlySelf: true });
groupIds.updateValueAndValidity({ onlySelf: true });
if (groupId.errors) {
groupId.markAsTouched({ onlySelf: true });
if (groupIds.errors) {
groupIds.markAsTouched({ onlySelf: true });
}
});
});
});
this.tags.valueChanges.subscribe(() => {
this.systems.controls.forEach((system) => {
tagsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (!systemsArray) { return; }
systemsArray.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => {
@ -156,8 +169,10 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
}
async save(): Promise<void> {
this.form?.markAsPristine();
if (!this.form) { return; }
await this.adminService.saveConfig(this.form?.getRawValue());
this.form.markAsPristine();
await this.adminService.saveConfig(this.form.getRawValue());
}
}

View file

@ -33,12 +33,48 @@
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Dirwatch type defines how the metadata are obtained.
<ul>
<li><b>Default</b> - Extract the metadata from a custom mask.</li>
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's aliases tab.</li>
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="default">Default</mat-option>
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Directory</span><br>
<span class="mat-caption">
Path of a local directory to monitor for file ingestion. Note that dirwatch is not compatible
with networked disk.
Path of the
<ng-container [ngSwitch]="dirWatch.get('type')?.value">
<ng-container *ngSwitchCase="'dsdplus'">
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
</ng-container>
<ng-container *ngSwitchCase="'sdr-trunk'">
<b>Recordings</b>
</ng-container>
<ng-container *ngSwitchCase="'trunk-recorder'">
<b>captureDir</b>
</ng-container>
<ng-container *ngSwitchDefault>
local
</ng-container>
</ng-container>
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked disk.
</span>
</p>
<mat-form-field floatLabel="never">
@ -49,9 +85,12 @@
<mat-error *ngIf="dirWatch.get('directory')?.hasError('duplicate')">
Directory is already defined
</mat-error>
<mat-error *ngIf="dirWatch.get('directory')?.hasError('network')">
Incompatible network folder
</mat-error>
</mat-form-field>
</div>
<div class="row">
<div class="row" *ngIf="['default','dsdplus','trunk-recorder'].includes(dirWatch.get('type')?.value)">
<p>
<span class="mat-body">Extension</span><br>
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
@ -64,29 +103,14 @@
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">When SDR Trunk, metadata are obtained from the MP3 tags. Note that
the label of the SDR Trunk system must match the label of the Rdio Scanner system label. When
trunk-recorder, metadata are obtained from the JSON file.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="type" placeholder="Type">
<mat-option [value]="null">Default</mat-option>
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
<mat-option value="sdr-trunk">SDR Trunk (aliases tab only)</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<div class="row" *ngIf="['default','dsdplus'].includes(dirWatch.get('type')?.value)">
<p>
<span class="mat-body">System</span><br>
<span class="mat-caption">System to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select #sysId formControlName="systemId" placeholder="System">
<mat-option [value]="null">Metatag #SYS</mat-option>
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let system of systems" [value]="system.value.id">
{{ system.value.label }}
</mat-option>
@ -96,14 +120,14 @@
</mat-error>
</mat-form-field>
</div>
<div class="row">
<div class="row" *ngIf="['default','dsdplus'].includes(dirWatch.get('type')?.value)">
<p>
<span class="mat-body">Talkgroup</span><br>
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
<mat-option [value]="null">Metatag #TG</mat-option>
<mat-option [value]="null"></mat-option>
<mat-option *ngFor="let talkgroup of talkgroups[dirWatch.value.systemId] || []"
[value]="talkgroup.value.id">
{{ talkgroup.value.label }}
@ -114,7 +138,7 @@
</mat-error>
</mat-form-field>
</div>
<div class="row">
<div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
<p>
<span class="mat-body">Mask</span><br>
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
@ -156,7 +180,7 @@
</mat-error>
</mat-form-field>
</div>
<div class="row">
<div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
@ -168,7 +192,7 @@
</mat-error>
</mat-form-field>
</div>
<div class="row">
<div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -0,0 +1,282 @@
<div class="row top">
<p class="mat-body">Define a dirwatch to monitor new audio files from any directory.</p>
<button type="button" mat-button color="accent" (click)="add()">New dir watch</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="dirwatches"
(cdkDropListDropped)="drop($event)">
@for (dirwatch of dirwatches; track dirwatch; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ dirwatch.value.directory || 'NewDirwatch' }}
@if (dirwatch.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="dirwatch">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the dirwatch.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Delete After</span><br>
<span class="mat-caption">Delete the audio file after being ingested. If activated, all pre-existing
audio files will be ingested and deleted as soon as the server starts. If not activated,
pre-existing audio files are neither ingested nor deleted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Dirwatch type defines how the metadata are obtained.
<ul>
<li><b>Default</b> - Extract the metadata from a custom mask.</li>
<li><b>DSDPlus Fast Lane</b> - Extract the metadata from the file path.</li>
<li><b>SDR Trunk</b> - Extract the metadata from the MP3 tags defined on the SDR Trunk's
aliases tab.</li>
<li><b>Trunk Recorder</b> - Extract the metadata from the json file.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="default">Default</mat-option>
<mat-option value="dsdplus">DSDPlus Fast Lane</mat-option>
<mat-option value="sdr-trunk">SDR Trunk</mat-option>
<mat-option value="trunk-recorder">Trunk Recorder</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Directory</span><br>
<span class="mat-caption">
Path of the
@switch (dirwatch.get('type')?.value) {
@case ('dsdplus') {
<b>Record</b>, <b>1R-Record</b> or <b>VC-Record</b>
}
@case ('sdr-trunk') {
<b>Recordings</b>
}
@case ('trunk-recorder') {
<b>captureDir</b>
}
@default {
local
}
}
directory to monitor for file ingestion. Note that dirwatch is not compatible with networked
disk.
</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="directory" placeholder="Directory">
@if (dirwatch.get('directory')?.hasError('required')) {
<mat-error>
Directory is required
</mat-error>
}
@if (dirwatch.get('directory')?.hasError('duplicate')) {
<mat-error>
Directory is already defined
</mat-error>
}
@if (dirwatch.get('directory')?.hasError('network')) {
<mat-error>
Incompatible network folder
</mat-error>
}
</mat-form-field>
</div>
@if (['default','dsdplus','trunk-recorder'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Extension</span><br>
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
"wav".</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="extension" placeholder="Extension">
@if (dirwatch.get('extension')?.hasError('invalid')) {
<mat-error>
Invalid extension
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">System</span><br>
<span class="mat-caption">System to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select #sysId formControlName="systemId" placeholder="System">
<mat-option [value]="null"></mat-option>
@for (system of systems; track system) {
@if (system.value.id > 0) {
<mat-option [value]="system.value.id">
{{ system.value.label }}
</mat-option>
}
}
</mat-select>
@if (dirwatch.get('systemId')?.hasError('required')) {
<mat-error>
System is required
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Site</span><br>
<span class="mat-caption">Site to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="siteId" placeholder="Site">
<mat-option [value]="null"></mat-option>
@for (site of sites[dirwatch.value.systemId] || []; track site) {
@if (site.value.id > 0) {
<mat-option [value]="site.value.id">
{{ site.value.label }}
</mat-option>
}
}
</mat-select>
</mat-form-field>
</div>
}
@if (['default','dsdplus'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Talkgroup</span><br>
<span class="mat-caption">Talkgroup to where the audio files should go.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="talkgroupId" placeholder="Talkgroup">
<mat-option [value]="null"></mat-option>
@for (talkgroup of talkgroups[dirwatch.value.systemId] || []; track talkgroup) {
@if (talkgroup.value.id > 0) {
<mat-option [value]="talkgroup.value.id">
{{ talkgroup.value.label }}
</mat-option>
}
}
</mat-select>
@if (dirwatch.get('talkgroupId')?.hasError('required')) {
<mat-error>
Talkgroup is required
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Mask</span><br>
<span class="mat-caption">Some metadata can be extracted from the file name of the audio file using
specific META tags. Possible META tags:
<ul>
<li><b>#DATE</b> - extract the date like 20201231&nbsp;(YYYYMMMDD),
2020-12-31&nbsp;(YYYY-MM-DD) or 2020_12_31&nbsp;(YYYY_MM_DD).
</li>
<li><b>#GROUP</b> - extract the group label.</li>
<li><b>#HZ</b> - extract the frequency in hertz like 119100000.</li>
<li><b>#KHZ</b> - extract the frequency in kilohertz like 119100.</li>
<li><b>#MHZ</b> - extract the frequency in megahertz like 119.100.</li>
<li><b>#SITE</b> - extract the site id like 1.</li>
<li><b>#SITELBL</b> - extract the site label.</li>
<li><b>#SYS</b> - extract the system id like 11.</li>
<li><b>#SYSLBL</b> - extract the system label.</li>
<li><b>#TAG</b> - extract the tag label.</li>
<li><b>#TG</b> - extract the talkgroup id like 1457 in decimal format.</li>
<li><b>#TGAFS</b> - extract the talkgroup id like 11-061 in AFS (agency-fleet-subfleet)
format.</li>
<li><b>#TGHZ</b> - extract the frequency in hertz like 119100000 and set the talkgroup id to
119100.</li>
<li><b>#TGKHZ</b> - extract the frequency in kilohertz like 119100.000 or 119100 and set the
talkgroup id to 119100.</li>
<li><b>#TGLBL</b> - extract the talkgroup label.</li>
<li><b>#TGMHZ</b> - extract the frequency in megahertz like 119.100000 or 119 and set the
talkgroup id to 119100.</li>
<li><b>#TIME</b> - extract the local time like 0853439&nbsp;(HHMMSS),
08-34-39&nbsp;(HH-MM-SS) or 08:34:39&nbsp;(HH:MM:SS).</li>
<li><b>#UNIT</b> - extract the unit id like 4424001.</li>
<li><b>#UNITLBL</b> - extract the unit label. Also requires #UNIT.</li>
<li><b>#ZTIME</b> - extract the zulu time like 0453439&nbsp;(HHMMSS),
08-34-39&nbsp;(HH-MM-SS) or 04:34:39&nbsp;(HH:MM:SS).</li>
</ul>
Example: cymx_#TG_#DATE_#TIME_#HZ
</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="mask" placeholder="Mask">
@if (dirwatch.get('mask')?.hasError('invalid')) {
<mat-error>
Invalid mask
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" matInput formControlName="frequency" placeholder="Frequency">
@if (dirwatch.get('frequency')?.errors) {
<mat-error>
Invalid frequency
</mat-error>
}
</mat-form-field>
</div>
}
@if (['default'].includes(dirwatch.get('type')?.value)) {
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the
recorder has created the file. You can set a delay value in milliseconds for the audio file to
settle before ingesting it.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" matInput formControlName="delay" min="2000" placeholder="Delay">
@if (dirwatch.get('delay')?.hasError('min')) {
<mat-error>
Delay value cannot be less than 2000 milliseconds.
</mat-error>
}
</mat-form-field>
</div>
}
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete dirwatch
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion>

View file

@ -0,0 +1,128 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2026 Chrystian Huot <chrystian@huot.qc.ca>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, OnChanges, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-dirwatch',
templateUrl: './dirwatch.component.html',
standalone: false
})
export class RdioScannerAdminDirwatchComponent implements OnChanges {
@Input() form: FormArray | undefined;
get dirwatches(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
get sites(): FormGroup[][] {
return this.systems.reduce((sites, system) => {
const faSites = system.get('sites') as FormArray;
sites[system.value.id] = faSites.controls as FormGroup[];
return sites;
}, [] as FormGroup[][]);
}
get systems(): FormGroup[] {
const systems = this.form?.root.get('systems') as FormArray;
return systems.controls as FormGroup[];
}
get talkgroups(): FormGroup[][] {
return this.systems.reduce((talkgroups, system) => {
const faTalkgroups = system.get('talkgroups') as FormArray;
talkgroups[system.value.id] = faTalkgroups.controls as FormGroup[];
return talkgroups;
}, [] as FormGroup[][]);
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
ngOnChanges(): void {
if (this.form) {
this.dirwatches.flatMap((control) => this.registerOnChanges(control));
}
}
add(): void {
const dirwatch = this.adminService.newDirwatchForm({
delay: 2000,
deleteAfter: true,
});
dirwatch.markAllAsTouched();
this.registerOnChanges(dirwatch);
this.form?.insert(0, dirwatch);
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
}
private registerOnChanges(control: FormGroup): void {
const mask = control.get('mask') as FormControl;
const type = control.get('type') as FormControl;
mask.valueChanges.subscribe(() => this.validateIds(control));
type.valueChanges.subscribe(() => this.validateIds(control));
}
private validateIds(control: FormGroup): void {
const systemId = control.get('systemId');
const talkgroupId = control.get('talkgroupId');
systemId?.updateValueAndValidity();
systemId?.markAsTouched();
talkgroupId?.updateValueAndValidity();
talkgroupId?.markAsTouched();
}
}

View file

@ -1,81 +1,101 @@
<div class="row top">
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p>
<button type="button" mat-button color="accent" (click)="add()">New downstream</button>
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p>
<button type="button" mat-button color="accent" (click)="add()">New downstream</button>
</div>
<p *ngIf="!downstreams.length" class="mat-small text-center">No defined downstreams</p>
@if (!downstreams.length) {
<p class="mat-small text-center">No defined downstreams</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let downstream of downstreams; index as i" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ downstream.value.url || 'NewDownstream' }}
<mat-icon *ngIf="downstream.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="downstream">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the downstream.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">API Key</span><br>
<span class="mat-caption">Api key of the remote instance.</span>
</p>
<mat-form-field floatLabel="never">
<input #key type="text" matInput formControlName="apiKey" placeholder="API key">
<mat-error *ngIf="downstream.get('apiKey')?.hasError('duplicate')">
API key is already defined
</mat-error>
<mat-error *ngIf="downstream.get('apiKey')?.hasError('required')">
API key is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">URL</span><br>
<span class="mat-caption">URL of the remote instance.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="url" placeholder="URL">
<mat-error *ngIf="downstream.get('url')?.hasError('required')">
URL is required
</mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('invalid')">
URL is invalid
</mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('duplicate')">
URL is already defined
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This downstream allows access to <u>
<ng-container *ngIf="downstream.value.systems === '*'">all</ng-container>
<ng-container *ngIf="downstream.value.systems !== '*'">some</ng-container>
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete downstream
</button>
</div>
</ng-container>
</mat-expansion-panel>
@for (downstream of downstreams; track downstream; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ downstream.value.url || 'NewDownstream' }}
@if (downstream.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="downstream">
<div class="row">
<p>
<span class="mat-body">Disabled</span><br>
<span class="mat-caption">Disable the downstream.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disabled"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">API Key</span><br>
<span class="mat-caption">Api key of the remote instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="apikey" placeholder="API key">
@if (downstream.get('apikey')?.hasError('duplicate')) {
<mat-error>
API key is already defined
</mat-error>
}
@if (downstream.get('apikey')?.hasError('required')) {
<mat-error>
API key is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">URL</span><br>
<span class="mat-caption">URL of the remote instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="url" placeholder="URL">
@if (downstream.get('url')?.hasError('required')) {
<mat-error>
URL is required
</mat-error>
}
@if (downstream.get('url')?.hasError('invalid')) {
<mat-error>
URL is invalid
</mat-error>
}
@if (downstream.get('url')?.hasError('duplicate')) {
<mat-error>
URL is already defined
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Access</span><br>
<span class="mat-caption">
This downstream allows access to <u>
@if (downstream.value.systems === '*') {
all
}
@if (downstream.value.systems !== '*') {
some
}
</u> systems and talkgroups.
</span>
</p>
<div>
<button type="button" mat-button [disabled]="downstream.disabled" (click)="select(downstream)">
Choose systems
</button>
</div>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete downstream
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({
selector: 'rdio-scanner-admin-downstreams',
templateUrl: './downstreams.component.html',
standalone: false
})
export class RdioScannerAdminDownstreamsComponent {
@Input() form: FormArray | undefined;

View file

@ -1,18 +1,68 @@
<div class="row top">
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New group</button>
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New Group</button>
</div>
<div class="groups">
<div *ngFor="let group of groups; index as i" [formGroup]="group">
<button type="button" mat-icon-button color="warn" (click)="remove(i)">
<mat-icon>clear</mat-icon>
</button>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Group">
<mat-error *ngIf="groups[i].get('label')?.hasError('required')">
Group is required
</mat-error>
<p *ngIf="!groups.length" class="mat-small text-center">No defined Groups</p>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="groups"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let group of groups; let i = index" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ group.value.label || 'NewGroup' }}
<mat-icon *ngIf="group.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="group">
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Group label displayed on the main screen, on the search panel and on the
select panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="group.get('label')!.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned to this group. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete Group
</button>
</div>
</ng-container>
</mat-expansion-panel>
</mat-accordion>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,40 +17,101 @@
* ****************************************************************************
*/
import { Component, Input } from '@angular/core';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren, OnDestroy } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'rdio-scanner-admin-groups',
styleUrls: ['./groups.component.scss'],
templateUrl: './groups.component.html',
standalone: false
})
export class RdioScannerAdminGroupsComponent {
@Input() form: FormArray | undefined;
export class RdioScannerAdminGroupsComponent implements OnDestroy {
private _form: FormArray | undefined;
private formSub: Subscription | undefined;
private cachedGroups: FormGroup[] | undefined;
private cacheValid = false;
readonly leds: string[] = [];
readonly alertsArray: string[] = [];
get groups(): FormGroup[] {
return this.form?.controls
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[];
@Input()
set form(value: FormArray | undefined) {
this._form = value;
this.cacheValid = false;
this.cachedGroups = undefined;
this.formSub?.unsubscribe();
if (this._form) {
this.formSub = this._form.valueChanges.subscribe(() => {
this.cacheValid = false;
});
}
}
get form(): FormArray | undefined {
return this._form;
}
constructor(private adminService: RdioScannerAdminService) { }
get alerts(): string[] {
return this.alertsArray;
}
get groups(): FormGroup[] {
if (this.cacheValid && this.cachedGroups) return this.cachedGroups;
const controls = (this._form?.controls?.slice() || []) as FormGroup[];
this.cachedGroups = controls.sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
this.cacheValid = true;
return this.cachedGroups;
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) {
this.leds = this.adminService.getLeds();
this.alertsArray = Object.keys(this.adminService.Alerts || {});
}
add(): void {
const id = this.groups.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0);
const group = this.adminService.newGroupForm({ _id: id });
const group = this.adminService.newGroupForm();
group.markAllAsTouched();
this.form?.insert(0, group);
this.form?.markAsDirty();
this.cacheValid = false;
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
this.cacheValid = false;
}
}
async playAlert(event: MatSelectChange): Promise<void> {
if (event.value) await this.adminService.playAlert(event.value);
}
remove(index: number): void {
this.form?.removeAt(index);
this.form?.markAsDirty();
this.cacheValid = false;
}
ngOnDestroy(): void {
this.formSub?.unsubscribe();
}
}

View file

@ -1,179 +1,168 @@
<ng-container *ngIf="form" [formGroup]="form">
<ng-container *ngIf="form">
<ng-container [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">12-Hour Time Format</span><br>
<span class="mat-caption">Display time stamp in 12-hour format.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">12-Hour Time Format</span><br>
<span class="mat-caption">Display time stamp in 12-hour format.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">AFS Systems</span><br>
<span class="mat-caption">A comma separated list of system Ids where talkgroup Ids should be displayed in
AFS (agency-fleet-subfleet) format.</span>
</p>
<mat-form-field floatLabel="never">
<textarea type="text" matInput formControlName="afsSystems" placeholder="AFS systems"></textarea>
<mat-error *ngIf="form?.get('afsSystems')?.hasError('invalid')">
Comma separated list of system Ids
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Audio Conversion</span><br>
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="audioConversion" placeholder="Audio Conversion">
<mat-option [value]="0">Disabled</mat-option>
<mat-option [value]="1">Enabled without normalization</mat-option>
<mat-option [value]="2">Enabled with normalization</mat-option>
<mat-option [value]="3">Enabled with loud normalization</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Audio Conversion</span><br>
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="audioConversion" placeholder="Audio Conversion">
<mat-option [value]="0">Disabled</mat-option>
<mat-option [value]="1">Enabled without normalization</mat-option>
<mat-option [value]="2">Enabled with normalization</mat-option>
<mat-option [value]="3">Enabled with loud normalization</mat-option>
</mat-select>
</mat-form-field>
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globally allows the automatic creation of unconfigured systems and
talkgroups.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globaly allows the automatic creation of unconfigured systems and
talkgroups.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Branding Label</span><br>
<span class="mat-caption">A simple line of text displayed above the main screen to help identify the Rdio
Scanner instance.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="branding" placeholder="Branding">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Dimmer Delay</span><br>
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
inactive.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required
</mat-error>
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Dimmer Delay</span><br>
<span class="mat-caption">Delay in milliseconds before turning off the screen backlight when
inactive.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
<mat-error *ngIf="form.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required
</mat-error>
<mat-error *ngIf="form.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Disable Duplicate Call Detection</span><br>
<span class="mat-caption">Disable duplicate audio file detection.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Disable Duplicate Call Detection</span><br>
<span class="mat-caption">Disable duplicate audio file detection.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="disableDuplicateDetection"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Duplicate Call Detection Time Frame</span><br>
<span class="mat-caption">New incoming calls will be rejected if a call already exists in the database with
a start time of +/- this delay in milliseconds.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('required')">
Duplicate call time frame is required
</mat-error>
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('min')">
Duplicate call time frame is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Duplicate Call Detection Time Frame</span><br>
<span class="mat-caption">New incoming calls will be rejected if a call already exists in the database with
a start time of +/- this delay in milliseconds.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
<mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('required')">
Duplicate call time frame is required
</mat-error>
<mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('min')">
Duplicate call time frame is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option>
</mat-select>
</mat-form-field>
<p>
<span class="mat-body">Email Support</span><br>
<span class="mat-caption">Email address where users can write to to get support.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="email" placeholder="Email">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Max Clients</span><br>
<span class="mat-caption">Max number of simultaneous clients.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="maxClients">
<mat-error *ngIf="form?.get('maxClients')?.hasError('required')">
Max clients is required
</mat-error>
<mat-error *ngIf="form?.get('maxClients')?.hasError('min')">
Max clients is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Playback Mode Goes Live</span><br>
<span class="mat-caption">Playback mode changes to live stream mode when the search list is
exhausted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Max Clients</span><br>
<span class="mat-caption">Max number of simultaneous clients.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="maxClients">
<mat-error *ngIf="form.get('maxClients')?.hasError('required')">
Max clients is required
</mat-error>
<mat-error *ngIf="form.get('maxClients')?.hasError('min')">
Max clients is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Prune Days</span><br>
<span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to
disable.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="pruneDays">
<mat-error *ngIf="form?.get('pruneDays')?.hasError('required')">
Prune days is required
</mat-error>
<mat-error *ngIf="form?.get('pruneDays')?.hasError('min')">
Prune days is invalid
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Playback Mode Goes Live</span><br>
<span class="mat-caption">Playback mode changes to live stream mode when the search list is
exhausted.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="playbackGoesLive"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Search Patched Talkgroups</span><br>
<span class="mat-caption">Search for patched talkgroups when using the search panel. Be aware this has a
negative impact on search response time.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="searchPatchedTalkgroups"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Prune Days</span><br>
<span class="mat-caption">Prune the database for data older than the specified number of days. Set to 0 to
disable.</span>
</p>
<mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="pruneDays">
<mat-error *ngIf="form.get('pruneDays')?.hasError('required')">
Prune days is required
</mat-error>
<mat-error *ngIf="form.get('pruneDays')?.hasError('min')">
Prune days is invalid
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Show Listeners Count</span><br>
<span class="mat-caption">Show listeners count on main screen.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Show Listeners Count</span><br>
<span class="mat-caption">Show listeners count on main screen.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="showListenersCount"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Sort Talkgroups</span><br>
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div>
</div>
<div class="row">
<p>
<span class="mat-body">Toggle By Tags</span><br>
<span class="mat-caption">Allow toggling talkgroups by their tag.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="tagsToggle"></mat-slide-toggle>
</div>
<p>
<span class="mat-body">Sort Talkgroups</span><br>
<span class="mat-caption">Automatically sort talkgroups by their id.</span>
</p>
<div>
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div>
</div>
</ng-container>
</ng-container>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,13 +17,19 @@
* ****************************************************************************
*/
import { Component, Input } from '@angular/core';
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({
selector: 'rdio-scanner-admin-options',
templateUrl: './options.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RdioScannerAdminOptionsComponent {
@Input() form: FormGroup | undefined;
@Input() form: FormGroup | null = null;
get controls() {
return this.form ? this.form.controls : {};
}
}

View file

@ -1,50 +1,57 @@
<div>
<div mat-dialog-title>Systems and talkgroups selection</div>
<div [formGroup]="select" mat-dialog-content>
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.everything">
Everything
</mat-checkbox>
<h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups">
<div *ngFor="let group of configGroups; index as groupIndex" [formGroupName]="groupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.groups[groupIndex]">
{{ group.value.label }}
</mat-checkbox>
</div>
</div>
<h3 class="mat-caption">Tags</h3>
<div class="flex-row wrap" formArrayName="tags">
<div *ngFor="let tag of configTags; index as tagIndex" [formGroupName]="tagIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.tags[tagIndex]">
{{ tag.value.label }}
</mat-checkbox>
</div>
</div>
<h3 class="mat-caption">Systems</h3>
<div formArrayName="systems">
<div *ngFor="let system of configSystems; index as systemIndex" [formGroupName]="systemIndex">
<div class="flex-row margin">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.systems[systemIndex]">
{{ system.value.label }}
</mat-checkbox>
</div>
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
<div *ngFor="let talkgroup of configTalkgroups[systemIndex]; index as talkgroupIndex"
[formGroupName]="talkgroupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
{{ talkgroup.value.label }}
</mat-checkbox>
</div>
</div>
</div>
<div mat-dialog-title>Systems and talkgroups selection</div>
<div [formGroup]="select" mat-dialog-content>
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.everything">
Everything
</mat-checkbox>
<h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups">
<ng-container *ngFor="let group of configGroups; let groupIndex = index">
<div [formGroupName]="groupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.groups[groupIndex]">
{{ group.value.label }}
</mat-checkbox>
</div>
</ng-container>
</div>
<div mat-dialog-actions>
<button mat-raised-button (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
<h3 class="mat-caption">Tags</h3>
<div class="flex-row wrap" formArrayName="tags">
<ng-container *ngFor="let tag of configTags; let tagIndex = index">
<div [formGroupName]="tagIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.tags[tagIndex]">
{{ tag.value.label }}
</mat-checkbox>
</div>
</ng-container>
</div>
<h3 class="mat-caption">Systems</h3>
<div formArrayName="systems">
<ng-container *ngFor="let system of configSystems; let systemIndex = index">
<div [formGroupName]="systemIndex">
<div class="flex-row margin">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.systems[systemIndex]">
{{ system.value.label }}
</mat-checkbox>
</div>
<div class="flex-row wrap indent margin" formArrayName="talkgroups">
<ng-container *ngFor="let talkgroup of configTalkgroups[systemIndex]; let talkgroupIndex = index">
<div [formGroupName]="talkgroupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
{{ talkgroup.value.label }}
</mat-checkbox>
</div>
</ng-container>
</div>
</div>
</ng-container>
</div>
</div>
<div mat-dialog-actions>
<button mat-raised-button (click)="cancel()">Cancel</button>
<button mat-raised-button color="primary" (click)="accept()">Ok</button>
</div>
</div>

View file

@ -35,18 +35,7 @@ rdio-scanner-admin-systems-selection {
margin-bottom: 1rem;
}
.mat-checkbox,
.mat-checkbox-layout {
max-width: 100%;
}
.mat-checkbox-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.mat-dialog-actions {
.mat-mdc-dialog-actions {
justify-content: space-between;
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,10 +17,11 @@
* ****************************************************************************
*/
import { ChangeDetectorRef, Component, Inject, ViewEncapsulation } from '@angular/core';
import { Component, Inject, ViewEncapsulation, OnDestroy } from '@angular/core';
import { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Access } from '../../../admin.service';
import { Subscription } from 'rxjs';
interface System {
all: boolean;
@ -38,8 +39,9 @@ interface Talkgroup {
selector: 'rdio-scanner-admin-systems-selection',
styleUrls: ['./select.component.scss'],
templateUrl: './select.component.html',
standalone: false
})
export class RdioScannerAdminSystemsSelectComponent {
export class RdioScannerAdminSystemsSelectComponent implements OnDestroy {
indeterminate = {
everything: false,
groups: [] as boolean[],
@ -47,233 +49,196 @@ export class RdioScannerAdminSystemsSelectComponent {
tags: [] as boolean[],
};
select = this.ngFormBuilder.group({
all: this.ngFormBuilder.control(false),
groups: this.ngFormBuilder.array([]),
tags: this.ngFormBuilder.array([]),
systems: this.ngFormBuilder.array([]),
});
select: FormGroup;
configTalkgroups = this.configSystems.map((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls as FormGroup[];
});
configTalkgroups: FormGroup[][];
get configGroups(): FormGroup[] {
const faGroups = this.access.root.get('groups') as FormArray;
return faGroups.controls as FormGroup[];
}
get configSystems(): FormGroup[] {
const faSystems = this.access.root.get('systems') as FormArray;
return faSystems.controls as FormGroup[];
}
get configTags(): FormGroup[] {
const faTags = this.access.root.get('tags') as FormArray;
return faTags.controls as FormGroup[];
}
private subs = new Subscription();
constructor(
@Inject(MAT_DIALOG_DATA) public access: FormGroup,
private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder,
) {
this.configTalkgroups = this.configSystems.map((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls as FormGroup[];
});
this.select = this.ngFormBuilder.group({
all: this.ngFormBuilder.nonNullable.control(false),
groups: this.ngFormBuilder.nonNullable.array<FormGroup>([]),
tags: this.ngFormBuilder.nonNullable.array<FormGroup>([]),
systems: this.ngFormBuilder.nonNullable.array<FormGroup>([]),
});
const fcAll = this.select.get('all') as FormControl;
const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
const faTags = this.select.get('tags') as FormArray;
this.configGroups.forEach((configGroup) => {
for (const configGroup of this.configGroups) {
const fgGroup = this.ngFormBuilder.group({
_id: [configGroup.value._id],
checked: [false],
id: this.ngFormBuilder.control(configGroup.get('id')?.value),
checked: this.ngFormBuilder.control(false),
});
faGroups.push(fgGroup);
fgGroup.valueChanges.subscribe((vGroup) => {
faSystems.controls.forEach((fgSystem) => {
this.subs.add(fgGroup.valueChanges.subscribe((vGroup) => {
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.groupId === vGroup._id && fgTalkgroup.value.checked !== vGroup.checked) {
for (const fgTalkgroup of faTalkgroups.controls) {
const ids = fgTalkgroup.get('groupIds')?.value;
if (ids && ids.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== vGroup.checked) {
fgTalkgroup.get('checked')?.setValue(vGroup.checked);
}
});
});
});
});
}
}
}));
}
this.configSystems.forEach((configSystem, index) => {
for (let index = 0; index < this.configSystems.length; index++) {
const configSystem = this.configSystems[index];
const fcSystemAll = this.ngFormBuilder.control(false);
const faSystemTalkgroups = this.ngFormBuilder.array([]);
const faSystemTalkgroups = this.ngFormBuilder.array<FormGroup>([]);
const fgSystem = this.ngFormBuilder.group({
all: fcSystemAll,
id: configSystem.value.id,
id: this.ngFormBuilder.control(configSystem.get('systemRef')?.value),
talkgroups: faSystemTalkgroups
});
this.configTalkgroups[index].forEach((configTalkgroup) => {
for (const configTalkgroup of this.configTalkgroups[index]) {
const fgSystemTalkgroup = this.ngFormBuilder.group({
checked: [false],
groupId: configTalkgroup.value.groupId,
id: configTalkgroup.value.id,
tagId: configTalkgroup.value.tagId
checked: this.ngFormBuilder.nonNullable.control(false),
groupIds: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('groupIds')?.value),
id: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('talkgroupRef')?.value),
tagId: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('tagId')?.value),
});
faSystemTalkgroups.push(fgSystemTalkgroup);
fgSystemTalkgroup.valueChanges.subscribe(() => {
const vAll = faSystemTalkgroups.controls.every((systemTalkgroup) => systemTalkgroup.value.checked);
this.subs.add(fgSystemTalkgroup.valueChanges.subscribe(() => {
const vAll = faSystemTalkgroups.controls.every((t) => t.get('checked')?.value);
fcSystemAll.setValue(vAll, { emitEvent: false });
});
});
}));
}
faSystems.push(fgSystem);
fgSystem.valueChanges.subscribe(() => {
this.subs.add(fgSystem.valueChanges.subscribe(() => {
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
}));
fcSystemAll.valueChanges.subscribe((vAll) => {
this.subs.add(fcSystemAll.valueChanges.subscribe((vAll) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => fgTalkgroup.get('checked')?.setValue(vAll));
for (const fgTalkgroup of faTalkgroups.controls) {
fgTalkgroup.get('checked')?.setValue(vAll);
}
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
}));
faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let off = 0;
this.subs.add(faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let on = 0;
vSystemTalkgroups.forEach((vSystemTalkgroup: Talkgroup) => {
if (vSystemTalkgroup.checked) {
on++;
} else {
off++;
}
});
let off = 0;
for (const v of vSystemTalkgroups) {
if (v.checked) on++; else off++;
}
this.indeterminate.systems[index] = !!off && !!on;
faSystems.at(index).get('all')?.setValue(!off && on, { emitEvent: false });
});
});
}));
}
this.configTags.forEach((configTag) => {
for (const configTag of this.configTags) {
const fgTag = this.ngFormBuilder.group({
_id: [configTag.value._id],
checked: [false],
id: this.ngFormBuilder.control(configTag.value.id),
checked: this.ngFormBuilder.control(false),
});
faTags.push(fgTag);
fgTag.valueChanges.subscribe((vTag) => {
faSystems.controls.forEach((fgSystem) => {
this.subs.add(fgTag.valueChanges.subscribe((vTag) => {
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.tagId === vTag._id && fgTalkgroup.value.checked !== vTag.checked) {
for (const fgTalkgroup of faTalkgroups.controls) {
if (fgTalkgroup.value.tagId === vTag.id && fgTalkgroup.value.checked !== vTag.checked) {
fgTalkgroup.get('checked')?.setValue(vTag.checked);
}
});
});
});
});
fcAll.valueChanges.subscribe((vAll) => {
faSystems.controls.flatMap((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls;
}).concat(faGroups.controls, faTags.controls).forEach((control) => {
control.get('checked')?.setValue(vAll);
});
});
faSystems.valueChanges.subscribe((vSystems) => {
let off = 0;
let on = 0;
vSystems.forEach((vSystem: System) => {
if (vSystem.all) {
on++;
} else {
off++;
}
}
});
}));
}
this.subs.add(fcAll.valueChanges.subscribe((vAll) => {
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
fgTalkgroup.get('checked')?.setValue(vAll);
}
}
for (const fg of faGroups.controls) fg.get('checked')?.setValue(vAll);
for (const fg of faTags.controls) fg.get('checked')?.setValue(vAll);
}));
this.subs.add(faSystems.valueChanges.subscribe((vSystems: System[]) => {
let on = 0;
let off = 0;
for (const vSystem of vSystems) {
if (vSystem.all) on++; else off++;
}
this.indeterminate.everything = !!off && !!on;
fcAll.setValue(!off && on, { emitEvent: false });
});
}));
const vAccess: Access = this.access.value;
if (vAccess.systems === '*') {
this.select.get('all')?.setValue(true);
} else if (Array.isArray(vAccess.systems)) {
vAccess.systems.forEach((vSystem: { id: number; talkgroups: { id: number }[] | number[] | '*' } | number) => {
for (const vSystem of vAccess.systems) {
if (typeof vSystem === 'number') {
faSystems.controls.find((fgSystem) => fgSystem.value.id === vSystem)?.get('all')?.setValue(true);
} else if (vSystem !== null && typeof vSystem === 'object') {
const fgSystem = faSystems.controls.find((fg) => fg.value.id === vSystem.id);
faSystems.controls.find((fgSystem) => fgSystem.get('id')?.value === vSystem)?.get('all')?.setValue(true);
} else if (vSystem && typeof vSystem === 'object') {
const fgSystem = faSystems.controls.find((fg) => fg.get('id')?.value === vSystem.id);
if (fgSystem) {
if (vSystem.talkgroups === '*') {
fgSystem.get('all')?.setValue(true);
} else if (Array.isArray(vSystem.talkgroups)) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
vSystem.talkgroups.forEach((talkgroup: { id: number } | number) => {
for (const talkgroup of vSystem.talkgroups) {
const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id;
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.value.id === talkgroupId);
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.get('id')?.value === talkgroupId);
fgTalkgroup?.get('checked')?.setValue(true);
});
fgSystem?.updateValueAndValidity();
}
fgSystem.updateValueAndValidity();
}
}
}
});
}
}
}
accept(): void {
const select = this.select.value;
const access: Access['systems'] = select.all ? '*' : select.systems.filter((system: System) => {
return system.all || system.talkgroups.some((talkgroup: Talkgroup) => talkgroup.checked);
const access: Access['systems'] = this.select.get('all')?.value ? '*' : this.select.get('systems')?.value.filter((system: System) => {
return system['all'] || system['talkgroups'].some((talkgroup: Talkgroup) => talkgroup.checked);
}).map((system: System) => {
if (system.all) {
return {
id: system.id,
talkgroups: '*',
};
if (system['all']) {
return { id: system['id'], talkgroups: '*' };
} else {
return {
id: system.id,
talkgroups: system.talkgroups
id: system['id'],
talkgroups: system['talkgroups']
.filter((talkgroup: Talkgroup) => talkgroup.checked)
.map((talkgroup: Talkgroup) => talkgroup.id),
};
@ -289,61 +254,47 @@ export class RdioScannerAdminSystemsSelectComponent {
private rebuildGroupIndeterminates(): void {
const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
faGroups.controls.forEach((fgGroup, index) => {
let off = 0;
for (let index = 0; index < faGroups.length; index++) {
const fgGroup = faGroups.at(index);
let on = 0;
faSystems.controls.forEach((fgSystem) => {
let off = 0;
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.groupId === fgGroup.value._id) {
if (fgTalkgroup.value.checked) {
on++;
} else {
off++;
}
for (const fgTalkgroup of faTalkgroups.controls) {
if (fgTalkgroup.get('groupIds')?.value.includes(fgGroup.get('id')?.value)) {
if (fgTalkgroup.get('checked')?.value) on++; else off++;
}
});
});
}
}
this.indeterminate.groups[index] = !!off && !!on;
fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false });
});
}
}
private rebuildTagIndeterminates(): void {
const faTags = this.select.get('tags') as FormArray;
const faSystems = this.select.get('systems') as FormArray;
faTags.controls.forEach((fgTag, index) => {
let off = 0;
for (let index = 0; index < faTags.length; index++) {
const fgTag = faTags.at(index);
let on = 0;
faSystems.controls.forEach((fgSystem) => {
let off = 0;
for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
faTalkgroups.controls.forEach((fgTalkgroup) => {
if (fgTalkgroup.value.tagId === fgTag.value._id) {
if (fgTalkgroup.value.checked) {
on++;
} else {
off++;
}
for (const fgTalkgroup of faTalkgroups.controls) {
if (fgTalkgroup.value.tagId === fgTag.value.id) {
if (fgTalkgroup.value.checked) on++; else off++;
}
});
});
}
}
this.indeterminate.tags[index] = !!off && !!on;
fgTag.get('checked')?.setValue(!off && on, { emitEvent: false });
});
}
}
ngOnDestroy(): void {
this.subs.unsubscribe();
}
}

View file

@ -0,0 +1,43 @@
<ng-container *ngIf="form as f" [formGroup]="f">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Site identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<ng-container *ngIf="f.get('siteRef') as siteRef">
<input type="number" min="1" step="1" matInput formControlName="siteRef" placeholder="Id" />
<mat-error *ngIf="siteRef.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="siteRef.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="siteRef.hasError('required')">
Id is required
</mat-error>
</ng-container>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Site label.</span>
</p>
<mat-form-field floatLabel="auto">
<ng-container *ngIf="f.get('label') as labelCtrl">
<input type="text" matInput formControlName="label" placeholder="Label" />
<mat-error *ngIf="labelCtrl.hasError('required')">
Label is required
</mat-error>
</ng-container>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete site
</button>
</div>
</ng-container>

View file

@ -0,0 +1,52 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2026 Chrystian Huot <chrystian@huot.qc.ca>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, ChangeDetectorRef } from '@angular/core';
import { FormGroup, AbstractControl } from '@angular/forms';
@Component({
selector: 'rdio-scanner-admin-site',
templateUrl: './site.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RdioScannerAdminSiteComponent {
private _form: FormGroup | undefined;
@Input()
set form(f: FormGroup | undefined) {
this._form = f;
this.cd.markForCheck();
}
get form(): FormGroup | undefined {
return this._form;
}
get siteRef(): AbstractControl | null | undefined {
return this._form?.get('siteRef');
}
get labelControl(): AbstractControl | null | undefined {
return this._form?.get('label');
}
@Output() remove = new EventEmitter<void>();
constructor(private readonly cd: ChangeDetectorRef) {}
}

View file

@ -1,141 +1,228 @@
<ng-container [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" min="1" step="1" matInput formControlName="id" placeholder="Id">
<mat-error *ngIf="form.get('id')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('id')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('id')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">Indicator color when playing an audio file from this system. If not
specified, the indicator will light green.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="null">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
</p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<div class="row">
<p>
<span class="mat-body">Blacklists</span><br>
<span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only
has an effect if the auto populate option is enabled.</span>
</p>
<mat-form-field floatLabel="never">
<textarea type="text" matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
<mat-error *ngIf="form?.get('blacklists')?.hasError('invalid')">
Comma separated list of talkgroup Ids
</mat-error>
</mat-form-field>
</div>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id">
<mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('systemRef')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned from this system. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Allows the automatic creation of unconfigured talkgroups.</span>
</p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div>
<div class="row">
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Blacklists</span><br>
<span class="mat-caption">A comma separated list of talkgroup Ids to blacklist from this system. This only
has an effect if the auto populate option is enabled.</span>
</p>
<mat-form-field floatLabel="auto">
<textarea matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
<mat-error *ngIf="form.get('blacklists')!.hasError('invalid')">
Comma separated list of talkgroup Ids
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Type of radio system displayed on main screen.
<ul>
<li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option>
</mat-select>
</mat-form-field>
</div>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Sites
<mat-icon *ngIf="form.get('sites')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">System sites, you can drag and drop sites to rearrange them.</p>
<button type="button" mat-button color="accent" (click)="addSite()">New Site</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="sites"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let site of sites; let i = index; trackBy: trackById" [formGroup]="site" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
Talkgroups
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ site.value.label?.trim() || 'NewSite' }}
<mat-icon *ngIf="site.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They
will appear with the same order on the talkgroup selection panel unless the sort talkgroups
option is enabled.</p>
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="talkgroups"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; index as i" [formGroup]="talkgroup"
cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
<mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
(remove)="removeTalkgroup(i)">
</rdio-scanner-admin-talkgroup>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<rdio-scanner-admin-site [form]="site" (remove)="removeSite(i)"></rdio-scanner-admin-site>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Talkgroups
<mat-icon *ngIf="form.get('talkgroups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">System talkgroups, you can drag and drop talkgroups to rearrange them. They
will appear with the same order on the talkgroup selection panel unless the sort talkgroups
option is enabled.</p>
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64"
[cdkDropListData]="talkgroups" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; let i = index; trackBy: trackById" [formGroup]="talkgroup" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
Units
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ talkgroup.value.label?.trim() || 'NewTalkgroup' }}
<mat-icon *ngIf="talkgroup.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main
screen.<br>You can drag and drop them to rearrange</p>
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="units"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let unit of units; index as i" [formGroup]="unit" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ unit.value.label?.trim() || 'NewUnit' }}
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
</rdio-scanner-admin-unit>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<rdio-scanner-admin-talkgroup [form]="talkgroup" (blacklist)="blacklistTalkgroup(i)"
(remove)="removeTalkgroup(i)">
</rdio-scanner-admin-talkgroup>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete system
</button>
</div>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title>
Units
<mat-icon *ngIf="form.get('units')?.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="row top">
<p class="mat-caption">Create units to display the label instead of showing the unit id on the main
screen.<br>You can drag and drop them to rearrange</p>
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="units"
(cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let unit of units; let i = index; trackBy: trackById" [formGroup]="unit" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ unit.value.label?.trim() || 'NewUnit' }}
<mat-icon *ngIf="unit.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<rdio-scanner-admin-unit [form]="unit" (remove)="removeUnit(i)">
</rdio-scanner-admin-unit>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete system
</button>
</div>
</ng-container>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -21,11 +21,13 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, EventEmitter, Input, Output, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({
selector: 'rdio-scanner-admin-system',
templateUrl: './system.component.html',
standalone: false
})
export class RdioScannerAdminSystemComponent {
@Input() form = new FormGroup({});
@ -38,38 +40,58 @@ export class RdioScannerAdminSystemComponent {
@Output() remove = new EventEmitter<void>();
leds = this.adminService.getLeds();
leds: string[];
get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {});
}
get sites(): FormGroup[] {
const sites = this.form.get('sites') as FormArray | null;
return (sites?.controls as FormGroup[]) || [];
}
get talkgroups(): FormGroup[] {
const talkgroups = this.form.get('talkgroups') as FormArray;
return talkgroups.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
const talkgroups = this.form.get('talkgroups') as FormArray | null;
return (talkgroups?.controls as FormGroup[]) || [];
}
get units(): FormGroup[] {
const units = this.form.get('units') as FormArray;
const units = this.form.get('units') as FormArray | null;
return (units?.controls as FormGroup[]) || [];
}
return units.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
// trackBy to preserve identity in ngFor lists
trackById(_index: number, group: FormGroup): any {
return group.get('id')?.value ?? _index;
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
constructor(private adminService: RdioScannerAdminService) {
this.leds = this.adminService.getLeds();
}
addSite(): void {
const sites = this.form.get('sites') as FormArray | null;
sites?.insert(0, this.adminService.newSiteForm());
this.form.markAsDirty();
}
addTalkgroup(): void {
const talkgroups = this.form.get('talkgroups') as FormArray;
const talkgroups = this.form.get('talkgroups') as FormArray | null;
talkgroups.insert(0, this.adminService.newTalkgroupForm());
talkgroups?.insert(0, this.adminService.newTalkgroupForm());
this.form.markAsDirty();
}
addUnit(): void {
const units = this.form.get('units') as FormArray;
const units = this.form.get('units') as FormArray | null;
units.insert(0, this.adminService.newUnitForm());
units?.insert(0, this.adminService.newUnitForm());
this.form.markAsDirty();
}
@ -83,9 +105,9 @@ export class RdioScannerAdminSystemComponent {
return;
}
const blacklists = this.form?.get('blacklists') as FormControl;
const blacklists = this.form.get('blacklists') as FormControl | null;
blacklists.setValue(blacklists.value?.trim() ? `${blacklists.value},${id}` : `${id}`);
blacklists?.setValue(blacklists.value?.trim() ? `${blacklists.value},${id}` : `${id}`);
this.removeTalkgroup(index);
}
@ -104,19 +126,31 @@ export class RdioScannerAdminSystemComponent {
}
}
async playAlert(event: MatSelectChange): Promise<void> {
if (event.value) await this.adminService.playAlert(event.value);
}
removeSite(index: number): void {
const sites = this.form.get('sites') as FormArray | null;
sites?.removeAt(index);
sites?.markAsDirty();
}
removeTalkgroup(index: number): void {
const talkgroups = this.form.get('talkgroups') as FormArray;
const talkgroups = this.form.get('talkgroups') as FormArray | null;
talkgroups.removeAt(index);
talkgroups?.removeAt(index);
talkgroups.markAsDirty();
talkgroups?.markAsDirty();
}
removeUnit(index: number): void {
const units = this.form.get('units') as FormArray;
const units = this.form.get('units') as FormArray | null;
units.removeAt(index);
units?.removeAt(index);
units.markAsDirty();
units?.markAsDirty();
}
}

View file

@ -1,20 +1,20 @@
<div class="row top">
<p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown
systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop
systems to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button>
<p class="mat-body">This is where you define all your systems and talkgroups are defined. Audio files to unknown
systems/talkgroups won't be ingested unless the auto populate option is activated. You can also drag and drop
systems to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button>
</div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; index as i" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ system.value.label?.trim() || 'NewSystem' }}
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
</rdio-scanner-admin-system>
</mat-expansion-panel>
<mat-accordion *ngIf="systems" displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; let i = index" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ system.value.label?.trim() || 'NewSystem' }}
<mat-icon *ngIf="system.invalid" color="warn">error</mat-icon>
</mat-panel-title>
</mat-expansion-panel-header>
<rdio-scanner-admin-system #systemComponent [form]="system" (add)="add()" (remove)="remove(i)">
</rdio-scanner-admin-system>
</mat-expansion-panel>
</mat-accordion>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,26 +18,44 @@
*/
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { Component, Input, QueryList, ViewChildren, ChangeDetectionStrategy, ChangeDetectorRef, OnDestroy } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({
selector: 'rdio-scanner-admin-systems',
templateUrl: './systems.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RdioScannerAdminSystemsComponent {
@Input() form: FormArray | undefined;
export class RdioScannerAdminSystemsComponent implements OnDestroy {
private _form: FormArray | undefined;
private subs = new Subscription();
private _systemsCache: FormGroup[] = [];
@Input()
set form(f: FormArray | undefined) {
if (this._form === f) { return; }
this.subs.unsubscribe();
this.subs = new Subscription();
this._form = f;
this.updateCache();
if (this._form) {
const s = this._form.valueChanges.subscribe(() => this.updateCache());
this.subs.add(s);
}
}
get form(): FormArray | undefined { return this._form; }
get systems(): FormGroup[] {
return this.form?.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
return this._systemsCache;
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { }
constructor(private adminService: RdioScannerAdminService, private cd: ChangeDetectorRef) { }
add(): void {
const system = this.adminService.newSystemForm();
@ -47,6 +65,8 @@ export class RdioScannerAdminSystemsComponent {
this.form?.insert(0, system);
this.form?.markAsDirty();
this.updateCache();
}
closeAll(): void {
@ -60,6 +80,8 @@ export class RdioScannerAdminSystemsComponent {
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
this.updateCache();
}
}
@ -67,5 +89,23 @@ export class RdioScannerAdminSystemsComponent {
this.form?.removeAt(index);
this.form?.markAsDirty();
this.updateCache();
}
private updateCache(): void {
if (!this._form) {
this._systemsCache = [];
this.cd.markForCheck();
return;
}
const arr = [...this._form.controls] as FormGroup[];
arr.sort((a, b) => (a.value.order || 0) - (b.value.order || 0));
this._systemsCache = arr;
this.cd.markForCheck();
}
ngOnDestroy(): void {
this.subs.unsubscribe();
}
}

View file

@ -1,111 +1,170 @@
<ng-container *ngIf="form" [formGroup]="form">
<ng-container *ngIf="form">
<ng-container [formGroup]="form">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" min="1" step="1" matInput formControlName="id" placeholder="Id">
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id">
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form.get('talkgroupRef')!.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')!.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form?.get('name')?.hasError('required')">
Name is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form.get('name')!.hasError('required')">
Name is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Group</span><br>
<span class="mat-caption">Group to which this talkgoups belongs.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="groupId" placeholder="Group">
<mat-option *ngFor="let group of groups" [value]="group._id">
{{ group.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form?.get('groupId')?.hasError('required')">
Group is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Groups</span><br>
<span class="mat-caption">Groups to which this talkgroups belongs.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true">
<mat-option *ngFor="let group of groups" [value]="group.id">
{{ group.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form.get('groupIds')?.hasError('required')">
Group is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgoups belongs.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag._id">
{{ tag.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form?.get('tagId')?.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgroups belongs.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag.id">
{{ tag.label }}
</mat-option>
</mat-select>
<mat-error *ngIf="form.get('tagId')!.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">Indicator color when playing an audio file from this talkgroup. If not specified,
the color configured for the system is used.</span>
</p>
<mat-form-field floatLabel="never">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="null">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
<p>
<span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned from this talkgroup. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event.value)">
<mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let alert of alerts" [value]="alert">
{{ alert | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="form?.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Delay</span><br>
<span class="mat-caption">Delay in minutes before propagating calls to clients.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="delay" placeholder="Minutes">
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency">
<mat-error *ngIf="form.get('frequency')?.errors">
Invalid frequency
</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Type</span><br>
<span class="mat-caption">
Type of radio system that will override the type defined at the system level, if any.
<ul>
<li><b>AM</b> - Amplitude modulation.</li>
<li><b>DMR</b> - Digital mobile radio system.</li>
<li><b>FM</b> - Frequency modulation.</li>
<li><b>NFM</b> - Narrow frequency modulation.</li>
<li><b>NXDN</b> - Next Generation Digital Narrowband radio system.</li>
<li><b>P25</b> - APCO-25 radio system.</li>
<li><b>Provoice</b> - EDACS Provoice radio system.</li>
<li><b>SmartNet</b> - Motorola SmartNet radio system.</li>
<li><b>Tetra</b> - Trans european trunked radio system.</li>
</ul>
</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="type" placeholder="Type">
<mat-option value="">No Type</mat-option>
<mat-option value="am">AM</mat-option>
<mat-option value="dmr">DMR</mat-option>
<mat-option value="fm">FM</mat-option>
<mat-option value="nfm">NFM</mat-option>
<mat-option value="nxdn">NXDN</mat-option>
<mat-option value="p25">P25</mat-option>
<mat-option value="provoice">Provoice</mat-option>
<mat-option value="smartnet">SmartNet</mat-option>
<mat-option value="tetra">Tetra</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
Blacklist talkgroup
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete talkgroup
</button>
<button *ngIf="form.get('id')?.value" type="button" mat-button (click)="blacklist.emit()">
Blacklist talkgroup
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete talkgroup
</button>
</div>
</ng-container>
</ng-container>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,12 +18,13 @@
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { FormGroup } from '@angular/forms';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({
selector: 'rdio-scanner-admin-talkgroup',
templateUrl: './talkgroup.component.html',
standalone: false
})
export class RdioScannerAdminTalkgroupComponent {
@Input() form: FormGroup | undefined;
@ -32,15 +33,25 @@ export class RdioScannerAdminTalkgroupComponent {
@Output() remove = new EventEmitter<void>();
leds = this.adminService.getLeds();
leds: string[] = [];
get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {});
}
get groups(): Group[] {
return this.form?.root.get('groups')?.value as Group[];
return (this.form?.root.get('groups')?.value as Group[]) || [];
}
get tags(): Tag[] {
return this.form?.root.get('tags')?.value as Tag[];
return (this.form?.root.get('tags')?.value as Tag[]) || [];
}
constructor(private adminService: RdioScannerAdminService) { }
constructor(private adminService: RdioScannerAdminService) {
this.leds = this.adminService.getLeds() || [];
}
async playAlert(value: string | null): Promise<void> {
if (value) await this.adminService.playAlert(value);
}
}

View file

@ -1,37 +1,52 @@
<ng-container *ngIf="form" [formGroup]="form">
<ng-container *ngIf="form as f">
<ng-container [formGroup]="f">
<div class="row">
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Unit identifier in decimal format.</span>
</p>
<mat-form-field floatLabel="never">
<input type="number" min="0" step="1" matInput formControlName="id" placeholder="Id">
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')">
Id is already defined
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('min')">
Id is invalid
</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('required')">
Id is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Id</span><br>
<span class="mat-caption">Unit identifier in decimal format. You can define a unique unit identifier here,
but you can also use a range of identifiers using the fields below.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id">
<mat-error *ngIf="f.get('unitRef')!.hasError('duplicate')">Id value is already defined</mat-error>
<mat-error *ngIf="f.get('unitRef')!.hasError('min')">Id value is invalid</mat-error>
<mat-error *ngIf="f.get('unitRef')!.hasError('required')">Id value is required</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span>
</p>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')">
Label is required
</mat-error>
</mat-form-field>
<p>
<span class="mat-body">Range from</span><br>
<span class="mat-caption">Unit identifier range from.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitFrom" placeholder="From">
<mat-error *ngIf="f.get('unitFrom')!.hasError('range')">From value should be lower than To value</mat-error>
<mat-error *ngIf="f.get('unitFrom')!.hasError('min')">From value is invalid</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Range to</span><br>
<span class="mat-caption">Unit identifier range to.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="unitTo" placeholder="To">
<mat-error *ngIf="f.get('unitTo')!.hasError('range')">To value should be higher than From value</mat-error>
<mat-error *ngIf="f.get('unitTo')!.hasError('min')">To value is invalid</mat-error>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="f.get('label')!.hasError('required')">Label is required</mat-error>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()">
Delete unit
</button>
<button type="button" mat-button color="warn" (click)="remove.emit()">Delete unit</button>
</div>
</ng-container>
</ng-container>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,15 +17,17 @@
* ****************************************************************************
*/
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output } from '@angular/core';
import { FormGroup } from '@angular/forms';
@Component({
selector: 'rdio-scanner-admin-unit',
templateUrl: './unit.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RdioScannerAdminUnitComponent {
@Input() form: FormGroup | undefined;
@Input() form: FormGroup | null = null;
@Output() remove = new EventEmitter<void>();
@Output() readonly remove = new EventEmitter<void>();
}

View file

@ -1,18 +1,79 @@
<div class="row top">
<p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls
based on their tag.</p>
<button type="button" mat-button color="accent" (click)="add()">New tag</button>
</div>
<div class="tags">
<div *ngFor="let tag of tags; index as i" [formGroup]="tag">
<button type="button" mat-icon-button color="warn" (click)="remove(i)">
<mat-icon>clear</mat-icon>
</button>
<mat-form-field floatLabel="never">
<input type="text" matInput formControlName="label" placeholder="Tag">
<mat-error *ngIf="tags[i].get('label')?.hasError('required')">
Tag is required
</mat-error>
</mat-form-field>
</div>
<p class="mat-body">All system talkgroups must be associated with a tag, which is then used to search for calls
based on their tag.</p>
<button type="button" mat-button color="accent" (click)="add()">New Tag</button>
</div>
@if (!tags.length) {
<p class="mat-small text-center">No defined Tags</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="tags"
(cdkDropListDropped)="drop($event)">
@for (tag of tags; track tag; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ tag.value.label || 'NewTag' }}
@if (tag.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-container [formGroup]="tag">
<div class="row">
<p>
<span class="mat-body">Label</span><br>
<span class="mat-caption">Tag label displayed on the main screen and on the search panel.</span>
</p>
<mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label">
@if (tag.get('label')!.hasError('required')) {
<mat-error>
Label is required
</mat-error>
}
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Prefixes an alert sound to all calls assigned to this tag. The alert
assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option value="">No Alert</mat-option>
@for (alert of alerts; track alert) {
<mat-option [value]="alert">
{{ alert | titlecase }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="row">
<p>
<span class="mat-body">Led Color</span><br>
<span class="mat-caption">The default LED color is green but it can be changed at several levels.
The color assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p>
<mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color">
<mat-option [value]="''">Default</mat-option>
@for (led of leds; track led) {
<mat-option [value]="led">
{{ led | titlecase }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove(i)">
Delete Tag
</button>
</div>
</ng-container>
</mat-expansion-panel>
}
</mat-accordion>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,29 +17,41 @@
* ****************************************************************************
*/
import { Component, Input } from '@angular/core';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { Component, Input, QueryList, ViewChildren } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-tags',
styleUrls: ['./tags.component.scss'],
templateUrl: './tags.component.html',
standalone: false
})
export class RdioScannerAdminTagsComponent {
@Input() form: FormArray | undefined;
get tags(): FormGroup[] {
return this.form?.controls
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[];
leds: string[];
get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {});
}
constructor(private adminService: RdioScannerAdminService) { }
get tags(): FormGroup[] {
return this.form?.controls
.sort((a, b) => a.value.order - b.value.order) as FormGroup[];
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService, private matDialog: MatDialog) {
this.leds = this.adminService.getLeds();
}
add(): void {
const id = this.tags.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0);
const tag = this.adminService.newTagForm({ _id: id });
const tag = this.adminService.newTagForm();
tag.markAllAsTouched();
@ -48,6 +60,24 @@ export class RdioScannerAdminTagsComponent {
this.form?.markAsDirty();
}
closeAll(): void {
this.panels?.forEach((panel) => panel.close());
}
drop(event: CdkDragDrop<FormGroup[]>): void {
if (event.previousIndex !== event.currentIndex) {
moveItemInArray(event.container.data, event.previousIndex, event.currentIndex);
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty();
}
}
async playAlert(event: MatSelectChange): Promise<void> {
if (event.value) await this.adminService.playAlert(event.value);
}
remove(index: number): void {
this.form?.removeAt(index);

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -1,9 +1,18 @@
<form [formGroup]="form" (ngSubmit)="login()">
<p class="mat-body-1">Please enter the admin password to gain access to the administrative dashboard</p>
<p class="mat-body-2">Please enter the admin password to gain access to the administrative dashboard</p>
<mat-form-field hideRequiredMarker>
<mat-label>Password</mat-label>
<input matInput formControlName="password" type="password" required>
<mat-error>{{ message }}</mat-error>
<input
matInput
formControlName="password"
type="password"
autocomplete="current-password"
[attr.aria-invalid]="form.get('password')?.invalid"
required
>
<mat-error *ngIf="(form.get('password')?.invalid && form.get('password')?.touched) || message">
{{ message || 'Password is required' }}
</mat-error>
</mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="form.disabled || form.invalid">Login</button>
<button mat-raised-button color="primary" type="submit" [disabled]="form.invalid || form.disabled">Login</button>
</form>

View file

@ -1,8 +1,8 @@
form > button {
form > button {
margin-top: 1rem;
}
.mat-raised-button,
.mat-form-field {
.mat-mdc-raised-button,
.mat-mdc-form-field {
display: block;
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,27 +18,30 @@
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RdioScannerAdminService } from '../admin.service';
@Component({
selector: 'rdio-scanner-admin-login',
styleUrls: ['./login.component.scss'],
templateUrl: './login.component.html',
standalone: false
})
export class RdioScannerAdminLoginComponent {
@Output() loggedIn = new EventEmitter<void>();
form = this.formBuilder.group({
password: [null, Validators.required],
});
form: FormGroup;
message = '';
constructor(
private adminService: RdioScannerAdminService,
private formBuilder: FormBuilder,
) { }
private ngFormBuilder: FormBuilder,
) {
this.form = this.ngFormBuilder.group({
password: this.ngFormBuilder.control(null, Validators.required),
});
}
async login(password = this.form.get('password')?.value): Promise<void> {
if (!password) {

View file

@ -1,85 +1,91 @@
<mat-table [dataSource]="logs">
<ng-container matColumnDef="level">
<mat-header-cell *matHeaderCellDef>
<span>Level</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<mat-icon *ngIf="log?.level === 'error'" class="error">error</mat-icon>
<mat-icon *ngIf="log?.level === 'info'" class="info">notifications</mat-icon>
<mat-icon *ngIf="log?.level === 'warn'" class="warn">warning</mat-icon>
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef>
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.dateTime | date:'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="message">
<mat-header-cell *matHeaderCellDef>
<span>Message</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.message }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']">
</mat-row>
<ng-container matColumnDef="level">
<mat-header-cell *matHeaderCellDef>
<span>Level</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
@if (log?.level === 'error') {
<mat-icon class="error">error</mat-icon>
}
@if (log?.level === 'info') {
<mat-icon class="info">notifications</mat-icon>
}
@if (log?.level === 'warn') {
<mat-icon class="warn">warning</mat-icon>
}
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef>
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.dateTime | date:'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="message">
<mat-header-cell *matHeaderCellDef>
<span>Message</span>
</mat-header-cell>
<mat-cell *matCellDef="let log">
<span>{{ log?.message }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['level', 'date', 'time', 'message']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['level', 'date', 'time', 'message']">
</mat-row>
</mat-table>
<mat-progress-bar color="primary" [mode]="logsQueryPending ? 'query' : 'determinate'">
</mat-progress-bar>
<mat-paginator [disabled]="logsQueryPending" [length]="logsQuery?.count" [hidePageSize]="true"
[pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()">
[pageSize]="logs.value.length" [showFirstLastButtons]="true" (page)="refresh()">
</mat-paginator>
<form autocomplete="off" [formGroup]="form">
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="logsQuery?.dateStop"
[min]="logsQuery?.dateStart" placeholder="Choose a date" (change)="formHandler()">
</mat-form-field>
<mat-form-field>
<mat-label>
Level
</mat-label>
<mat-select formControlName="level" (selectionChange)="formHandler()">
<mat-option value="info">Informative</mat-option>
<mat-option value="warn">Warning</mat-option>
<mat-option value="error">Error</mat-option>
</mat-select>
</mat-form-field>
<div class="reset">
<button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()">
Reload
</button>
<button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()">
Reset
</button>
</div>
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="logsQuery?.dateStop"
[min]="logsQuery?.dateStart" placeholder="Choose a date" (change)="formHandler()">
</mat-form-field>
<mat-form-field>
<mat-label>
Level
</mat-label>
<mat-select formControlName="level" (selectionChange)="formHandler()">
<mat-option value="info">Informative</mat-option>
<mat-option value="warn">Warning</mat-option>
<mat-option value="error">Error</mat-option>
</mat-select>
</mat-form-field>
<div class="reset">
<button type="button" mat-raised-button [disabled]="logsQueryPending" (click)="reload()">
Reload
</button>
<button type="button" mat-raised-button [disabled]="logsQueryPending || form.pristine" (click)="reset()">
Reset
</button>
</div>
</form>

View file

@ -10,8 +10,8 @@
color: orange;
}
.mat-cell,
.mat-header-cell {
.mat-mdc-cell,
.mat-mdc-header-cell {
&:nth-child(1),
&:nth-child(2),
&:nth-child(3) {
@ -25,13 +25,13 @@ form {
flex-direction: row;
flex-wrap: wrap;
.mat-form-field,
.mat-mdc-form-field,
.reset {
box-sizing: border-box;
padding: 0 0.5rem 1rem 0.5rem;
}
.mat-form-field {
.mat-mdc-form-field {
min-width: 220px;
}
@ -44,13 +44,13 @@ form {
}
@media (max-width: 719px) {
form > .mat-form-field {
form > .mat-mdc-form-field {
flex: 100%;
}
}
@media (min-width: 720px) {
form > .mat-form-field {
form > .mat-mdc-form-field {
flex: 33%;
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,7 +18,7 @@
*/
import { Component, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { BehaviorSubject } from 'rxjs';
import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../admin.service';
@ -27,13 +27,10 @@ import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../ad
selector: 'rdio-scanner-admin-logs',
styleUrls: ['./logs.component.scss'],
templateUrl: './logs.component.html',
standalone: false
})
export class RdioScannerAdminLogsComponent {
form = this.ngFormBuilder.group({
date: [null],
level: [null],
sort: [-1],
});
form: FormGroup;
logs = new BehaviorSubject(new Array<Log | null>(10));
@ -47,7 +44,13 @@ export class RdioScannerAdminLogsComponent {
@ViewChild(MatPaginator) private paginator: MatPaginator | undefined;
constructor(private adminService: RdioScannerAdminService, private ngFormBuilder: FormBuilder) { }
constructor(private adminService: RdioScannerAdminService, private ngFormBuilder: FormBuilder) {
this.form = this.ngFormBuilder.group({
date: [null],
level: [null],
sort: [-1],
});
}
formHandler(): void {
this.paginator?.firstPage();
@ -98,11 +101,11 @@ export class RdioScannerAdminLogsComponent {
const options: LogsQueryOptions = {
limit: this.limit,
offset: this.offset,
sort: this.form.value.sort,
sort: this.form.get('sort')?.value ?? -1,
};
if (typeof this.form.value.level === 'string') {
options.level = this.form.value.level;
if (typeof this.form.get('level')?.value === 'string') {
options.level = this.form.get('level')?.value ?? undefined;
}
if (typeof this.form.value.date === 'string') {

View file

@ -1,12 +1,14 @@
<mat-card *ngIf="todos.length">
<ng-container *ngIf="todos.length">
<mat-card appearance="outlined">
<mat-card-title>Todos</mat-card-title>
<mat-list>
<mat-list-item *ngFor="let todo of todos">
<mat-icon *ngIf="todo.level === 'info'" class="info" mat-list-icon>notifications</mat-icon>
<mat-icon *ngIf="todo.level === 'warn'" class="warn" mat-list-icon>warning</mat-icon>
<div mat-line>
<span class="todo">{{ todo.message }}</span>
</div>
</mat-list-item>
<mat-list-item *ngFor="let todo of todos">
<mat-icon *ngIf="todo.level === 'info'" class="info" matListItemIcon>notifications</mat-icon>
<mat-icon *ngIf="todo.level === 'warn'" class="warn" matListItemIcon>warning</mat-icon>
<div mat-line>
<span class="todo">{{ todo.message }}</span>
</div>
</mat-list-item>
</mat-list>
</mat-card>
</mat-card>
</ng-container>

View file

@ -1,12 +1,12 @@
.info {
.mat-mdc-list-item .info {
color: green;
}
.warn {
.mat-mdc-list-item .warn {
color: orange;
}
.todo {
.mat-mdc-list-item .todo {
text-overflow: unset;
white-space: normal;
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -22,34 +22,39 @@ import { AdminEvent, Config, RdioScannerAdminService } from '../admin.service';
interface Todo {
level: 'info' | 'warn';
message: String;
message: string;
}
@Component({
selector: 'rdio-scanner-admin-todos',
styleUrls: ['./todos.component.scss'],
templateUrl: './todos.component.html',
standalone: false
})
export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
todos: Todo[] = [];
private config: Config | undefined;
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('config' in event) {
this.config = event.config;
}
private eventSubscription;
if ('passwordNeedChange' in event) {
this.passwordNeedChange = event.passwordNeedChange || false;
}
private passwordNeedChange;
this.rebuildTodos();
});
constructor(private adminService: RdioScannerAdminService) {
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('config' in event) {
this.config = event.config;
}
private passwordNeedChange = this.adminService.passwordNeedChange;
if ('passwordNeedChange' in event) {
this.passwordNeedChange = event.passwordNeedChange || false;
}
constructor(private adminService: RdioScannerAdminService) { }
this.rebuildTodos();
});
this.passwordNeedChange = this.adminService.passwordNeedChange;
}
ngOnDestroy(): void {
this.eventSubscription.unsubscribe();
@ -76,14 +81,14 @@ export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
if (!this.config?.systems?.length) {
todos.push({
level: 'info',
message: 'No systems defined. You can define one from the systems menu, or import one from a CSV file from the tools menu, or turn on the global auto populate option from the options menu.',
message: 'No systems defined. You can define one from the systems menu, or let the auto populate feature create it for you if the global option is turned on.',
});
}
if (!this.config?.apiKeys?.length && !this.config?.dirWatch?.length) {
if (!this.config?.apikeys?.length && !this.config?.dirwatch?.length) {
todos.push({
level: 'info',
message: 'No apikeys or dirwatch defined. Please set at least one to allow ingesting audio files.',
message: 'No apikeys or dirwatch defined. Set at least one to allow ingesting audio files.',
});
}

View file

@ -1,6 +1,6 @@
<div>
<p>
<span class="mat-body">Import</span><br>
<span class="mat-body">Import config</span><br>
<span class="mat-caption">Import a JSON file into the configuration panel where you can then review it before submitting it.</span>
</p>
<button mat-raised-button (click)="input.click()">Import</button>
@ -8,7 +8,7 @@
</div>
<div>
<p>
<span class="mat-body">Export</span><br>
<span class="mat-body">Export config</span><br>
<span class="mat-caption">Export the configuration to a JSON file.</span>
</p>
<button mat-raised-button (click)="export()">Export</button>

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,19 +17,23 @@
* ****************************************************************************
*/
import { DOCUMENT } from '@angular/common';
import { Component, EventEmitter, Inject, Output } from '@angular/core';
import { Component, EventEmitter, Inject, Output, DOCUMENT } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Config, RdioScannerAdminService } from '../../admin.service';
import packageInfo from '../../../../../../../package.json';
@Component({
selector: 'rdio-scanner-admin-import-export-config',
styleUrls: ['./import-export-config.component.scss'],
templateUrl: './import-export-config.component.html',
standalone: false
})
export class RdioScannerAdminImportExportConfigComponent {
@Output() config = new EventEmitter<Config>();
version = packageInfo.version;
constructor(
private adminService: RdioScannerAdminService,
@Inject(DOCUMENT) private document: Document,
@ -42,7 +46,7 @@ export class RdioScannerAdminImportExportConfigComponent {
const file = encodeURIComponent(JSON.stringify(config)).replace(/%([0-9A-F]{2})/g, (_, c) => {
return String.fromCharCode(parseInt(c, 16));
});
const fileName = 'rdio-scanner.json';
const fileName = `rdio-scanner-${this.version}.config.json`;
const fileType = 'application/json';
const fileUri = `data:${fileType};base64,${window.btoa(file)}`;
@ -77,7 +81,75 @@ export class RdioScannerAdminImportExportConfigComponent {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
}).join(''));
this.config.emit(JSON.parse(res));
const config = JSON.parse(res);
if (Array.isArray(config.access))
config.access.forEach((access: { [key: string]: unknown }) => {
access['id'] = access['_id'];
});
config['apikeys'] = config['apiKeys'];
if (Array.isArray(config.apikeys))
config.access.forEach((access: { [key: string]: unknown }) => {
access['id'] = access['_id'];
});
config['dirwatch'] = config['dirWatch'];
if (Array.isArray(config.dirwatch))
config.dirwatch.forEach((dirwatch: { [key: string]: unknown }) => {
dirwatch['id'] = dirwatch['_id'];
});
if (Array.isArray(config.downstreams))
config.downstreams.forEach((downstream: { [key: string]: unknown }) => {
downstream['id'] = downstream['_id'];
downstream['apikey'] = downstream['apiKey'];
});
if (Array.isArray(config.groups))
config.groups.forEach((group: { [key: string]: unknown }) => {
group['id'] = group['_id'];
});
config.groups = (config.groups as { label: string }[]).sort((a, b) => a.label.localeCompare(b.label));
if (Array.isArray(config.tags))
config.tags.forEach((tag: { [key: string]: unknown }) => {
tag['id'] = tag['_id'];
});
config.tags = (config.tags as { label: string }[]).sort((a, b) => a.label.localeCompare(b.label));
if (Array.isArray(config.systems))
config.systems.forEach((system: { [key:string]:unknown}) => {
system['systemRef'] = system['id'];
system['id'] = system['_id'];
const talkgroups = system['talkgroups'];
if (Array.isArray(talkgroups))
talkgroups.forEach((talkgroup: {[key:string]: unknown}) => {
const groupId = talkgroup['groupId'];
if (typeof groupId === 'number') talkgroup['groupIds'] = [groupId];
talkgroup['talkgroupRef'] = talkgroup['id'];
delete talkgroup['id'];
});
});
this.config.emit(config);
// const versionMajor = this.version.split('.')[0];
// if (config['version']?.split('.')[0] === versionMajor) {
// this.config.emit(config);
// } else {
// this.matSnackBar.open('Config file version mismatch', '', { duration: 5000 });
// }
} catch (error) {
this.matSnackBar.open(error as string, '', { duration: 5000 });

View file

@ -1,93 +1,119 @@
<section>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
<ng-container *ngIf="csv.length === 0">
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
</ng-container>
<ng-container *ngIf="csv.length !== 0">
<button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container>
</p>
</section>
<p class="mat-body note">Before going any further, note that there is no need to preload talkgroups into a system as the
autopopulate feature will automatically create them as audio files are ingested. So give the autopopulate feature a
chance before trying to import talkgroups.</p>
<section>
<p class="mat-body">step 2: select CSV style</p>
<p class="text-center">
<mat-radio-group [(ngModel)]="mode">
<mat-radio-button [value]="0">Trunk Recorder</mat-radio-button>
<mat-radio-button [value]="1">Radio Reference</mat-radio-button>
</mat-radio-group>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
@if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
<br>
<p>
<span class="mat-body-1">Column format is: </span>
<span class="mat-body-1">Id</span>,
<span>unused</span>,
<span class="mat-body-1">Label</span>,
<span>unused</span>,
<span class="mat-body-1">Name</span>,
<span class="mat-body-1">Tag</span>,
<span class="mat-body-1">Group</span>
</p>
}
@if (csv.length !== 0) {
<button mat-raised-button (click)="csv=[]">Reset</button>
}
</p>
</section>
</section>
<section>
<section>
<p class="mat-body">step 2: select system</p>
<p class="text-center">
<mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system">
@for (system of baseConfig.systems; track system) {
<mat-option [value]="system">
{{ system.label}}
</mat-option>
}
</mat-select>
</mat-form-field>
</p>
</section>
<section>
<p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll">
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][0]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][1]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>
<span>Description</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][2]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="tag">
<mat-header-cell *matHeaderCellDef>
<span>Tag</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][3]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef>
<span>Group</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][4]] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[2] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="description">
<mat-header-cell *matHeaderCellDef>
<span>Description</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[4] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="tag">
<mat-header-cell *matHeaderCellDef>
<span>Tag</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[5] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="group">
<mat-header-cell *matHeaderCellDef>
<span>Group</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[6] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
</div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p>
</section>
@if (!csv.length) {
<p class="mat-body text-center">No CSV data yet</p>
}
</section>
<section>
<section>
<p class="mat-body">step 4: import to configuration</p>
<p class="text-center">
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration
</button>
<button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration
</button>
</p>
<p *ngIf="csv.length > 800" class="mat-caption mat-error text-center text-uppercase">
Warning, you are trying to import more than 800 talkgroups, {{csv.length}} in this case. The graphic interface has not designed for this number of talkgroups. You can continue, but expect Rdio Scanner to be quite slow, if not unusable.
</p>
</section>
@if (csv.length > 800) {
<p class="mat-caption mat-error text-center text-uppercase">
Warning, you are trying to import more than 800 talkgroups, {{csv.length}} in this case. The graphic interface
has not designed for this number of talkgroups. You can continue, but expect Rdio Scanner to be quite slow, if
not unusable.
</p>
}
</section>

View file

@ -1,4 +1,4 @@
.mat-radio-group {
.mat-mdc-radio-group {
display: flex;
flex-direction: row;
justify-content: center;
@ -8,18 +8,25 @@
}
}
.mat-table {
.mat-mdc-table {
min-width: 720px;
}
.mat-cell >  span {
.mat-mdc-cell >  span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-column-action {
max-width: 40px;
max-width: 48px;
}
.note {
border: 2px solid darkred;
color: darkred;
margin: 0 4rem;
padding: 2px;
}
.scroll {

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,71 +17,73 @@
* ****************************************************************************
*/
import { Component, EventEmitter, Output } from '@angular/core';
import { Config, RdioScannerAdminService } from '../../admin.service';
import { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { Config, RdioScannerAdminService, System } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-import-talkgroups',
styleUrls: ['./import-talkgroups.component.scss'],
templateUrl: './import-talkgroups.component.html',
standalone: false
})
export class RdioScannerAdminImportTalkgroupsComponent {
export class RdioScannerAdminImportTalkgroupsComponent implements OnInit {
@Output() config = new EventEmitter<Config>();
baseConfig: Config = {};
csv: string[][] = [];
fields = [
// [id, label, description, tag, group]
[0, 3, 4, 5, 6], // trunk-recorder
[0, 2, 4, 5, 6], // radioreference.com
];
mode = 0;
system: System | undefined;
tableColumns = ['id', 'label', 'description', 'tag', 'group', 'action'];
constructor(private adminService: RdioScannerAdminService) { }
async ngOnInit(): Promise<void> {
this.baseConfig = await this.adminService.getConfig();
if (Array.isArray(this.baseConfig.systems) && this.baseConfig.systems.length > 0) {
this.system = this.baseConfig.systems[0];
}
}
async import(): Promise<void> {
const config = await this.adminService.getConfig();
if (this.system === undefined) return;
this.csv.forEach((tg) => {
const group = tg[this.fields[this.mode][4]];
this.csv.forEach((csv) => {
const group = csv[6];
if (!config.groups?.find((g) => g.label === group)) {
const id = config.groups?.reduce((pv, cv) => typeof cv._id === 'number' && cv._id >= pv ? cv._id + 1 : pv, 1);
if (!this.baseConfig.groups?.find((g) => g.label === group)) {
const id = this.baseConfig.groups?.reduce((pv, cv) => typeof cv.id === 'number' && cv.id >= pv ? cv.id + 1 : pv, 1);
config.groups?.push({ _id: id, label: group });
this.baseConfig.groups?.push({ id: id, label: group });
}
const tag = tg[this.fields[this.mode][3]];
const tag = csv[5];
if (!config.tags?.find((t) => t.label === tag)) {
const id = config.tags?.reduce((pv, cv) => typeof cv._id === 'number' && cv._id >= pv ? cv._id + 1 : pv, 1);
if (!this.baseConfig.tags?.find((t) => t.label === tag)) {
const id = this.baseConfig.tags?.reduce((pv, cv) => typeof cv.id === 'number' && cv.id >= pv ? cv.id + 1 : pv, 1);
config.tags?.push({ _id: id, label: tag });
this.baseConfig.tags?.push({ id: id, label: tag });
}
});
const talkgroups = this.csv.map((csv, idx) => {
const groupId = config.groups?.find((g) => g.label === csv[this.fields[this.mode][4]])?._id;
const tagId = config.tags?.find((t) => t.label === csv[this.fields[this.mode][3]])?._id;
this.csv.forEach((csv) => {
const groupId = this.baseConfig.groups?.find((g) => g.label === csv[6])?.id;
const tagId = this.baseConfig.tags?.find((t) => t.label === csv[5])?.id;
return {
id: +csv[this.fields[this.mode][0]],
label: csv[this.fields[this.mode][1]],
name: csv[this.fields[this.mode][2]],
order: idx + 1,
this.system?.talkgroups?.unshift({
talkgroupRef: +csv[0],
label: csv[2],
name: csv[4],
groupIds: groupId ? [groupId] : [],
tagId,
groupId,
};
});
});
config.systems?.unshift({ talkgroups });
this.csv = [];
this.config.emit(config);
this.config.emit(this.baseConfig);
}
async read(event: Event): Promise<void> {

View file

@ -1,74 +1,86 @@
<section>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
<ng-container *ngIf="csv.length === 0">
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
</ng-container>
<ng-container *ngIf="csv.length !== 0">
<button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container>
<p class="mat-body">step 1: read the CSV file</p>
<p class="text-center">
@if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
<br>
<p class="mat-body-1">
<span>Column format is: </span>
<span>Id</span>,
<span>Label</span>,
</p>
}
@if (csv.length !== 0) {
<button mat-raised-button (click)="csv=[]">Reset</button>
}
</p>
</section>
</section>
<section>
<section>
<p class="mat-body">step 2: select system</p>
<p class="text-center">
<mat-form-field floatLabel="never">
<mat-select [(ngModel)]="system">
<mat-option *ngFor="let system of baseConfig.systems" [value]="system">
{{ system.label}}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system">
@for (system of baseConfig.systems; track system) {
<mat-option [value]="system">
{{ system.label}}
</mat-option>
}
</mat-select>
</mat-form-field>
</p>
</section>
</section>
<section>
<section>
<p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll">
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[1] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
<mat-table #table [dataSource]="csv">
<ng-container matColumnDef="id">
<mat-header-cell *matHeaderCellDef>
<span>Id</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[0] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="label">
<mat-header-cell *matHeaderCellDef>
<span>Label</span>
</mat-header-cell>
<mat-cell *matCellDef="let tg">
<span>{{ tg[1] }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="action">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let tg; index as i">
<button mat-icon-button (click)="csv.splice(i, 1); table.renderRows()">
<mat-icon>clear</mat-icon>
</button>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="tableColumns"></mat-header-row>
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table>
</div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p>
</section>
@if (!csv.length) {
<p class="mat-body text-center">No CSV data yet</p>
}
</section>
<section>
<section>
<p class="mat-body">step 4: import to configuration</p>
<p class="text-center">
<button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()">
Import to configuration
</button>
<button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()">
Import to configuration
</button>
</p>
<p *ngIf="csv.length > 1000" class="mat-caption mat-error text-center text-uppercase">
@if (csv.length > 1000) {
<p class="mat-caption mat-error text-center text-uppercase">
Warning, you are trying to import more than 1000 units, {{csv.length}} in this case. The graphic interface has
not designed for this number of units. You can continue, but expect Rdio Scanner to be quite slow, if not
unusable.
</p>
</section>
</p>
}
</section>

View file

@ -1,4 +1,4 @@
.mat-radio-group {
.mat-mdc-radio-group {
display: flex;
flex-direction: row;
justify-content: center;
@ -8,18 +8,18 @@
}
}
.mat-table {
.mat-mdc-table {
min-width: 720px;
}
.mat-cell >  span {
.mat-mdc-cell >  span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mat-column-action {
max-width: 40px;
max-width: 48px;
}
.scroll {

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -24,6 +24,7 @@ import { Config, RdioScannerAdminService, System } from '../../admin.service';
selector: 'rdio-scanner-admin-import-units',
styleUrls: ['./import-units.component.scss'],
templateUrl: './import-units.component.html',
standalone: false
})
export class RdioScannerAdminImportUnitsComponent implements OnInit{
@Output() config = new EventEmitter<Config>();
@ -42,7 +43,7 @@ export class RdioScannerAdminImportUnitsComponent implements OnInit{
this.baseConfig = await this.adminService.getConfig();
if (Array.isArray(this.baseConfig.systems) && this.baseConfig.systems.length > 0) {
this.system = this.baseConfig.systems![0];
this.system = this.baseConfig.systems[0];
}
}
@ -50,12 +51,12 @@ export class RdioScannerAdminImportUnitsComponent implements OnInit{
if (this.system === undefined) return;
const units = this.csv.map((csv, idx) => ({
id: +csv[0],
unitRef: +csv[0],
label: csv[1],
order: idx + 1,
}));
this.system!.units = this.system!.units!.concat(units);
this.system.units = this.system?.units?.concat(units);
this.csv = [];

View file

@ -1,38 +1,39 @@
<p class="mat-body">
Change the administrator password which gives access to the server administration dashboard.
Change the administrator password which gives access to the server administration dashboard.
</p>
<form [formGroup]="form">
<mat-form-field>
<mat-label>Current password</mat-label>
<input type="password" matInput formControlName="currentPassword" required>
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
Current password is required
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>New password</mat-label>
<input type="password" matInput formControlName="newPassword" required>
<mat-error *ngIf="form.get('newPassword')?.hasError('required')">
New password is required
</mat-error>
<mat-error *ngIf="form.get('newPassword')?.hasError('minlength')">
Password is too short
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Verify new password</mat-label>
<input type="password" matInput formControlName="verifyNewPassword" required>
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
New password verification is required
</mat-error>
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('invalid')">
Password don't match
</mat-error>
</mat-form-field>
<div class="row bottom">
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched"
(click)="reset();">Reset</button>
<button type="button" mat-raised-button color="primary"
[disabled]="form.disabled || form.pristine || !form.valid" (click)="save()">Save</button>
</div>
<form [formGroup]="form" (ngSubmit)="save()">
<mat-form-field>
<input matInput type="text" name="username" value="admin" autocomplete="username" tabindex="-1" aria-label="Username (for password manager)" readonly>
</mat-form-field>
<mat-form-field>
<mat-label>Current password</mat-label>
<input type="password" matInput formControlName="currentPassword" required autocomplete="current-password">
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
Current password is required
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>New password</mat-label>
<input type="password" matInput formControlName="newPassword" required minlength="8" autocomplete="new-password">
<mat-error *ngIf="form.get('newPassword')?.hasError('required')">
New password is required
</mat-error>
<mat-error *ngIf="form.get('newPassword')?.hasError('minlength')">
Password is too short
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Verify new password</mat-label>
<input type="password" matInput formControlName="verifyNewPassword" required autocomplete="new-password">
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
New password verification is required
</mat-error>
<mat-error *ngIf="form.hasError('passwordMismatch')">
Passwords don't match
</mat-error>
</mat-form-field>
<div class="row bottom">
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched" (click)="reset()">Reset</button>
<button type="submit" mat-raised-button color="primary" [disabled]="form.disabled || form.pristine || form.invalid">Save</button>
</div>
</form>

View file

@ -8,7 +8,7 @@
justify-content: space-between;
}
.mat-form-field {
.mat-mdc-form-field {
margin-bottom: 1rem;
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,55 +18,68 @@
*/
import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, ValidatorFn, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar';
import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';
import { RdioScannerAdminService } from '../../admin.service';
@Component({
selector: 'rdio-scanner-admin-password',
styleUrls: ['./password.component.scss'],
templateUrl: './password.component.html',
standalone: false
})
export class RdioScannerAdminPasswordComponent {
form = this.ngFormBuilder.group({
currentPassword: [null, Validators.required],
newPassword: [null, [Validators.required, Validators.minLength(8)]],
verifyNewPassword: [null, [Validators.required, this.validatePassword()]],
});
form: FormGroup;
constructor(
private adminService: RdioScannerAdminService,
private matSnackBar: MatSnackBar,
private ngFormBuilder: FormBuilder,
) { }
private readonly adminService: RdioScannerAdminService,
private readonly matSnackBar: MatSnackBar,
private readonly ngFormBuilder: FormBuilder,
) {
this.form = this.ngFormBuilder.group({
currentPassword: [null, Validators.required],
newPassword: [null, [Validators.required, Validators.minLength(8)]],
verifyNewPassword: [null, Validators.required],
}, { validators: this.passwordsMatchValidator });
private validatePassword(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return typeof control.value === 'string' && control.value.length
? control.value === control.parent?.value.newPassword
? null : { invalid: true } : null;
}
this.form.get('newPassword')?.valueChanges.subscribe(() =>
this.form.get('verifyNewPassword')?.updateValueAndValidity({ onlySelf: true })
);
}
private passwordsMatchValidator(group: AbstractControl): ValidationErrors | null {
const newPwd = group.get('newPassword')?.value;
const verify = group.get('verifyNewPassword')?.value;
return newPwd && verify && newPwd !== verify ? { passwordMismatch: true } : null;
}
get currentPassword() { return this.form.get('currentPassword'); }
get newPassword() { return this.form.get('newPassword'); }
get verifyNewPassword() { return this.form.get('verifyNewPassword'); }
reset(): void {
this.form.reset();
}
async save(): Promise<void> {
const config: MatSnackBarConfig = { duration: 5000 };
this.form.disable();
try {
await this.adminService.changePassword(this.form.value.currentPassword, this.form.value.newPassword);
this.matSnackBar.open('Password changed successfully', '', config);
this.form.reset();
} catch (_) {
this.matSnackBar.open('Unable to change password', '', config);
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.form.enable();
const config = { duration: 5000 };
const currentPassword = this.currentPassword?.value;
const newPassword = this.newPassword?.value;
this.form.disable();
try {
await this.adminService.changePassword(currentPassword, newPassword);
this.matSnackBar.open('Password changed successfully', '', config);
this.form.reset();
} catch {
this.matSnackBar.open('Unable to change password', '', config);
} finally {
this.form.enable();
}
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -24,6 +24,7 @@ import { Config } from '../admin.service';
@Component({
selector: 'rdio-scanner-admin-tools',
templateUrl: './tools.component.html',
standalone: false
})
export class RdioScannerAdminToolsComponent {
@Output() config = new EventEmitter<Config>();

View file

@ -1,6 +1,11 @@
.rdio-button,
.rdio-button-mini {
background: rgb(45, 45, 45);
--def: rgb(45, 45, 45);
--green: rgb(0, 230, 118);
--red: rgb(255, 23, 68);
--yellow: rgb(255, 234, 0);
background: var(--def);
border-style: solid;
border-width: 1px;
border-bottom-color: rgba(0, 0, 0, 0.87);
@ -41,24 +46,71 @@
right: 4px;
top: 4px;
width: 6px;
animation: blink 1s linear infinite both;
}
&.off::after {
background: rgb(255, 23, 68);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
0 0 3px 1px rgb(255, 23, 68);
background: var(--red);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--red);
}
&.off.blink::after {
animation: blink-off 500ms linear infinite;
}
@keyframes blink-off {
0%, 50% {
background: var(--def);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 0px 0px var(--def);
}
50%, 100% {
background: var(--red);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--red);
}
}
&.on::after {
background: rgb(0, 230, 118);;
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
0 0 3px 1px rgb(0, 230, 118);;
background: var(--green);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--green);
}
&.on.blink::after {
animation: blink-on 500ms linear infinite;
}
@keyframes blink-on {
0%, 50% {
background: var(--def);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 0px 0px var(--def);
}
50%, 100% {
background: var(--def);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--green);
}
}
&.partial::after {
background: rgb(255, 234, 0);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset,
0 0 3px 1px rgb(255, 234, 0);
background: var(--yellow);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--yellow);
}
&.partial.blink::after {
animation: blink-partial 500ms linear infinite;
}
@keyframes blink-partial {
0%, 50% {
background: var(--def);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 0px 0px var(--def);
}
50%, 100% {
background: var(--yellow);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--yellow);
}
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -1,156 +1,193 @@
<div class="rdio-status">
<div class="led" [ngClass]="ledStyle"></div>
<div class="branding">{{ branding }}</div>
<div class="led" [ngClass]="ledStyle"></div>
</div>
<div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()">
<div class="row">
<div>
<span>{{ clock | date:timeFormat }}</span>
</div>
<div *ngIf="!linked">
<span>NO LINK</span>
</div>
<div *ngIf="linked && showListenersCount">
<span>L: {{ listeners }}</span>
</div>
<div>
<span>Q: {{ callQueue }}</span>
</div>
<div class="row three">
<div>
<span>{{ clock | date:timeFormat }}</span>
</div>
<div class="row small"></div>
<div class="row">
<div>
<span>{{ callSystem }}</span>
</div>
<div>
<span>{{ callTag }}</span>
</div>
<div>
@if (!linked) {
<span>NO LINK</span>
}
@if (linked && showListenersCount) {
<span>L: {{ listeners }}</span>
}
</div>
<div class="row">
<div>
<span>{{ callTalkgroup }}</span>
</div>
<div>
<span>{{ callProgress | date:timeFormat }}</span>
</div>
<div>
<span>Q: {{ callQueue }}</span>
</div>
<div class="row big">
<span>{{ callTalkgroupName }}</span>
</div>
<div class="row small"></div>
<div class="row">
<div>
<span>{{ callSystem }}</span>
</div>
<div class="row">
<div>
<span>F: {{ callFrequency || 0 }}</span>
</div>
<div>
<span>TGID: {{ callTalkgroupId || 0 }}</span>
</div>
<div>
<span>{{ type | uppercase }}</span>
</div>
<div class="row">
<div>
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }}</span>
</div>
<div>
<span *ngIf="callUnit">UID: {{ callUnit }}</span>
</div>
<div>
<span>{{ callTag }}</span>
</div>
<div class="row right small">
<div>
<span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: patched }">PATCH</span>
</div>
</div>
<div class="row">
<div>
<span>{{ callTalkgroup }}</span>
</div>
<div class="wrapper">
<div class="auth" [ngClass]="{ visible: auth }">
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
<mat-form-field>
<input #password matInput type="password" autocomplete="off" formControlName="password"
placeholder="Unlock code" (blur)="authFocus()">
<mat-error *ngIf="authForm.get('password')?.hasError('expired')">
This unlock code has expired
</mat-error>
<mat-error *ngIf="authForm.get('password')?.hasError('tooMany')">
Too many connections
</mat-error>
</mat-form-field>
</form>
</div>
<table class="history">
<thead>
<tr>
<th>
<span>Time</span>
</th>
<th>
<span>System</span>
</th>
<th>
<span>Talkgroup</span>
</th>
<th>
<span>Name</span>
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let previousCall of callHistory"
[ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} ">
<td>
<span>{{ previousCall?.dateTime | date:timeFormat }}</span>
</td>
<td>
<span>{{ previousCall?.systemData?.label || previousCall?.system}}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.label || previousCall?.talkgroup }}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
</td>
</tr>
</tbody>
</table>
<div></div>
<div>
<span>{{ callDate | date:'MM/dd' }} </span>
<span>{{ callProgress | date:timeFormat }}</span>
</div>
</div>
<div class="row big full">
<span>{{ callTalkgroupName }}</span>
</div>
<div class="row">
<div>
<span>F: {{ callFrequency || 0 }}</span>
</div>
<div></div>
<div>
<span>TGID: {{ callTalkgroupId || 0 }}</span>
</div>
</div>
<div class="row">
<div>
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }} </span>
</div>
<div></div>
<div>
@if (callUnit) {
<span>UID: {{ callUnit }}</span>
}
</div>
</div>
<div class="row right small">
<div></div>
<div></div>
<div>
@if (tempAvoid) {
<div>
<span class="flag" [ngClass]="{ flaged: avoided || patched }">&#x23f2;&#xFE0E; {{ tempAvoid }}M</span>
</div>
}
<div>
<span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: patched }">PATCH</span>
</div>
<div>
<span class="flag" [ngClass]="{ flaged: delayed }">DELAYED</span>
</div>
</div>
</div>
<div class="wrapper">
<div class="auth" [ngClass]="{ visible: auth }">
<form class="mat-elevation-z8" autocomplete="off" [formGroup]="authForm" (ngSubmit)="authenticate()">
<mat-form-field>
<input #password matInput type="password" autocomplete="off" formControlName="password"
placeholder="Unlock code" (blur)="authFocus()">
@if (authForm.get('password')?.hasError('expired')) {
<mat-error>
This unlock code has expired
</mat-error>
}
@if (authForm.get('password')?.hasError('tooMany')) {
<mat-error>
Too many connections
</mat-error>
}
</mat-form-field>
</form>
</div>
<table class="history">
<thead>
<tr>
<th>
<span>Time</span>
</th>
<th>
<span>System</span>
</th>
<th>
<span>Talkgroup</span>
</th>
<th>
<span>Name</span>
</th>
</tr>
</thead>
<tbody>
@for (previousCall of callHistory; track $index) {
<tr
[ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} ">
<td>
<span>{{ previousCall?.dateTime | date:timeFormat }}</span>
</td>
<td>
<span>{{ previousCall?.systemData?.label || previousCall?.system}}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.label || previousCall?.talkgroup }}</span>
</td>
<td>
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="rdio-control">
<div class="row">
<button class="rdio-button" (click)="livefeed()"
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
LIVE<br>FEED
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
HOLD<br>SYS
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
HOLD<br>TG
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="replay()">
REPLAY<br>LAST
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="skip()">
SKIP<br>NEXT
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="avoid()">
AVOID
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="showSearchPanel()">
SEARCH<br>CALL
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
PAUSE
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="showSelectPanel()">
SELECT<br>TG
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="livefeed()"
[ngClass]="{ off: livefeedOffline && !playbackMode, on: livefeedOnline, partial: playbackMode }">
LIVE<br>FEED
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdSystem()" [ngClass]="{ off: !holdSys, on: holdSys }">
HOLD<br>SYS
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="holdTalkgroup()" [ngClass]="{ off: !holdTg, on: holdTg }">
HOLD<br>TG
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="replay()">
REPLAY<br>LAST
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="skip()">
SKIP<br>NEXT
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="avoid()">
AVOID
</button>
</div>
<div class="row">
<button class="rdio-button" (click)="showSearchPanel()">
SEARCH<br>CALL
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="pause()" [ngClass]="{ off: !livefeedPaused, on: livefeedPaused }">
PAUSE
</button>
<div class="spacer"></div>
<button class="rdio-button" (click)="showSelectPanel()">
SELECT<br>TG
</button>
</div>
</div>
@if (email) {
<div class="rdio-help">
<button mat-icon-button (click)="showHelp()">
<mat-icon>help_center</mat-icon>
</button>
</div>
}

View file

@ -103,7 +103,20 @@
display: flex;
flex-direction: row;
height: 20px;
justify-content: space-between;
&:not(.full) > * {
display: flex;
flex: 33%;
flex-direction: row;
&:nth-child(2) {
justify-content: center;
}
&:nth-child(3) {
justify-content: flex-end;
}
}
&.big {
font-size: 24px;
@ -171,11 +184,30 @@
}
.rdio-status {
align-items: center;
display: flex;
flex-direction: row;
min-height: 1.5rem;
.branding {
color: rgb(64, 64, 64);
flex: 1;
font-size: 16px;
font-weight: normal;
letter-spacing: 2px;
line-height: 1;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: -1px -1px 1px rgba(0, 0, 0, 0.7), 1px 1px 1px rgba(255, 255, 255, 0.3);
text-transform: uppercase;
white-space: nowrap;
}
.led {
background: rgb(80, 80, 80);
display: block;
height: 12px;
margin-left: auto;
margin-left: 24px;
width: 24px;
&.on {
@ -240,3 +272,10 @@
.rdio-status {
margin-bottom: 24px;
}
.rdio-help {
bottom: 24px;
opacity: 0.3;
position: absolute;
right: 24px;
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -18,8 +18,9 @@
*/
import { ChangeDetectorRef, Component, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatInput } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Subscription, timer } from 'rxjs';
import packageInfo from '../../../../../package.json';
import {
@ -32,8 +33,7 @@ import {
RdioScannerLivefeedMode,
} from '../rdio-scanner';
import { RdioScannerService } from '../rdio-scanner.service';
const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
import { RdioScannerSupportComponent } from './support/support.component';
@Component({
selector: 'rdio-scanner-main',
@ -42,14 +42,18 @@ const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
'./main.component.scss',
],
templateUrl: './main.component.html',
standalone: false
})
export class RdioScannerMainComponent implements OnDestroy, OnInit {
auth = false;
authForm = this.ngFormBuilder.group({ password: [] });
authForm: FormGroup;
avoided = false;
branding = '';
call: RdioScannerCall | undefined;
callDate: Date | undefined;
callError = '0';
callFrequency: string = this.formatFrequency(0);
callHistory: RdioScannerCall[] = new Array<RdioScannerCall>(5);
@ -61,27 +65,32 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
callTag = 'Tag';
callTalkgroup = 'Talkgroup';
callTalkgroupId = '0';
/*
* BEGIN OF RED TAPE:
*
* By modifying, deleting or disabling the following lines, you harm
* the open source project and its author. Rdio Scanner represents a lot of
* investment in time, support, testing and hardware.
*
* Be respectful, sponsor the project if you can, use native apps when possible.
*
*/
//
// BEGIN OF RED TAPE:
//
// By modifying, deleting or disabling the following lines, you harm
// the open source project and its author. Rdio Scanner represents a lot of
// investment in time, support, testing and hardware.
//
// Be respectful, sponsor the project, use native apps when possible.
//
callTalkgroupName = `Rdio Scanner v${packageInfo.version}`;
/**
* END OF RED TAPE.
*/
//
// END OF RED TAPE.
//
callTime = 0;
callUnit = '0';
clock = new Date();
delayed = false;
dimmer = false;
email = '';
holdSys = false;
holdTg = false;
@ -104,8 +113,12 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
replayOffset = 0;
replayTimer: Subscription | undefined;
tempAvoid = 0;
timeFormat = 'HH:mm';
type = '';
get showListenersCount(): boolean {
return this.config?.showListenersCount || false;
}
@ -124,18 +137,29 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
private dimmerTimer: Subscription | undefined;
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription;
constructor(
private rdioScannerService: RdioScannerService,
private matSnackBar: MatSnackBar,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder,
) { }
) {
this.authForm = this.ngFormBuilder.group<{
password: string | null;
}>({
password: null
});
authenticate(password = this.authForm.value.password): void {
this.authForm.disable();
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
}
this.rdioScannerService.authenticate(password);
authenticate(password = this.authForm.get('password')?.value): void {
if (password) {
this.authForm.disable();
this.rdioScannerService.authenticate(password);
}
}
authFocus(): void {
@ -145,28 +169,43 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
}
avoid(options?: RdioScannerAvoidOptions): void {
const call = this.call || this.callPrevious;
if (this.auth) {
this.authFocus();
} else {
const call = this.call || this.callPrevious;
if (options || call) {
} else if (options || call) {
if (options) {
this.rdioScannerService.avoid(options);
} else if (call) {
const avoided = this.rdioScannerService.isAvoided(call);
const minutes = this.rdioScannerService.isAvoidedTimer(call);
if (call && !this.map[call.system][call.talkgroup]) {
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
if (!avoided) {
this.rdioScannerService.avoid({ status: false });
} else if (!minutes) {
this.rdioScannerService.avoid({ minutes: 30, status: false });
} else if (minutes === 30) {
this.rdioScannerService.avoid({ minutes: 60, status: false });
} else if (minutes === 60) {
this.rdioScannerService.avoid({ minutes: 120, status: false });
} else {
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
this.rdioScannerService.avoid({ status: true });
}
}
if (call && this.rdioScannerService.isAvoided(call)) {
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
} else {
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
}
this.updateDimmer();
} else {
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
}
}
holdSystem(): void {
@ -286,6 +325,12 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
}
}
showHelp(): void {
this.matSnackBar.openFromComponent(RdioScannerSupportComponent, {
data: { email: this.email },
});
}
showSearchPanel(): void {
if (!this.config) {
return;
@ -335,17 +380,11 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
private eventHandler(event: RdioScannerEvent): void {
if ('auth' in event && event.auth) {
let password: string | null = null;
password = window?.localStorage?.getItem(LOCAL_STORAGE_KEY);
const password = this.rdioScannerService.readPin();
if (password) {
password = window.atob(password);
this.rdioScannerService.clearPin();
window.localStorage.removeItem(LOCAL_STORAGE_KEY);
}
if (password) {
this.authForm.get('password')?.setValue(password);
this.rdioScannerService.authenticate(password);
@ -378,12 +417,16 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
if ('config' in event) {
this.config = event.config;
this.branding = this.config?.branding ?? '';
this.email = this.config?.email ?? '';
this.timeFormat = this.config?.time12hFormat ? 'h:mm a' : 'HH:mm';
const password = this.authForm.get('password')?.value;
if (password) {
window?.localStorage?.setItem(LOCAL_STORAGE_KEY, window.btoa(password));
this.rdioScannerService.savePin(password);
this.authForm.reset();
}
@ -464,12 +507,31 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
.concat(' Hz') : '';
}
private isAfsSystem(talkgroupId: number): boolean {
if (typeof this.config?.afs !== 'string') {
return false;
private getLedColor(call: RdioScannerCall | undefined): string {
const colors = ['blue', 'cyan', 'green', 'magenta', 'orange', 'red', 'white', 'yellow'];
let color;
if (Array.isArray(call?.groupsData)) {
const group = call?.groupsData.find((g) => g.led);
if (group?.led) color = group.led;
} else if (call?.tagData?.led) {
color = call.tagData?.led
} else if (call?.systemData?.led) {
color = call?.systemData.led;
} else if (call?.talkgroupData?.led) {
color = call.talkgroupData.led;
}
return this.config.afs.split(',').includes(talkgroupId.toString());
return color && colors.includes(color) ? color : 'green';
}
private isAfsSystem(call: RdioScannerCall): boolean {
return (call.systemData?.type === 'provoice') || (call.talkgroupData?.type === 'provoice');
}
private syncClock(): void {
@ -500,11 +562,17 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
private updateDisplay(time = this.callTime): void {
if (this.call) {
let isAfs = this.isAfsSystem(this.call.system);
const isAfs = this.isAfsSystem(this.call);
this.callProgress = new Date(this.call.dateTime);
this.callProgress.setSeconds(this.callProgress.getSeconds() + time);
if (Date.now() - this.callProgress.getTime() >= 86400000) {
this.callDate = this.call.dateTime;
} else {
this.callDate = undefined;
}
this.callSystem = this.call.systemData?.label || `${this.call.system}`;
this.callTag = this.call.talkgroupData?.tag || '';
@ -537,19 +605,27 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
this.callTalkgroupId = isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup.toString();
if (typeof source.src === 'number' && Array.isArray(this.call.systemData?.units)) {
const callUnit = this.call?.systemData?.units?.find((u) => u.id === source.src);
if (typeof source.src === 'number') {
if (Array.isArray(this.call.systemData?.units)) {
this.callUnit = this.call.systemData?.units?.find((u) => {
if (typeof u.unitFrom === 'number' && typeof u.unitTo === 'number')
if (u.unitFrom <= (source.src as number) && u.unitTo >= (source.src as number))
return true;
this.callUnit = callUnit ? callUnit.label : `${source.src}`;
return u.id === source.src;
})?.label ?? `${source.src}`;
} else {
this.callUnit = typeof this.call.source === 'number' ? `${this.call.source}` : '';
console.log('here', this.callUnit);
} else {
this.callUnit = `${source.src}`;
}
}
} else {
this.callTalkgroupId = isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup.toString();
this.callUnit = typeof this.call.source === 'number' ? `${this.call.source}` : '';
this.callUnit = this.call.systemData?.units?.find((u) => u.id === this.call?.source)?.label ?? `${this.call.source ?? ''}`;
}
if (
@ -566,24 +642,30 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
const call = this.call || this.callPrevious;
if (call) {
this.delayed = call.delayed;
this.tempAvoid = this.rdioScannerService.isAvoidedTimer(call);
if (call.talkgroupData?.type)
this.type = call.talkgroupData.type;
else if (call.systemData?.type)
this.type = call.systemData.type;
if (this.rdioScannerService.isPatched(call)) {
this.avoided = false;
this.patched = true;
} else {
this.avoided = this.rdioScannerService.isAvoided(call);
this.patched = false;
}
}
const colors = ['blue', 'cyan', 'green', 'magenta', 'orange', 'red', 'white', 'yellow'];
this.ledStyle = this.call && this.livefeedPaused ? 'on paused' : this.call ? 'on' : 'off';
if (colors.includes(this.call?.talkgroupData?.led as string)) {
this.ledStyle = `${this.ledStyle} ${this.call?.talkgroupData?.led}`;
} else if (colors.includes(this.call?.systemData?.led as string)) {
this.ledStyle = `${this.ledStyle} ${this.call?.systemData?.led}`;
if (this.call) {
this.ledStyle = `${this.ledStyle} ${this.getLedColor(call)}`;
}
this.ngChangeDetectorRef.detectChanges();

View file

@ -0,0 +1,10 @@
<span class="support">
@if (email) {
For further assistance please contact <b><a href="mailto:{{ email }}" target="_blank">{{ email }}</a></b>
}
@if (!email) {
For further assistance please go to <b><a href="https://github.com/chuot/rdio-scanner/discussions"
target="_blank">Rdio&nbsp;Scanner&nbsp;discussions</a></b>
}
</span>
<span class="mat-caption countdown">{{ countdown }}</span>

View file

@ -0,0 +1,21 @@
:host {
a {
color: currentColor;
text-decoration: none;
}
b {
font-weight: 500;
}
.support {
margin: 0 2rem;
}
.countdown {
bottom: 4px;
font-size: 12px;
position: absolute;
right: 4px;
}
}

View file

@ -0,0 +1,56 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>
* ****************************************************************************
*/
import { Component, Inject, Optional } from '@angular/core';
import { MAT_SNACK_BAR_DATA, MatSnackBarRef } from '@angular/material/snack-bar';
import { timer } from 'rxjs';
@Component({
selector: 'RdioScannerSupport',
styleUrls: ['./support.component.scss'],
templateUrl: './support.component.html',
standalone: false
})
export class RdioScannerSupportComponent {
countdown = 10;
email: string | undefined;
constructor(
@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerSupportComponent>,
@Inject(MAT_SNACK_BAR_DATA) public data: { email: string },
) {
this.email = data?.email;
this.wait();
}
private wait(): void {
timer(1000).subscribe(() => {
this.countdown--;
if (this.countdown < 1) {
this.matSnackBarRef?.dismiss();
} else {
this.wait();
}
});
}
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({
selector: 'app-store-badge',
templateUrl: './app-store-badge.component.svg',
standalone: false
})
export class AppStoreBadgeComponent {}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({
selector: 'google-play-badge',
templateUrl: './google-play-badge.component.svg',
standalone: false
})
export class GooglePlayBadgeComponent {}

View file

@ -1,18 +1,18 @@
<div>
<div>
<p>Do you know that Rdio&nbsp;Scanner works natively on {{ deviceType }} devices?</p>
</div>
<div>
<ng-container *ngIf="isAndroid">
<a href="https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner" target="_blank">
<google-play-badge></google-play-badge>
</a>
</ng-container>
<ng-container *ngIf="isApple">
<a href="https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone" target="_blank">
<app-store-badge></app-store-badge>
</a>
</ng-container>
</div>
<div>
<p>Do you know that Rdio&nbsp;Scanner works natively on {{ deviceType }} devices?</p>
</div>
<div>
@if (isAndroid) {
<a href="https://play.google.com/store/apps/details?id=solutions.saubeo.rdioScanner" target="_blank">
<google-play-badge></google-play-badge>
</a>
}
@if (isApple) {
<a href="https://apps.apple.com/us/app/rdio-scanner/id1563065667#?platform=iphone" target="_blank">
<app-store-badge></app-store-badge>
</a>
}
</div>
</div>
<div class="countdown">{{ countdown }}</div>

View file

@ -15,7 +15,6 @@
.countdown {
bottom: 4px;
color: rgba(0, 0, 0, 0.67);
font-size: 12px;
position: absolute;
right: 4px;

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,19 +23,19 @@ import { timer } from 'rxjs';
@Component({
selector: 'RdioScannerNative',
styleUrls: ['./native.scss'],
styleUrls: ['./native.component.scss'],
templateUrl: './native.component.html',
standalone: false
})
export class RdioScannerNativeComponent implements OnInit {
countdown: number = 10;
countdown = 10;
deviceType: string = 'mobile';
deviceType = 'mobile';
isAndroid: boolean = false;
isApple: boolean = false;
isAndroid = false;
isApple = false;
constructor(@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerNativeComponent>) {
}
constructor(@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerNativeComponent>) { }
ngOnInit(): void {
const ua = navigator.userAgent;

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by

View file

@ -6,6 +6,7 @@ $background-color: rgb(30, 30, 30);
display: block;
height: 100%;
min-width: 320px;
position: relative;
.mat-sidenav {
background-color: inherit;

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -29,9 +29,10 @@ import { RdioScannerNativeComponent } from './native/native.component';
selector: 'rdio-scanner',
styleUrls: ['./rdio-scanner.component.scss'],
templateUrl: './rdio-scanner.component.html',
standalone: false
})
export class RdioScannerComponent implements OnDestroy, OnInit {
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription;
private livefeedMode: RdioScannerLivefeedMode = RdioScannerLivefeedMode.Offline;
@ -43,7 +44,9 @@ export class RdioScannerComponent implements OnDestroy, OnInit {
private matSnackBar: MatSnackBar,
private ngElementRef: ElementRef,
private rdioScannerService: RdioScannerService,
) { }
) {
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
}
@HostListener('window:beforeunload', ['$event'])
exitNotification(event: BeforeUnloadEvent): void {
@ -66,14 +69,14 @@ export class RdioScannerComponent implements OnDestroy, OnInit {
* the open source project and its author. Rdio Scanner represents a lot of
* investment in time, support, testing and hardware.
*
* Be respectful, sponsor the project if you can, use native apps when possible.
* Be respectful, sponsor the project, use native apps when possible.
*
*/
timer(10000).subscribe(() => {
const ua: String = navigator.userAgent;
const ua: string = navigator.userAgent;
if (ua.includes('Android') || ua.includes('iPad') || ua.includes('iPhone')) {
this.matSnackBar.openFromComponent(RdioScannerNativeComponent, { panelClass: 'snackbar-white' });
this.matSnackBar.openFromComponent(RdioScannerNativeComponent);
}
});
/**

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,6 +23,7 @@ import { AppSharedModule } from '../../shared/shared.module';
import { RdioScannerComponent } from './rdio-scanner.component';
import { RdioScannerService } from './rdio-scanner.service';
import { RdioScannerMainComponent } from './main/main.component';
import { RdioScannerSupportComponent } from './main/support/support.component';
import { RdioScannerNativeModule } from './native/native.module';
import { RdioScannerSearchComponent } from './search/search.component';
import { RdioScannerSelectComponent } from './select/select.component';
@ -33,9 +34,13 @@ import { RdioScannerSelectComponent } from './select/select.component';
RdioScannerMainComponent,
RdioScannerSearchComponent,
RdioScannerSelectComponent,
RdioScannerSupportComponent,
],
exports: [RdioScannerComponent],
imports: [AppSharedModule, RdioScannerNativeModule],
imports: [
AppSharedModule,
RdioScannerNativeModule,
],
providers: [
RdioScannerService,
{ provide: OverlayContainer, useClass: FullscreenOverlayContainer },

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,8 +17,9 @@
* ****************************************************************************
*/
import { DOCUMENT } from '@angular/common';
import { EventEmitter, Inject, Injectable, OnDestroy } from '@angular/core';
import { EventEmitter, Inject, Injectable, OnDestroy, DOCUMENT } from '@angular/core';
import { Router } from '@angular/router';
import { interval, Subscription, timer } from 'rxjs';
import { takeWhile } from 'rxjs/operators';
import { AppUpdateService } from '../../shared/update/update.service';
@ -31,8 +32,10 @@ import {
RdioScannerCategoryType,
RdioScannerConfig,
RdioScannerEvent,
RdioScannerLivefeed,
RdioScannerLivefeedMap,
RdioScannerLivefeedMode,
RdioScannerOscillatorData,
RdioScannerPlaybackList,
RdioScannerSearchOptions,
} from './rdio-scanner';
@ -57,11 +60,14 @@ enum WebsocketCommand {
LivefeedMap = 'LFM',
Max = 'MAX',
Pin = 'PIN',
Version = 'VER',
}
@Injectable()
export class RdioScannerService implements OnDestroy {
static LOCAL_STORAGE_KEY = 'rdio-scanner';
static LOCAL_STORAGE_KEY_LEGACY = 'rdio-scanner';
static LOCAL_STORAGE_KEY_LFM = 'rdio-scanner-lfm';
static LOCAL_STORAGE_KEY_PIN = 'rdio-scanner-pin';
event = new EventEmitter<RdioScannerEvent>();
@ -70,8 +76,6 @@ export class RdioScannerService implements OnDestroy {
private audioSource: AudioBufferSourceNode | undefined;
private audioSourceStartTime = NaN;
private beepContext: AudioContext | undefined;
private call: RdioScannerCall | undefined;
private callPrevious: RdioScannerCall | undefined;
private callQueue: RdioScannerCall[] = [];
@ -81,38 +85,54 @@ export class RdioScannerService implements OnDestroy {
private config: RdioScannerConfig = {
dimmerDelay: false,
groups: {},
keypadBeeps: false,
groupsData: [],
keypadBeeps: undefined,
playbackGoesLive: false,
showListenersCount: false,
systems: [],
tags: {},
tagsToggle: false,
tagsData: [],
time12hFormat: false,
};
private instanceId = 'default';
private livefeedMap = {} as RdioScannerLivefeedMap;
private livefeedMapPriorToHoldSystem: RdioScannerLivefeedMap | undefined;
private livefeedMapPriorToHoldTalkgroup: RdioScannerLivefeedMap | undefined;
private livefeedMode = RdioScannerLivefeedMode.Offline;
private livefeedPaused = false;
private oscillatorContext: AudioContext | undefined;
private playbackList: RdioScannerPlaybackList | undefined;
private playbackPending: number | undefined;
private playbackRefreshing = false;
private replayDelay: Subscription | undefined;
private skipDelay: Subscription | undefined;
private websocket: WebSocket | undefined;
constructor(
appUpdateService: AppUpdateService,
private router: Router,
@Inject(DOCUMENT) private document: Document,
) {
if (router.url.endsWith('/reset')) {
window?.localStorage?.clear();
router.navigateByUrl(router.url.replace('/reset', ''), {
replaceUrl: true,
}).then(() => window?.location?.reload());
return;
}
this.bootstrapAudio();
this.restoreLivefeed();
this.initializeInstanceId();
this.readLivefeedMap();
this.openWebsocket();
}
@ -122,6 +142,29 @@ export class RdioScannerService implements OnDestroy {
}
avoid(options: RdioScannerAvoidOptions = {}): void {
const clearTimer = (lfm: RdioScannerLivefeed): void => {
lfm.minutes = undefined;
lfm.timer?.unsubscribe();
lfm.timer = undefined;
};
const setTimer = (lfm: RdioScannerLivefeed, minutes: number): void => {
lfm.minutes = minutes;
lfm.timer = timer(minutes * 60 * 1000).subscribe(() => {
lfm.active = true;
lfm.minutes = undefined;
lfm.timer = undefined;
this.rebuildCategories();
this.saveLivefeedMap();
this.event.emit({
categories: this.categories,
map: this.livefeedMap,
});
});
};
if (this.livefeedMapPriorToHoldSystem) {
this.livefeedMapPriorToHoldSystem = undefined;
}
@ -133,37 +176,39 @@ export class RdioScannerService implements OnDestroy {
if (typeof options.all === 'boolean') {
Object.keys(this.livefeedMap).map((sys: string) => +sys).forEach((sys: number) => {
Object.keys(this.livefeedMap[sys]).map((tg: string) => +tg).forEach((tg: number) => {
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !!options.all;
const lfm = this.livefeedMap[sys][tg];
clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !!options.all;
});
});
} else if (options.call) {
const sys = options.call.system;
const tg = options.call.talkgroup;
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
const lfm = this.livefeedMap[options.call.system][options.call.talkgroup];
clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
} else if (options.system && options.talkgroup) {
const sys = options.system.id;
const tg = options.talkgroup.id;
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
const lfm = this.livefeedMap[options.system.id][options.talkgroup.id];
clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
} else if (options.system && !options.talkgroup) {
const sys = options.system.id;
Object.keys(this.livefeedMap[sys]).map((tg: string) => +tg).forEach((tg: number) => {
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
const lfm = this.livefeedMap[sys][tg];
clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
});
} else {
const call = this.call || this.callPrevious;
if (call) {
const sys = call.system;
const tg = call.talkgroup;
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg];
const lfm = this.livefeedMap[call.system][call.talkgroup];
clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
}
}
@ -173,7 +218,7 @@ export class RdioScannerService implements OnDestroy {
this.rebuildCategories();
this.storeLivefeedMap();
this.saveLivefeedMap();
if (this.livefeedMode === RdioScannerLivefeedMode.Online) {
this.startLivefeed();
@ -188,42 +233,14 @@ export class RdioScannerService implements OnDestroy {
});
}
beep(style = RdioScannerBeepStyle.Activate): Promise<void> {
return new Promise((resolve) => {
const context = this.beepContext;
async beep(style = RdioScannerBeepStyle.Activate): Promise<void> {
const seq = this.config.keypadBeeps && this.config.keypadBeeps[style];
const seq = this.config.keypadBeeps && this.config.keypadBeeps[style];
if (seq) await this.playOscillatorSequence(seq);
}
if (!context || !seq) {
resolve();
return;
}
const gn = context.createGain();
gn.gain.value = .1;
gn.connect(context.destination);
seq.forEach((beep, index) => {
const osc = context.createOscillator();
osc.connect(gn);
osc.frequency.value = beep.frequency;
osc.type = beep.type;
if (index === seq.length - 1) {
osc.onended = () => resolve();
}
osc.start(context.currentTime + beep.begin);
osc.stop(context.currentTime + beep.end);
});
});
clearPin(): void {
window?.localStorage.removeItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN);
}
holdSystem(options?: { resubscribe?: boolean }): void {
@ -243,18 +260,17 @@ export class RdioScannerService implements OnDestroy {
this.livefeedMapPriorToHoldSystem = this.livefeedMap;
this.livefeedMap = Object.keys(this.livefeedMap).map((sys) => +sys).reduce((sysMap, sys) => {
const allOn = Object.keys(this.livefeedMap[sys]).every((tg) => !this.livefeedMap[sys][tg]);
const allOn = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).every((tg) => !this.livefeedMap[sys][tg]);
sysMap[sys] = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).reduce((tgMap, tg) => {
if (sys === call.system) {
tgMap[tg] = allOn || this.livefeedMap[sys][tg];
this.livefeedMap[sys][tg].timer?.unsubscribe();
} else {
tgMap[tg] = false;
}
tgMap[tg] = {
active: sys === call.system ? allOn || this.livefeedMap[sys][tg].active : false,
} as RdioScannerLivefeed;
return tgMap;
}, {} as { [key: number]: boolean });
}, {} as { [key: number]: RdioScannerLivefeed });
return sysMap;
}, {} as RdioScannerLivefeedMap);
@ -298,15 +314,14 @@ export class RdioScannerService implements OnDestroy {
this.livefeedMap = Object.keys(this.livefeedMap).map((sys) => +sys).reduce((sysMap, sys) => {
sysMap[sys] = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).reduce((tgMap, tg) => {
if (sys === call.system) {
tgMap[tg] = tg === call.talkgroup;
this.livefeedMap[sys][tg].timer?.unsubscribe();
} else {
tgMap[tg] = false;
}
tgMap[tg] = {
active: sys === call.system ? tg === call.talkgroup : false,
} as RdioScannerLivefeed;
return tgMap;
}, {} as { [key: number]: boolean });
}, {} as { [key: number]: RdioScannerLivefeed });
return sysMap;
}, {} as RdioScannerLivefeedMap);
@ -333,12 +348,19 @@ export class RdioScannerService implements OnDestroy {
}
isAvoided(call: RdioScannerCall): boolean {
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][call.talkgroup] === false;
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][call.talkgroup]?.active !== true;
}
isAvoidedTimer(call: RdioScannerCall): number {
if (!!this.livefeedMap[call.system] && this.livefeedMap[call.system][call.talkgroup]?.minutes !== undefined) {
return this.livefeedMap[call.system][call.talkgroup]?.minutes || 0;
}
return 0;
}
isPatched(call: RdioScannerCall): boolean {
return this.isAvoided(call) && call.patches.some((tg) => {
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][tg];
return this.isAvoided(call) && call.patches?.some((tg) => {
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][tg]?.active || false;
});
}
@ -451,11 +473,13 @@ export class RdioScannerService implements OnDestroy {
arrayBufferView[i] = this.call.audio.data[i];
}
this.audioContext?.decodeAudioData(arrayBuffer, (buffer) => {
this.audioContext?.decodeAudioData(arrayBuffer, async (buffer) => {
if (!this.audioContext || this.audioSource || !this.call) {
return;
}
await this.playAlert(this.call);
this.audioSource = this.audioContext.createBufferSource();
this.audioSource.buffer = buffer;
this.audioSource.connect(this.audioContext.destination);
@ -508,6 +532,16 @@ export class RdioScannerService implements OnDestroy {
this.play(this.call || this.callPrevious);
}
readPin(): string | undefined {
const pin = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN);
return pin ? window.atob(pin) : undefined;
}
savePin(pin: string): void {
window?.localStorage?.setItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN, window.btoa(pin));
}
searchCalls(options: RdioScannerSearchOptions): void {
this.sendtoWebsocket(WebsocketCommand.ListCall, options);
}
@ -543,11 +577,19 @@ export class RdioScannerService implements OnDestroy {
}
startLivefeed(): void {
const lfm = Object.keys(this.livefeedMap).reduce((sysMap: { [key: number]: { [key: number]: boolean } }, sys) => {
sysMap[+sys] = Object.keys(this.livefeedMap[+sys]).reduce((tgMap: { [key: number]: boolean }, tg: string) => {
tgMap[+tg] = this.livefeedMap[+sys][+tg].active;
return tgMap;
}, {});
return sysMap;
}, {});
this.livefeedMode = RdioScannerLivefeedMode.Online;
this.event.emit({ livefeedMode: this.livefeedMode });
this.sendtoWebsocket(WebsocketCommand.LivefeedMap, this.livefeedMap);
this.sendtoWebsocket(WebsocketCommand.LivefeedMap, lfm);
}
stop(options?: { emit?: boolean }): void {
@ -595,6 +637,12 @@ export class RdioScannerService implements OnDestroy {
}
toggleCategory(category: RdioScannerCategory): void {
const clearTimer = (lfm: RdioScannerLivefeed): void => {
lfm.minutes = 0;
lfm.timer?.unsubscribe();
lfm.timer = undefined;
};
if (category) {
if (this.livefeedMapPriorToHoldSystem) {
this.livefeedMapPriorToHoldSystem = undefined;
@ -608,20 +656,23 @@ export class RdioScannerService implements OnDestroy {
this.config?.systems.forEach((sys) => {
sys.talkgroups?.forEach((tg) => {
if (category.type == RdioScannerCategoryType.Group && tg.group === category.label) {
this.livefeedMap[sys.id][tg.id] = status;
const lfm = this.livefeedMap[sys.id][tg.id];
if (category.type == RdioScannerCategoryType.Group && tg.groups.includes(category.label)) {
clearTimer(lfm);
lfm.active = status;
} else if (category.type == RdioScannerCategoryType.Tag && tg.tag === category.label) {
this.livefeedMap[sys.id][tg.id] = status;
clearTimer(lfm);
lfm.active = status;
}
});
});
this.rebuildCategories();
if (this.call && !this.livefeedMap[this.call.system] &&
this.livefeedMap[this.call.system][this.call.talkgroup]) {
if (this.call && !this.livefeedMap[this.call.system] && this.livefeedMap[this.call.system][this.call.talkgroup]) {
clearTimer(this.livefeedMap[this.call.system][this.call.talkgroup]);
this.skip();
}
@ -629,7 +680,7 @@ export class RdioScannerService implements OnDestroy {
this.startLivefeed();
}
this.storeLivefeedMap();
this.saveLivefeedMap();
this.cleanQueue();
@ -651,8 +702,8 @@ export class RdioScannerService implements OnDestroy {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
}
if (!this.beepContext) {
this.beepContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
if (!this.oscillatorContext) {
this.oscillatorContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
}
if (this.audioContext) {
@ -669,19 +720,19 @@ export class RdioScannerService implements OnDestroy {
this.audioContext.onstatechange = () => resume();
}
if (this.beepContext) {
if (this.oscillatorContext) {
const resume = () => {
if (this.beepContext?.state === 'suspended') {
this.beepContext?.resume().then(() => resume());
if (this.oscillatorContext?.state === 'suspended') {
this.oscillatorContext?.resume().then(() => resume());
}
};
await this.beepContext.resume();
await this.oscillatorContext.resume();
this.beepContext.onstatechange = () => resume();
this.oscillatorContext.onstatechange = () => resume();
}
if (this.audioContext && this.beepContext) {
if (this.audioContext && this.oscillatorContext) {
events.forEach((event) => document.body.removeEventListener(event, bootstrap));
}
};
@ -690,8 +741,8 @@ export class RdioScannerService implements OnDestroy {
}
private cleanQueue(): void {
let isActive = (call: RdioScannerCall) => {
let lfm = (sys: number, tg: number) => this.livefeedMap && this.livefeedMap[sys] && this.livefeedMap[sys][tg];
const isActive = (call: RdioScannerCall) => {
const lfm = (sys: number, tg: number): boolean => this.livefeedMap && this.livefeedMap[sys] && this.livefeedMap[sys][tg]?.active;
let active = lfm(call.system, call.talkgroup);
if (!active && Array.isArray(call.patches)) {
for (let i = 0; i < call.patches.length; i++) {
@ -773,6 +824,10 @@ export class RdioScannerService implements OnDestroy {
return queueCount;
}
private initializeInstanceId(): void {
this.instanceId = this.router.parseUrl(this.router.url).queryParams['id'] || this.instanceId;
}
private openWebsocket(): void {
const websocketUrl = window.location.href.replace(/^http/, 'ws');
@ -793,6 +848,7 @@ export class RdioScannerService implements OnDestroy {
this.websocket.onmessage = (ev: MessageEvent) => this.parseWebsocketMessage(ev.data);
}
this.sendtoWebsocket(WebsocketCommand.Version);
this.sendtoWebsocket(WebsocketCommand.Config);
};
}
@ -809,8 +865,8 @@ export class RdioScannerService implements OnDestroy {
switch (message[0]) {
case WebsocketCommand.Call:
if (message[1] !== null) {
let call: RdioScannerCall = message[1];
let flag: string = message[2];
const call: RdioScannerCall = message[1];
const flag: string = message[2];
if (flag === WebsocketCallFlag.Download) {
this.download(message[1]);
@ -831,21 +887,21 @@ export class RdioScannerService implements OnDestroy {
const config = message[1];
this.config = {
alerts: config.alerts,
branding: typeof config.branding === 'string' ? config.branding : '',
dimmerDelay: typeof config.dimmerDelay === 'number' ? config.dimmerDelay : 5000,
email: typeof config.email === 'string' ? config.email : '',
groups: typeof config.groups !== null && typeof config.groups === 'object' ? config.groups : {},
groupsData: Array.isArray(config.groupsData) ? config.groupsData : [],
keypadBeeps: config.keypadBeeps !== null && typeof config.keypadBeeps === 'object' ? config.keypadBeeps : {},
playbackGoesLive: typeof config.playbackGoesLive === 'boolean' ? config.playbackGoesLive : false,
showListenersCount: typeof config.showListenersCount === 'boolean' ? config.showListenersCount : false,
systems: Array.isArray(config.systems) ? config.systems.slice() : [],
tags: typeof config.tags !== null && typeof config.tags === 'object' ? config.tags : {},
tagsToggle: typeof config.tagsToggle === 'boolean' ? config.tagsToggle : false,
tagsData: Array.isArray(config.tagsData) ? config.tagsData : [],
time12hFormat: typeof config.time12hFormat === 'boolean' ? config.time12hFormat : false,
};
if (typeof config.afs === 'string' && config.afs.length) {
this.config['afs'] = config.afs;
}
this.rebuildLivefeedMap();
if (this.livefeedMode === RdioScannerLivefeedMode.Online) {
@ -898,10 +954,64 @@ export class RdioScannerService implements OnDestroy {
this.event.emit({ auth: true });
break;
case WebsocketCommand.Version: {
const data = message[1];
if (data !== null && typeof data === 'object') {
const branding = data['branding'];
const email = data['email'];
if (typeof branding === 'string') {
this.config.branding = branding;
}
if (typeof email === 'string') {
this.config.email = email;
}
if (this.config.branding || this.config.email) {
this.event.emit({ config: this.config });
}
}
break;
}
}
}
}
private async playAlert(call: RdioScannerCall): Promise<void> {
if (this.config.alerts) {
let alert: string | undefined;
call?.talkgroupData?.groups.some((label) => {
const group = this.config.groupsData?.find((group) => group.label == label);
if (group && group.alert) {
alert = group.alert;
return true;
}
return false;
});
if (!alert) {
const tag = this.config.tagsData?.find((tag) => tag.label == call.talkgroupData?.tag);
if (tag && tag.alert) alert = tag.alert;
}
if (!alert) alert = call.systemData?.alert;
if (!alert) alert = call.talkgroupData?.alert;
if (alert) await this.playOscillatorSequence(this.config.alerts[alert]);
console.log(alert);
}
}
private playbackNextCall(): void {
if (this.call || this.livefeedMode !== RdioScannerLivefeedMode.Playback || !this.playbackList || this.playbackPending) {
return;
@ -965,62 +1075,116 @@ export class RdioScannerService implements OnDestroy {
}
}
private playOscillatorSequence(seq: RdioScannerOscillatorData[]): Promise<void> {
return new Promise((resolve) => {
const context = this.oscillatorContext;
if (!context || !seq) {
resolve();
return;
}
const gn = context.createGain();
gn.gain.value = .1;
gn.connect(context.destination);
seq.forEach((data, index) => {
const osc = context.createOscillator();
osc.connect(gn);
osc.frequency.value = data.frequency;
osc.type = data.type;
if (index === seq.length - 1) {
osc.onended = () => resolve();
}
osc.start(context.currentTime + data.begin);
osc.stop(context.currentTime + data.end);
});
});
}
private readLivefeedMap(): void {
try {
let lfm: { [key: number]: { [key: number]: boolean } } = {};
let store = window?.localStorage?.getItem(`${RdioScannerService.LOCAL_STORAGE_KEY_LFM}-${this.instanceId}`);
if (store !== null) {
lfm = JSON.parse(store);
} else {
store = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY_LEGACY);
if (store !== null) {
lfm = JSON.parse(store);
}
}
Object.keys(lfm ?? {}).forEach((sys: string) => {
Object.keys(lfm[+sys]).forEach((tg) => {
if (!this.livefeedMap[+sys]) this.livefeedMap[+sys] = {};
if (!this.livefeedMap[+sys][+tg]) this.livefeedMap[+sys][+tg] = {} as RdioScannerLivefeed;
this.livefeedMap[+sys][+tg].active = lfm[+sys][+tg];
});
});
} catch (_) {
//
}
}
private rebuildCategories(): void {
this.categories = Object.keys(this.config.groups || []).map((label) => {
const allOff = Object.keys(this.config.groups[label]).map((sys) => +sys)
.every((sys: number) => this.config.groups[label] && this.config.groups[label][sys]
.every((tg) => this.livefeedMap[sys] && !this.livefeedMap[sys][tg]));
.every((tg) => this.livefeedMap[sys] && !this.livefeedMap[sys][tg].active));
const allOn = Object.keys(this.config.groups[label]).map((sys) => +sys)
.every((sys: number) => this.config.groups[label] && this.config.groups[label][sys]
.every((tg) => this.livefeedMap[sys] && this.livefeedMap[sys][tg]));
.every((tg) => this.livefeedMap[sys] && this.livefeedMap[sys][tg].active));
const status = allOff ? RdioScannerCategoryStatus.Off : allOn ? RdioScannerCategoryStatus.On : RdioScannerCategoryStatus.Partial;
return { label, status, type: RdioScannerCategoryType.Group };
})
if (this.config.tagsToggle) {
this.categories = this.categories.concat(Object.keys(this.config.tags || []).map((label) => {
const allOff = Object.keys(this.config.tags[label]).map((sys) => +sys)
.every((sys: number) => this.config.tags[label] && this.config.tags[label][sys]
.every((tg) => this.livefeedMap[sys] && !this.livefeedMap[sys][tg]));
const allOn = Object.keys(this.config.tags[label]).map((sys) => +sys)
.every((sys: number) => this.config.tags[label] && this.config.tags[label][sys]
.every((tg) => this.livefeedMap[sys] && this.livefeedMap[sys][tg]));
const status = allOff ? RdioScannerCategoryStatus.Off : allOn ? RdioScannerCategoryStatus.On : RdioScannerCategoryStatus.Partial;
return { label, status, type: RdioScannerCategoryType.Tag };
}))
}
this.categories.sort((a, b) => a.label.localeCompare(b.label));
}
private rebuildLivefeedMap(): void {
const livefeedMap = this.config.systems.reduce((sysMap, sys) => {
const lfm = this.config.systems.reduce((sysMap, sys) => {
sysMap[sys.id] = sys.talkgroups.reduce((tgMap, tg) => {
const state = this.livefeedMap && this.livefeedMap[sys.id] && this.livefeedMap[sys.id][tg.id];
const group = this.categories.find((cat) => tg.groups.includes(cat.label));
const tag = this.categories.find((cat) => cat.label === tg.tag);
tgMap[tg.id] = typeof state === 'boolean' ? state : true;
tgMap[tg.id] = (this.livefeedMap[sys.id] && this.livefeedMap[sys.id][tg.id])
? this.livefeedMap[sys.id][tg.id]
: {
active: !(group?.status === RdioScannerCategoryStatus.Off || tag?.status === RdioScannerCategoryStatus.Off),
} as RdioScannerLivefeed;
return tgMap;
}, sysMap[sys.id] || {});
}, sysMap[sys.id] || {} as { [key: number]: RdioScannerLivefeed });
return sysMap;
}, {} as RdioScannerLivefeedMap);
if (this.livefeedMapPriorToHoldSystem != null) {
this.livefeedMapPriorToHoldSystem = livefeedMap;
this.livefeedMapPriorToHoldSystem = lfm;
} else if (this.livefeedMapPriorToHoldTalkgroup != null) {
this.livefeedMapPriorToHoldTalkgroup = livefeedMap;
this.livefeedMapPriorToHoldTalkgroup = lfm;
} else {
this.livefeedMap = livefeedMap;
this.livefeedMap = lfm;
}
this.storeLivefeedMap();
this.saveLivefeedMap();
this.rebuildCategories();
}
@ -1031,17 +1195,16 @@ export class RdioScannerService implements OnDestroy {
this.openWebsocket();
}
private restoreLivefeed(): void {
const map = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY);
private saveLivefeedMap(): void {
const lfm = Object.keys(this.livefeedMap).reduce((sysMap: { [key: number]: { [key: number]: boolean } }, sys: string) => {
sysMap[+sys] = Object.keys(this.livefeedMap[+sys]).reduce((tgMap: { [key: number]: boolean }, tg: string) => {
tgMap[+tg] = this.livefeedMap[+sys][+tg].active;
return tgMap;
}, {});
return sysMap;
}, {});
if (map) {
try {
this.livefeedMap = JSON.parse(map);
} catch (err) {
this.livefeedMap = {};
}
}
window?.localStorage?.setItem(`${RdioScannerService.LOCAL_STORAGE_KEY_LFM}-${this.instanceId}`, JSON.stringify(lfm));
}
private sendtoWebsocket(command: string, payload?: unknown, flags?: string): void {
@ -1060,9 +1223,6 @@ export class RdioScannerService implements OnDestroy {
}
}
private storeLivefeedMap(): void {
window?.localStorage?.setItem(RdioScannerService.LOCAL_STORAGE_KEY, JSON.stringify(this.livefeedMap));
}
private transformCall(call: RdioScannerCall): RdioScannerCall {
if (call && Array.isArray(this.config?.systems)) {
@ -1075,6 +1235,10 @@ export class RdioScannerService implements OnDestroy {
if (call.talkgroupData?.frequency) {
call.frequency = call.talkgroupData.frequency;
}
call.groupsData = this.config.groupsData.filter((gd) => call.talkgroupData?.groups.some((l) => l === gd.label));
call.tagData = this.config.tagsData.find((td) => td.label === call.talkgroupData?.tag);
}
return call;

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,19 +17,19 @@
* ****************************************************************************
*/
import { Subscription } from "rxjs";
export interface RdioScannerAvoidOptions {
all?: boolean;
call?: RdioScannerCall;
minutes?: number;
status?: boolean;
system?: RdioScannerSystem;
talkgroup?: RdioScannerTalkgroup;
status?: boolean;
}
export interface RdioScannerBeep {
begin: number;
end: number;
frequency: number;
type: OscillatorType;
export interface RdioScannerAlerts {
[key: string]: RdioScannerOscillatorData[];
}
export enum RdioScannerBeepStyle {
@ -46,19 +46,23 @@ export interface RdioScannerCall {
audioName?: string;
audioType?: string;
dateTime: Date;
delayed: boolean;
frequencies?: RdioScannerCallFrequency[];
frequency?: number;
groupsData?: RdioScannerGroupData[];
id: number;
patches: number[];
source?: number;
sources?: RdioScannerCallSource[];
system: number;
tagData?: RdioScannerTagData;
talkgroup: number;
talkgroupData?: RdioScannerTalkgroup;
systemData?: RdioScannerSystem;
}
export interface RdioScannerCallFrequency {
dbm?: number;
errorCount?: number;
freq?: number;
len?: number;
@ -89,15 +93,18 @@ export enum RdioScannerCategoryType {
}
export interface RdioScannerConfig {
afs?: string;
alerts?: RdioScannerAlerts;
branding?: string;
dimmerDelay: number | false;
email?: string;
groups: { [key: string]: { [key: number]: number[] } };
keypadBeeps: RdioScannerKeypadBeeps | false;
groupsData: RdioScannerGroupData[];
keypadBeeps: RdioScannerKeypadBeeps | undefined;
playbackGoesLive: boolean;
showListenersCount: boolean;
systems: RdioScannerSystem[];
tags: { [key: string]: { [key: number]: number[] } };
tagsToggle: boolean;
tagsData: RdioScannerTagData[];
time12hFormat: boolean;
}
@ -121,15 +128,28 @@ export interface RdioScannerEvent {
tooMany?: boolean;
}
export interface RdioScannerGroupData {
id: number;
alert?: string;
label?: string;
led?: string;
}
export interface RdioScannerKeypadBeeps {
[RdioScannerBeepStyle.Activate]: RdioScannerBeep[];
[RdioScannerBeepStyle.Deactivate]: RdioScannerBeep[];
[RdioScannerBeepStyle.Denied]: RdioScannerBeep[];
[RdioScannerBeepStyle.Activate]: RdioScannerOscillatorData[];
[RdioScannerBeepStyle.Deactivate]: RdioScannerOscillatorData[];
[RdioScannerBeepStyle.Denied]: RdioScannerOscillatorData[];
}
export interface RdioScannerLivefeed {
active: boolean;
minutes: number | undefined;
timer: Subscription | undefined;
}
export interface RdioScannerLivefeedMap {
[key: string]: {
[key: string]: boolean;
[key: number]: {
[key: number]: RdioScannerLivefeed;
};
}
@ -139,6 +159,13 @@ export enum RdioScannerLivefeedMode {
Playback = 'playback',
}
export interface RdioScannerOscillatorData {
begin: number;
end: number;
frequency: number;
type: OscillatorType;
}
export interface RdioScannerPlaybackList {
count: number;
dateStart: Date;
@ -156,28 +183,43 @@ export interface RdioScannerSearchOptions {
system?: number;
tag?: string;
talkgroup?: number;
unit?: number;
}
export interface RdioScannerSystem {
id: number;
alert?: string;
label: string;
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow';
order?: number;
talkgroups: RdioScannerTalkgroup[];
type?: string;
units: RdioScannerUnit[];
}
export interface RdioScannerTagData {
id: number;
alert?: string;
label?: string;
led?: string;
}
export interface RdioScannerTalkgroup {
alert?: string;
frequency?: number;
group: string;
groups: string[];
id: number;
label: string;
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow';
name: string;
tag: string;
type?: string;
}
export interface RdioScannerUnit {
id: number;
label: string;
unitRef: number;
unitFrom: number;
unitTo: number;
}

View file

@ -1,158 +1,181 @@
<mat-card class="rdio-list">
<mat-table [dataSource]="results">
<ng-container matColumnDef="control">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row">
<button *ngIf="downloadMode.checked && row" mat-icon-button (click)="download(+row.id)">
<mat-icon>save_alt</mat-icon>
</button>
<button *ngIf="row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending"
mat-icon-button (click)="play(+row.id)">
<mat-icon>play_arrow</mat-icon>
</button>
<button *ngIf="row && !downloadMode.checked && row?.id == callPending" mat-icon-button>
<mat-icon class="spin">cached</mat-icon>
</button>
<button *ngIf="row && !downloadMode.checked && row?.id == call?.id" mat-icon-button (click)="stop()">
<mat-icon>stop</mat-icon>
</button>
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef [ngClass]="{'time12h': time12h}">
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let row" [ngClass]="{'time12h': time12h}">
<span>{{ row?.dateTime | date: time12h ? 'h:mm a' : 'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="system">
<mat-header-cell *matHeaderCellDef>
<span>System</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.systemData?.label || row?.system }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="alpha">
<mat-header-cell *matHeaderCellDef>
<span>Talkgroup</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.label || row?.talkgroup }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>
<span>Name</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.name }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-row>
</mat-table>
<mat-progress-bar color="primary" [mode]="resultsPending ? 'query' : 'determinate'">
</mat-progress-bar>
<div class="paginator">
<mat-slide-toggle #downloadMode color="primary" labelPosition="before">
<mat-card appearance="outlined" class="rdio-list">
<mat-table [dataSource]="results">
<ng-container matColumnDef="control">
<mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row">
@if (downloadMode.checked && row) {
<button mat-icon-button (click)="download(+row.id)">
<mat-icon>save_alt</mat-icon>
</mat-slide-toggle>
<mat-paginator [disabled]="livefeedPlayback || resultsPending" [length]="playbackList?.count"
[hidePageSize]="true" [pageSize]="results.value.length" [showFirstLastButtons]="true"
(page)="refreshResults()">
</mat-paginator>
</button>
}
@if (row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending) {
<button
mat-icon-button (click)="play(+row.id)">
<mat-icon>play_arrow</mat-icon>
</button>
}
@if (row && !downloadMode.checked && row?.id == callPending) {
<button mat-icon-button>
<mat-icon class="spin">cached</mat-icon>
</button>
}
@if (row && !downloadMode.checked && row?.id == call?.id) {
<button mat-icon-button (click)="stop()">
<mat-icon>stop</mat-icon>
</button>
}
</mat-cell>
</ng-container>
<ng-container matColumnDef="date">
<mat-header-cell *matHeaderCellDef>
<span>Date</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.dateTime | date:'MM/dd' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef [ngClass]="{'time12h': time12h}">
<span>Time</span>
</mat-header-cell>
<mat-cell *matCellDef="let row" [ngClass]="{'time12h': time12h}">
<span>{{ row?.dateTime | date: time12h ? 'h:mm a' : 'HH:mm' }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="system">
<mat-header-cell *matHeaderCellDef>
<span>System</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.systemData?.label || row?.system }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="alpha">
<mat-header-cell *matHeaderCellDef>
<span>Talkgroup</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.label || row?.talkgroup }}</span>
</mat-cell>
</ng-container>
<ng-container matColumnDef="name">
<mat-header-cell *matHeaderCellDef>
<span>Name</span>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<span>{{ row?.talkgroupData?.name }}</span>
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-header-row>
<mat-row *matRowDef="let row; columns: ['control', 'date', 'time', 'system', 'alpha', 'name']">
</mat-row>
</mat-table>
<mat-progress-bar color="primary" [mode]="resultsPending ? 'query' : 'determinate'">
</mat-progress-bar>
<div class="paginator">
<mat-slide-toggle #downloadMode color="primary" labelPosition="before">
<mat-icon>save_alt</mat-icon>
</mat-slide-toggle>
<mat-paginator [disabled]="livefeedPlayback || resultsPending" [length]="playbackList?.count"
[hidePageSize]="true" [pageSize]="results.value.length" [showFirstLastButtons]="true"
(page)="refreshResults()">
</mat-paginator>
</div>
</mat-card>
<mat-card appearance="outlined" class="rdio-filters">
<form autocomplete="off" [formGroup]="form">
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
</mat-form-field>
<mat-form-field>
<mat-label>
System
</mat-label>
<mat-select formControlName="system" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Systems
</mat-option>
@for (system of optionsSystem; track system; let i = $index) {
<mat-option [value]="i">
{{ system }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Talkgroup
</mat-label>
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Talkgroups
</mat-option>
@for (talkgroup of optionsTalkgroup; track talkgroup; let i = $index) {
<mat-option [value]="i">
{{ talkgroup }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Group
</mat-label>
<mat-select formControlName="group" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Groups
</mat-option>
@for (group of optionsGroup; track group; let i = $index) {
<mat-option [value]="i">
{{ group }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Tag
</mat-label>
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Tags
</mat-option>
@for (tag of optionsTag; track tag; let i = $index) {
<mat-option [value]="i">
{{ tag }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Unit
</mat-label>
<input type="number" matInput>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
<div class="reset">
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()">
Reset
</button>
</div>
</mat-card>
<mat-card class="rdio-filters">
<form autocomplete="off" [formGroup]="form">
<mat-form-field>
<mat-label>
Sort order
</mat-label>
<mat-select formControlName="sort" (selectionChange)="formChangeHandler()">
<mat-option [value]="1">
Ascending
</mat-option>
<mat-option [value]="-1">
Descending
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Date
</mat-label>
<input matInput type="datetime-local" formControlName="date" [max]="playbackList?.dateStop"
[min]="playbackList?.dateStart" placeholder="Choose a date" (change)="formChangeHandler()">
</mat-form-field>
<mat-form-field>
<mat-label>
System
</mat-label>
<mat-select formControlName="system" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Systems
</mat-option>
<mat-option *ngFor="let system of optionsSystem; index as i" [value]="i">
{{ system }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Talkgroup
</mat-label>
<mat-select formControlName="talkgroup" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Talkgroups
</mat-option>
<mat-option *ngFor="let talkgroup of optionsTalkgroup; index as i" [value]="i">
{{ talkgroup }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Group
</mat-label>
<mat-select formControlName="group" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Groups
</mat-option>
<mat-option *ngFor="let group of optionsGroup; index as i" [value]="i">
{{ group }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field>
<mat-label>
Tag
</mat-label>
<mat-select formControlName="tag" (selectionChange)="formChangeHandler()">
<mat-option [value]="-1">
All Tags
</mat-option>
<mat-option *ngFor="let tag of optionsTag; index as i" [value]="i">
{{ tag }}
</mat-option>
</mat-select>
</mat-form-field>
<div class="reset">
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()">
Reset
</button>
</div>
</form>
</form>
</mat-card>

View file

@ -5,13 +5,18 @@
user-select: none;
}
.rdio-filters,
.rdio-list {
padding: 1rem;
}
.rdio-filters form {
display: flex;
flex-direction: row;
flex-wrap: wrap;
.mat-form-field {
@for $i from 1 through 6 {
.mat-mdc-form-field {
@for $i from 1 through 7 {
&:nth-of-type(#{$i}) {
order: #{$i};
}
@ -33,8 +38,8 @@
overflow: hidden;
padding: 0;
.mat-cell,
.mat-header-cell {
.mat-mdc-cell,
.mat-mdc-header-cell {
&:first-of-type {
padding-left: 0;
}
@ -44,19 +49,19 @@
}
&:nth-child(1) {
flex: 0 0 40px;
flex: 0 0 48px;
}
&:nth-child(2) {
flex: 0 0 50px;
flex: 0 0 55px;
}
&:nth-child(3):not(.time12h) {
flex: 0 0 50px;
flex: 0 0 53px;
}
&:nth-child(3).time12h {
flex: 0 0 70px;
flex: 0 0 78px;
}
&:nth-child(4),
@ -71,12 +76,17 @@
}
}
.mdc-data-table__header-cell,
.mdc-data-table__cell {
padding: 0 8px;
}
.paginator {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
padding-left: 8px;;
padding-left: 8px;
}
.spin {
@ -98,13 +108,19 @@
}
@media (max-width: 459px) {
.rdio-filters form .mat-form-field {
.rdio-filters form .mat-mdc-form-field {
flex: 100%;
}
}
@media (min-width: 460px) {
.rdio-list .mat-mdc-slide-toggle .mat-icon {
padding: 0 16px 0 4px;
}
}
@media (min-width: 460px) and (max-width: 979px) {
.rdio-filters form .mat-form-field {
.rdio-filters form .mat-mdc-form-field {
flex: calc(50% - 1em);
&:nth-of-type(2),
@ -116,7 +132,7 @@
}
@media (min-width: 980px) {
.rdio-filters form .mat-form-field {
.rdio-filters form .mat-mdc-form-field {
flex: calc(100% / 3 - 2em);
&:nth-of-type(2),

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -17,8 +17,8 @@
* ****************************************************************************
*/
import { ChangeDetectorRef, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms';
import { ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core';
import { FormBuilder, FormGroup } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { BehaviorSubject } from 'rxjs';
import {
@ -37,19 +37,13 @@ import { RdioScannerService } from '../rdio-scanner.service';
selector: 'rdio-scanner-search',
styleUrls: ['./search.component.scss'],
templateUrl: './search.component.html',
standalone: false
})
export class RdioScannerSearchComponent implements OnDestroy, OnInit {
export class RdioScannerSearchComponent implements OnDestroy {
call: RdioScannerCall | undefined;
callPending: number | undefined;
form = this.ngFormBuilder.group({
date: [null],
group: [-1],
sort: [-1],
system: [-1],
tag: [-1],
talkgroup: [-1],
});
form: FormGroup;
livefeedOnline = false;
livefeedPlayback = false;
@ -70,7 +64,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
private config: RdioScannerConfig | undefined;
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
private eventSubscription;
private limit = 200;
@ -82,7 +76,27 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
private rdioScannerService: RdioScannerService,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder,
) { }
) {
this.form = this.ngFormBuilder.group<{
date: string | null;
group: number;
sort: number;
system: number;
tag: number;
talkgroup: number;
unit: number;
}>({
date: null,
group: -1,
sort: -1,
system: -1,
tag: -1,
talkgroup: -1,
unit: -1,
});
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
}
download(id: number): void {
this.rdioScannerService.loadAndDownload(id);
@ -104,10 +118,6 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
this.eventSubscription.unsubscribe();
}
ngOnInit(): void {
}
play(id: number): void {
this.rdioScannerService.loadAndPlay(id);
}
@ -125,7 +135,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
this.optionsSystem = this.config.systems
.filter((system) => {
const group = selectedGroup === undefined ||
system.talkgroups.some((talkgroup) => talkgroup.group === selectedGroup);
system.talkgroups.some((talkgroup) => talkgroup.groups.includes(selectedGroup));
const tag = selectedTag === undefined ||
system.talkgroups.some((talkgroup) => talkgroup.tag === selectedTag);
return group && tag;
@ -137,7 +147,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
: selectedSystem.talkgroups
.filter((talkgroup) => {
const group = selectedGroup == undefined ||
talkgroup.group === selectedGroup;
talkgroup.groups.includes(selectedGroup);
const tag = selectedTag == undefined ||
talkgroup.tag === selectedTag;
return group && tag;
@ -147,14 +157,14 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
this.optionsGroup = Object.keys(this.config.groups)
.filter((group) => {
const system: boolean = selectedSystem === undefined ||
selectedSystem.talkgroups.some((talkgroup) => talkgroup.group === group)
selectedSystem.talkgroups.some((talkgroup) => talkgroup.groups.includes(group))
const talkgroup: boolean = selectedTalkgroup === undefined ||
selectedTalkgroup.group === group;
selectedTalkgroup.groups.includes(group);
const tag: boolean = selectedTag === undefined ||
(selectedTalkgroup !== undefined && selectedTalkgroup.tag === selectedTag) ||
(this.config !== undefined && this.config.systems
.flatMap((system) => system.talkgroups)
.some((talkgroup) => talkgroup.group === group && talkgroup.tag === selectedTag))
.some((talkgroup) => talkgroup.groups.includes(group) && talkgroup.tag === selectedTag))
return system && talkgroup && tag;
})
.sort((a, b) => a.localeCompare(b))
@ -166,10 +176,10 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
const talkgroup: boolean = selectedTalkgroup === undefined ||
selectedTalkgroup.tag === tag;
const group: boolean = selectedGroup === undefined ||
(selectedTalkgroup !== undefined && selectedTalkgroup.group === selectedGroup) ||
(selectedTalkgroup !== undefined && selectedTalkgroup.groups.includes(selectedGroup)) ||
(this.config !== undefined && this.config.systems
.flatMap((system) => system.talkgroups)
.some((talkgroup) => talkgroup.tag === tag && talkgroup.group === selectedGroup))
.some((talkgroup) => talkgroup.tag === tag && talkgroup.groups.includes(selectedGroup)))
return system && talkgroup && group;
})
.sort((a, b) => a.localeCompare(b))
@ -213,6 +223,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
system: -1,
tag: -1,
talkgroup: -1,
unit: -1,
});
this.paginator?.firstPage();
@ -234,14 +245,14 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
const options: RdioScannerSearchOptions = {
limit: this.limit,
offset: this.offset,
sort: this.form.value.sort,
sort: this.form.get('sort')?.value ?? -1,
};
if (typeof this.form.value.date === 'string') {
options.date = new Date(Date.parse(this.form.value.date));
}
if (this.form.value.group >= 0) {
if ((this.form.get('group')?.value ?? -1) >= 0) {
const group = this.getSelectedGroup();
if (group) {
@ -249,7 +260,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
}
}
if (this.form.value.system >= 0) {
if ((this.form.get('system')?.value ?? -1) >= 0) {
const system = this.getSelectedSystem();
if (system) {
@ -257,7 +268,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
}
}
if (this.form.value.tag >= 0) {
if ((this.form.get('tag')?.value ?? -1) >= 0) {
const tag = this.getSelectedTag();
if (tag) {
@ -265,7 +276,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
}
}
if (this.form.value.talkgroup >= 0) {
if ((this.form.get('talkgroup')?.value ?? -1) >= 0) {
const talkgroup = this.getSelectedTalkgroup();
if (talkgroup) {
@ -297,7 +308,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
const index = this.results.value.findIndex((call) => call?.id === this.callPending);
if (index === -1) {
if (this.form.value.sort === -1) {
if (this.form.get('sort')?.value === -1) {
this.paginator?.previousPage();
} else {
@ -349,22 +360,22 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
}
private getSelectedGroup(): string | undefined {
return this.optionsGroup[this.form.value.group];
return this.optionsGroup[this.form.get('group')?.value ?? -1];
}
private getSelectedSystem(): RdioScannerSystem | undefined {
return this.config?.systems.find((system) => system.label === this.optionsSystem[this.form.value.system]);
return this.config?.systems.find((system) => system.label === this.optionsSystem[this.form.get('system')?.value ?? -1]);
}
private getSelectedTag(): string | undefined {
return this.optionsTag[this.form.value.tag];
return this.optionsTag[this.form.get('tag')?.value ?? -1];
}
private getSelectedTalkgroup(): RdioScannerTalkgroup | undefined {
const system = this.getSelectedSystem();
return system
? system.talkgroups.find((talkgroup) => talkgroup.label === this.optionsTalkgroup[this.form.value.talkgroup])
? system.talkgroups.find((talkgroup) => talkgroup.label === this.optionsTalkgroup[this.form.get('talkgroup')?.value ?? -1])
: undefined;
}
}

View file

@ -1,39 +1,47 @@
<fieldset *ngIf="categories?.length && (categories?.length || 0) >= 2" class="fieldset">
@if (categories?.length && (categories?.length || 0) >= 2) {
<fieldset class="fieldset">
<div>
<button *ngFor="let category of categories" class="rdio-button" [ngClass]="category.status"
(click)="toggle(category)">
{{ category.label }}
</button>
<button class="rdio-button-mini all-off" (click)="avoid({ all: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ all: true })">
ON
@for (category of categories; track category) {
<button class="rdio-button" [ngClass]="category.status"
(click)="toggle(category)">
{{ category.label }}
</button>
}
<button class="rdio-button-mini all-off" (click)="avoid({ all: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ all: true })">
ON
</button>
</div>
</fieldset>
<ng-container *ngFor="let system of systems">
<fieldset *ngIf="system.talkgroups.length" class="fieldset">
<legend>
{{ system.label }}
</legend>
<div>
<button *ngFor="let talkgroup of system.talkgroups" class="rdio-button"
[ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]), on: map[system.id] && map[system.id][talkgroup.id] }"
(click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }}
</button>
<ng-container *ngIf="system.talkgroups.length > 1">
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
</ng-container>
</div>
</fieldset>
}
@for (system of systems; track system) {
@if (system.talkgroups.length) {
<fieldset class="fieldset">
<legend>
{{ system.label }}
</legend>
<div>
@for (talkgroup of system.talkgroups; track talkgroup) {
<button class="rdio-button"
[ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]).active, on: map[system.id] && map[system.id][talkgroup.id].active, blink: map[system.id][talkgroup.id].minutes }"
(click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }}
</button>
}
@if (system.talkgroups.length > 1) {
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF
</button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON
</button>
}
</div>
</fieldset>
</ng-container>
<ng-container *ngIf="systems?.length === 0">
No talkgroup configured!
</ng-container>
}
}
@if (systems?.length === 0) {
No talkgroup configured!
}

View file

@ -1,6 +1,6 @@
/*
* *****************************************************************************
* Copyright (C) 2019-2022 Chrystian Huot <chrystian.huot@saubeo.solutions>
* Copyright (C) 2019-2026 Chrystian Huot <chrystian.huot@saubeo.solutions>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -36,6 +36,7 @@ import { RdioScannerService } from '../rdio-scanner.service';
'./select.component.scss',
],
templateUrl: './select.component.html',
standalone: false
})
export class RdioScannerSelectComponent implements OnDestroy {
categories: RdioScannerCategory[] | undefined;
@ -44,11 +45,11 @@ export class RdioScannerSelectComponent implements OnDestroy {
systems: RdioScannerSystem[] | undefined;
tagsToggle: boolean | undefined;
private eventSubscription;
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
constructor(private rdioScannerService: RdioScannerService) { }
constructor(private rdioScannerService: RdioScannerService) {
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
}
avoid(options?: RdioScannerAvoidOptions): void {
if (options?.all == true) {
@ -58,7 +59,7 @@ export class RdioScannerSelectComponent implements OnDestroy {
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
} else if (options?.system !== undefined && options?.talkgroup !== undefined) {
this.rdioScannerService.beep(this.map[options!.system.id][options!.talkgroup.id]
this.rdioScannerService.beep(this.map[options.system.id][options.talkgroup.id].active
? RdioScannerBeepStyle.Deactivate
: RdioScannerBeepStyle.Activate
);
@ -84,10 +85,7 @@ export class RdioScannerSelectComponent implements OnDestroy {
}
private eventHandler(event: RdioScannerEvent): void {
if (event.config) {
this.tagsToggle = event.config.tagsToggle;
this.systems = event.config.systems;
}
if (event.config) this.systems = event.config.systems;
if (event.categories) this.categories = event.categories;
if (event.map) this.map = event.map;
}

View file

@ -1,6 +1,6 @@
<h1 class="mat-h1">Rdio Scanner server v{{version}}</h1>
<div>
<mat-card>
<mat-card appearance="outlined">
<rdio-scanner-admin></rdio-scanner-admin>
</mat-card>
</div>

View file

@ -23,7 +23,7 @@
line-height: 32px;
}
.mat-card {
.mat-mdc-card {
padding: 8px;
}
}

Some files were not shown because too many files have changed in this diff Show more