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 # 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 ## Version 6.4
- New `-cmd` command line options to allow advanced administrative tasks. - New `-cmd` command line options to allow advanced administrative tasks.
- New playback mode goes live options which is not enabled by default (issue #175). - 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). - Improved field conversions when retrieving calls from a mysql/mariadb database (issue #194, #198).
- Highlight replayed call on the history list (issue #196). - 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. - New audio conversion options which replace the disable audio conversion option.
- Keep database connections open and don't close them when idle. - Keep database connections open and don't close them when idle.
- Log the origin of listeners. - Log the origin of listeners.
- Fix timestamp format when checking for call duplicates. - Fixed 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 http timeouts on call ingestions or admin config save when dowstream takes too long (issue #197).
_v6.4.2_ _v6.4.2_
@ -26,9 +122,18 @@ _v6.4.3_
- Add a note on the dirwatch admin screen about sdr-trunk. - Add a note on the dirwatch admin screen about sdr-trunk.
- Starts client read/write pumps before registering with the controller (issue #181, #212). - 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 ## 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). - 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). - 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). - Playback mode will now catch up to live (issue #175).
@ -42,70 +147,70 @@ _v6.3.1_
_v6.3.2_ _v6.3.2_
- New #TGLBL metatag for dirwatch for ProScan (%C) or alike. - 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). - Replay button now replays from history if pressed multiple times quickly (issue #186).
_v6.3.3_ _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. - 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. - Add loudnorm audio filter to the ffmpeg audio conversion.
- Show the real IP address in the logs taking into account if behind a proxy. - 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_ _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. - 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. - Dirwatch now catches panic errors and logs them.
_v6.3.5_ _v6.3.5_
- Replace standard map with sync.map in dirwatch. - Replace standard map with sync.map in dirwatch.
- Fix the ffmpeg version test. - Fixed the ffmpeg version test.
- Fix led color type, orage -> orange. - Fixed led color type, orage -> orange.
- Fix incorrect options when reading from a mysql database (issue #190). - Fixed incorrect options when reading from a mysql database (issue #190).
_v6.3.6_ _v6.3.6_
- Fix systems order properties not sent to clients. - Fixed systems order properties not sent to clients.
- Fix side panels not scrolling to top when opened. - Fixed side panels not scrolling to top when opened.
## Version 6.2 ## Version 6.2
- New max clients options which is 200 by default. - New max clients options which is 200 by default.
- New show listeners count options which is disabled by default (issue #125). - New show listeners count options which is disabled by default (issue #125).
- Fix panic: concurrent write to websocket connection on goroutine. - Fixed panic: concurrent write to websocket connection on goroutine.
- Fix units import from SDR Trunk (issue #150). - Fixed units import from SDR Trunk (issue #150).
_v6.2.1_ _v6.2.1_
- Fix SIGSEGV error in Units.Merge (issue #151). - Fixed SIGSEGV error in Units.Merge (issue #151).
_v6.2.2_ _v6.2.2_
- Fix another SIGSEGV error in Units.Merge (issue #151). - Fixed another SIGSEGV error in Units.Merge (issue #151).
_v6.2.3_ _v6.2.3_
- New random UUID in the JSON-Web Token payload. - New random UUID in the JSON-Web Token payload.
- Fix dirwatch not properly shutting down when a new configuration is applied. - Fixed dirwatch not properly shutting down when a new configuration is applied.
- Fix dashboard logout not sending HTTP 200 OK status. - Fixed dashboard logout not sending HTTP 200 OK status.
- Clear the active dirwatch list when stopped. - Clear the active dirwatch list when stopped.
- Pauses calls ingestion before database pruning. - 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. - Update SQLite driver.
_v6.2.4_ _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. - 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_ _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 ## Version 6.1
@ -113,55 +218,55 @@ _v6.2.5_
- New search patched talkgroups option which is disabled by default. - New search patched talkgroups option which is disabled by default.
- Talkgroups and units are now stored in their own database table. - Talkgroups and units are now stored in their own database table.
- New units CSV importer. - New units CSV importer.
- Fix blacklisted talkgroups being created anyway when autopopulate is enabled. - Fixed blacklisted talkgroups being created anyway when autopopulate is enabled.
- Fix compatibility with mysql/mariadb (default sqlite is still recommended). - Fixed compatibility with mysql/mariadb (default sqlite is still recommended).
_v6.1.1_ _v6.1.1_
- Fix `unknown datetime format sql.NullString` error. - Fixed `unknown datetime format sql.NullString` error.
_v6.1.2_ _v6.1.2_
- Fix image links in webapp.md (issue #76). - Fixed image links in webapp.md (issue #76).
- Fix SIGSEGV when trying to autopopulate (issue #77). - Fixed SIGSEGV when trying to autopopulate (issue #77).
- Fix parsing SDRTrunk meta data. - Fixed parsing SDRTrunk meta data.
- Dirwatch type trunk-recorder now deletes json files without audio (when deleteAfter is set). - Dirwatch type trunk-recorder now deletes json files without audio (when deleteAfter is set).
- Add a new `docs/update-from-v5.md` document. - Add a new `docs/update-from-v5.md` document.
_v6.1.3_ _v6.1.3_
- Fix concurrent config write when autopopulate is enabled (issue #77). - Fixed concurrent config write when autopopulate is enabled (issue #77).
- Fix API in regards to audio filename and audio type (issue #78). - Fixed API in regards to audio filename and audio type (issue #78).
- Fix migration error on mysql database (issue #86). - Fixed migration error on mysql database (issue #86).
- Fix some calls not playing on the native app (issue #87). - Fixed some calls not playing on the native app (issue #87).
- Fix admin password not read from mysql. - Fixed admin password not read from mysql.
_v6.1.4_ _v6.1.4_
- Talkgroup label now syncs with the talkgroup_tag from the API or dirwatch (issue #80). - Talkgroup label now syncs with the talkgroup_tag from the API or dirwatch (issue #80).
- Fix more migration errors on mysql database (issue #86). - Fixed more migration errors on mysql database (issue #86).
- Fix config export not working with non latin-1 characters (issue #89). - Fixed config export not working with non latin-1 characters (issue #89).
- Fix talkgroup label from dirwatch type sdrtrunk (discussion #98). - Fixed talkgroup label from dirwatch type sdrtrunk (discussion #98).
- Fix SIGSEGV (issue #100). - Fixed SIGSEGV (issue #100).
- New `patch` indicator for patched talkgroups. - New `patch` indicator for patched talkgroups.
_v6.1.5_ _v6.1.5_
- Fix trunk-recorder API (issue #104). - Fixed trunk-recorder API (issue #104).
- Fix for avoid/patch flags on main display not beaving as expected. - Fixed for avoid/patch flags on main display not beaving as expected.
- Fix downstream not sending sources data. - Fixed downstream not sending sources data.
- Fix dirwatch crashing when config is updated. - Fixed dirwatch crashing when config is updated.
_v6.1.6_ _v6.1.6_
- Fix webapp not reporting the correct version. - Fixed webapp not reporting the correct version.
_v6.1.7_ _v6.1.7_
- More concurrency mutexes to resolve SQL_BUSY errors. - More concurrency mutexes to resolve SQL_BUSY errors.
- Better internal management of dirwatches. - Better internal management of dirwatches.
- Fix SDRTrunk files not being ingested (discussion #108). - Fixed SDRTrunk files not being ingested (discussion #108).
- Fix Trunk Recorder talkgroup_tag assign to the wrong property (issue #115). - 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. - 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_ _v6.1.8_
@ -170,8 +275,8 @@ _v6.1.8_
_v6.1.9_ _v6.1.9_
- Fix talkgroup sorting issue when importing from a CSV file (issue #119). - Fixed talkgroup sorting issue when importing from a CSV file (issue #119).
- Fix SIGSEGV (issue #120). - Fixed SIGSEGV (issue #120).
_v6.1.10_ _v6.1.10_
@ -179,36 +284,36 @@ _v6.1.10_
_v6.1.11_ _v6.1.11_
- Fix connection errors when behind a reverse-proxy. - Fixed connection errors when behind a reverse-proxy.
- Fix disappearing talkgroups (issue #127). - Fixed disappearing talkgroups (issue #127).
_v6.1.12_ _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. - Cosmetic: AVOID and PATCH flags now only appear when needed.
_v6.1.13_ _v6.1.13_
- Better handling of dead client connections. - 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). - Remove net.http error messages from the output (issue #131).
_v6.1.14_ _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. - 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. - Remove the gitter.im support forum from the documentation and prefer github discussions.
_v6.1.15_ _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. - 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). - 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_ _v6.1.16_
- Fix concurrent map access for clients. - Fixed concurrent map access for clients.
- Some tweaks to websocket management. - Some tweaks to websocket management.
## Version 6.0 ## Version 6.0
@ -233,24 +338,24 @@ The backend server has been completely rewritten in GO language. Therefore, all
_v6.0.1_ _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) - Auto populate system units (issue #66)
_v6.0.2_ _v6.0.2_
- Try to fix the SQL_BUSY error (issue #67). - 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. - 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_ _v6.0.3_
- Fix strconv.Atoi: invalid syntax for dirwatch type sdrtrunk. - Fixed strconv.Atoi: invalid syntax for dirwatch type sdrtrunk.
- Fix the new version available dialog opening more than once. - Fixed the new version available dialog opening more than once.
_v6.0.4_ _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). - 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. - 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. - Some code refactoring.
@ -263,26 +368,26 @@ _v6.0.5_
_v6.0.6_ _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. - Fixed SDRTunk parser when artist tag contains CTCSS tones.
- Platforms linux/amd64, linux/arm and linux/arm64 are now available for the Docker container. - Platforms linux/amd64, linux/arm and linux/arm64 are now available for the Docker container.
_v6.0.7_ _v6.0.7_
- Fix dropped connections when going through a proxy. - Fixed dropped connections when going through a proxy.
## Version 5.2 ## Version 5.2
- Change to how the server reports version. - Change to how the server reports version.
- Fix cmd.js exiting on inexistant session token keystore. - Fixed cmd.js exiting on inexistant session token keystore.
- Fix issue with iframe. - Fixed issue with iframe.
- Node modules updated for security fixes. - Node modules updated for security fixes.
_v5.2.1_ _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). - 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. - Configurable call duplicate detection time frame.
_v5.2.2_ _v5.2.2_
@ -293,13 +398,13 @@ _v5.2.2_
_v5.2.3_ _v5.2.3_
- Change history columns padding from 1px to 6px on the main screen. - 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_ _v5.2.4_
- Updated to Angular 12.2. - Updated to Angular 12.2.
- New update prompt for clients when server is updated. - 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_ _v5.2.5_
@ -310,20 +415,20 @@ _v5.2.5_
_v5.2.6_ _v5.2.6_
- Fix crash when when options.pruneDays = 0. - Fixed crash when when options.pruneDays = 0.
_v5.2.7_ _v5.2.7_
- Fix handling of JSON datatypes on MySQL/MariaDB database backend. - Fixed handling of JSON datatypes on MySQL/MariaDB database backend.
- Fix listeners count. - Fixed listeners count.
_V5.2.8_ _V5.2.8_
- Fix SQLite does not support TEXT with options. - Fixed SQLite does not support TEXT with options.
_V5.2.9_ _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. - Increase dirwatch polling interval from 1000ms to 2500ms.
## Version 5.1 ## 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_ _v5.1.1_
- Fix database migration script to version 5.1 to filter out duplicate property values on unique fields. - Fixed 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 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. - Bring back the load-rrdb, load-tr and random uuid command line tools.
_v5.1.2_ _v5.1.2_
- Fix config class not returning proper id properties when new records are added. - Fixed config class not returning proper id properties when new records are added.
- Fix database migration script to version 5.1 when on mysql. - Fixed database migration script to version 5.1 when on mysql.
- Fix bad logic in apiKey validation. - Fixed bad logic in apiKey validation.
- Remove the autoJsonMap from the sequelize dialectOptions. - Remove the autoJsonMap from the sequelize dialectOptions.
- Client updated to angular 12. - Client updated to angular 12.
@ -368,15 +473,15 @@ _v5.1.2_
_v5.0.1_ _v5.0.1_
- Remove the EBU R128 loudness normalization as it's not working as intended. - 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_ _v5.0.2_
- Fix rdioScanner.options.disableAudioConversion which was ignored when true. - Fixed rdioScanner.options.disableAudioConversion which was ignored when true.
_v5.0.3_ _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_ _v5.0.4_
@ -403,7 +508,7 @@ _v4.9.1_
- Add EBU R128 loudness normalization. - Add EBU R128 loudness normalization.
- dirWatch.type="trunk-recorder" now deletes the JSON file in case the audio file is missing. - 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_ _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 downstream.system.id_as property to allow export system with a different id.
- Add system.order for system list ordering on the client side. - Add system.order for system list ordering on the client side.
- Fix client main screen unscrollable overflow while in landscape. - Fixed client main screen unscrollable overflow while in landscape.
- Fix issue 26 - date in documentation for mask isn't clear. - 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. - The skip button now also allows you to skip the one second delay between calls.
- Node modules update. - Node modules update.
@ -425,26 +530,26 @@ _v4.8.1_
_v4.8.2_ _v4.8.2_
- Fix dirWatch.type='sdr-trunk' metatag artist as source is now optional. - Fixed 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 title as talkgroup.id.
- Web app now running with Angular 11. - Web app now running with Angular 11.
- Node modules update. - Node modules update.
_v4.8.3_ _v4.8.3_
- Add the ability to overwrite the default dirWatch extension for type sdr-trunk and trunk-recorder. - 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. - Node modules update.
_v4.8.4_ _v4.8.4_
- Fix the timezone issue when on mariadb. - Fixed the timezone issue when on mariadb.
- Fix downstream sending wrong talkgroup id. - Fixed downstream sending wrong talkgroup id.
- Node modules security update. - Node modules security update.
_v4.8.5_ _v4.8.5_
- Fix broken dirwatch.delay. - Fixed broken dirwatch.delay.
- Node modules update. - Node modules update.
## Version 4.7 ## Version 4.7
@ -458,37 +563,37 @@ _v4.8.5_
_v4.7.1_ _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_ _v4.7.2_
- Fix Keypad beeps not working on iOS. - Fixed Keypad beeps not working on iOS.
- Fix pause not going off due to the above bug. - Fixed pause not going off due to the above bug.
_v4.7.3_ _v4.7.3_
- Fix websocket not connection on ssl. - Fixed websocket not connection on ssl.
_v4.7.4_ _v4.7.4_
- Fix display width too wide when long talkgroup name. - Fixed display width too wide when long talkgroup name.
_v4.7.5_ _v4.7.5_
- Fix playback mode getting mixed up if clicking too fast on play. - Fixed playback mode getting mixed up if clicking too fast on play.
- Fix side panels background color inheritance. - Fixed side panels background color inheritance.
- Node modules update. - Node modules update.
_v4.7.6_ _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. - Skip next button no longer emit a denied beeps sequence when pushed while there's no audio playing.
- Node modules update. - Node modules update.
## Version 4.6 ## Version 4.6
- Fix documentation in regards to load-rrd in install-github.md. - Fixed documentation in regards to load-rrd in install-github.md.
- Fix database absolute path in config.json. - Fixed database absolute path in config.json.
- Remove config.options.useLed. - Remove config.options.useLed.
- Rename Config.options.keyBeep to Config.options.keypadBeeps. - Rename Config.options.keyBeep to Config.options.keypadBeeps.
- Config.options.keypadBeeps now with presets instead of full pattern declaration. - 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.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. - 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. - 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. - Talkgroup ID is displayed no matter what and unit ID is displayed only if known.
## Version 4.3 ## Version 4.3
@ -513,15 +618,15 @@ _v4.7.6_
- Automatic database migration on startup. - Automatic database migration on startup.
- Client now on Angular 10 in strict mode. - Client now on Angular 10 in strict mode.
- Dockerized. - Dockerized.
- Fix downstream not being triggered when a new call imported. - Fixed downstream not being triggered when a new call imported.
- Fix dirWatch mask parser and new mask metatags. - Fixed dirWatch mask parser and new mask metatags.
- Fix stop button on the search panel when in offline play mode. - Fixed stop button on the search panel when in offline play mode.
- Fix SSL certificate handling. - Fixed SSL certificate handling.
- Rewritten documentation. - Rewritten documentation.
## Version 4.2 ## Version 4.2
- Fix possible race conditions.... - Fixed possible race conditions....
- Added websocket keepalive which helps mobile clients when switching from/to wifi/wan. - Added websocket keepalive which helps mobile clients when switching from/to wifi/wan.
- Better playback offline mode animations and queue count. - Better playback offline mode animations and queue count.
- New dirWatch.mask option to simplify meta data import. - New dirWatch.mask option to simplify meta data import.

View file

@ -1,6 +1,6 @@
# How to compile # 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. 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. 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-amd64-v7.0.0.zip
rdio-scanner-darwin-arm64-v6.4.3.zip rdio-scanner-darwin-arm64-v7.0.0.zip
rdio-scanner-freebsd-amd64-v6.4.3.zip rdio-scanner-freebsd-amd64-v7.0.0.zip
rdio-scanner-linux-386-v6.4.3.zip rdio-scanner-linux-386-v7.0.0.zip
rdio-scanner-linux-amd64-v6.4.3.zip rdio-scanner-linux-amd64-v7.0.0.zip
rdio-scanner-linux-arm64-v6.4.3.zip rdio-scanner-linux-arm64-v7.0.0.zip
rdio-scanner-linux-arm-v6.4.3.zip rdio-scanner-linux-arm-v7.0.0.zip
rdio-scanner-windows-amd64-v6.4.3.zip rdio-scanner-windows-amd64-v7.0.0.zip
**Happy Rdio scanning !** **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. 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 ## 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). 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 ## 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 ## it under the terms of the GNU General Public License as published by
@ -16,20 +16,20 @@
################################################################################ ################################################################################
app := rdio-scanner app := rdio-scanner
date := 2022/06/14 date := 2026/05/16
ver := 6.4.3 ver := 7.0.0-dev
client := $(wildcard client/*.json client/*.ts) client := $(wildcard client/*.json client/*.ts)
server := $(wildcard server/*.go) server := $(wildcard server/*.go)
build = @cd server && GOOS=$(1) GOARCH=$(2) go build -o ../dist/$(1)-$(2)/$(3) 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/webapp.md docs/faq.md CHANGELOG.md 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 .. zip = @cd dist/$(1)-$(2) && zip -q ../$(app)-$(1)-$(2)-v$(ver).zip * && cd ..
.PHONY: all clean container dist sed .PHONY: all clean container dist sed
.PHONY: darwin darwin-amd64 darwin-arm64
.PHONY: freebsd freebsd-amd64 .PHONY: freebsd freebsd-amd64
.PHONY: linux linux-386 linux-amd64 linux-arm linux-arm64 .PHONY: linux linux-386 linux-amd64 linux-arm linux-arm64
.PHONY: macos macos-amd64 macos-arm64
.PHONY: windows windows-amd64 .PHONY: windows windows-amd64
all: clean dist 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 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 @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:
@sed -i -re "s|^(\s*\"version\":).*$$|\1 \"$(ver)\"|" client/package.json @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|^(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|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/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 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 && test -d node_modules || npm ci --loglevel=error --no-progress
@cd client && npm run build @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: freebsd-amd64
freebsd-amd64: webapp dist/$(app)-freebsd-amd64-v$(ver).zip freebsd-amd64: webapp dist/$(app)-freebsd-amd64-v$(ver).zip
dist/$(app)-freebsd-amd64-v$(ver).zip: $(server) dist/$(app)-freebsd-amd64-v$(ver).zip: $(server)
$(call pandoc,freebsd,amd64,rdio-scanner.pdf,docs/platforms/freebsd.md) $(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)) $(call zip,freebsd,amd64,$(app))
linux: linux-386 linux-amd64 linux-arm linux-arm64 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) dist/$(app)-linux-386-v$(ver).zip: $(server)
$(call pandoc,linux,386,rdio-scanner.pdf,docs/platforms/linux.md) $(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)) $(call zip,linux,386,$(app))
dist/$(app)-linux-amd64-v$(ver).zip: $(server) dist/$(app)-linux-amd64-v$(ver).zip: $(server)
$(call pandoc,linux,amd64,rdio-scanner.pdf,docs/platforms/linux.md) $(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)) $(call zip,linux,amd64,$(app))
dist/$(app)-linux-arm-v$(ver).zip: $(server) dist/$(app)-linux-arm-v$(ver).zip: $(server)
$(call pandoc,linux,arm,rdio-scanner.pdf,docs/platforms/linux.md) $(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)) $(call zip,linux,arm,$(app))
dist/$(app)-linux-arm64-v$(ver).zip: $(server) dist/$(app)-linux-arm64-v$(ver).zip: $(server)
$(call pandoc,linux,arm64,rdio-scanner.pdf,docs/platforms/linux.md) $(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)) $(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: windows-amd64
windows-amd64: webapp dist/$(app)-windows-amd64-v$(ver).zip windows-amd64: webapp dist/$(app)-windows-amd64-v$(ver).zip
dist/$(app)-windows-amd64-v$(ver).zip: $(server) dist/$(app)-windows-amd64-v$(ver).zip: $(server)
$(call pandoc,windows,amd64,rdio-scanner.pdf,docs/platforms/windows.md) $(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)) $(call zip,windows,amd64,$(app))

View file

@ -1,12 +1,12 @@
![Rdio Scanner](./docs/images/rdio-scanner.png?raw=true) ![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. [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. 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. [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 | | [Trunk Recorder](https://github.com/robotastic/trunk-recorder) | X | X |
| [RTLSDR-Airband](https://github.com/szpajder/RTLSDR-Airband) | | 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 | | | [voxcall](https://github.com/aaknitt/voxcall) | X | |
| [ProScan](https://www.proscan.org/) | | 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)**. 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. 2. Extract the contents of the archive somewhere on your computer.
3. Run the [Rdio Scanner](https://github.com/chuot/rdio-scanner) executable. 3. Run the [Rdio Scanner](https://github.com/chuot/rdio-scanner) executable.
4. Access the administrative dashboard to finalize the configuration. 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. 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 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. 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. 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) [![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) [![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 !** **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": "", "root": "",
"sourceRoot": "src", "sourceRoot": "src",
"prefix": "rdio-scanner", "prefix": "rdio-scanner",
"architect": { "targets": {
"build": { "build": {
"builder": "@angular-devkit/build-angular:browser", "builder": "@angular-devkit/build-angular:browser",
"options": { "options": {
"outputPath": "../server/webapp", "outputPath": "../server/webapp",
"index": "src/index.html", "index": "src/index.html",
"main": "src/main.ts", "polyfills": [
"polyfills": "src/polyfills.ts", "src/polyfills.ts"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
@ -36,15 +37,16 @@
], ],
"scripts": [], "scripts": [],
"serviceWorker": true, "serviceWorker": true,
"ngswConfigPath": "ngsw-config.json" "ngswConfigPath": "ngsw-config.json",
"main": "src/main.ts"
}, },
"configurations": { "configurations": {
"production": { "production": {
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "1500kb", "maximumWarning": "2mb",
"maximumError": "1500kb" "maximumError": "2mb"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
@ -61,9 +63,7 @@
"outputHashing": "all" "outputHashing": "all"
}, },
"development": { "development": {
"buildOptimizer": false,
"optimization": false, "optimization": false,
"vendorChunk": true,
"extractLicenses": false, "extractLicenses": false,
"sourceMap": true, "sourceMap": true,
"namedChunks": true "namedChunks": true
@ -75,11 +75,11 @@
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
"configurations": { "configurations": {
"production": { "production": {
"browserTarget": "rdio-scanner:build:production" "buildTarget": "rdio-scanner:build:production"
}, },
"development": { "development": {
"browserTarget": "rdio-scanner:build:development", "proxyConfig": "src/proxy.conf.js",
"proxyConfig": "src/proxy.conf.js" "buildTarget": "rdio-scanner:build:development"
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
@ -87,14 +87,16 @@
"extract-i18n": { "extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n", "builder": "@angular-devkit/build-angular:extract-i18n",
"options": { "options": {
"browserTarget": "rdio-scanner:build" "buildTarget": "rdio-scanner:build"
} }
}, },
"test": { "test": {
"builder": "@angular-devkit/build-angular:karma", "builder": "@angular-devkit/build-angular:karma",
"options": { "options": {
"main": "src/test.ts", "main": "src/test.ts",
"polyfills": "src/polyfills.ts", "polyfills": [
"src/polyfills.ts"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"karmaConfig": "karma.conf.js", "karmaConfig": "karma.conf.js",
"inlineStyleLanguage": "scss", "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": "."
}
}
} }

27171
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,34 @@
{ {
"author": "Chrystian Huot <chrystian.huot@saubeo.solutions>", "author": "Chrystian Huot <chrystian@huot.qc.ca>",
"dependencies": { "dependencies": {
"@angular/animations": "^13.3.10", "@angular/animations": "^21.0.6",
"@angular/cdk": "^13.3.8", "@angular/cdk": "^21.0.5",
"@angular/common": "~13.3.10", "@angular/common": "^21.0.6",
"@angular/compiler": "~13.3.10", "@angular/compiler": "^21.0.6",
"@angular/core": "~13.3.10", "@angular/core": "^21.0.6",
"@angular/forms": "~13.3.10", "@angular/forms": "^21.0.6",
"@angular/material": "^13.3.8", "@angular/material": "^21.0.5",
"@angular/platform-browser": "~13.3.10", "@angular/platform-browser": "^21.0.6",
"@angular/platform-browser-dynamic": "~13.3.10", "@angular/platform-browser-dynamic": "^21.0.6",
"@angular/router": "^13.3.10", "@angular/router": "^21.0.6",
"@angular/service-worker": "^13.3.10", "@angular/service-worker": "^21.0.6",
"rxjs": "~7.5.5", "material-icons": "^1.13.14",
"tslib": "^2.4.0", "rxjs": "~7.8.2",
"zone.js": "~0.11.5" "tslib": "^2.8.1",
"zone.js": "~0.16.0"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^13.3.7", "@angular-devkit/build-angular": "^21.0.4",
"@angular/cli": "^13.3.7", "@angular/build": "^21.0.4",
"@angular/compiler-cli": "~13.3.10", "@angular/cli": "^21.0.4",
"@types/node": "^17.0.40", "@angular/compiler-cli": "^21.0.6",
"@typescript-eslint/eslint-plugin": "^5.27.1", "@types/node": "^25.0.3",
"@typescript-eslint/parser": "^5.27.1", "@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"ajv-keywords": "^5.1.0", "ajv-keywords": "^5.1.0",
"eslint": "^8.17.0", "eslint": "^9.39.2",
"ts-node": "~10.8.1", "ts-node": "~10.9.2",
"typescript": "~4.6.4" "typescript": "~5.9.3"
}, },
"engines": { "engines": {
"node": ">=16.0" "node": ">=16.0"
@ -36,7 +38,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "ng build --base-href ./ --configuration production", "build": "ng build --base-href ./ --configuration production",
"start": "ng serve" "serve": "ng serve"
}, },
"version": "6.4.3" "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 * 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 * 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', selector: 'app-root',
styleUrls: ['./app.component.scss'], styleUrls: ['./app.component.scss'],
templateUrl: './app.component.html', templateUrl: './app.component.html',
standalone: false
}) })
export class AppComponent { } 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 * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,10 +1,10 @@
<ng-container *ngIf="!authenticated"> @if (!authenticated) {
<rdio-scanner-admin-login></rdio-scanner-admin-login> <rdio-scanner-admin-login></rdio-scanner-admin-login>
</ng-container> }
<ng-container *ngIf="authenticated"> @if (authenticated) {
<rdio-scanner-admin-todos></rdio-scanner-admin-todos> <rdio-scanner-admin-todos></rdio-scanner-admin-todos>
<mat-accordion displayMode="flat"> <mat-accordion displayMode="flat">
<mat-expansion-panel #configPanel (afterCollapse)="configComponent?.closeAll()"> <mat-expansion-panel #configPanel (afterCollapse)="configComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>settings</mat-icon> <mat-icon>settings</mat-icon>
@ -22,7 +22,7 @@
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs> <rdio-scanner-admin-logs #logsComponent></rdio-scanner-admin-logs>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="toolsComponent?.closeAll()"> <mat-expansion-panel (afterCollapse)="toolsComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>build</mat-icon> <mat-icon>build</mat-icon>
@ -40,4 +40,4 @@
</mat-expansion-panel-header> </mat-expansion-panel-header>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
</ng-container> }

View file

@ -1,4 +1,7 @@
rdio-scanner-admin { rdio-scanner-admin {
display: block;
padding: 1rem;
.mat-expansion-panel-header { .mat-expansion-panel-header {
user-select: none; 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 * 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 * 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', selector: 'rdio-scanner-admin',
styleUrls: ['./admin.component.scss'], styleUrls: ['./admin.component.scss'],
templateUrl: './admin.component.html', templateUrl: './admin.component.html',
standalone: false
}) })
export class RdioScannerAdminComponent implements OnDestroy { export class RdioScannerAdminComponent implements OnDestroy {
authenticated = this.adminService.authenticated; authenticated = true;
groups: Group[] = []; groups: Group[] = [];
tags: Tag[] = []; tags: Tag[] = [];
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => { private eventSubscription;
constructor(private adminService: RdioScannerAdminService) {
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('authenticated' in event) { if ('authenticated' in event) {
this.authenticated = event.authenticated || false; this.authenticated = event.authenticated || false;
} }
}); });
}
constructor(private adminService: RdioScannerAdminService) { }
ngOnDestroy(): void { ngOnDestroy(): void {
this.eventSubscription.unsubscribe(); 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 * 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 * 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 { NgModule } from '@angular/core';
import { AppSharedModule } from '../../../shared/shared.module'; import { AppSharedModule } from '../../../shared/shared.module';
import { RdioScannerAdminComponent } from './admin.component'; import { RdioScannerAdminComponent } from './admin.component';
import { RdioScannerAdminService } from './admin.service'; import { RdioScannerAdminService } from './admin.service';
import { RdioScannerAdminConfigComponent } from './config/config.component'; import { RdioScannerAdminConfigComponent } from './config/config.component';
import { RdioScannerAdminAccessComponent } from './config/access/access.component'; import { RdioScannerAdminAccessComponent } from './config/access/access.component';
import { RdioScannerAdminApiKeysComponent } from './config/api-keys/api-keys.component'; import { RdioScannerAdminApikeysComponent } from './config/apikeys/apikeys.component';
import { RdioScannerAdminDirWatchComponent } from './config/dir-watch/dir-watch.component'; import { RdioScannerAdminDirwatchComponent } from './config/dirwatch/dirwatch.component';
import { RdioScannerAdminDownstreamsComponent } from './config/downstreams/downstreams.component'; import { RdioScannerAdminDownstreamsComponent } from './config/downstreams/downstreams.component';
import { RdioScannerAdminGroupsComponent } from './config/groups/groups.component'; import { RdioScannerAdminGroupsComponent } from './config/groups/groups.component';
import { RdioScannerAdminOptionsComponent } from './config/options/options.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 { RdioScannerAdminSystemsSelectComponent } from './config/systems/select/select.component';
import { RdioScannerAdminSystemComponent } from './config/systems/system/system.component'; import { RdioScannerAdminSystemComponent } from './config/systems/system/system.component';
import { RdioScannerAdminSystemsComponent } from './config/systems/systems.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 { RdioScannerAdminImportUnitsComponent } from './tools/import-units/import-units.component';
import { RdioScannerAdminPasswordComponent } from './tools/password/password.component'; import { RdioScannerAdminPasswordComponent } from './tools/password/password.component';
@NgModule({ @NgModule({ declarations: [
declarations: [
RdioScannerAdminComponent, RdioScannerAdminComponent,
RdioScannerAdminConfigComponent, RdioScannerAdminConfigComponent,
RdioScannerAdminAccessComponent, RdioScannerAdminAccessComponent,
RdioScannerAdminApiKeysComponent, RdioScannerAdminApikeysComponent,
RdioScannerAdminDirWatchComponent, RdioScannerAdminDirwatchComponent,
RdioScannerAdminDownstreamsComponent, RdioScannerAdminDownstreamsComponent,
RdioScannerAdminGroupsComponent, RdioScannerAdminGroupsComponent,
RdioScannerAdminImportExportConfigComponent, RdioScannerAdminImportExportConfigComponent,
@ -60,6 +60,7 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
RdioScannerAdminLogsComponent, RdioScannerAdminLogsComponent,
RdioScannerAdminOptionsComponent, RdioScannerAdminOptionsComponent,
RdioScannerAdminPasswordComponent, RdioScannerAdminPasswordComponent,
RdioScannerAdminSiteComponent,
RdioScannerAdminSystemComponent, RdioScannerAdminSystemComponent,
RdioScannerAdminSystemsComponent, RdioScannerAdminSystemsComponent,
RdioScannerAdminSystemsSelectComponent, RdioScannerAdminSystemsSelectComponent,
@ -69,9 +70,5 @@ import { RdioScannerAdminPasswordComponent } from './tools/password/password.com
RdioScannerAdminToolsComponent, RdioScannerAdminToolsComponent,
RdioScannerAdminUnitComponent, RdioScannerAdminUnitComponent,
], ],
entryComponents: [RdioScannerAdminSystemsSelectComponent], exports: [RdioScannerAdminComponent], imports: [AppSharedModule], providers: [RdioScannerAdminService, provideHttpClient(withInterceptorsFromDi())] })
exports: [RdioScannerAdminComponent],
imports: [AppSharedModule, HttpClientModule],
providers: [RdioScannerAdminService],
})
export class RdioScannerAdminModule { } 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 * 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 * 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'; import { AppUpdateService } from '../../../shared/update/update.service';
export interface Access { export interface Access {
_id?: string; id?: string;
code?: string; code?: string;
expiration?: Date; expiration?: Date;
ident?: string; ident?: string;
@ -39,6 +39,17 @@ export interface Access {
}[] | number[] | '*'; }[] | number[] | '*';
} }
export interface Alert {
begin: number;
end: number;
frequency: number;
type: OscillatorType;
}
export interface Alerts {
[key: string]: Alert[];
}
export interface AdminEvent { export interface AdminEvent {
authenticated?: boolean; authenticated?: boolean;
config?: Config; config?: Config;
@ -46,8 +57,8 @@ export interface AdminEvent {
passwordNeedChange?: boolean; passwordNeedChange?: boolean;
} }
export interface ApiKey { export interface Apikey {
_id?: string; id?: string;
disabled?: boolean; disabled?: boolean;
ident?: string; ident?: string;
key?: string; key?: string;
@ -60,17 +71,18 @@ export interface ApiKey {
export interface Config { export interface Config {
access?: Access[]; access?: Access[];
apiKeys?: ApiKey[]; apikeys?: Apikey[];
dirWatch?: DirWatch[]; dirwatch?: Dirwatch[];
downstreams?: Downstream[]; downstreams?: Downstream[];
groups?: Group[]; groups?: Group[];
options?: Options; options?: Options;
systems?: System[]; systems?: System[];
tags?: Tag[]; tags?: Tag[];
version?: string;
} }
export interface DirWatch { export interface Dirwatch {
_id?: string; id?: string;
delay?: number; delay?: number;
deleteAfter?: boolean; deleteAfter?: boolean;
directory?: string; directory?: string;
@ -79,14 +91,15 @@ export interface DirWatch {
frequency?: number; frequency?: number;
mask?: string; mask?: string;
order?: number; order?: number;
siteId?: number;
systemId?: number; systemId?: number;
talkgroupId?: number; talkgroupId?: number;
type?: string; type?: string;
} }
export interface Downstream { export interface Downstream {
_id?: string; id?: string;
apiKey?: string; apikey?: string;
disabled?: boolean; disabled?: boolean;
order?: number; order?: number;
systems?: { systems?: {
@ -101,12 +114,15 @@ export interface Downstream {
} }
export interface Group { export interface Group {
_id?: number; id?: number;
alert?: string;
label?: string; label?: string;
led?: string;
order?: number;
} }
export interface Log { export interface Log {
_id: number; id?: number;
dateTime: Date; dateTime: Date;
level: number; level: number;
message: string; message: string;
@ -129,58 +145,79 @@ export interface LogsQueryOptions {
} }
export interface Options { export interface Options {
afsSystems?: string;
audioConversion?: 0 | 1 | 2 | 3; audioConversion?: 0 | 1 | 2 | 3;
autoPopulate?: boolean; autoPopulate?: boolean;
branding?: string;
dimmerDelay?: number; dimmerDelay?: number;
disableDuplicateDetection?: boolean; disableDuplicateDetection?: boolean;
duplicateDetectionTimeFrame?: number; duplicateDetectionTimeFrame?: number;
email?: string;
keypadBeeps?: string; keypadBeeps?: string;
maxClients?: number; maxClients?: number;
playbackGoesLive?: boolean; playbackGoesLive?: boolean;
pruneDays?: number; pruneDays?: number;
searchPatchedTalkgroups?: boolean;
showListenersCount?: boolean; showListenersCount?: boolean;
sortTalkgroups?: boolean; sortTalkgroups?: boolean;
tagsToggle?: boolean;
time12hFormat?: boolean; time12hFormat?: boolean;
} }
export interface Site {
id?: number | null;
label?: string;
order?: number;
siteRef?: number;
}
export interface System { export interface System {
_id?: number; id?: number | null;
alert?: string;
autoPopulate?: boolean; autoPopulate?: boolean;
blacklists?: string; blacklists?: string;
id?: number; delay?: number;
label?: string; label?: string;
led?: string | null; led?: string | null;
order?: number | null; order?: number | null;
sites?: Site[];
systemRef?: number;
talkgroups?: Talkgroup[]; talkgroups?: Talkgroup[];
type?: string;
units?: Unit[]; units?: Unit[];
} }
export interface Tag { export interface Tag {
_id?: number; id?: number;
alert?: string;
label?: string; label?: string;
led?: string;
order?: number;
} }
export interface Talkgroup { export interface Talkgroup {
id?: number | null;
alert?: string;
delay?: number;
frequency?: number | null; frequency?: number | null;
groupId?: number; groupIds?: number[];
id?: number;
label?: string; label?: string;
led?: string | null; led?: string | null;
name?: string; name?: string;
order?: number; order?: number;
tagId?: number; tagId?: number;
talkgroupRef?: number;
type?: string;
} }
export interface Unit { export interface Unit {
id?: number | null; id?: number | null;
label?: string; label?: string;
order?: number; order?: number;
unitRef?: number;
unitFrom?: number;
unitTo?: number;
} }
enum url { enum url {
alerts = 'alerts',
config = 'config', config = 'config',
login = 'login', login = 'login',
logout = 'logout', logout = 'logout',
@ -190,8 +227,16 @@ enum url {
const SESSION_STORAGE_KEY = 'rdio-scanner-admin-token'; const SESSION_STORAGE_KEY = 'rdio-scanner-admin-token';
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
@Injectable() @Injectable()
export class RdioScannerAdminService implements OnDestroy { export class RdioScannerAdminService implements OnDestroy {
Alerts: Alerts | undefined;
event = new EventEmitter<AdminEvent>(); event = new EventEmitter<AdminEvent>();
get authenticated() { get authenticated() {
@ -206,6 +251,8 @@ export class RdioScannerAdminService implements OnDestroy {
return this._passwordNeedChange; return this._passwordNeedChange;
} }
private audioContext: AudioContext | undefined;
private configWebSocket: WebSocket | undefined; private configWebSocket: WebSocket | undefined;
private _docker = false; 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> { async login(password: string): Promise<boolean> {
try { try {
const res = await firstValueFrom(this.ngHttpClient.post<{ 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> { async saveConfig(config: Config): Promise<Config> {
try { try {
const res = await firstValueFrom(this.ngHttpClient.put<{ config: Config }>( const res = await firstValueFrom(this.ngHttpClient.put<{ config: Config }>(
@ -381,134 +482,161 @@ export class RdioScannerAdminService implements OnDestroy {
newAccessForm(access?: Access): FormGroup { newAccessForm(access?: Access): FormGroup {
return this.ngFormBuilder.group({ return this.ngFormBuilder.group({
_id: [access?._id], id: this.ngFormBuilder.nonNullable.control(access?.id),
code: [access?.code, [Validators.required, this.validateAccessCode()]], code: this.ngFormBuilder.nonNullable.control(access?.code, [Validators.required, this.validateAccessCode()]),
expiration: [access?.expiration], expiration: this.ngFormBuilder.nonNullable.control(access?.expiration),
ident: [access?.ident, Validators.required], ident: this.ngFormBuilder.nonNullable.control(access?.ident, Validators.required),
limit: [access?.limit], limit: this.ngFormBuilder.nonNullable.control(access?.limit),
order: [access?.order], order: this.ngFormBuilder.nonNullable.control(access?.order),
systems: [access?.systems, Validators.required], systems: this.ngFormBuilder.nonNullable.control(access?.systems),
}); });
} }
newApiKeyForm(apiKey?: ApiKey): FormGroup { newApikeyForm(apikey?: Apikey): FormGroup {
return this.ngFormBuilder.group({ return this.ngFormBuilder.group({
_id: [apiKey?._id], id: this.ngFormBuilder.control(apikey?.id),
disabled: [apiKey?.disabled], disabled: this.ngFormBuilder.control(apikey?.disabled),
ident: [apiKey?.ident, Validators.required], ident: this.ngFormBuilder.control(apikey?.ident, Validators.required),
key: [apiKey?.key, [Validators.required, this.validateApiKey()]], key: this.ngFormBuilder.control(apikey?.key, [Validators.required, this.validateApikey()]),
order: [apiKey?.order], order: this.ngFormBuilder.control(apikey?.order),
systems: [apiKey?.systems, Validators.required], systems: this.ngFormBuilder.control(apikey?.systems, Validators.required),
}); });
} }
newConfigForm(config?: Config): FormGroup { newConfigForm(config?: Config): FormGroup {
return this.ngFormBuilder.group({ return this.ngFormBuilder.group({
access: this.ngFormBuilder.array(config?.access?.map((access) => this.newAccessForm(access)) || []), access: this.ngFormBuilder.array(config?.access?.map((access) => this.newAccessForm(access)) || []),
apiKeys: this.ngFormBuilder.array(config?.apiKeys?.map((apiKey) => this.newApiKeyForm(apiKey)) || []), apikeys: this.ngFormBuilder.array(config?.apikeys?.map((apikey) => this.newApikeyForm(apikey)) || []),
dirWatch: this.ngFormBuilder.array(config?.dirWatch?.map((dirWatch) => this.newDirWatchForm(dirWatch)) || []), dirwatch: this.ngFormBuilder.array(config?.dirwatch?.map((dirwatch) => this.newDirwatchForm(dirwatch)) || []),
downstreams: this.ngFormBuilder.array(config?.downstreams?.map((downstream) => this.newDownstreamForm(downstream)) || []), downstreams: this.ngFormBuilder.array(config?.downstreams?.map((downstream) => this.newDownstreamForm(downstream)) || []),
groups: this.ngFormBuilder.array(config?.groups?.map((group) => this.newGroupForm(group)) || []), groups: this.ngFormBuilder.array(config?.groups?.map((group) => this.newGroupForm(group)) || []),
options: this.newOptionsForm(config?.options), options: this.newOptionsForm(config?.options),
systems: this.ngFormBuilder.array(config?.systems?.map((system) => this.newSystemForm(system)) || []), systems: this.ngFormBuilder.array(config?.systems?.map((system) => this.newSystemForm(system)) || []),
tags: this.ngFormBuilder.array(config?.tags?.map((tag) => this.newTagForm(tag)) || []), 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({ return this.ngFormBuilder.group({
_id: [dirWatch?._id], id: this.ngFormBuilder.control(dirwatch?.id),
delay: [typeof dirWatch?.delay === 'number' ? Math.max(2000, dirWatch?.delay) : 2000], delay: this.ngFormBuilder.control(typeof dirwatch?.delay === 'number' ? Math.max(2000, dirwatch?.delay) : 2000),
deleteAfter: [dirWatch?.deleteAfter], deleteAfter: this.ngFormBuilder.control(dirwatch?.deleteAfter),
directory: [dirWatch?.directory, [Validators.required, this.validateDirectory()]], directory: this.ngFormBuilder.control(dirwatch?.directory, [Validators.required, this.validateDirectory()]),
disabled: [dirWatch?.disabled], disabled: this.ngFormBuilder.control(dirwatch?.disabled),
extension: [dirWatch?.extension, this.validateExtension()], extension: this.ngFormBuilder.control(dirwatch?.extension, this.validateExtension()),
frequency: [dirWatch?.frequency, Validators.min(0)], frequency: this.ngFormBuilder.control(dirwatch?.frequency, Validators.min(1)),
mask: [dirWatch?.mask, this.validateMask()], mask: this.ngFormBuilder.control(dirwatch?.mask, this.validateMask()),
order: [dirWatch?.order], order: this.ngFormBuilder.control(dirwatch?.order),
systemId: [dirWatch?.systemId, this.validateDirwatchSystemId()], siteId: this.ngFormBuilder.control(dirwatch?.siteId),
talkgroupId: [dirWatch?.talkgroupId, this.validateDirwatchTalkgroupId()], systemId: this.ngFormBuilder.control(dirwatch?.systemId, this.validateDirwatchSystemId()),
type: [dirWatch?.type], talkgroupId: this.ngFormBuilder.control(dirwatch?.talkgroupId, this.validateDirwatchTalkgroupId()),
type: this.ngFormBuilder.control(dirwatch?.type ?? 'default'),
}); });
} }
newDownstreamForm(downstream?: Downstream): FormGroup { newDownstreamForm(downstream?: Downstream): FormGroup {
return this.ngFormBuilder.group({ return this.ngFormBuilder.group({
_id: [downstream?._id], id: this.ngFormBuilder.control(downstream?.id),
apiKey: [downstream?.apiKey, [Validators.required, this.validateApiKey()]], apikey: this.ngFormBuilder.control(downstream?.apikey, [Validators.required, this.validateApikey()]),
disabled: [downstream?.disabled], disabled: this.ngFormBuilder.control(downstream?.disabled),
order: [downstream?.order], order: this.ngFormBuilder.control(downstream?.order),
systems: [downstream?.systems, Validators.required], systems: this.ngFormBuilder.control(downstream?.systems, Validators.required),
url: [downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]], url: this.ngFormBuilder.control(downstream?.url, [Validators.required, this.validateUrl(), this.validateDownstreamUrl()]),
}); });
} }
newGroupForm(group?: Group): FormGroup { newGroupForm(group?: Group): FormGroup {
return this.ngFormBuilder.group({ return this.ngFormBuilder.group({
_id: [group?._id], id: this.ngFormBuilder.control(group?.id),
label: [group?.label, Validators.required], 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),
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],
}); });
} }
newOptionsForm(options?: Options): FormGroup { newOptionsForm(options?: Options): FormGroup {
return this.ngFormBuilder.group({ return this.ngFormBuilder.group({
afsSystems: [options?.afsSystems, this.validateAfsSystems()], audioConversion: this.ngFormBuilder.control(options?.audioConversion),
audioConversion: [options?.audioConversion], autoPopulate: this.ngFormBuilder.control(options?.autoPopulate),
autoPopulate: [options?.autoPopulate], branding: this.ngFormBuilder.control(options?.branding),
dimmerDelay: [options?.dimmerDelay, [Validators.required, Validators.min(0)]], dimmerDelay: this.ngFormBuilder.control(options?.dimmerDelay, [Validators.required, Validators.min(0)]),
disableDuplicateDetection: [options?.disableDuplicateDetection], disableDuplicateDetection: this.ngFormBuilder.control(options?.disableDuplicateDetection),
duplicateDetectionTimeFrame: [options?.duplicateDetectionTimeFrame, [Validators.required, Validators.min(0)]], duplicateDetectionTimeFrame: this.ngFormBuilder.control(options?.duplicateDetectionTimeFrame, [Validators.required, Validators.min(0)]),
keypadBeeps: [options?.keypadBeeps, Validators.required], email: this.ngFormBuilder.control(options?.email),
maxClients: [options?.maxClients, [Validators.required, Validators.min(1)]], keypadBeeps: this.ngFormBuilder.control(options?.keypadBeeps, Validators.required),
playbackGoesLive: [options?.playbackGoesLive], maxClients: this.ngFormBuilder.control(options?.maxClients, [Validators.required, Validators.min(1)]),
pruneDays: [options?.pruneDays, [Validators.required, Validators.min(0)]], playbackGoesLive: this.ngFormBuilder.control(options?.playbackGoesLive),
searchPatchedTalkgroups: [options?.searchPatchedTalkgroups], pruneDays: this.ngFormBuilder.control(options?.pruneDays, [Validators.required, Validators.min(0)]),
showListenersCount: [options?.showListenersCount], showListenersCount: this.ngFormBuilder.control(options?.showListenersCount),
sortTalkgroups: [options?.sortTalkgroups], sortTalkgroups: this.ngFormBuilder.control(options?.sortTalkgroups),
tagsToggle: [options?.tagsToggle], time12hFormat: this.ngFormBuilder.control(options?.time12hFormat),
time12hFormat: [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 { private validateApikey(): 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 {
return (control: AbstractControl): ValidationErrors | null => { return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) { if (typeof control.value !== 'string' || !control.value.length) {
return null; 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; return count > 1 ? { duplicate: true } : null;
}; };
@ -633,9 +755,13 @@ export class RdioScannerAdminService implements OnDestroy {
return null; 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; return count > 1 ? { duplicate: true } : null;
}; };
@ -649,7 +775,7 @@ export class RdioScannerAdminService implements OnDestroy {
const type = dirwatch.type; 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; 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; 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; 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 { private validateMask(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string') { if (typeof control.value !== 'string') {
return null; 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) => { const count = metas.reduce((c, m) => {
if (masks.includes(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 { private validateTag(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'number') { if (typeof control.value !== 'number') {
return null; 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; 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 { private validateUrl(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => { return (control: AbstractControl): ValidationErrors | null => {
if (typeof control.value !== 'string' || !control.value.length) { if (typeof control.value !== 'string' || !control.value.length) {

View file

@ -4,15 +4,20 @@
internally on the client side and reused thereafter.</p> internally on the client side and reused thereafter.</p>
<button type="button" mat-button color="accent" (click)="add()">New access</button> <button type="button" mat-button color="accent" (click)="add()">New access</button>
</div> </div>
<p *ngIf="!accesses.length" class="mat-small text-center">No access is defined, your instance is open and accessible to @if (!accesses.length) {
<p class="mat-small text-center">No access is defined, your instance is open and accessible to
all</p> all</p>
}
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)"> <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="accesses" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let access of accesses; index as i" cdkDrag> @for (access of accesses; track access; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ access.value.ident || 'NewAccess' }} {{ access.value.ident || 'NewAccess' }}
<mat-icon *ngIf="access.invalid" color="warn">error</mat-icon> @if (access.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-container [formGroup]="access"> <ng-container [formGroup]="access">
@ -21,17 +26,21 @@
<span class="mat-body">Code</span><br> <span class="mat-body">Code</span><br>
<span class="mat-caption">The access code to unlock the client.</span> <span class="mat-caption">The access code to unlock the client.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code"> <input [type]="hideCode ? 'password' : 'text'" matInput formControlName="code" placeholder="Code">
<button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode"> <button type="button" mat-icon-button matSuffix (click)="hideCode = !hideCode">
<mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon> <mat-icon>{{ hideCode ? 'visibility_off' : 'visibility' }}</mat-icon>
</button> </button>
<mat-error *ngIf="access.get('code')?.hasError('duplicate')"> @if (access.get('code')?.hasError('duplicate')) {
<mat-error>
Code is already defined Code is already defined
</mat-error> </mat-error>
<mat-error *ngIf="access.get('code')?.hasError('required')"> }
@if (access.get('code')?.hasError('required')) {
<mat-error>
Code is required Code is required
</mat-error> </mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
@ -40,11 +49,13 @@
<span class="mat-caption">Dummy identifier which serves only to identify this access code is for <span class="mat-caption">Dummy identifier which serves only to identify this access code is for
whom.</span> whom.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="ident" placeholder="Ident"> <input type="text" matInput formControlName="ident" placeholder="Ident">
<mat-error *ngIf="access.get('ident')?.hasError('required')"> @if (access.get('ident')?.hasError('required')) {
<mat-error>
Ident is required Ident is required
</mat-error> </mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
@ -52,7 +63,7 @@
<span class="mat-body">Expiration</span><br> <span class="mat-body">Expiration</span><br>
<span class="mat-caption">Expiration date for this access code.</span> <span class="mat-caption">Expiration date for this access code.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="expiration" placeholder="Expiration date" <input type="text" matInput formControlName="expiration" placeholder="Expiration date"
[matDatepicker]="expirationDate" (click)="expirationDate.open()"> [matDatepicker]="expirationDate" (click)="expirationDate.open()">
<mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle> <mat-datepicker-toggle matSuffix [for]="expirationDate"></mat-datepicker-toggle>
@ -65,7 +76,7 @@
<span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection <span class="mat-caption">Simultaneous connection limit. Access will be refused if the connection
with this access code exceeds the defined limit.</span> with this access code exceeds the defined limit.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit"> <input type="number" min="0" step="1" matInput formControlName="limit" placeholder="Limit">
</mat-form-field> </mat-form-field>
</div> </div>
@ -74,8 +85,12 @@
<span class="mat-body">Access</span><br> <span class="mat-body">Access</span><br>
<span class="mat-caption"> <span class="mat-caption">
The access code allows access to <u> The access code allows access to <u>
<ng-container *ngIf="access.value.systems === '*'">all</ng-container> @if (access.value.systems === '*') {
<ng-container *ngIf="access.value.systems !== '*'">some</ng-container> all
}
@if (access.value.systems !== '*') {
some
}
</u> systems and talkgroups. </u> systems and talkgroups.
</span> </span>
</p> </p>
@ -92,4 +107,5 @@
</div> </div>
</ng-container> </ng-container>
</mat-expansion-panel> </mat-expansion-panel>
}
</mat-accordion> </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 * 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 * it under the terms of the GNU General Public License as published by
@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({ @Component({
selector: 'rdio-scanner-admin-access', selector: 'rdio-scanner-admin-access',
templateUrl: './access.component.html', templateUrl: './access.component.html',
standalone: false
}) })
export class RdioScannerAdminAccessComponent { export class RdioScannerAdminAccessComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -33,10 +33,10 @@
<button type="button" mat-icon-button matSuffix (click)="copy(key)"> <button type="button" mat-icon-button matSuffix (click)="copy(key)">
<mat-icon>content_copy</mat-icon> <mat-icon>content_copy</mat-icon>
</button> </button>
<mat-error *ngIf="apiKey.get('key')?.hasError('duplicate')"> <mat-error *ngIf="apiKey.get('key').hasError('duplicate')">
Key is already defined Key is already defined
</mat-error> </mat-error>
<mat-error *ngIf="apiKey.get('key')?.hasError('required')"> <mat-error *ngIf="apiKey.get('key').hasError('required')">
Key is required Key is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -49,7 +49,7 @@
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="never">
<input type="text" matInput formControlName="ident" placeholder="Ident"> <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 Ident is required
</mat-error> </mat-error>
</mat-form-field> </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 * 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 * 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,41 +1,41 @@
<form *ngIf="form" autocomplete="off" [formGroup]="form" (ngSubmit)="save()"> <form *ngIf="form as f" autocomplete="off" [formGroup]="f" (ngSubmit)="save()">
<mat-accordion displayMode="flat"> <mat-accordion displayMode="flat">
<mat-expansion-panel (afterCollapse)="accessComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="accessComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>manage_accounts</mat-icon> <mat-icon>manage_accounts</mat-icon>
Access Access
<mat-icon *ngIf="form?.get('access')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('access')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access> <rdio-scanner-admin-access #accessComponent [form]="access"></rdio-scanner-admin-access>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="apiKeyComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="apikeyComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>vpn_key</mat-icon> <mat-icon>vpn_key</mat-icon>
ApiKey ApiKey
<mat-icon *ngIf="form?.get('apiKey')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('apikey')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-api-keys #apiKeyComponent [form]="apiKeys"></rdio-scanner-admin-api-keys> <rdio-scanner-admin-apikeys #apikeyComponent [form]="apikeys"></rdio-scanner-admin-apikeys>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirWatchComponent.closeAll()"> <mat-expansion-panel *ngIf="!docker" (afterCollapse)="dirwatchComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>folder</mat-icon> <mat-icon>folder</mat-icon>
Dirwatch Dirwatch
<mat-icon *ngIf="form?.get('dirWatch')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('dirwatch')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-dir-watch #dirWatchComponent [form]="dirWatch"></rdio-scanner-admin-dir-watch> <rdio-scanner-admin-dirwatch #dirwatchComponent [form]="dirwatch"></rdio-scanner-admin-dirwatch>
</mat-expansion-panel> </mat-expansion-panel>
<mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()"> <mat-expansion-panel (afterCollapse)="downstreamsComponent.closeAll()">
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon>share</mat-icon> <mat-icon>share</mat-icon>
Downstreams Downstreams
<mat-icon *ngIf="form?.get('downstreams')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('downstreams')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams> <rdio-scanner-admin-downstreams #downstreamsComponent [form]="downstreams"></rdio-scanner-admin-downstreams>
@ -45,7 +45,7 @@
<mat-panel-title> <mat-panel-title>
<mat-icon>workspaces</mat-icon> <mat-icon>workspaces</mat-icon>
Groups Groups
<mat-icon *ngIf="form?.get('groups')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('groups')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups> <rdio-scanner-admin-groups [form]="groups"></rdio-scanner-admin-groups>
@ -55,7 +55,7 @@
<mat-panel-title> <mat-panel-title>
<mat-icon>tune</mat-icon> <mat-icon>tune</mat-icon>
Options Options
<mat-icon *ngIf="form?.get('options')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('options')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options> <rdio-scanner-admin-options [form]="options"></rdio-scanner-admin-options>
@ -65,7 +65,7 @@
<mat-panel-title> <mat-panel-title>
<mat-icon>podcasts</mat-icon> <mat-icon>podcasts</mat-icon>
Systems Systems
<mat-icon *ngIf="form?.get('systems')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('systems')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems> <rdio-scanner-admin-systems #systemsComponent [form]="systems"></rdio-scanner-admin-systems>
@ -75,16 +75,16 @@
<mat-panel-title> <mat-panel-title>
<mat-icon>sell</mat-icon> <mat-icon>sell</mat-icon>
Tags Tags
<mat-icon *ngIf="form?.get('tags')?.invalid" color="warn">error</mat-icon> <mat-icon *ngIf="f.get('tags')?.invalid" color="warn">error</mat-icon>
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags> <rdio-scanner-admin-tags [form]="tags"></rdio-scanner-admin-tags>
</mat-expansion-panel> </mat-expansion-panel>
</mat-accordion> </mat-accordion>
<div class="row bottom"> <div class="row bottom">
<button type="button" mat-raised-button [disabled]="form?.disabled || form?.pristine" <button type="button" mat-raised-button [disabled]="f.disabled || f.pristine"
(click)="reset()">Reset</button> (click)="reset()">Reset</button>
<button type="submit" mat-raised-button color="primary" <button type="submit" mat-raised-button color="primary"
[disabled]="form?.disabled || form?.pristine || !form?.valid">Save</button> [disabled]="f.disabled || f.pristine || !f.valid">Save</button>
</div> </div>
</form> </form>

View file

@ -25,7 +25,7 @@ rdio-scanner-admin-config {
padding-left: 1rem; padding-left: 1rem;
} }
.mat-button:last-child { .mat-mdc-button:last-child {
margin-left: auto; margin-left: auto;
} }
} }
@ -52,7 +52,7 @@ rdio-scanner-admin-config {
} }
@media (max-width: 719px) { @media (max-width: 719px) {
rdio-scanner-admin-config .mat-form-field { rdio-scanner-admin-config .mat-mdc-form-field {
flex: 100%; flex: 100%;
} }
} }
@ -60,13 +60,13 @@ rdio-scanner-admin-config {
@media (min-width: 720px) { @media (min-width: 720px) {
.row { .row {
> p ~ div, > p ~ div,
.mat-form-field { .mat-mdc-form-field {
min-width: 260px; min-width: 260px;
} }
> p ~ div > { > p ~ div > {
.mat-button, .mat-mdc-button,
.mat-slide-toggle { .mat-mdc-slide-toggle {
float: right; 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 * 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 * 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 { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service'; import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -28,9 +30,10 @@ import { AdminEvent, RdioScannerAdminService, Config } from '../admin.service';
selector: 'rdio-scanner-admin-config', selector: 'rdio-scanner-admin-config',
styleUrls: ['./config.component.scss'], styleUrls: ['./config.component.scss'],
templateUrl: './config.component.html', templateUrl: './config.component.html',
standalone: false
}) })
export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit { export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
docker: boolean = false; docker = false;
form: FormGroup | undefined; form: FormGroup | undefined;
@ -38,12 +41,12 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
return this.form?.get('access') as FormArray; return this.form?.get('access') as FormArray;
} }
get apiKeys(): FormArray { get apikeys(): FormArray {
return this.form?.get('apiKeys') as FormArray; return this.form?.get('apikeys') as FormArray;
} }
get dirWatch(): FormArray { get dirwatch(): FormArray {
return this.form?.get('dirWatch') as FormArray; return this.form?.get('dirwatch') as FormArray;
} }
get downstreams(): FormArray { get downstreams(): FormArray {
@ -68,10 +71,17 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
private config: Config | undefined; private config: Config | undefined;
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => { 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) { if ('authenticated' in event && event.authenticated === true) {
this.config = await this.adminService.getConfig(); this.config = await this.adminService.getConfig();
this.reset(); this.reset();
} }
@ -84,22 +94,19 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
} }
if ('docker' in event) { if ('docker' in event) {
this.docker = event.docker!; this.docker = event.docker ?? false;
} }
}); });
}
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(
private adminService: RdioScannerAdminService,
private ngChangeDetectorRef: ChangeDetectorRef,
) { }
ngOnDestroy(): void { ngOnDestroy(): void {
this.eventSubscription.unsubscribe(); this.destroy$.next();
this.destroy$.complete();
} }
async ngOnInit(): Promise<void> { async ngOnInit(): Promise<void> {
await this.adminService.loadAlerts();
this.config = await this.adminService.getConfig(); this.config = await this.adminService.getConfig();
this.reset(); this.reset();
@ -112,28 +119,34 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
reset(config = this.config, options?: { dirty?: boolean }): void { reset(config = this.config, options?: { dirty?: boolean }): void {
this.form = this.adminService.newConfigForm(config); this.form = this.adminService.newConfigForm(config);
this.form.statusChanges.subscribe(() => { this.form.statusChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.ngChangeDetectorRef.markForCheck(); this.ngChangeDetectorRef.markForCheck();
}); });
this.groups.valueChanges.subscribe(() => { const systemsArray = this.systems;
this.systems.controls.forEach((system) => { 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; const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => { 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) { if (groupIds.errors) {
groupId.markAsTouched({ onlySelf: true }); groupIds.markAsTouched({ onlySelf: true });
} }
}); });
}); });
}); });
this.tags.valueChanges.subscribe(() => { tagsArray?.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.systems.controls.forEach((system) => { if (!systemsArray) { return; }
systemsArray.controls.forEach((system) => {
const talkgroups = system.get('talkgroups') as FormArray; const talkgroups = system.get('talkgroups') as FormArray;
talkgroups.controls.forEach((talkgroup) => { talkgroups.controls.forEach((talkgroup) => {
@ -156,8 +169,10 @@ export class RdioScannerAdminConfigComponent implements OnDestroy, OnInit {
} }
async save(): Promise<void> { 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> <mat-slide-toggle color="primary" formControlName="deleteAfter"></mat-slide-toggle>
</div> </div>
</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"> <div class="row">
<p> <p>
<span class="mat-body">Directory</span><br> <span class="mat-body">Directory</span><br>
<span class="mat-caption"> <span class="mat-caption">
Path of a local directory to monitor for file ingestion. Note that dirwatch is not compatible Path of the
with networked disk. <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> </span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="never">
@ -49,9 +85,12 @@
<mat-error *ngIf="dirWatch.get('directory')?.hasError('duplicate')"> <mat-error *ngIf="dirWatch.get('directory')?.hasError('duplicate')">
Directory is already defined Directory is already defined
</mat-error> </mat-error>
<mat-error *ngIf="dirWatch.get('directory')?.hasError('network')">
Incompatible network folder
</mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row" *ngIf="['default','dsdplus','trunk-recorder'].includes(dirWatch.get('type')?.value)">
<p> <p>
<span class="mat-body">Extension</span><br> <span class="mat-body">Extension</span><br>
<span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3", <span class="mat-caption">The audio call extension to monitor without the period. Ex.: "mp3",
@ -64,29 +103,14 @@
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row" *ngIf="['default','dsdplus'].includes(dirWatch.get('type')?.value)">
<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">
<p> <p>
<span class="mat-body">System</span><br> <span class="mat-body">System</span><br>
<span class="mat-caption">System to where the audio files should go.</span> <span class="mat-caption">System to where the audio files should go.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="never">
<mat-select #sysId formControlName="systemId" placeholder="System"> <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"> <mat-option *ngFor="let system of systems" [value]="system.value.id">
{{ system.value.label }} {{ system.value.label }}
</mat-option> </mat-option>
@ -96,14 +120,14 @@
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row" *ngIf="['default','dsdplus'].includes(dirWatch.get('type')?.value)">
<p> <p>
<span class="mat-body">Talkgroup</span><br> <span class="mat-body">Talkgroup</span><br>
<span class="mat-caption">Talkgroup to where the audio files should go.</span> <span class="mat-caption">Talkgroup to where the audio files should go.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="never">
<mat-select formControlName="talkgroupId" placeholder="Talkgroup"> <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] || []" <mat-option *ngFor="let talkgroup of talkgroups[dirWatch.value.systemId] || []"
[value]="talkgroup.value.id"> [value]="talkgroup.value.id">
{{ talkgroup.value.label }} {{ talkgroup.value.label }}
@ -114,7 +138,7 @@
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
<p> <p>
<span class="mat-body">Mask</span><br> <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 <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-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
<p> <p>
<span class="mat-body">Frequency</span><br> <span class="mat-body">Frequency</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span> <span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span>
@ -168,7 +192,7 @@
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row" *ngIf="['default'].includes(dirWatch.get('type')?.value)">
<p> <p>
<span class="mat-body">Delay</span><br> <span class="mat-body">Delay</span><br>
<span class="mat-caption">Depending on the recorder, audio files can be ingested too soon after the <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 * 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 * 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

@ -2,14 +2,19 @@
<p class="mat-body">Ingested audio calls can be sent downstream to other instances.</p> <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> <button type="button" mat-button color="accent" (click)="add()">New downstream</button>
</div> </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-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="downstreams" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let downstream of downstreams; index as i" cdkDrag> @for (downstream of downstreams; track downstream; let i = $index) {
<mat-expansion-panel cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-icon cdkDragHandle>drag_indicator</mat-icon>
{{ downstream.value.url || 'NewDownstream' }} {{ downstream.value.url || 'NewDownstream' }}
<mat-icon *ngIf="downstream.invalid" color="warn">error</mat-icon> @if (downstream.invalid) {
<mat-icon color="warn">error</mat-icon>
}
</mat-panel-title> </mat-panel-title>
</mat-expansion-panel-header> </mat-expansion-panel-header>
<ng-container [formGroup]="downstream"> <ng-container [formGroup]="downstream">
@ -27,14 +32,18 @@
<span class="mat-body">API Key</span><br> <span class="mat-body">API Key</span><br>
<span class="mat-caption">Api key of the remote instance.</span> <span class="mat-caption">Api key of the remote instance.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input #key type="text" matInput formControlName="apiKey" placeholder="API key"> <input #key type="text" matInput formControlName="apikey" placeholder="API key">
<mat-error *ngIf="downstream.get('apiKey')?.hasError('duplicate')"> @if (downstream.get('apikey')?.hasError('duplicate')) {
<mat-error>
API key is already defined API key is already defined
</mat-error> </mat-error>
<mat-error *ngIf="downstream.get('apiKey')?.hasError('required')"> }
@if (downstream.get('apikey')?.hasError('required')) {
<mat-error>
API key is required API key is required
</mat-error> </mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
@ -42,17 +51,23 @@
<span class="mat-body">URL</span><br> <span class="mat-body">URL</span><br>
<span class="mat-caption">URL of the remote instance.</span> <span class="mat-caption">URL of the remote instance.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="url" placeholder="URL"> <input type="text" matInput formControlName="url" placeholder="URL">
<mat-error *ngIf="downstream.get('url')?.hasError('required')"> @if (downstream.get('url')?.hasError('required')) {
<mat-error>
URL is required URL is required
</mat-error> </mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('invalid')"> }
@if (downstream.get('url')?.hasError('invalid')) {
<mat-error>
URL is invalid URL is invalid
</mat-error> </mat-error>
<mat-error *ngIf="downstream.get('url')?.hasError('duplicate')"> }
@if (downstream.get('url')?.hasError('duplicate')) {
<mat-error>
URL is already defined URL is already defined
</mat-error> </mat-error>
}
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
@ -60,8 +75,12 @@
<span class="mat-body">Access</span><br> <span class="mat-body">Access</span><br>
<span class="mat-caption"> <span class="mat-caption">
This downstream allows access to <u> This downstream allows access to <u>
<ng-container *ngIf="downstream.value.systems === '*'">all</ng-container> @if (downstream.value.systems === '*') {
<ng-container *ngIf="downstream.value.systems !== '*'">some</ng-container> all
}
@if (downstream.value.systems !== '*') {
some
}
</u> systems and talkgroups. </u> systems and talkgroups.
</span> </span>
</p> </p>
@ -78,4 +97,5 @@
</div> </div>
</ng-container> </ng-container>
</mat-expansion-panel> </mat-expansion-panel>
}
</mat-accordion> </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 * 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 * it under the terms of the GNU General Public License as published by
@ -28,6 +28,7 @@ import { RdioScannerAdminSystemsSelectComponent } from '../systems/select/select
@Component({ @Component({
selector: 'rdio-scanner-admin-downstreams', selector: 'rdio-scanner-admin-downstreams',
templateUrl: './downstreams.component.html', templateUrl: './downstreams.component.html',
standalone: false
}) })
export class RdioScannerAdminDownstreamsComponent { export class RdioScannerAdminDownstreamsComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;

View file

@ -1,18 +1,68 @@
<div class="row top"> <div class="row top">
<p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between <p class="mat-body">All system talkgroups must be associated with a group, which is then used to toggle between
active talkgroups.</p> active talkgroups.</p>
<button type="button" mat-button color="accent" (click)="add()">New group</button> <button type="button" mat-button color="accent" (click)="add()">New Group</button>
</div> </div>
<div class="groups"> <p *ngIf="!groups.length" class="mat-small text-center">No defined Groups</p>
<div *ngFor="let group of groups; index as i" [formGroup]="group"> <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="groups"
<button type="button" mat-icon-button color="warn" (click)="remove(i)"> (cdkDropListDropped)="drop($event)">
<mat-icon>clear</mat-icon> <mat-expansion-panel *ngFor="let group of groups; let i = index" cdkDrag>
</button> <mat-expansion-panel-header>
<mat-form-field floatLabel="never"> <mat-panel-title>
<input type="text" matInput formControlName="label" placeholder="Group"> <mat-icon cdkDragHandle>drag_indicator</mat-icon>
<mat-error *ngIf="groups[i].get('label')?.hasError('required')"> {{ group.value.label || 'NewGroup' }}
Group is required <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-error>
</mat-form-field> </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 * 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 * 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 { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'rdio-scanner-admin-groups', selector: 'rdio-scanner-admin-groups',
styleUrls: ['./groups.component.scss'],
templateUrl: './groups.component.html', templateUrl: './groups.component.html',
standalone: false
}) })
export class RdioScannerAdminGroupsComponent { export class RdioScannerAdminGroupsComponent implements OnDestroy {
@Input() form: FormArray | undefined; private _form: FormArray | undefined;
private formSub: Subscription | undefined;
private cachedGroups: FormGroup[] | undefined;
private cacheValid = false;
readonly leds: string[] = [];
readonly alertsArray: string[] = [];
get groups(): FormGroup[] { @Input()
return this.form?.controls set form(value: FormArray | undefined) {
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[]; 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 { add(): void {
const id = this.groups.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0); const group = this.adminService.newGroupForm();
const group = this.adminService.newGroupForm({ _id: id });
group.markAllAsTouched(); group.markAllAsTouched();
this.form?.insert(0, group); this.form?.insert(0, group);
this.form?.markAsDirty(); 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 { remove(index: number): void {
this.form?.removeAt(index); this.form?.removeAt(index);
this.form?.markAsDirty(); this.form?.markAsDirty();
this.cacheValid = false;
}
ngOnDestroy(): void {
this.formSub?.unsubscribe();
} }
} }

View file

@ -1,4 +1,5 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form">
<ng-container [formGroup]="form">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">12-Hour Time Format</span><br> <span class="mat-body">12-Hour Time Format</span><br>
@ -8,25 +9,12 @@
<mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="time12hFormat"></mat-slide-toggle>
</div> </div>
</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>
</div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Audio Conversion</span><br> <span class="mat-body">Audio Conversion</span><br>
<span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span> <span class="mat-caption">Convert incoming audio files to H.264 m4a with ffmpeg.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<mat-select formControlName="audioConversion" placeholder="Audio Conversion"> <mat-select formControlName="audioConversion" placeholder="Audio Conversion">
<mat-option [value]="0">Disabled</mat-option> <mat-option [value]="0">Disabled</mat-option>
<mat-option [value]="1">Enabled without normalization</mat-option> <mat-option [value]="1">Enabled without normalization</mat-option>
@ -38,13 +26,23 @@
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Auto Populate</span><br> <span class="mat-body">Auto Populate</span><br>
<span class="mat-caption">Globaly allows the automatic creation of unconfigured systems and <span class="mat-caption">Globally allows the automatic creation of unconfigured systems and
talkgroups.</span> talkgroups.</span>
</p> </p>
<div> <div>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row">
<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"> <div class="row">
<p> <p>
<span class="mat-body">Dimmer Delay</span><br> <span class="mat-body">Dimmer Delay</span><br>
@ -53,10 +51,10 @@
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="dimmerDelay"> <input type="number" min="0" step="1" matInput formControlName="dimmerDelay">
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('required')"> <mat-error *ngIf="form.get('dimmerDelay')?.hasError('required')">
Dimmer delay is required Dimmer delay is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('dimmerDelay')?.hasError('min')"> <mat-error *ngIf="form.get('dimmerDelay')?.hasError('min')">
Dimmer delay is invalid Dimmer delay is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -78,20 +76,29 @@
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame"> <input type="number" min="0" step="1" matInput formControlName="duplicateDetectionTimeFrame">
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('required')"> <mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('required')">
Duplicate call time frame is required Duplicate call time frame is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('duplicateDetectionTimeFrame')?.hasError('min')"> <mat-error *ngIf="form.get('duplicateDetectionTimeFrame')?.hasError('min')">
Duplicate call time frame is invalid Duplicate call time frame is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row">
<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"> <div class="row">
<p> <p>
<span class="mat-body">Keypad Beep Style</span><br> <span class="mat-body">Keypad Beep Style</span><br>
<span class="mat-caption">Provide audio feedback when pressing buttons.</span> <span class="mat-caption">Provide audio feedback when pressing buttons.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<mat-select formControlName="keypadBeeps" placeholder="Style"> <mat-select formControlName="keypadBeeps" placeholder="Style">
<mat-option value="uniden">Uniden</mat-option> <mat-option value="uniden">Uniden</mat-option>
<mat-option value="whistler">Whistler</mat-option> <mat-option value="whistler">Whistler</mat-option>
@ -105,10 +112,10 @@
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="maxClients"> <input type="number" min="0" step="1" matInput formControlName="maxClients">
<mat-error *ngIf="form?.get('maxClients')?.hasError('required')"> <mat-error *ngIf="form.get('maxClients')?.hasError('required')">
Max clients is required Max clients is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('maxClients')?.hasError('min')"> <mat-error *ngIf="form.get('maxClients')?.hasError('min')">
Max clients is invalid Max clients is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -131,24 +138,14 @@
</p> </p>
<mat-form-field> <mat-form-field>
<input type="number" min="0" step="1" matInput formControlName="pruneDays"> <input type="number" min="0" step="1" matInput formControlName="pruneDays">
<mat-error *ngIf="form?.get('pruneDays')?.hasError('required')"> <mat-error *ngIf="form.get('pruneDays')?.hasError('required')">
Prune days is required Prune days is required
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('pruneDays')?.hasError('min')"> <mat-error *ngIf="form.get('pruneDays')?.hasError('min')">
Prune days is invalid Prune days is invalid
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</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>
</div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Show Listeners Count</span><br> <span class="mat-body">Show Listeners Count</span><br>
@ -167,13 +164,5 @@
<mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="sortTalkgroups"></mat-slide-toggle>
</div> </div>
</div> </div>
<div class="row"> </ng-container>
<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>
</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 * 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 * 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'; import { FormGroup } from '@angular/forms';
@Component({ @Component({
selector: 'rdio-scanner-admin-options', selector: 'rdio-scanner-admin-options',
templateUrl: './options.component.html', templateUrl: './options.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminOptionsComponent { export class RdioScannerAdminOptionsComponent {
@Input() form: FormGroup | undefined; @Input() form: FormGroup | null = null;
get controls() {
return this.form ? this.form.controls : {};
}
} }

View file

@ -7,25 +7,30 @@
</mat-checkbox> </mat-checkbox>
<h3 class="mat-caption">Groups</h3> <h3 class="mat-caption">Groups</h3>
<div class="flex-row wrap" formArrayName="groups"> <div class="flex-row wrap" formArrayName="groups">
<div *ngFor="let group of configGroups; index as groupIndex" [formGroupName]="groupIndex"> <ng-container *ngFor="let group of configGroups; let groupIndex = index">
<div [formGroupName]="groupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked" <mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.groups[groupIndex]"> [indeterminate]="indeterminate.groups[groupIndex]">
{{ group.value.label }} {{ group.value.label }}
</mat-checkbox> </mat-checkbox>
</div> </div>
</ng-container>
</div> </div>
<h3 class="mat-caption">Tags</h3> <h3 class="mat-caption">Tags</h3>
<div class="flex-row wrap" formArrayName="tags"> <div class="flex-row wrap" formArrayName="tags">
<div *ngFor="let tag of configTags; index as tagIndex" [formGroupName]="tagIndex"> <ng-container *ngFor="let tag of configTags; let tagIndex = index">
<div [formGroupName]="tagIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked" <mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"
[indeterminate]="indeterminate.tags[tagIndex]"> [indeterminate]="indeterminate.tags[tagIndex]">
{{ tag.value.label }} {{ tag.value.label }}
</mat-checkbox> </mat-checkbox>
</div> </div>
</ng-container>
</div> </div>
<h3 class="mat-caption">Systems</h3> <h3 class="mat-caption">Systems</h3>
<div formArrayName="systems"> <div formArrayName="systems">
<div *ngFor="let system of configSystems; index as systemIndex" [formGroupName]="systemIndex"> <ng-container *ngFor="let system of configSystems; let systemIndex = index">
<div [formGroupName]="systemIndex">
<div class="flex-row margin"> <div class="flex-row margin">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="all" <mat-checkbox color="primary" [disableRipple]="true" formControlName="all"
[indeterminate]="indeterminate.systems[systemIndex]"> [indeterminate]="indeterminate.systems[systemIndex]">
@ -33,14 +38,16 @@
</mat-checkbox> </mat-checkbox>
</div> </div>
<div class="flex-row wrap indent margin" formArrayName="talkgroups"> <div class="flex-row wrap indent margin" formArrayName="talkgroups">
<div *ngFor="let talkgroup of configTalkgroups[systemIndex]; index as talkgroupIndex" <ng-container *ngFor="let talkgroup of configTalkgroups[systemIndex]; let talkgroupIndex = index">
[formGroupName]="talkgroupIndex"> <div [formGroupName]="talkgroupIndex">
<mat-checkbox color="primary" [disableRipple]="true" formControlName="checked"> <mat-checkbox color="primary" [disableRipple]="true" formControlName="checked">
{{ talkgroup.value.label }} {{ talkgroup.value.label }}
</mat-checkbox> </mat-checkbox>
</div> </div>
</ng-container>
</div> </div>
</div> </div>
</ng-container>
</div> </div>
</div> </div>
<div mat-dialog-actions> <div mat-dialog-actions>

View file

@ -35,18 +35,7 @@ rdio-scanner-admin-systems-selection {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.mat-checkbox, .mat-mdc-dialog-actions {
.mat-checkbox-layout {
max-width: 100%;
}
.mat-checkbox-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.mat-dialog-actions {
justify-content: space-between; 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 * 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 * 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 { FormArray, FormBuilder, FormControl, FormGroup } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Access } from '../../../admin.service'; import { Access } from '../../../admin.service';
import { Subscription } from 'rxjs';
interface System { interface System {
all: boolean; all: boolean;
@ -38,8 +39,9 @@ interface Talkgroup {
selector: 'rdio-scanner-admin-systems-selection', selector: 'rdio-scanner-admin-systems-selection',
styleUrls: ['./select.component.scss'], styleUrls: ['./select.component.scss'],
templateUrl: './select.component.html', templateUrl: './select.component.html',
standalone: false
}) })
export class RdioScannerAdminSystemsSelectComponent { export class RdioScannerAdminSystemsSelectComponent implements OnDestroy {
indeterminate = { indeterminate = {
everything: false, everything: false,
groups: [] as boolean[], groups: [] as boolean[],
@ -47,233 +49,196 @@ export class RdioScannerAdminSystemsSelectComponent {
tags: [] as boolean[], tags: [] as boolean[],
}; };
select = this.ngFormBuilder.group({ select: FormGroup;
all: this.ngFormBuilder.control(false),
groups: this.ngFormBuilder.array([]),
tags: this.ngFormBuilder.array([]),
systems: this.ngFormBuilder.array([]),
});
configTalkgroups = this.configSystems.map((fgSystem) => { configTalkgroups: FormGroup[][];
const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
return faTalkgroups.controls as FormGroup[];
});
get configGroups(): FormGroup[] { get configGroups(): FormGroup[] {
const faGroups = this.access.root.get('groups') as FormArray; const faGroups = this.access.root.get('groups') as FormArray;
return faGroups.controls as FormGroup[]; return faGroups.controls as FormGroup[];
} }
get configSystems(): FormGroup[] { get configSystems(): FormGroup[] {
const faSystems = this.access.root.get('systems') as FormArray; const faSystems = this.access.root.get('systems') as FormArray;
return faSystems.controls as FormGroup[]; return faSystems.controls as FormGroup[];
} }
get configTags(): FormGroup[] { get configTags(): FormGroup[] {
const faTags = this.access.root.get('tags') as FormArray; const faTags = this.access.root.get('tags') as FormArray;
return faTags.controls as FormGroup[]; return faTags.controls as FormGroup[];
} }
private subs = new Subscription();
constructor( constructor(
@Inject(MAT_DIALOG_DATA) public access: FormGroup, @Inject(MAT_DIALOG_DATA) public access: FormGroup,
private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>, private matDialogRef: MatDialogRef<RdioScannerAdminSystemsSelectComponent>,
private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder, 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 fcAll = this.select.get('all') as FormControl;
const faGroups = this.select.get('groups') as FormArray; const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray; const faSystems = this.select.get('systems') as FormArray;
const faTags = this.select.get('tags') 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({ const fgGroup = this.ngFormBuilder.group({
_id: [configGroup.value._id], id: this.ngFormBuilder.control(configGroup.get('id')?.value),
checked: [false], checked: this.ngFormBuilder.control(false),
}); });
faGroups.push(fgGroup); faGroups.push(fgGroup);
this.subs.add(fgGroup.valueChanges.subscribe((vGroup) => {
fgGroup.valueChanges.subscribe((vGroup) => { for (const fgSystem of faSystems.controls) {
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => { const ids = fgTalkgroup.get('groupIds')?.value;
if (fgTalkgroup.value.groupId === vGroup._id && fgTalkgroup.value.checked !== vGroup.checked) { if (ids && ids.includes(vGroup.id) && fgTalkgroup.get('checked')?.value !== vGroup.checked) {
fgTalkgroup.get('checked')?.setValue(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 fcSystemAll = this.ngFormBuilder.control(false);
const faSystemTalkgroups = this.ngFormBuilder.array<FormGroup>([]);
const faSystemTalkgroups = this.ngFormBuilder.array([]);
const fgSystem = this.ngFormBuilder.group({ const fgSystem = this.ngFormBuilder.group({
all: fcSystemAll, all: fcSystemAll,
id: configSystem.value.id, id: this.ngFormBuilder.control(configSystem.get('systemRef')?.value),
talkgroups: faSystemTalkgroups talkgroups: faSystemTalkgroups
}); });
this.configTalkgroups[index].forEach((configTalkgroup) => { for (const configTalkgroup of this.configTalkgroups[index]) {
const fgSystemTalkgroup = this.ngFormBuilder.group({ const fgSystemTalkgroup = this.ngFormBuilder.group({
checked: [false], checked: this.ngFormBuilder.nonNullable.control(false),
groupId: configTalkgroup.value.groupId, groupIds: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('groupIds')?.value),
id: configTalkgroup.value.id, id: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('talkgroupRef')?.value),
tagId: configTalkgroup.value.tagId tagId: this.ngFormBuilder.nonNullable.control(configTalkgroup.get('tagId')?.value),
}); });
faSystemTalkgroups.push(fgSystemTalkgroup); faSystemTalkgroups.push(fgSystemTalkgroup);
this.subs.add(fgSystemTalkgroup.valueChanges.subscribe(() => {
fgSystemTalkgroup.valueChanges.subscribe(() => { const vAll = faSystemTalkgroups.controls.every((t) => t.get('checked')?.value);
const vAll = faSystemTalkgroups.controls.every((systemTalkgroup) => systemTalkgroup.value.checked);
fcSystemAll.setValue(vAll, { emitEvent: false }); fcSystemAll.setValue(vAll, { emitEvent: false });
}); }));
}); }
faSystems.push(fgSystem); faSystems.push(fgSystem);
fgSystem.valueChanges.subscribe(() => { this.subs.add(fgSystem.valueChanges.subscribe(() => {
this.rebuildGroupIndeterminates(); this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates(); this.rebuildTagIndeterminates();
}); }));
fcSystemAll.valueChanges.subscribe((vAll) => { this.subs.add(fcSystemAll.valueChanges.subscribe((vAll) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => fgTalkgroup.get('checked')?.setValue(vAll)); fgTalkgroup.get('checked')?.setValue(vAll);
this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
});
faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let off = 0;
let on = 0;
vSystemTalkgroups.forEach((vSystemTalkgroup: Talkgroup) => {
if (vSystemTalkgroup.checked) {
on++;
} else {
off++;
} }
}); this.rebuildGroupIndeterminates();
this.rebuildTagIndeterminates();
}));
this.subs.add(faSystemTalkgroups.valueChanges.subscribe((vSystemTalkgroups) => {
let on = 0;
let off = 0;
for (const v of vSystemTalkgroups) {
if (v.checked) on++; else off++;
}
this.indeterminate.systems[index] = !!off && !!on; this.indeterminate.systems[index] = !!off && !!on;
faSystems.at(index).get('all')?.setValue(!off && on, { emitEvent: false }); 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({ const fgTag = this.ngFormBuilder.group({
_id: [configTag.value._id], id: this.ngFormBuilder.control(configTag.value.id),
checked: [false], checked: this.ngFormBuilder.control(false),
}); });
faTags.push(fgTag); faTags.push(fgTag);
this.subs.add(fgTag.valueChanges.subscribe((vTag) => {
fgTag.valueChanges.subscribe((vTag) => { for (const fgSystem of faSystems.controls) {
faSystems.controls.forEach((fgSystem) => {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => { if (fgTalkgroup.value.tagId === vTag.id && fgTalkgroup.value.checked !== vTag.checked) {
if (fgTalkgroup.value.tagId === vTag._id && fgTalkgroup.value.checked !== vTag.checked) {
fgTalkgroup.get('checked')?.setValue(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; this.indeterminate.everything = !!off && !!on;
fcAll.setValue(!off && on, { emitEvent: false }); fcAll.setValue(!off && on, { emitEvent: false });
}); }));
const vAccess: Access = this.access.value; const vAccess: Access = this.access.value;
if (vAccess.systems === '*') { if (vAccess.systems === '*') {
this.select.get('all')?.setValue(true); this.select.get('all')?.setValue(true);
} else if (Array.isArray(vAccess.systems)) { } 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') { if (typeof vSystem === 'number') {
faSystems.controls.find((fgSystem) => fgSystem.value.id === vSystem)?.get('all')?.setValue(true); faSystems.controls.find((fgSystem) => fgSystem.get('id')?.value === vSystem)?.get('all')?.setValue(true);
} else if (vSystem && typeof vSystem === 'object') {
} else if (vSystem !== null && typeof vSystem === 'object') { const fgSystem = faSystems.controls.find((fg) => fg.get('id')?.value === vSystem.id);
const fgSystem = faSystems.controls.find((fg) => fg.value.id === vSystem.id);
if (fgSystem) { if (fgSystem) {
if (vSystem.talkgroups === '*') { if (vSystem.talkgroups === '*') {
fgSystem.get('all')?.setValue(true); fgSystem.get('all')?.setValue(true);
} else if (Array.isArray(vSystem.talkgroups)) { } else if (Array.isArray(vSystem.talkgroups)) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const talkgroup of vSystem.talkgroups) {
vSystem.talkgroups.forEach((talkgroup: { id: number } | number) => {
const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id; const talkgroupId = typeof talkgroup === 'number' ? talkgroup : talkgroup.id;
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.get('id')?.value === talkgroupId);
const fgTalkgroup = faTalkgroups.controls.find((fg) => fg.value.id === talkgroupId);
fgTalkgroup?.get('checked')?.setValue(true); fgTalkgroup?.get('checked')?.setValue(true);
}); }
fgSystem.updateValueAndValidity();
fgSystem?.updateValueAndValidity(); }
} }
} }
} }
});
} }
} }
accept(): void { accept(): void {
const select = this.select.value; 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);
const access: Access['systems'] = select.all ? '*' : select.systems.filter((system: System) => {
return system.all || system.talkgroups.some((talkgroup: Talkgroup) => talkgroup.checked);
}).map((system: System) => { }).map((system: System) => {
if (system.all) { if (system['all']) {
return { return { id: system['id'], talkgroups: '*' };
id: system.id,
talkgroups: '*',
};
} else { } else {
return { return {
id: system.id, id: system['id'],
talkgroups: system.talkgroups talkgroups: system['talkgroups']
.filter((talkgroup: Talkgroup) => talkgroup.checked) .filter((talkgroup: Talkgroup) => talkgroup.checked)
.map((talkgroup: Talkgroup) => talkgroup.id), .map((talkgroup: Talkgroup) => talkgroup.id),
}; };
@ -289,61 +254,47 @@ export class RdioScannerAdminSystemsSelectComponent {
private rebuildGroupIndeterminates(): void { private rebuildGroupIndeterminates(): void {
const faGroups = this.select.get('groups') as FormArray; const faGroups = this.select.get('groups') as FormArray;
const faSystems = this.select.get('systems') as FormArray; const faSystems = this.select.get('systems') as FormArray;
faGroups.controls.forEach((fgGroup, index) => { for (let index = 0; index < faGroups.length; index++) {
let off = 0; const fgGroup = faGroups.at(index);
let on = 0; let on = 0;
let off = 0;
faSystems.controls.forEach((fgSystem) => { for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => { if (fgTalkgroup.get('groupIds')?.value.includes(fgGroup.get('id')?.value)) {
if (fgTalkgroup.value.groupId === fgGroup.value._id) { if (fgTalkgroup.get('checked')?.value) on++; else off++;
if (fgTalkgroup.value.checked) { }
on++;
} else {
off++;
} }
} }
});
});
this.indeterminate.groups[index] = !!off && !!on; this.indeterminate.groups[index] = !!off && !!on;
fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false }); fgGroup.get('checked')?.setValue(!off && on, { emitEvent: false });
}); }
} }
private rebuildTagIndeterminates(): void { private rebuildTagIndeterminates(): void {
const faTags = this.select.get('tags') as FormArray; const faTags = this.select.get('tags') as FormArray;
const faSystems = this.select.get('systems') as FormArray; const faSystems = this.select.get('systems') as FormArray;
faTags.controls.forEach((fgTag, index) => { for (let index = 0; index < faTags.length; index++) {
let off = 0; const fgTag = faTags.at(index);
let on = 0; let on = 0;
let off = 0;
faSystems.controls.forEach((fgSystem) => { for (const fgSystem of faSystems.controls) {
const faTalkgroups = fgSystem.get('talkgroups') as FormArray; const faTalkgroups = fgSystem.get('talkgroups') as FormArray;
for (const fgTalkgroup of faTalkgroups.controls) {
faTalkgroups.controls.forEach((fgTalkgroup) => { if (fgTalkgroup.value.tagId === fgTag.value.id) {
if (fgTalkgroup.value.tagId === fgTag.value._id) { if (fgTalkgroup.value.checked) on++; else off++;
if (fgTalkgroup.value.checked) { }
on++;
} else {
off++;
} }
} }
});
});
this.indeterminate.tags[index] = !!off && !!on; this.indeterminate.tags[index] = !!off && !!on;
fgTag.get('checked')?.setValue(!off && on, { emitEvent: false }); 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

@ -4,15 +4,15 @@
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">System identifier in decimal format.</span> <span class="mat-caption">System identifier in decimal format.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="id" placeholder="Id"> <input type="number" min="1" step="1" matInput formControlName="systemRef" placeholder="Id">
<mat-error *ngIf="form.get('id')?.hasError('duplicate')"> <mat-error *ngIf="form.get('systemRef')?.hasError('duplicate')">
Id is already defined Id is already defined
</mat-error> </mat-error>
<mat-error *ngIf="form.get('id')?.hasError('min')"> <mat-error *ngIf="form.get('systemRef')?.hasError('min')">
Id is invalid Id is invalid
</mat-error> </mat-error>
<mat-error *ngIf="form.get('id')?.hasError('required')"> <mat-error *ngIf="form.get('systemRef')?.hasError('required')">
Id is required Id is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -22,7 +22,7 @@
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">System label displayed on the main screen and on the search panel.</span> <span class="mat-caption">System label displayed on the main screen and on the search panel.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form.get('label')?.hasError('required')"> <mat-error *ngIf="form.get('label')?.hasError('required')">
Label is required Label is required
@ -31,15 +31,15 @@
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Led Color</span><br> <span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Indicator color when playing an audio file from this system. If not <span class="mat-caption">Prefixes an alert sound to all calls assigned from this system. The alert
specified, the indicator will light green.</span> assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<mat-select formControlName="led" placeholder="Color"> <mat-select formControlName="alert" placeholder="Alert" (selectionChange)="playAlert($event)">
<mat-option [value]="null">Default</mat-option> <mat-option value="">No Alert</mat-option>
<mat-option *ngFor="let led of leds" [value]="led"> <mat-option *ngFor="let alert of alerts" [value]="alert">
{{ led | titlecase }} {{ alert | titlecase }}
</mat-option> </mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
@ -51,19 +51,107 @@
</p> </p>
<mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle> <mat-slide-toggle color="primary" formControlName="autoPopulate"></mat-slide-toggle>
</div> </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"> <div class="row">
<p> <p>
<span class="mat-body">Blacklists</span><br> <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 <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> has an effect if the auto populate option is enabled.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<textarea type="text" matInput formControlName="blacklists" placeholder="Blacklists"></textarea> <textarea matInput formControlName="blacklists" placeholder="Blacklists"></textarea>
<mat-error *ngIf="form?.get('blacklists')?.hasError('invalid')"> <mat-error *ngIf="form.get('blacklists')!.hasError('invalid')">
Comma separated list of talkgroup Ids Comma separated list of talkgroup Ids
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </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>
<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>
<rdio-scanner-admin-site [form]="site" (remove)="removeSite(i)"></rdio-scanner-admin-site>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
</ng-template>
</mat-expansion-panel>
</mat-accordion>
<mat-accordion displayMode="flat"> <mat-accordion displayMode="flat">
<mat-expansion-panel> <mat-expansion-panel>
<mat-expansion-panel-header> <mat-expansion-panel-header>
@ -79,10 +167,9 @@
option is enabled.</p> option is enabled.</p>
<button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button> <button type="button" mat-button color="accent" (click)="addTalkgroup()">New talkgroup</button>
</div> </div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="talkgroups" <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64"
(cdkDropListDropped)="drop($event)"> [cdkDropListData]="talkgroups" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let talkgroup of talkgroups; index as i" [formGroup]="talkgroup" <mat-expansion-panel *ngFor="let talkgroup of talkgroups; let i = index; trackBy: trackById" [formGroup]="talkgroup" cdkDrag>
cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-icon cdkDragHandle>drag_indicator</mat-icon>
@ -114,9 +201,9 @@
screen.<br>You can drag and drop them to rearrange</p> screen.<br>You can drag and drop them to rearrange</p>
<button type="button" mat-button color="accent" (click)="addUnit()">New unit</button> <button type="button" mat-button color="accent" (click)="addUnit()">New unit</button>
</div> </div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="units" <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="units"
(cdkDropListDropped)="drop($event)"> (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let unit of units; index as i" [formGroup]="unit" cdkDrag> <mat-expansion-panel *ngFor="let unit of units; let i = index; trackBy: trackById" [formGroup]="unit" cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>
<mat-icon cdkDragHandle>drag_indicator</mat-icon> <mat-icon cdkDragHandle>drag_indicator</mat-icon>

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 * 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 * 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 { Component, EventEmitter, Input, Output, QueryList, ViewChildren } from '@angular/core';
import { FormArray, FormControl, FormGroup } from '@angular/forms'; import { FormArray, FormControl, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService, Group, Tag } from '../../../admin.service'; import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-system', selector: 'rdio-scanner-admin-system',
templateUrl: './system.component.html', templateUrl: './system.component.html',
standalone: false
}) })
export class RdioScannerAdminSystemComponent { export class RdioScannerAdminSystemComponent {
@Input() form = new FormGroup({}); @Input() form = new FormGroup({});
@ -38,38 +40,58 @@ export class RdioScannerAdminSystemComponent {
@Output() remove = new EventEmitter<void>(); @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[] { get talkgroups(): FormGroup[] {
const talkgroups = this.form.get('talkgroups') as FormArray; const talkgroups = this.form.get('talkgroups') as FormArray | null;
return (talkgroups?.controls as FormGroup[]) || [];
return talkgroups.controls
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
} }
get units(): 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 // trackBy to preserve identity in ngFor lists
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[]; trackById(_index: number, group: FormGroup): any {
return group.get('id')?.value ?? _index;
} }
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined; @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 { 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(); this.form.markAsDirty();
} }
addUnit(): void { 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(); this.form.markAsDirty();
} }
@ -83,9 +105,9 @@ export class RdioScannerAdminSystemComponent {
return; 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); 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 { 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 { 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

@ -4,8 +4,8 @@
systems to rearrange their order.</p> systems to rearrange their order.</p>
<button type="button" mat-button color="accent" (click)="add()">New system</button> <button type="button" mat-button color="accent" (click)="add()">New system</button>
</div> </div>
<mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)"> <mat-accordion *ngIf="systems" displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]="64" [cdkDropListData]="systems" (cdkDropListDropped)="drop($event)">
<mat-expansion-panel *ngFor="let system of systems; index as i" [formGroup]="system" <mat-expansion-panel *ngFor="let system of systems; let i = index" [formGroup]="system"
(afterCollapse)="systemComponent.closeAll()" cdkDrag> (afterCollapse)="systemComponent.closeAll()" cdkDrag>
<mat-expansion-panel-header> <mat-expansion-panel-header>
<mat-panel-title> <mat-panel-title>

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 * 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 * 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 { 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 { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion'; import { MatExpansionPanel } from '@angular/material/expansion';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
import { Subscription } from 'rxjs';
@Component({ @Component({
selector: 'rdio-scanner-admin-systems', selector: 'rdio-scanner-admin-systems',
templateUrl: './systems.component.html', templateUrl: './systems.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminSystemsComponent { export class RdioScannerAdminSystemsComponent implements OnDestroy {
@Input() form: FormArray | undefined; 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[] { get systems(): FormGroup[] {
return this.form?.controls return this._systemsCache;
.sort((a, b) => (a.value.order || 0) - (b.value.order || 0)) as FormGroup[];
} }
@ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined; @ViewChildren(MatExpansionPanel) private panels: QueryList<MatExpansionPanel> | undefined;
constructor(private adminService: RdioScannerAdminService) { } constructor(private adminService: RdioScannerAdminService, private cd: ChangeDetectorRef) { }
add(): void { add(): void {
const system = this.adminService.newSystemForm(); const system = this.adminService.newSystemForm();
@ -47,6 +65,8 @@ export class RdioScannerAdminSystemsComponent {
this.form?.insert(0, system); this.form?.insert(0, system);
this.form?.markAsDirty(); this.form?.markAsDirty();
this.updateCache();
} }
closeAll(): void { closeAll(): void {
@ -60,6 +80,8 @@ export class RdioScannerAdminSystemsComponent {
event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false })); event.container.data.forEach((dat, idx) => dat.get('order')?.setValue(idx + 1, { emitEvent: false }));
this.form?.markAsDirty(); this.form?.markAsDirty();
this.updateCache();
} }
} }
@ -67,5 +89,23 @@ export class RdioScannerAdminSystemsComponent {
this.form?.removeAt(index); this.form?.removeAt(index);
this.form?.markAsDirty(); 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,18 +1,19 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form">
<ng-container [formGroup]="form">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">Talkgroup identifier in decimal format.</span> <span class="mat-caption">Talkgroup identifier in decimal format.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="number" min="1" step="1" matInput formControlName="id" placeholder="Id"> <input type="number" min="1" step="1" matInput formControlName="talkgroupRef" placeholder="Id">
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')"> <mat-error *ngIf="form.get('talkgroupRef')!.hasError('duplicate')">
Id is already defined Id is already defined
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('min')"> <mat-error *ngIf="form.get('talkgroupRef')!.hasError('min')">
Id is invalid Id is invalid
</mat-error> </mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('required')"> <mat-error *ngIf="form.get('talkgroupRef')!.hasError('required')">
Id is required Id is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -22,9 +23,9 @@
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span> <span class="mat-caption">Talkgroup label displayed on the main screen and on buttons.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')"> <mat-error *ngIf="form.get('label')!.hasError('required')">
Label is required Label is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -34,25 +35,25 @@
<span class="mat-body">Name</span><br> <span class="mat-body">Name</span><br>
<span class="mat-caption">Talkgroup name displayed on the main screen.</span> <span class="mat-caption">Talkgroup name displayed on the main screen.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="name" placeholder="Name"> <input type="text" matInput formControlName="name" placeholder="Name">
<mat-error *ngIf="form?.get('name')?.hasError('required')"> <mat-error *ngIf="form.get('name')!.hasError('required')">
Name is required Name is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Group</span><br> <span class="mat-body">Groups</span><br>
<span class="mat-caption">Group to which this talkgoups belongs.</span> <span class="mat-caption">Groups to which this talkgroups belongs.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<mat-select formControlName="groupId" placeholder="Group"> <mat-select formControlName="groupIds" placeholder="Groups" [multiple]="true">
<mat-option *ngFor="let group of groups" [value]="group._id"> <mat-option *ngFor="let group of groups" [value]="group.id">
{{ group.label }} {{ group.label }}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-error *ngIf="form?.get('groupId')?.hasError('required')"> <mat-error *ngIf="form.get('groupIds')?.hasError('required')">
Group is required Group is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
@ -60,28 +61,64 @@
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Tag</span><br> <span class="mat-body">Tag</span><br>
<span class="mat-caption">Tag to which this talkgoups belongs.</span> <span class="mat-caption">Tag to which this talkgroups belongs.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<mat-select formControlName="tagId" placeholder="Tag"> <mat-select formControlName="tagId" placeholder="Tag">
<mat-option *ngFor="let tag of tags" [value]="tag._id"> <mat-option *ngFor="let tag of tags" [value]="tag.id">
{{ tag.label }} {{ tag.label }}
</mat-option> </mat-option>
</mat-select> </mat-select>
<mat-error *ngIf="form?.get('tagId')?.hasError('required')"> <mat-error *ngIf="form.get('tagId')!.hasError('required')">
Tag is required Tag is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Led Color</span><br> <span class="mat-body">Audio alert</span><br>
<span class="mat-caption">Indicator color when playing an audio file from this talkgroup. If not specified, <span class="mat-caption">Prefixes an alert sound to all calls assigned from this talkgroup. The alert
the color configured for the system is used.</span> assignment order is as follows: Group -> Tag -> System -> Talkgroup.</span>
</p> </p>
<mat-form-field floatLabel="never"> <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">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-select formControlName="led" placeholder="Color">
<mat-option [value]="null">Default</mat-option> <mat-option value="">Default</mat-option>
<mat-option *ngFor="let led of leds" [value]="led"> <mat-option *ngFor="let led of leds" [value]="led">
{{ led | titlecase }} {{ led | titlecase }}
</mat-option> </mat-option>
@ -90,14 +127,35 @@
</div> </div>
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Frequency</span><br> <span class="mat-body">Type</span><br>
<span class="mat-caption">Fake frequency in hertz displayed on the main screen.</span> <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> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="number" min="0" matInput formControlName="frequency" placeholder="Frequency"> <mat-select formControlName="type" placeholder="Type">
<mat-error *ngIf="form?.get('frequency')?.errors"> <mat-option value="">No Type</mat-option>
Invalid frequency <mat-option value="am">AM</mat-option>
</mat-error> <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> </mat-form-field>
</div> </div>
<div class="row bottom"> <div class="row bottom">
@ -108,4 +166,5 @@
Delete talkgroup Delete talkgroup
</button> </button>
</div> </div>
</ng-container>
</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 * 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 * 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 { 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'; import { RdioScannerAdminService, Group, Tag } from '../../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-talkgroup', selector: 'rdio-scanner-admin-talkgroup',
templateUrl: './talkgroup.component.html', templateUrl: './talkgroup.component.html',
standalone: false
}) })
export class RdioScannerAdminTalkgroupComponent { export class RdioScannerAdminTalkgroupComponent {
@Input() form: FormGroup | undefined; @Input() form: FormGroup | undefined;
@ -32,15 +33,25 @@ export class RdioScannerAdminTalkgroupComponent {
@Output() remove = new EventEmitter<void>(); @Output() remove = new EventEmitter<void>();
leds = this.adminService.getLeds(); leds: string[] = [];
get alerts(): string[] {
return Object.keys(this.adminService.Alerts || {});
}
get groups(): Group[] { get groups(): Group[] {
return this.form?.root.get('groups')?.value as Group[]; return (this.form?.root.get('groups')?.value as Group[]) || [];
} }
get tags(): Tag[] { 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,20 +1,38 @@
<ng-container *ngIf="form" [formGroup]="form"> <ng-container *ngIf="form as f">
<ng-container [formGroup]="f">
<div class="row"> <div class="row">
<p> <p>
<span class="mat-body">Id</span><br> <span class="mat-body">Id</span><br>
<span class="mat-caption">Unit identifier in decimal format.</span> <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> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="number" min="0" step="1" matInput formControlName="id" placeholder="Id"> <input type="number" min="1" step="1" matInput formControlName="unitRef" placeholder="Id">
<mat-error *ngIf="form?.get('id')?.hasError('duplicate')"> <mat-error *ngIf="f.get('unitRef')!.hasError('duplicate')">Id value is already defined</mat-error>
Id is already defined <mat-error *ngIf="f.get('unitRef')!.hasError('min')">Id value is invalid</mat-error>
</mat-error> <mat-error *ngIf="f.get('unitRef')!.hasError('required')">Id value is required</mat-error>
<mat-error *ngIf="form?.get('id')?.hasError('min')"> </mat-form-field>
Id is invalid </div>
</mat-error> <div class="row">
<mat-error *ngIf="form?.get('id')?.hasError('required')"> <p>
Id is required <span class="mat-body">Range from</span><br>
</mat-error> <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> </mat-form-field>
</div> </div>
<div class="row"> <div class="row">
@ -22,16 +40,13 @@
<span class="mat-body">Label</span><br> <span class="mat-body">Label</span><br>
<span class="mat-caption">Unit label.</span> <span class="mat-caption">Unit label.</span>
</p> </p>
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<input type="text" matInput formControlName="label" placeholder="Label"> <input type="text" matInput formControlName="label" placeholder="Label">
<mat-error *ngIf="form?.get('label')?.hasError('required')"> <mat-error *ngIf="f.get('label')!.hasError('required')">Label is required</mat-error>
Label is required
</mat-error>
</mat-form-field> </mat-form-field>
</div> </div>
<div class="row bottom"> <div class="row bottom">
<button type="button" mat-button color="warn" (click)="remove.emit()"> <button type="button" mat-button color="warn" (click)="remove.emit()">Delete unit</button>
Delete unit
</button>
</div> </div>
</ng-container>
</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 * 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 * 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'; import { FormGroup } from '@angular/forms';
@Component({ @Component({
selector: 'rdio-scanner-admin-unit', selector: 'rdio-scanner-admin-unit',
templateUrl: './unit.component.html', templateUrl: './unit.component.html',
standalone: false,
changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RdioScannerAdminUnitComponent { 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"> <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 <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> based on their tag.</p>
<button type="button" mat-button color="accent" (click)="add()">New tag</button> <button type="button" mat-button color="accent" (click)="add()">New Tag</button>
</div> </div>
<div class="tags"> @if (!tags.length) {
<div *ngFor="let tag of tags; index as i" [formGroup]="tag"> <p class="mat-small text-center">No defined Tags</p>
<button type="button" mat-icon-button color="warn" (click)="remove(i)"> }
<mat-icon>clear</mat-icon> <mat-accordion displayMode="flat" cdkDropList [cdkDropListAutoScrollStep]=64 [cdkDropListData]="tags"
</button> (cdkDropListDropped)="drop($event)">
<mat-form-field floatLabel="never"> @for (tag of tags; track tag; let i = $index) {
<input type="text" matInput formControlName="label" placeholder="Tag"> <mat-expansion-panel cdkDrag>
<mat-error *ngIf="tags[i].get('label')?.hasError('required')"> <mat-expansion-panel-header>
Tag is required <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-error>
}
</mat-form-field> </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 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 * 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 * 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 { FormArray, FormGroup } from '@angular/forms';
import { MatExpansionPanel } from '@angular/material/expansion';
import { MatSelectChange } from '@angular/material/select';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-tags', selector: 'rdio-scanner-admin-tags',
styleUrls: ['./tags.component.scss'],
templateUrl: './tags.component.html', templateUrl: './tags.component.html',
standalone: false
}) })
export class RdioScannerAdminTagsComponent { export class RdioScannerAdminTagsComponent {
@Input() form: FormArray | undefined; @Input() form: FormArray | undefined;
get tags(): FormGroup[] { leds: string[];
return this.form?.controls
.sort((a, b) => (a.value.label || '').localeCompare(b.value.label || '')) as FormGroup[]; 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 { add(): void {
const id = this.tags.reduce((pv, cv) => cv.value._id >= pv ? cv.value._id + 1 : pv, 0); const tag = this.adminService.newTagForm();
const tag = this.adminService.newTagForm({ _id: id });
tag.markAllAsTouched(); tag.markAllAsTouched();
@ -48,6 +60,24 @@ export class RdioScannerAdminTagsComponent {
this.form?.markAsDirty(); 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 { remove(index: number): void {
this.form?.removeAt(index); 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 * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,9 +1,18 @@
<form [formGroup]="form" (ngSubmit)="login()"> <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-form-field hideRequiredMarker>
<mat-label>Password</mat-label> <mat-label>Password</mat-label>
<input matInput formControlName="password" type="password" required> <input
<mat-error>{{ message }}</mat-error> 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> </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> </form>

View file

@ -1,8 +1,8 @@
form > button { form > button {
margin-top: 1rem; margin-top: 1rem;
} }
.mat-raised-button, .mat-mdc-raised-button,
.mat-form-field { .mat-mdc-form-field {
display: block; 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 * 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 * 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 { Component, EventEmitter, Output } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { RdioScannerAdminService } from '../admin.service'; import { RdioScannerAdminService } from '../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-login', selector: 'rdio-scanner-admin-login',
styleUrls: ['./login.component.scss'], styleUrls: ['./login.component.scss'],
templateUrl: './login.component.html', templateUrl: './login.component.html',
standalone: false
}) })
export class RdioScannerAdminLoginComponent { export class RdioScannerAdminLoginComponent {
@Output() loggedIn = new EventEmitter<void>(); @Output() loggedIn = new EventEmitter<void>();
form = this.formBuilder.group({ form: FormGroup;
password: [null, Validators.required],
});
message = ''; message = '';
constructor( constructor(
private adminService: RdioScannerAdminService, 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> { async login(password = this.form.get('password')?.value): Promise<void> {
if (!password) { if (!password) {

View file

@ -4,9 +4,15 @@
<span>Level</span> <span>Level</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let log"> <mat-cell *matCellDef="let log">
<mat-icon *ngIf="log?.level === 'error'" class="error">error</mat-icon> @if (log?.level === 'error') {
<mat-icon *ngIf="log?.level === 'info'" class="info">notifications</mat-icon> <mat-icon class="error">error</mat-icon>
<mat-icon *ngIf="log?.level === 'warn'" class="warn">warning</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> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">

View file

@ -10,8 +10,8 @@
color: orange; color: orange;
} }
.mat-cell, .mat-mdc-cell,
.mat-header-cell { .mat-mdc-header-cell {
&:nth-child(1), &:nth-child(1),
&:nth-child(2), &:nth-child(2),
&:nth-child(3) { &:nth-child(3) {
@ -25,13 +25,13 @@ form {
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
.mat-form-field, .mat-mdc-form-field,
.reset { .reset {
box-sizing: border-box; box-sizing: border-box;
padding: 0 0.5rem 1rem 0.5rem; padding: 0 0.5rem 1rem 0.5rem;
} }
.mat-form-field { .mat-mdc-form-field {
min-width: 220px; min-width: 220px;
} }
@ -44,13 +44,13 @@ form {
} }
@media (max-width: 719px) { @media (max-width: 719px) {
form > .mat-form-field { form > .mat-mdc-form-field {
flex: 100%; flex: 100%;
} }
} }
@media (min-width: 720px) { @media (min-width: 720px) {
form > .mat-form-field { form > .mat-mdc-form-field {
flex: 33%; 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 * 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 * it under the terms of the GNU General Public License as published by
@ -18,7 +18,7 @@
*/ */
import { Component, ViewChild } from '@angular/core'; import { Component, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../admin.service'; import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../admin.service';
@ -27,13 +27,10 @@ import { Log, LogsQuery, LogsQueryOptions, RdioScannerAdminService } from '../ad
selector: 'rdio-scanner-admin-logs', selector: 'rdio-scanner-admin-logs',
styleUrls: ['./logs.component.scss'], styleUrls: ['./logs.component.scss'],
templateUrl: './logs.component.html', templateUrl: './logs.component.html',
standalone: false
}) })
export class RdioScannerAdminLogsComponent { export class RdioScannerAdminLogsComponent {
form = this.ngFormBuilder.group({ form: FormGroup;
date: [null],
level: [null],
sort: [-1],
});
logs = new BehaviorSubject(new Array<Log | null>(10)); logs = new BehaviorSubject(new Array<Log | null>(10));
@ -47,7 +44,13 @@ export class RdioScannerAdminLogsComponent {
@ViewChild(MatPaginator) private paginator: MatPaginator | undefined; @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 { formHandler(): void {
this.paginator?.firstPage(); this.paginator?.firstPage();
@ -98,11 +101,11 @@ export class RdioScannerAdminLogsComponent {
const options: LogsQueryOptions = { const options: LogsQueryOptions = {
limit: this.limit, limit: this.limit,
offset: this.offset, offset: this.offset,
sort: this.form.value.sort, sort: this.form.get('sort')?.value ?? -1,
}; };
if (typeof this.form.value.level === 'string') { if (typeof this.form.get('level')?.value === 'string') {
options.level = this.form.value.level; options.level = this.form.get('level')?.value ?? undefined;
} }
if (typeof this.form.value.date === 'string') { 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-card-title>Todos</mat-card-title>
<mat-list> <mat-list>
<mat-list-item *ngFor="let todo of todos"> <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 === 'info'" class="info" matListItemIcon>notifications</mat-icon>
<mat-icon *ngIf="todo.level === 'warn'" class="warn" mat-list-icon>warning</mat-icon> <mat-icon *ngIf="todo.level === 'warn'" class="warn" matListItemIcon>warning</mat-icon>
<div mat-line> <div mat-line>
<span class="todo">{{ todo.message }}</span> <span class="todo">{{ todo.message }}</span>
</div> </div>
</mat-list-item> </mat-list-item>
</mat-list> </mat-list>
</mat-card> </mat-card>
</ng-container>

View file

@ -1,12 +1,12 @@
.info { .mat-mdc-list-item .info {
color: green; color: green;
} }
.warn { .mat-mdc-list-item .warn {
color: orange; color: orange;
} }
.todo { .mat-mdc-list-item .todo {
text-overflow: unset; text-overflow: unset;
white-space: normal; 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 * 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 * it under the terms of the GNU General Public License as published by
@ -22,20 +22,26 @@ import { AdminEvent, Config, RdioScannerAdminService } from '../admin.service';
interface Todo { interface Todo {
level: 'info' | 'warn'; level: 'info' | 'warn';
message: String; message: string;
} }
@Component({ @Component({
selector: 'rdio-scanner-admin-todos', selector: 'rdio-scanner-admin-todos',
styleUrls: ['./todos.component.scss'], styleUrls: ['./todos.component.scss'],
templateUrl: './todos.component.html', templateUrl: './todos.component.html',
standalone: false
}) })
export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit { export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
todos: Todo[] = []; todos: Todo[] = [];
private config: Config | undefined; private config: Config | undefined;
private eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => { private eventSubscription;
private passwordNeedChange;
constructor(private adminService: RdioScannerAdminService) {
this.eventSubscription = this.adminService.event.subscribe(async (event: AdminEvent) => {
if ('config' in event) { if ('config' in event) {
this.config = event.config; this.config = event.config;
} }
@ -47,9 +53,8 @@ export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
this.rebuildTodos(); this.rebuildTodos();
}); });
private passwordNeedChange = this.adminService.passwordNeedChange; this.passwordNeedChange = this.adminService.passwordNeedChange;
}
constructor(private adminService: RdioScannerAdminService) { }
ngOnDestroy(): void { ngOnDestroy(): void {
this.eventSubscription.unsubscribe(); this.eventSubscription.unsubscribe();
@ -76,14 +81,14 @@ export class RdioScannerAdminTodosComponent implements OnDestroy, OnInit {
if (!this.config?.systems?.length) { if (!this.config?.systems?.length) {
todos.push({ todos.push({
level: 'info', 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({ todos.push({
level: 'info', 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> <div>
<p> <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> <span class="mat-caption">Import a JSON file into the configuration panel where you can then review it before submitting it.</span>
</p> </p>
<button mat-raised-button (click)="input.click()">Import</button> <button mat-raised-button (click)="input.click()">Import</button>
@ -8,7 +8,7 @@
</div> </div>
<div> <div>
<p> <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> <span class="mat-caption">Export the configuration to a JSON file.</span>
</p> </p>
<button mat-raised-button (click)="export()">Export</button> <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 * 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 * 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 { MatSnackBar } from '@angular/material/snack-bar';
import { Config, RdioScannerAdminService } from '../../admin.service'; import { Config, RdioScannerAdminService } from '../../admin.service';
import packageInfo from '../../../../../../../package.json';
@Component({ @Component({
selector: 'rdio-scanner-admin-import-export-config', selector: 'rdio-scanner-admin-import-export-config',
styleUrls: ['./import-export-config.component.scss'], styleUrls: ['./import-export-config.component.scss'],
templateUrl: './import-export-config.component.html', templateUrl: './import-export-config.component.html',
standalone: false
}) })
export class RdioScannerAdminImportExportConfigComponent { export class RdioScannerAdminImportExportConfigComponent {
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();
version = packageInfo.version;
constructor( constructor(
private adminService: RdioScannerAdminService, private adminService: RdioScannerAdminService,
@Inject(DOCUMENT) private document: Document, @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) => { const file = encodeURIComponent(JSON.stringify(config)).replace(/%([0-9A-F]{2})/g, (_, c) => {
return String.fromCharCode(parseInt(c, 16)); return String.fromCharCode(parseInt(c, 16));
}); });
const fileName = 'rdio-scanner.json'; const fileName = `rdio-scanner-${this.version}.config.json`;
const fileType = 'application/json'; const fileType = 'application/json';
const fileUri = `data:${fileType};base64,${window.btoa(file)}`; const fileUri = `data:${fileType};base64,${window.btoa(file)}`;
@ -77,7 +81,75 @@ export class RdioScannerAdminImportExportConfigComponent {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
}).join('')); }).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) { } catch (error) {
this.matSnackBar.open(error as string, '', { duration: 5000 }); this.matSnackBar.open(error as string, '', { duration: 5000 });

View file

@ -1,27 +1,47 @@
<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> <section>
<p class="mat-body">step 1: read the CSV file</p> <p class="mat-body">step 1: read the CSV file</p>
<p class="text-center"> <p class="text-center">
<ng-container *ngIf="csv.length === 0"> @if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button> <button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)"> <input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
</ng-container> <br>
<ng-container *ngIf="csv.length !== 0"> <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> <button mat-raised-button (click)="csv=[]">Reset</button>
</ng-container> }
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 2: select CSV style</p> <p class="mat-body">step 2: select system</p>
<p class="text-center"> <p class="text-center">
<mat-radio-group [(ngModel)]="mode"> <mat-form-field floatLabel="auto">
<mat-radio-button [value]="0">Trunk Recorder</mat-radio-button> <mat-select [(ngModel)]="system">
<mat-radio-button [value]="1">Radio Reference</mat-radio-button> @for (system of baseConfig.systems; track system) {
</mat-radio-group> <mat-option [value]="system">
{{ system.label}}
</mat-option>
}
</mat-select>
</mat-form-field>
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 3: review talkgroups to import</p> <p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll"> <div class="scroll">
<mat-table #table [dataSource]="csv"> <mat-table #table [dataSource]="csv">
@ -30,7 +50,7 @@
<span>Id</span> <span>Id</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][0]] }}</span> <span>{{ tg[0] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="label"> <ng-container matColumnDef="label">
@ -38,7 +58,7 @@
<span>Label</span> <span>Label</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][1]] }}</span> <span>{{ tg[2] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="description"> <ng-container matColumnDef="description">
@ -46,7 +66,7 @@
<span>Description</span> <span>Description</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][2]] }}</span> <span>{{ tg[4] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="tag"> <ng-container matColumnDef="tag">
@ -54,7 +74,7 @@
<span>Tag</span> <span>Tag</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][3]] }}</span> <span>{{ tg[5] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="group"> <ng-container matColumnDef="group">
@ -62,7 +82,7 @@
<span>Group</span> <span>Group</span>
</mat-header-cell> </mat-header-cell>
<mat-cell *matCellDef="let tg"> <mat-cell *matCellDef="let tg">
<span>{{ tg[fields[mode][4]] }}</span> <span>{{ tg[6] }}</span>
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="action"> <ng-container matColumnDef="action">
@ -77,17 +97,23 @@
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row> <mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table> </mat-table>
</div> </div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p> @if (!csv.length) {
</section> <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="mat-body">step 4: import to configuration</p>
<p class="text-center"> <p class="text-center">
<button mat-raised-button [disabled]="!csv.length" (click)="import()"> <button mat-raised-button [disabled]="!csv.length" (click)="import()">
Import to configuration Import to configuration
</button> </button>
</p> </p>
<p *ngIf="csv.length > 800" class="mat-caption mat-error text-center text-uppercase"> @if (csv.length > 800) {
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 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> </p>
</section> }
</section>

View file

@ -1,4 +1,4 @@
.mat-radio-group { .mat-mdc-radio-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
@ -8,18 +8,25 @@
} }
} }
.mat-table { .mat-mdc-table {
min-width: 720px; min-width: 720px;
} }
.mat-cell >  span { .mat-mdc-cell >  span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.mat-column-action { .mat-column-action {
max-width: 40px; max-width: 48px;
}
.note {
border: 2px solid darkred;
color: darkred;
margin: 0 4rem;
padding: 2px;
} }
.scroll { .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 * 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 * 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 { Component, EventEmitter, OnInit, Output } from '@angular/core';
import { Config, RdioScannerAdminService } from '../../admin.service'; import { Config, RdioScannerAdminService, System } from '../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-import-talkgroups', selector: 'rdio-scanner-admin-import-talkgroups',
styleUrls: ['./import-talkgroups.component.scss'], styleUrls: ['./import-talkgroups.component.scss'],
templateUrl: './import-talkgroups.component.html', templateUrl: './import-talkgroups.component.html',
standalone: false
}) })
export class RdioScannerAdminImportTalkgroupsComponent { export class RdioScannerAdminImportTalkgroupsComponent implements OnInit {
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();
baseConfig: Config = {};
csv: string[][] = []; csv: string[][] = [];
fields = [ system: System | undefined;
// [id, label, description, tag, group]
[0, 3, 4, 5, 6], // trunk-recorder
[0, 2, 4, 5, 6], // radioreference.com
];
mode = 0;
tableColumns = ['id', 'label', 'description', 'tag', 'group', 'action']; tableColumns = ['id', 'label', 'description', 'tag', 'group', 'action'];
constructor(private adminService: RdioScannerAdminService) { } 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> { async import(): Promise<void> {
const config = await this.adminService.getConfig(); if (this.system === undefined) return;
this.csv.forEach((tg) => { this.csv.forEach((csv) => {
const group = tg[this.fields[this.mode][4]]; const group = csv[6];
if (!config.groups?.find((g) => g.label === group)) { if (!this.baseConfig.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); 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)) { if (!this.baseConfig.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); 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) => { this.csv.forEach((csv) => {
const groupId = config.groups?.find((g) => g.label === csv[this.fields[this.mode][4]])?._id; const groupId = this.baseConfig.groups?.find((g) => g.label === csv[6])?.id;
const tagId = config.tags?.find((t) => t.label === csv[this.fields[this.mode][3]])?._id; const tagId = this.baseConfig.tags?.find((t) => t.label === csv[5])?.id;
return { this.system?.talkgroups?.unshift({
id: +csv[this.fields[this.mode][0]], talkgroupRef: +csv[0],
label: csv[this.fields[this.mode][1]], label: csv[2],
name: csv[this.fields[this.mode][2]], name: csv[4],
order: idx + 1, groupIds: groupId ? [groupId] : [],
tagId, tagId,
groupId,
};
}); });
});
config.systems?.unshift({ talkgroups });
this.csv = []; this.csv = [];
this.config.emit(config); this.config.emit(this.baseConfig);
} }
async read(event: Event): Promise<void> { async read(event: Event): Promise<void> {

View file

@ -1,30 +1,38 @@
<section> <section>
<p class="mat-body">step 1: read the CSV file</p> <p class="mat-body">step 1: read the CSV file</p>
<p class="text-center"> <p class="text-center">
<ng-container *ngIf="csv.length === 0"> @if (csv.length === 0) {
<button mat-raised-button (click)="input.click()">Read the CSV file</button> <button mat-raised-button (click)="input.click()">Read the CSV file</button>
<input #input type="file" accept=".csv" style="display: none" (change)="read($event)"> <input #input type="file" accept=".csv" style="display: none" (change)="read($event)">
</ng-container> <br>
<ng-container *ngIf="csv.length !== 0"> <p class="mat-body-1">
<button mat-raised-button (click)="csv=[]">Reset</button> <span>Column format is: </span>
</ng-container> <span>Id</span>,
<span>Label</span>,
</p> </p>
</section> }
@if (csv.length !== 0) {
<button mat-raised-button (click)="csv=[]">Reset</button>
}
</p>
</section>
<section> <section>
<p class="mat-body">step 2: select system</p> <p class="mat-body">step 2: select system</p>
<p class="text-center"> <p class="text-center">
<mat-form-field floatLabel="never"> <mat-form-field floatLabel="auto">
<mat-select [(ngModel)]="system"> <mat-select [(ngModel)]="system">
<mat-option *ngFor="let system of baseConfig.systems" [value]="system"> @for (system of baseConfig.systems; track system) {
<mat-option [value]="system">
{{ system.label}} {{ system.label}}
</mat-option> </mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</p> </p>
</section> </section>
<section> <section>
<p class="mat-body">step 3: review talkgroups to import</p> <p class="mat-body">step 3: review talkgroups to import</p>
<div class="scroll"> <div class="scroll">
<mat-table #table [dataSource]="csv"> <mat-table #table [dataSource]="csv">
@ -56,19 +64,23 @@
<mat-row *matRowDef="let row; columns: tableColumns"></mat-row> <mat-row *matRowDef="let row; columns: tableColumns"></mat-row>
</mat-table> </mat-table>
</div> </div>
<p *ngIf="!csv.length" class="mat-body text-center">No CSV data yet</p> @if (!csv.length) {
</section> <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="mat-body">step 4: import to configuration</p>
<p class="text-center"> <p class="text-center">
<button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()"> <button mat-raised-button [disabled]="!csv.length || system === undefined" (click)="import()">
Import to configuration Import to configuration
</button> </button>
</p> </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 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 not designed for this number of units. You can continue, but expect Rdio Scanner to be quite slow, if not
unusable. unusable.
</p> </p>
</section> }
</section>

View file

@ -1,4 +1,4 @@
.mat-radio-group { .mat-mdc-radio-group {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: center; justify-content: center;
@ -8,18 +8,18 @@
} }
} }
.mat-table { .mat-mdc-table {
min-width: 720px; min-width: 720px;
} }
.mat-cell >  span { .mat-mdc-cell >  span {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.mat-column-action { .mat-column-action {
max-width: 40px; max-width: 48px;
} }
.scroll { .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 * 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 * 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', selector: 'rdio-scanner-admin-import-units',
styleUrls: ['./import-units.component.scss'], styleUrls: ['./import-units.component.scss'],
templateUrl: './import-units.component.html', templateUrl: './import-units.component.html',
standalone: false
}) })
export class RdioScannerAdminImportUnitsComponent implements OnInit{ export class RdioScannerAdminImportUnitsComponent implements OnInit{
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();
@ -42,7 +43,7 @@ export class RdioScannerAdminImportUnitsComponent implements OnInit{
this.baseConfig = await this.adminService.getConfig(); this.baseConfig = await this.adminService.getConfig();
if (Array.isArray(this.baseConfig.systems) && this.baseConfig.systems.length > 0) { 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; if (this.system === undefined) return;
const units = this.csv.map((csv, idx) => ({ const units = this.csv.map((csv, idx) => ({
id: +csv[0], unitRef: +csv[0],
label: csv[1], label: csv[1],
order: idx + 1, order: idx + 1,
})); }));
this.system!.units = this.system!.units!.concat(units); this.system.units = this.system?.units?.concat(units);
this.csv = []; this.csv = [];

View file

@ -1,17 +1,20 @@
<p class="mat-body"> <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> </p>
<form [formGroup]="form"> <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-form-field>
<mat-label>Current password</mat-label> <mat-label>Current password</mat-label>
<input type="password" matInput formControlName="currentPassword" required> <input type="password" matInput formControlName="currentPassword" required autocomplete="current-password">
<mat-error *ngIf="form.get('currentPassword')?.hasError('required')"> <mat-error *ngIf="form.get('currentPassword')?.hasError('required')">
Current password is required Current password is required
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label>New password</mat-label> <mat-label>New password</mat-label>
<input type="password" matInput formControlName="newPassword" required> <input type="password" matInput formControlName="newPassword" required minlength="8" autocomplete="new-password">
<mat-error *ngIf="form.get('newPassword')?.hasError('required')"> <mat-error *ngIf="form.get('newPassword')?.hasError('required')">
New password is required New password is required
</mat-error> </mat-error>
@ -21,18 +24,16 @@
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
<mat-label>Verify new password</mat-label> <mat-label>Verify new password</mat-label>
<input type="password" matInput formControlName="verifyNewPassword" required> <input type="password" matInput formControlName="verifyNewPassword" required autocomplete="new-password">
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')"> <mat-error *ngIf="form.get('verifyNewPassword')?.hasError('required')">
New password verification is required New password verification is required
</mat-error> </mat-error>
<mat-error *ngIf="form.get('verifyNewPassword')?.hasError('invalid')"> <mat-error *ngIf="form.hasError('passwordMismatch')">
Password don't match Passwords don't match
</mat-error> </mat-error>
</mat-form-field> </mat-form-field>
<div class="row bottom"> <div class="row bottom">
<button type="button" mat-raised-button [disabled]="form.disabled || form.untouched" <button type="button" mat-raised-button [disabled]="form.disabled || form.untouched" (click)="reset()">Reset</button>
(click)="reset();">Reset</button> <button type="submit" mat-raised-button color="primary" [disabled]="form.disabled || form.pristine || form.invalid">Save</button>
<button type="button" mat-raised-button color="primary"
[disabled]="form.disabled || form.pristine || !form.valid" (click)="save()">Save</button>
</div> </div>
</form> </form>

View file

@ -8,7 +8,7 @@
justify-content: space-between; justify-content: space-between;
} }
.mat-form-field { .mat-mdc-form-field {
margin-bottom: 1rem; 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 * 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 * it under the terms of the GNU General Public License as published by
@ -18,55 +18,68 @@
*/ */
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AbstractControl, FormBuilder, ValidatorFn, ValidationErrors, Validators } from '@angular/forms'; import { AbstractControl, FormBuilder, FormGroup, ValidationErrors, Validators } from '@angular/forms';
import { MatSnackBar, MatSnackBarConfig } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { RdioScannerAdminService } from '../../admin.service'; import { RdioScannerAdminService } from '../../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-password', selector: 'rdio-scanner-admin-password',
styleUrls: ['./password.component.scss'], styleUrls: ['./password.component.scss'],
templateUrl: './password.component.html', templateUrl: './password.component.html',
standalone: false
}) })
export class RdioScannerAdminPasswordComponent { export class RdioScannerAdminPasswordComponent {
form = this.ngFormBuilder.group({ form: FormGroup;
currentPassword: [null, Validators.required],
newPassword: [null, [Validators.required, Validators.minLength(8)]],
verifyNewPassword: [null, [Validators.required, this.validatePassword()]],
});
constructor( constructor(
private adminService: RdioScannerAdminService, private readonly adminService: RdioScannerAdminService,
private matSnackBar: MatSnackBar, private readonly matSnackBar: MatSnackBar,
private ngFormBuilder: FormBuilder, 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 { this.form.get('newPassword')?.valueChanges.subscribe(() =>
return (control: AbstractControl): ValidationErrors | null => { this.form.get('verifyNewPassword')?.updateValueAndValidity({ onlySelf: true })
return typeof control.value === 'string' && control.value.length );
? control.value === control.parent?.value.newPassword
? null : { invalid: true } : null;
} }
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 { reset(): void {
this.form.reset(); this.form.reset();
} }
async save(): Promise<void> { async save(): Promise<void> {
const config: MatSnackBarConfig = { duration: 5000 }; if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
const config = { duration: 5000 };
const currentPassword = this.currentPassword?.value;
const newPassword = this.newPassword?.value;
this.form.disable(); this.form.disable();
try { try {
await this.adminService.changePassword(this.form.value.currentPassword, this.form.value.newPassword); await this.adminService.changePassword(currentPassword, newPassword);
this.matSnackBar.open('Password changed successfully', '', config); this.matSnackBar.open('Password changed successfully', '', config);
this.form.reset(); this.form.reset();
} catch (_) { } catch {
this.matSnackBar.open('Unable to change password', '', config); this.matSnackBar.open('Unable to change password', '', config);
} } finally {
this.form.enable(); 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 * 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 * it under the terms of the GNU General Public License as published by
@ -24,6 +24,7 @@ import { Config } from '../admin.service';
@Component({ @Component({
selector: 'rdio-scanner-admin-tools', selector: 'rdio-scanner-admin-tools',
templateUrl: './tools.component.html', templateUrl: './tools.component.html',
standalone: false
}) })
export class RdioScannerAdminToolsComponent { export class RdioScannerAdminToolsComponent {
@Output() config = new EventEmitter<Config>(); @Output() config = new EventEmitter<Config>();

View file

@ -1,6 +1,11 @@
.rdio-button, .rdio-button,
.rdio-button-mini { .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-style: solid;
border-width: 1px; border-width: 1px;
border-bottom-color: rgba(0, 0, 0, 0.87); border-bottom-color: rgba(0, 0, 0, 0.87);
@ -41,24 +46,71 @@
right: 4px; right: 4px;
top: 4px; top: 4px;
width: 6px; width: 6px;
animation: blink 1s linear infinite both;
} }
&.off::after { &.off::after {
background: rgb(255, 23, 68); background: var(--red);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--red);
0 0 3px 1px rgb(255, 23, 68); }
&.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 { &.on::after {
background: rgb(0, 230, 118);; background: var(--green);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--green);
0 0 3px 1px rgb(0, 230, 118);; }
&.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 { &.partial::after {
background: rgb(255, 234, 0); background: var(--yellow);
box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, box-shadow: 1px 1px 1px rgba(255, 255, 255, 0.7) inset, 0 0 3px 1px var(--yellow);
0 0 3px 1px rgb(255, 234, 0); }
&.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 * 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 * it under the terms of the GNU General Public License as published by

View file

@ -1,16 +1,19 @@
<div class="rdio-status"> <div class="rdio-status">
<div class="branding">{{ branding }}</div>
<div class="led" [ngClass]="ledStyle"></div> <div class="led" [ngClass]="ledStyle"></div>
</div> </div>
<div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()"> <div class="rdio-display" [ngClass]="{ idle: !dimmer }" (dblclick)="toggleFullscreen.emit()">
<div class="row"> <div class="row three">
<div> <div>
<span>{{ clock | date:timeFormat }}</span> <span>{{ clock | date:timeFormat }}</span>
</div> </div>
<div *ngIf="!linked"> <div>
@if (!linked) {
<span>NO LINK</span> <span>NO LINK</span>
</div> }
<div *ngIf="linked && showListenersCount"> @if (linked && showListenersCount) {
<span>L: {{ listeners }}</span> <span>L: {{ listeners }}</span>
}
</div> </div>
<div> <div>
<span>Q: {{ callQueue }}</span> <span>Q: {{ callQueue }}</span>
@ -21,6 +24,9 @@
<div> <div>
<span>{{ callSystem }}</span> <span>{{ callSystem }}</span>
</div> </div>
<div>
<span>{{ type | uppercase }}</span>
</div>
<div> <div>
<span>{{ callTag }}</span> <span>{{ callTag }}</span>
</div> </div>
@ -29,36 +35,54 @@
<div> <div>
<span>{{ callTalkgroup }}</span> <span>{{ callTalkgroup }}</span>
</div> </div>
<div></div>
<div> <div>
<span>{{ callDate | date:'MM/dd' }} </span>
<span>{{ callProgress | date:timeFormat }}</span> <span>{{ callProgress | date:timeFormat }}</span>
</div> </div>
</div> </div>
<div class="row big"> <div class="row big full">
<span>{{ callTalkgroupName }}</span> <span>{{ callTalkgroupName }}</span>
</div> </div>
<div class="row"> <div class="row">
<div> <div>
<span>F: {{ callFrequency || 0 }}</span> <span>F: {{ callFrequency || 0 }}</span>
</div> </div>
<div></div>
<div> <div>
<span>TGID: {{ callTalkgroupId || 0 }}</span> <span>TGID: {{ callTalkgroupId || 0 }}</span>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div> <div>
<span>E: {{ callError || 0 }} S: {{ callSpike || 0 }}</span> <span>E: {{ callError || 0 }} S: {{ callSpike || 0 }} </span>
</div> </div>
<div></div>
<div> <div>
<span *ngIf="callUnit">UID: {{ callUnit }}</span> @if (callUnit) {
<span>UID: {{ callUnit }}</span>
}
</div> </div>
</div> </div>
<div class="row right small"> <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> <div>
<span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span> <span class="flag" [ngClass]="{ flaged: avoided }">AVOID</span>
</div> </div>
<div> <div>
<span class="flag" [ngClass]="{ flaged: patched }">PATCH</span> <span class="flag" [ngClass]="{ flaged: patched }">PATCH</span>
</div> </div>
<div>
<span class="flag" [ngClass]="{ flaged: delayed }">DELAYED</span>
</div>
</div>
</div> </div>
<div class="wrapper"> <div class="wrapper">
<div class="auth" [ngClass]="{ visible: auth }"> <div class="auth" [ngClass]="{ visible: auth }">
@ -66,12 +90,16 @@
<mat-form-field> <mat-form-field>
<input #password matInput type="password" autocomplete="off" formControlName="password" <input #password matInput type="password" autocomplete="off" formControlName="password"
placeholder="Unlock code" (blur)="authFocus()"> placeholder="Unlock code" (blur)="authFocus()">
<mat-error *ngIf="authForm.get('password')?.hasError('expired')"> @if (authForm.get('password')?.hasError('expired')) {
<mat-error>
This unlock code has expired This unlock code has expired
</mat-error> </mat-error>
<mat-error *ngIf="authForm.get('password')?.hasError('tooMany')"> }
@if (authForm.get('password')?.hasError('tooMany')) {
<mat-error>
Too many connections Too many connections
</mat-error> </mat-error>
}
</mat-form-field> </mat-form-field>
</form> </form>
</div> </div>
@ -93,7 +121,8 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr *ngFor="let previousCall of callHistory" @for (previousCall of callHistory; track $index) {
<tr
[ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} "> [ngClass]="{ 'replaying': previousCall?.id !== null && previousCall?.id === call?.id} ">
<td> <td>
<span>{{ previousCall?.dateTime | date:timeFormat }}</span> <span>{{ previousCall?.dateTime | date:timeFormat }}</span>
@ -108,6 +137,7 @@
<span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span> <span>{{ previousCall?.talkgroupData?.name || previousCall?.frequency }}</span>
</td> </td>
</tr> </tr>
}
</tbody> </tbody>
</table> </table>
</div> </div>
@ -154,3 +184,10 @@
</button> </button>
</div> </div>
</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; display: flex;
flex-direction: row; flex-direction: row;
height: 20px; 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 { &.big {
font-size: 24px; font-size: 24px;
@ -171,11 +184,30 @@
} }
.rdio-status { .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 { .led {
background: rgb(80, 80, 80); background: rgb(80, 80, 80);
display: block; display: block;
height: 12px; height: 12px;
margin-left: auto; margin-left: 24px;
width: 24px; width: 24px;
&.on { &.on {
@ -240,3 +272,10 @@
.rdio-status { .rdio-status {
margin-bottom: 24px; 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 * 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 * 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 { 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 { MatInput } from '@angular/material/input';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Subscription, timer } from 'rxjs'; import { Subscription, timer } from 'rxjs';
import packageInfo from '../../../../../package.json'; import packageInfo from '../../../../../package.json';
import { import {
@ -32,8 +33,7 @@ import {
RdioScannerLivefeedMode, RdioScannerLivefeedMode,
} from '../rdio-scanner'; } from '../rdio-scanner';
import { RdioScannerService } from '../rdio-scanner.service'; import { RdioScannerService } from '../rdio-scanner.service';
import { RdioScannerSupportComponent } from './support/support.component';
const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
@Component({ @Component({
selector: 'rdio-scanner-main', selector: 'rdio-scanner-main',
@ -42,14 +42,18 @@ const LOCAL_STORAGE_KEY = RdioScannerService.LOCAL_STORAGE_KEY + '-pin';
'./main.component.scss', './main.component.scss',
], ],
templateUrl: './main.component.html', templateUrl: './main.component.html',
standalone: false
}) })
export class RdioScannerMainComponent implements OnDestroy, OnInit { export class RdioScannerMainComponent implements OnDestroy, OnInit {
auth = false; auth = false;
authForm = this.ngFormBuilder.group({ password: [] }); authForm: FormGroup;
avoided = false; avoided = false;
branding = '';
call: RdioScannerCall | undefined; call: RdioScannerCall | undefined;
callDate: Date | undefined;
callError = '0'; callError = '0';
callFrequency: string = this.formatFrequency(0); callFrequency: string = this.formatFrequency(0);
callHistory: RdioScannerCall[] = new Array<RdioScannerCall>(5); callHistory: RdioScannerCall[] = new Array<RdioScannerCall>(5);
@ -61,27 +65,32 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
callTag = 'Tag'; callTag = 'Tag';
callTalkgroup = 'Talkgroup'; callTalkgroup = 'Talkgroup';
callTalkgroupId = '0'; callTalkgroupId = '0';
/*
* BEGIN OF RED TAPE: //
* // 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 // By modifying, deleting or disabling the following lines, you harm
* investment in time, support, testing and hardware. // 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.
*/ //
callTalkgroupName = `Rdio Scanner v${packageInfo.version}`; callTalkgroupName = `Rdio Scanner v${packageInfo.version}`;
/** //
* END OF RED TAPE. // END OF RED TAPE.
*/ //
callTime = 0; callTime = 0;
callUnit = '0'; callUnit = '0';
clock = new Date(); clock = new Date();
delayed = false;
dimmer = false; dimmer = false;
email = '';
holdSys = false; holdSys = false;
holdTg = false; holdTg = false;
@ -104,8 +113,12 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
replayOffset = 0; replayOffset = 0;
replayTimer: Subscription | undefined; replayTimer: Subscription | undefined;
tempAvoid = 0;
timeFormat = 'HH:mm'; timeFormat = 'HH:mm';
type = '';
get showListenersCount(): boolean { get showListenersCount(): boolean {
return this.config?.showListenersCount || false; return this.config?.showListenersCount || false;
} }
@ -124,19 +137,30 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
private dimmerTimer: Subscription | undefined; private dimmerTimer: Subscription | undefined;
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event)); private eventSubscription;
constructor( constructor(
private rdioScannerService: RdioScannerService, private rdioScannerService: RdioScannerService,
private matSnackBar: MatSnackBar,
private ngChangeDetectorRef: ChangeDetectorRef, private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder, private ngFormBuilder: FormBuilder,
) { } ) {
this.authForm = this.ngFormBuilder.group<{
password: string | null;
}>({
password: null
});
authenticate(password = this.authForm.value.password): void { this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
}
authenticate(password = this.authForm.get('password')?.value): void {
if (password) {
this.authForm.disable(); this.authForm.disable();
this.rdioScannerService.authenticate(password); this.rdioScannerService.authenticate(password);
} }
}
authFocus(): void { authFocus(): void {
if (this.auth && this.authPassword instanceof MatInput) { if (this.auth && this.authPassword instanceof MatInput) {
@ -145,28 +169,43 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
} }
avoid(options?: RdioScannerAvoidOptions): void { avoid(options?: RdioScannerAvoidOptions): void {
const call = this.call || this.callPrevious;
if (this.auth) { if (this.auth) {
this.authFocus(); this.authFocus();
} else { } else if (options || call) {
const call = this.call || this.callPrevious; if (options) {
if (options || call) {
this.rdioScannerService.avoid(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]) { 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.avoid({ status: true });
}
}
if (call && this.rdioScannerService.isAvoided(call)) {
this.rdioScannerService.beep(RdioScannerBeepStyle.Activate); this.rdioScannerService.beep(RdioScannerBeepStyle.Activate);
} else { } else {
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate); this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
} }
this.updateDimmer();
} else { } else {
this.rdioScannerService.beep(RdioScannerBeepStyle.Denied); this.rdioScannerService.beep(RdioScannerBeepStyle.Denied);
} }
this.updateDimmer();
}
} }
holdSystem(): void { holdSystem(): void {
@ -286,6 +325,12 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
} }
} }
showHelp(): void {
this.matSnackBar.openFromComponent(RdioScannerSupportComponent, {
data: { email: this.email },
});
}
showSearchPanel(): void { showSearchPanel(): void {
if (!this.config) { if (!this.config) {
return; return;
@ -335,17 +380,11 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
private eventHandler(event: RdioScannerEvent): void { private eventHandler(event: RdioScannerEvent): void {
if ('auth' in event && event.auth) { if ('auth' in event && event.auth) {
let password: string | null = null; const password = this.rdioScannerService.readPin();
password = window?.localStorage?.getItem(LOCAL_STORAGE_KEY);
if (password) { if (password) {
password = window.atob(password); this.rdioScannerService.clearPin();
window.localStorage.removeItem(LOCAL_STORAGE_KEY);
}
if (password) {
this.authForm.get('password')?.setValue(password); this.authForm.get('password')?.setValue(password);
this.rdioScannerService.authenticate(password); this.rdioScannerService.authenticate(password);
@ -378,12 +417,16 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
if ('config' in event) { if ('config' in event) {
this.config = event.config; this.config = event.config;
this.branding = this.config?.branding ?? '';
this.email = this.config?.email ?? '';
this.timeFormat = this.config?.time12hFormat ? 'h:mm a' : 'HH:mm'; this.timeFormat = this.config?.time12hFormat ? 'h:mm a' : 'HH:mm';
const password = this.authForm.get('password')?.value; const password = this.authForm.get('password')?.value;
if (password) { if (password) {
window?.localStorage?.setItem(LOCAL_STORAGE_KEY, window.btoa(password)); this.rdioScannerService.savePin(password);
this.authForm.reset(); this.authForm.reset();
} }
@ -464,12 +507,31 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
.concat(' Hz') : ''; .concat(' Hz') : '';
} }
private isAfsSystem(talkgroupId: number): boolean { private getLedColor(call: RdioScannerCall | undefined): string {
if (typeof this.config?.afs !== 'string') { const colors = ['blue', 'cyan', 'green', 'magenta', 'orange', 'red', 'white', 'yellow'];
return false;
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 { private syncClock(): void {
@ -500,11 +562,17 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
private updateDisplay(time = this.callTime): void { private updateDisplay(time = this.callTime): void {
if (this.call) { 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 = new Date(this.call.dateTime);
this.callProgress.setSeconds(this.callProgress.getSeconds() + time); 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.callSystem = this.call.systemData?.label || `${this.call.system}`;
this.callTag = this.call.talkgroupData?.tag || ''; 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(); this.callTalkgroupId = isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup.toString();
if (typeof source.src === 'number' && Array.isArray(this.call.systemData?.units)) { if (typeof source.src === 'number') {
const callUnit = this.call?.systemData?.units?.find((u) => u.id === source.src); 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}`;
console.log('here', this.callUnit);
} else { } else {
this.callUnit = typeof this.call.source === 'number' ? `${this.call.source}` : ''; this.callUnit = `${source.src}`;
}
} }
} else { } else {
this.callTalkgroupId = isAfs ? this.formatAfs(this.call.talkgroup) : this.call.talkgroup.toString(); 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 ( if (
@ -566,24 +642,30 @@ export class RdioScannerMainComponent implements OnDestroy, OnInit {
const call = this.call || this.callPrevious; const call = this.call || this.callPrevious;
if (call) { 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)) { if (this.rdioScannerService.isPatched(call)) {
this.avoided = false; this.avoided = false;
this.patched = true; this.patched = true;
} else { } else {
this.avoided = this.rdioScannerService.isAvoided(call); this.avoided = this.rdioScannerService.isAvoided(call);
this.patched = false; 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'; this.ledStyle = this.call && this.livefeedPaused ? 'on paused' : this.call ? 'on' : 'off';
if (colors.includes(this.call?.talkgroupData?.led as string)) { if (this.call) {
this.ledStyle = `${this.ledStyle} ${this.call?.talkgroupData?.led}`; this.ledStyle = `${this.ledStyle} ${this.getLedColor(call)}`;
} else if (colors.includes(this.call?.systemData?.led as string)) {
this.ledStyle = `${this.ledStyle} ${this.call?.systemData?.led}`;
} }
this.ngChangeDetectorRef.detectChanges(); 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 * 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 * it under the terms of the GNU General Public License as published by
@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'app-store-badge', selector: 'app-store-badge',
templateUrl: './app-store-badge.component.svg', templateUrl: './app-store-badge.component.svg',
standalone: false
}) })
export class AppStoreBadgeComponent {} 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 * 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 * it under the terms of the GNU General Public License as published by
@ -22,5 +22,6 @@ import { Component } from '@angular/core';
@Component({ @Component({
selector: 'google-play-badge', selector: 'google-play-badge',
templateUrl: './google-play-badge.component.svg', templateUrl: './google-play-badge.component.svg',
standalone: false
}) })
export class GooglePlayBadgeComponent {} export class GooglePlayBadgeComponent {}

View file

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

View file

@ -15,7 +15,6 @@
.countdown { .countdown {
bottom: 4px; bottom: 4px;
color: rgba(0, 0, 0, 0.67);
font-size: 12px; font-size: 12px;
position: absolute; position: absolute;
right: 4px; 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 * 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 * it under the terms of the GNU General Public License as published by
@ -23,19 +23,19 @@ import { timer } from 'rxjs';
@Component({ @Component({
selector: 'RdioScannerNative', selector: 'RdioScannerNative',
styleUrls: ['./native.scss'], styleUrls: ['./native.component.scss'],
templateUrl: './native.component.html', templateUrl: './native.component.html',
standalone: false
}) })
export class RdioScannerNativeComponent implements OnInit { export class RdioScannerNativeComponent implements OnInit {
countdown: number = 10; countdown = 10;
deviceType: string = 'mobile'; deviceType = 'mobile';
isAndroid: boolean = false; isAndroid = false;
isApple: boolean = false; isApple = false;
constructor(@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerNativeComponent>) { constructor(@Optional() private matSnackBarRef: MatSnackBarRef<RdioScannerNativeComponent>) { }
}
ngOnInit(): void { ngOnInit(): void {
const ua = navigator.userAgent; 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 * 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 * 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; display: block;
height: 100%; height: 100%;
min-width: 320px; min-width: 320px;
position: relative;
.mat-sidenav { .mat-sidenav {
background-color: inherit; 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 * 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 * 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', selector: 'rdio-scanner',
styleUrls: ['./rdio-scanner.component.scss'], styleUrls: ['./rdio-scanner.component.scss'],
templateUrl: './rdio-scanner.component.html', templateUrl: './rdio-scanner.component.html',
standalone: false
}) })
export class RdioScannerComponent implements OnDestroy, OnInit { export class RdioScannerComponent implements OnDestroy, OnInit {
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event)); private eventSubscription;
private livefeedMode: RdioScannerLivefeedMode = RdioScannerLivefeedMode.Offline; private livefeedMode: RdioScannerLivefeedMode = RdioScannerLivefeedMode.Offline;
@ -43,7 +44,9 @@ export class RdioScannerComponent implements OnDestroy, OnInit {
private matSnackBar: MatSnackBar, private matSnackBar: MatSnackBar,
private ngElementRef: ElementRef, private ngElementRef: ElementRef,
private rdioScannerService: RdioScannerService, private rdioScannerService: RdioScannerService,
) { } ) {
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
}
@HostListener('window:beforeunload', ['$event']) @HostListener('window:beforeunload', ['$event'])
exitNotification(event: BeforeUnloadEvent): void { 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 * the open source project and its author. Rdio Scanner represents a lot of
* investment in time, support, testing and hardware. * 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(() => { timer(10000).subscribe(() => {
const ua: String = navigator.userAgent; const ua: string = navigator.userAgent;
if (ua.includes('Android') || ua.includes('iPad') || ua.includes('iPhone')) { 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 * 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 * 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 { RdioScannerComponent } from './rdio-scanner.component';
import { RdioScannerService } from './rdio-scanner.service'; import { RdioScannerService } from './rdio-scanner.service';
import { RdioScannerMainComponent } from './main/main.component'; import { RdioScannerMainComponent } from './main/main.component';
import { RdioScannerSupportComponent } from './main/support/support.component';
import { RdioScannerNativeModule } from './native/native.module'; import { RdioScannerNativeModule } from './native/native.module';
import { RdioScannerSearchComponent } from './search/search.component'; import { RdioScannerSearchComponent } from './search/search.component';
import { RdioScannerSelectComponent } from './select/select.component'; import { RdioScannerSelectComponent } from './select/select.component';
@ -33,9 +34,13 @@ import { RdioScannerSelectComponent } from './select/select.component';
RdioScannerMainComponent, RdioScannerMainComponent,
RdioScannerSearchComponent, RdioScannerSearchComponent,
RdioScannerSelectComponent, RdioScannerSelectComponent,
RdioScannerSupportComponent,
], ],
exports: [RdioScannerComponent], exports: [RdioScannerComponent],
imports: [AppSharedModule, RdioScannerNativeModule], imports: [
AppSharedModule,
RdioScannerNativeModule,
],
providers: [ providers: [
RdioScannerService, RdioScannerService,
{ provide: OverlayContainer, useClass: FullscreenOverlayContainer }, { 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 * 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 * 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 { interval, Subscription, timer } from 'rxjs';
import { takeWhile } from 'rxjs/operators'; import { takeWhile } from 'rxjs/operators';
import { AppUpdateService } from '../../shared/update/update.service'; import { AppUpdateService } from '../../shared/update/update.service';
@ -31,8 +32,10 @@ import {
RdioScannerCategoryType, RdioScannerCategoryType,
RdioScannerConfig, RdioScannerConfig,
RdioScannerEvent, RdioScannerEvent,
RdioScannerLivefeed,
RdioScannerLivefeedMap, RdioScannerLivefeedMap,
RdioScannerLivefeedMode, RdioScannerLivefeedMode,
RdioScannerOscillatorData,
RdioScannerPlaybackList, RdioScannerPlaybackList,
RdioScannerSearchOptions, RdioScannerSearchOptions,
} from './rdio-scanner'; } from './rdio-scanner';
@ -57,11 +60,14 @@ enum WebsocketCommand {
LivefeedMap = 'LFM', LivefeedMap = 'LFM',
Max = 'MAX', Max = 'MAX',
Pin = 'PIN', Pin = 'PIN',
Version = 'VER',
} }
@Injectable() @Injectable()
export class RdioScannerService implements OnDestroy { 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>(); event = new EventEmitter<RdioScannerEvent>();
@ -70,8 +76,6 @@ export class RdioScannerService implements OnDestroy {
private audioSource: AudioBufferSourceNode | undefined; private audioSource: AudioBufferSourceNode | undefined;
private audioSourceStartTime = NaN; private audioSourceStartTime = NaN;
private beepContext: AudioContext | undefined;
private call: RdioScannerCall | undefined; private call: RdioScannerCall | undefined;
private callPrevious: RdioScannerCall | undefined; private callPrevious: RdioScannerCall | undefined;
private callQueue: RdioScannerCall[] = []; private callQueue: RdioScannerCall[] = [];
@ -81,38 +85,54 @@ export class RdioScannerService implements OnDestroy {
private config: RdioScannerConfig = { private config: RdioScannerConfig = {
dimmerDelay: false, dimmerDelay: false,
groups: {}, groups: {},
keypadBeeps: false, groupsData: [],
keypadBeeps: undefined,
playbackGoesLive: false, playbackGoesLive: false,
showListenersCount: false, showListenersCount: false,
systems: [], systems: [],
tags: {}, tags: {},
tagsToggle: false, tagsData: [],
time12hFormat: false, time12hFormat: false,
}; };
private instanceId = 'default';
private livefeedMap = {} as RdioScannerLivefeedMap; private livefeedMap = {} as RdioScannerLivefeedMap;
private livefeedMapPriorToHoldSystem: RdioScannerLivefeedMap | undefined; private livefeedMapPriorToHoldSystem: RdioScannerLivefeedMap | undefined;
private livefeedMapPriorToHoldTalkgroup: RdioScannerLivefeedMap | undefined; private livefeedMapPriorToHoldTalkgroup: RdioScannerLivefeedMap | undefined;
private livefeedMode = RdioScannerLivefeedMode.Offline; private livefeedMode = RdioScannerLivefeedMode.Offline;
private livefeedPaused = false; private livefeedPaused = false;
private oscillatorContext: AudioContext | undefined;
private playbackList: RdioScannerPlaybackList | undefined; private playbackList: RdioScannerPlaybackList | undefined;
private playbackPending: number | undefined; private playbackPending: number | undefined;
private playbackRefreshing = false; private playbackRefreshing = false;
private replayDelay: Subscription | undefined;
private skipDelay: Subscription | undefined; private skipDelay: Subscription | undefined;
private websocket: WebSocket | undefined; private websocket: WebSocket | undefined;
constructor( constructor(
appUpdateService: AppUpdateService, appUpdateService: AppUpdateService,
private router: Router,
@Inject(DOCUMENT) private document: Document, @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.bootstrapAudio();
this.restoreLivefeed(); this.initializeInstanceId();
this.readLivefeedMap();
this.openWebsocket(); this.openWebsocket();
} }
@ -122,6 +142,29 @@ export class RdioScannerService implements OnDestroy {
} }
avoid(options: RdioScannerAvoidOptions = {}): void { 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) { if (this.livefeedMapPriorToHoldSystem) {
this.livefeedMapPriorToHoldSystem = undefined; this.livefeedMapPriorToHoldSystem = undefined;
} }
@ -133,37 +176,39 @@ export class RdioScannerService implements OnDestroy {
if (typeof options.all === 'boolean') { if (typeof options.all === 'boolean') {
Object.keys(this.livefeedMap).map((sys: string) => +sys).forEach((sys: number) => { Object.keys(this.livefeedMap).map((sys: string) => +sys).forEach((sys: number) => {
Object.keys(this.livefeedMap[sys]).map((tg: string) => +tg).forEach((tg: 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) { } else if (options.call) {
const sys = options.call.system; const lfm = this.livefeedMap[options.call.system][options.call.talkgroup];
const tg = options.call.talkgroup; clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg]; if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
} else if (options.system && options.talkgroup) { } else if (options.system && options.talkgroup) {
const sys = options.system.id; const lfm = this.livefeedMap[options.system.id][options.talkgroup.id];
const tg = options.talkgroup.id; clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg]; if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
} else if (options.system && !options.talkgroup) { } else if (options.system && !options.talkgroup) {
const sys = options.system.id; const sys = options.system.id;
Object.keys(this.livefeedMap[sys]).map((tg: string) => +tg).forEach((tg: number) => { 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 { } else {
const call = this.call || this.callPrevious; const call = this.call || this.callPrevious;
if (call) { if (call) {
const sys = call.system; const lfm = this.livefeedMap[call.system][call.talkgroup];
const tg = call.talkgroup; clearTimer(lfm);
lfm.active = typeof options.status === 'boolean' ? options.status : !lfm.active;
this.livefeedMap[sys][tg] = typeof options.status === 'boolean' ? options.status : !this.livefeedMap[sys][tg]; if (typeof options.minutes === 'number') setTimer(lfm, options.minutes);
} }
} }
@ -173,7 +218,7 @@ export class RdioScannerService implements OnDestroy {
this.rebuildCategories(); this.rebuildCategories();
this.storeLivefeedMap(); this.saveLivefeedMap();
if (this.livefeedMode === RdioScannerLivefeedMode.Online) { if (this.livefeedMode === RdioScannerLivefeedMode.Online) {
this.startLivefeed(); this.startLivefeed();
@ -188,42 +233,14 @@ export class RdioScannerService implements OnDestroy {
}); });
} }
beep(style = RdioScannerBeepStyle.Activate): Promise<void> { async beep(style = RdioScannerBeepStyle.Activate): Promise<void> {
return new Promise((resolve) => {
const context = this.beepContext;
const seq = this.config.keypadBeeps && this.config.keypadBeeps[style]; const seq = this.config.keypadBeeps && this.config.keypadBeeps[style];
if (!context || !seq) { if (seq) await this.playOscillatorSequence(seq);
resolve();
return;
} }
const gn = context.createGain(); clearPin(): void {
window?.localStorage.removeItem(RdioScannerService.LOCAL_STORAGE_KEY_PIN);
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);
});
});
} }
holdSystem(options?: { resubscribe?: boolean }): void { holdSystem(options?: { resubscribe?: boolean }): void {
@ -243,18 +260,17 @@ export class RdioScannerService implements OnDestroy {
this.livefeedMapPriorToHoldSystem = this.livefeedMap; this.livefeedMapPriorToHoldSystem = this.livefeedMap;
this.livefeedMap = Object.keys(this.livefeedMap).map((sys) => +sys).reduce((sysMap, sys) => { 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) => { sysMap[sys] = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).reduce((tgMap, tg) => {
if (sys === call.system) { this.livefeedMap[sys][tg].timer?.unsubscribe();
tgMap[tg] = allOn || this.livefeedMap[sys][tg];
} else { tgMap[tg] = {
tgMap[tg] = false; active: sys === call.system ? allOn || this.livefeedMap[sys][tg].active : false,
} } as RdioScannerLivefeed;
return tgMap; return tgMap;
}, {} as { [key: number]: boolean }); }, {} as { [key: number]: RdioScannerLivefeed });
return sysMap; return sysMap;
}, {} as RdioScannerLivefeedMap); }, {} as RdioScannerLivefeedMap);
@ -298,15 +314,14 @@ export class RdioScannerService implements OnDestroy {
this.livefeedMap = Object.keys(this.livefeedMap).map((sys) => +sys).reduce((sysMap, sys) => { 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) => { sysMap[sys] = Object.keys(this.livefeedMap[sys]).map((tg) => +tg).reduce((tgMap, tg) => {
if (sys === call.system) { this.livefeedMap[sys][tg].timer?.unsubscribe();
tgMap[tg] = tg === call.talkgroup;
} else { tgMap[tg] = {
tgMap[tg] = false; active: sys === call.system ? tg === call.talkgroup : false,
} } as RdioScannerLivefeed;
return tgMap; return tgMap;
}, {} as { [key: number]: boolean }); }, {} as { [key: number]: RdioScannerLivefeed });
return sysMap; return sysMap;
}, {} as RdioScannerLivefeedMap); }, {} as RdioScannerLivefeedMap);
@ -333,12 +348,19 @@ export class RdioScannerService implements OnDestroy {
} }
isAvoided(call: RdioScannerCall): boolean { 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 { isPatched(call: RdioScannerCall): boolean {
return this.isAvoided(call) && call.patches.some((tg) => { return this.isAvoided(call) && call.patches?.some((tg) => {
return !!this.livefeedMap[call.system] && this.livefeedMap[call.system][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]; 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) { if (!this.audioContext || this.audioSource || !this.call) {
return; return;
} }
await this.playAlert(this.call);
this.audioSource = this.audioContext.createBufferSource(); this.audioSource = this.audioContext.createBufferSource();
this.audioSource.buffer = buffer; this.audioSource.buffer = buffer;
this.audioSource.connect(this.audioContext.destination); this.audioSource.connect(this.audioContext.destination);
@ -508,6 +532,16 @@ export class RdioScannerService implements OnDestroy {
this.play(this.call || this.callPrevious); 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 { searchCalls(options: RdioScannerSearchOptions): void {
this.sendtoWebsocket(WebsocketCommand.ListCall, options); this.sendtoWebsocket(WebsocketCommand.ListCall, options);
} }
@ -543,11 +577,19 @@ export class RdioScannerService implements OnDestroy {
} }
startLivefeed(): void { 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.livefeedMode = RdioScannerLivefeedMode.Online;
this.event.emit({ livefeedMode: this.livefeedMode }); this.event.emit({ livefeedMode: this.livefeedMode });
this.sendtoWebsocket(WebsocketCommand.LivefeedMap, this.livefeedMap); this.sendtoWebsocket(WebsocketCommand.LivefeedMap, lfm);
} }
stop(options?: { emit?: boolean }): void { stop(options?: { emit?: boolean }): void {
@ -595,6 +637,12 @@ export class RdioScannerService implements OnDestroy {
} }
toggleCategory(category: RdioScannerCategory): void { toggleCategory(category: RdioScannerCategory): void {
const clearTimer = (lfm: RdioScannerLivefeed): void => {
lfm.minutes = 0;
lfm.timer?.unsubscribe();
lfm.timer = undefined;
};
if (category) { if (category) {
if (this.livefeedMapPriorToHoldSystem) { if (this.livefeedMapPriorToHoldSystem) {
this.livefeedMapPriorToHoldSystem = undefined; this.livefeedMapPriorToHoldSystem = undefined;
@ -608,20 +656,23 @@ export class RdioScannerService implements OnDestroy {
this.config?.systems.forEach((sys) => { this.config?.systems.forEach((sys) => {
sys.talkgroups?.forEach((tg) => { sys.talkgroups?.forEach((tg) => {
if (category.type == RdioScannerCategoryType.Group && tg.group === category.label) { const lfm = this.livefeedMap[sys.id][tg.id];
this.livefeedMap[sys.id][tg.id] = status;
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) { } else if (category.type == RdioScannerCategoryType.Tag && tg.tag === category.label) {
this.livefeedMap[sys.id][tg.id] = status; clearTimer(lfm);
lfm.active = status;
} }
}); });
}); });
this.rebuildCategories(); this.rebuildCategories();
if (this.call && !this.livefeedMap[this.call.system] && if (this.call && !this.livefeedMap[this.call.system] && this.livefeedMap[this.call.system][this.call.talkgroup]) {
this.livefeedMap[this.call.system][this.call.talkgroup]) { clearTimer(this.livefeedMap[this.call.system][this.call.talkgroup]);
this.skip(); this.skip();
} }
@ -629,7 +680,7 @@ export class RdioScannerService implements OnDestroy {
this.startLivefeed(); this.startLivefeed();
} }
this.storeLivefeedMap(); this.saveLivefeedMap();
this.cleanQueue(); this.cleanQueue();
@ -651,8 +702,8 @@ export class RdioScannerService implements OnDestroy {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' }); this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'playback' });
} }
if (!this.beepContext) { if (!this.oscillatorContext) {
this.beepContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' }); this.oscillatorContext = new (window.AudioContext || window.webkitAudioContext)({ latencyHint: 'interactive' });
} }
if (this.audioContext) { if (this.audioContext) {
@ -669,19 +720,19 @@ export class RdioScannerService implements OnDestroy {
this.audioContext.onstatechange = () => resume(); this.audioContext.onstatechange = () => resume();
} }
if (this.beepContext) { if (this.oscillatorContext) {
const resume = () => { const resume = () => {
if (this.beepContext?.state === 'suspended') { if (this.oscillatorContext?.state === 'suspended') {
this.beepContext?.resume().then(() => resume()); 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)); events.forEach((event) => document.body.removeEventListener(event, bootstrap));
} }
}; };
@ -690,8 +741,8 @@ export class RdioScannerService implements OnDestroy {
} }
private cleanQueue(): void { private cleanQueue(): void {
let isActive = (call: RdioScannerCall) => { const isActive = (call: RdioScannerCall) => {
let lfm = (sys: number, tg: number) => this.livefeedMap && this.livefeedMap[sys] && this.livefeedMap[sys][tg]; const lfm = (sys: number, tg: number): boolean => this.livefeedMap && this.livefeedMap[sys] && this.livefeedMap[sys][tg]?.active;
let active = lfm(call.system, call.talkgroup); let active = lfm(call.system, call.talkgroup);
if (!active && Array.isArray(call.patches)) { if (!active && Array.isArray(call.patches)) {
for (let i = 0; i < call.patches.length; i++) { for (let i = 0; i < call.patches.length; i++) {
@ -773,6 +824,10 @@ export class RdioScannerService implements OnDestroy {
return queueCount; return queueCount;
} }
private initializeInstanceId(): void {
this.instanceId = this.router.parseUrl(this.router.url).queryParams['id'] || this.instanceId;
}
private openWebsocket(): void { private openWebsocket(): void {
const websocketUrl = window.location.href.replace(/^http/, 'ws'); 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.websocket.onmessage = (ev: MessageEvent) => this.parseWebsocketMessage(ev.data);
} }
this.sendtoWebsocket(WebsocketCommand.Version);
this.sendtoWebsocket(WebsocketCommand.Config); this.sendtoWebsocket(WebsocketCommand.Config);
}; };
} }
@ -809,8 +865,8 @@ export class RdioScannerService implements OnDestroy {
switch (message[0]) { switch (message[0]) {
case WebsocketCommand.Call: case WebsocketCommand.Call:
if (message[1] !== null) { if (message[1] !== null) {
let call: RdioScannerCall = message[1]; const call: RdioScannerCall = message[1];
let flag: string = message[2]; const flag: string = message[2];
if (flag === WebsocketCallFlag.Download) { if (flag === WebsocketCallFlag.Download) {
this.download(message[1]); this.download(message[1]);
@ -831,21 +887,21 @@ export class RdioScannerService implements OnDestroy {
const config = message[1]; const config = message[1];
this.config = { this.config = {
alerts: config.alerts,
branding: typeof config.branding === 'string' ? config.branding : '',
dimmerDelay: typeof config.dimmerDelay === 'number' ? config.dimmerDelay : 5000, 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 : {}, 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 : {}, keypadBeeps: config.keypadBeeps !== null && typeof config.keypadBeeps === 'object' ? config.keypadBeeps : {},
playbackGoesLive: typeof config.playbackGoesLive === 'boolean' ? config.playbackGoesLive : false, playbackGoesLive: typeof config.playbackGoesLive === 'boolean' ? config.playbackGoesLive : false,
showListenersCount: typeof config.showListenersCount === 'boolean' ? config.showListenersCount : false, showListenersCount: typeof config.showListenersCount === 'boolean' ? config.showListenersCount : false,
systems: Array.isArray(config.systems) ? config.systems.slice() : [], systems: Array.isArray(config.systems) ? config.systems.slice() : [],
tags: typeof config.tags !== null && typeof config.tags === 'object' ? config.tags : {}, 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, time12hFormat: typeof config.time12hFormat === 'boolean' ? config.time12hFormat : false,
}; };
if (typeof config.afs === 'string' && config.afs.length) {
this.config['afs'] = config.afs;
}
this.rebuildLivefeedMap(); this.rebuildLivefeedMap();
if (this.livefeedMode === RdioScannerLivefeedMode.Online) { if (this.livefeedMode === RdioScannerLivefeedMode.Online) {
@ -898,7 +954,61 @@ export class RdioScannerService implements OnDestroy {
this.event.emit({ auth: true }); this.event.emit({ auth: true });
break; 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);
} }
} }
@ -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 { private rebuildCategories(): void {
this.categories = Object.keys(this.config.groups || []).map((label) => { this.categories = Object.keys(this.config.groups || []).map((label) => {
const allOff = Object.keys(this.config.groups[label]).map((sys) => +sys) const allOff = Object.keys(this.config.groups[label]).map((sys) => +sys)
.every((sys: number) => this.config.groups[label] && this.config.groups[label][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) const allOn = Object.keys(this.config.groups[label]).map((sys) => +sys)
.every((sys: number) => this.config.groups[label] && this.config.groups[label][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; const status = allOff ? RdioScannerCategoryStatus.Off : allOn ? RdioScannerCategoryStatus.On : RdioScannerCategoryStatus.Partial;
return { label, status, type: RdioScannerCategoryType.Group }; 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)); this.categories.sort((a, b) => a.label.localeCompare(b.label));
} }
private rebuildLivefeedMap(): void { 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) => { 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; return tgMap;
}, sysMap[sys.id] || {}); }, sysMap[sys.id] || {} as { [key: number]: RdioScannerLivefeed });
return sysMap; return sysMap;
}, {} as RdioScannerLivefeedMap); }, {} as RdioScannerLivefeedMap);
if (this.livefeedMapPriorToHoldSystem != null) { if (this.livefeedMapPriorToHoldSystem != null) {
this.livefeedMapPriorToHoldSystem = livefeedMap; this.livefeedMapPriorToHoldSystem = lfm;
} else if (this.livefeedMapPriorToHoldTalkgroup != null) { } else if (this.livefeedMapPriorToHoldTalkgroup != null) {
this.livefeedMapPriorToHoldTalkgroup = livefeedMap; this.livefeedMapPriorToHoldTalkgroup = lfm;
} else { } else {
this.livefeedMap = livefeedMap; this.livefeedMap = lfm;
} }
this.storeLivefeedMap(); this.saveLivefeedMap();
this.rebuildCategories(); this.rebuildCategories();
} }
@ -1031,17 +1195,16 @@ export class RdioScannerService implements OnDestroy {
this.openWebsocket(); this.openWebsocket();
} }
private restoreLivefeed(): void { private saveLivefeedMap(): void {
const map = window?.localStorage?.getItem(RdioScannerService.LOCAL_STORAGE_KEY); 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) { window?.localStorage?.setItem(`${RdioScannerService.LOCAL_STORAGE_KEY_LFM}-${this.instanceId}`, JSON.stringify(lfm));
try {
this.livefeedMap = JSON.parse(map);
} catch (err) {
this.livefeedMap = {};
}
}
} }
private sendtoWebsocket(command: string, payload?: unknown, flags?: string): void { 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 { private transformCall(call: RdioScannerCall): RdioScannerCall {
if (call && Array.isArray(this.config?.systems)) { if (call && Array.isArray(this.config?.systems)) {
@ -1075,6 +1235,10 @@ export class RdioScannerService implements OnDestroy {
if (call.talkgroupData?.frequency) { if (call.talkgroupData?.frequency) {
call.frequency = 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; 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 * 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 * it under the terms of the GNU General Public License as published by
@ -17,19 +17,19 @@
* **************************************************************************** * ****************************************************************************
*/ */
import { Subscription } from "rxjs";
export interface RdioScannerAvoidOptions { export interface RdioScannerAvoidOptions {
all?: boolean; all?: boolean;
call?: RdioScannerCall; call?: RdioScannerCall;
minutes?: number;
status?: boolean;
system?: RdioScannerSystem; system?: RdioScannerSystem;
talkgroup?: RdioScannerTalkgroup; talkgroup?: RdioScannerTalkgroup;
status?: boolean;
} }
export interface RdioScannerBeep { export interface RdioScannerAlerts {
begin: number; [key: string]: RdioScannerOscillatorData[];
end: number;
frequency: number;
type: OscillatorType;
} }
export enum RdioScannerBeepStyle { export enum RdioScannerBeepStyle {
@ -46,19 +46,23 @@ export interface RdioScannerCall {
audioName?: string; audioName?: string;
audioType?: string; audioType?: string;
dateTime: Date; dateTime: Date;
delayed: boolean;
frequencies?: RdioScannerCallFrequency[]; frequencies?: RdioScannerCallFrequency[];
frequency?: number; frequency?: number;
groupsData?: RdioScannerGroupData[];
id: number; id: number;
patches: number[]; patches: number[];
source?: number; source?: number;
sources?: RdioScannerCallSource[]; sources?: RdioScannerCallSource[];
system: number; system: number;
tagData?: RdioScannerTagData;
talkgroup: number; talkgroup: number;
talkgroupData?: RdioScannerTalkgroup; talkgroupData?: RdioScannerTalkgroup;
systemData?: RdioScannerSystem; systemData?: RdioScannerSystem;
} }
export interface RdioScannerCallFrequency { export interface RdioScannerCallFrequency {
dbm?: number;
errorCount?: number; errorCount?: number;
freq?: number; freq?: number;
len?: number; len?: number;
@ -89,15 +93,18 @@ export enum RdioScannerCategoryType {
} }
export interface RdioScannerConfig { export interface RdioScannerConfig {
afs?: string; alerts?: RdioScannerAlerts;
branding?: string;
dimmerDelay: number | false; dimmerDelay: number | false;
email?: string;
groups: { [key: string]: { [key: number]: number[] } }; groups: { [key: string]: { [key: number]: number[] } };
keypadBeeps: RdioScannerKeypadBeeps | false; groupsData: RdioScannerGroupData[];
keypadBeeps: RdioScannerKeypadBeeps | undefined;
playbackGoesLive: boolean; playbackGoesLive: boolean;
showListenersCount: boolean; showListenersCount: boolean;
systems: RdioScannerSystem[]; systems: RdioScannerSystem[];
tags: { [key: string]: { [key: number]: number[] } }; tags: { [key: string]: { [key: number]: number[] } };
tagsToggle: boolean; tagsData: RdioScannerTagData[];
time12hFormat: boolean; time12hFormat: boolean;
} }
@ -121,15 +128,28 @@ export interface RdioScannerEvent {
tooMany?: boolean; tooMany?: boolean;
} }
export interface RdioScannerGroupData {
id: number;
alert?: string;
label?: string;
led?: string;
}
export interface RdioScannerKeypadBeeps { export interface RdioScannerKeypadBeeps {
[RdioScannerBeepStyle.Activate]: RdioScannerBeep[]; [RdioScannerBeepStyle.Activate]: RdioScannerOscillatorData[];
[RdioScannerBeepStyle.Deactivate]: RdioScannerBeep[]; [RdioScannerBeepStyle.Deactivate]: RdioScannerOscillatorData[];
[RdioScannerBeepStyle.Denied]: RdioScannerBeep[]; [RdioScannerBeepStyle.Denied]: RdioScannerOscillatorData[];
}
export interface RdioScannerLivefeed {
active: boolean;
minutes: number | undefined;
timer: Subscription | undefined;
} }
export interface RdioScannerLivefeedMap { export interface RdioScannerLivefeedMap {
[key: string]: { [key: number]: {
[key: string]: boolean; [key: number]: RdioScannerLivefeed;
}; };
} }
@ -139,6 +159,13 @@ export enum RdioScannerLivefeedMode {
Playback = 'playback', Playback = 'playback',
} }
export interface RdioScannerOscillatorData {
begin: number;
end: number;
frequency: number;
type: OscillatorType;
}
export interface RdioScannerPlaybackList { export interface RdioScannerPlaybackList {
count: number; count: number;
dateStart: Date; dateStart: Date;
@ -156,28 +183,43 @@ export interface RdioScannerSearchOptions {
system?: number; system?: number;
tag?: string; tag?: string;
talkgroup?: number; talkgroup?: number;
unit?: number;
} }
export interface RdioScannerSystem { export interface RdioScannerSystem {
id: number; id: number;
alert?: string;
label: string; label: string;
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow'; led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow';
order?: number; order?: number;
talkgroups: RdioScannerTalkgroup[]; talkgroups: RdioScannerTalkgroup[];
type?: string;
units: RdioScannerUnit[]; units: RdioScannerUnit[];
} }
export interface RdioScannerTagData {
id: number;
alert?: string;
label?: string;
led?: string;
}
export interface RdioScannerTalkgroup { export interface RdioScannerTalkgroup {
alert?: string;
frequency?: number; frequency?: number;
group: string; groups: string[];
id: number; id: number;
label: string; label: string;
led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow'; led?: 'blue' | 'cyan' | 'green' | 'magenta' | 'orange' | 'red' | 'white' | 'yellow';
name: string; name: string;
tag: string; tag: string;
type?: string;
} }
export interface RdioScannerUnit { export interface RdioScannerUnit {
id: number; id: number;
label: string; label: string;
unitRef: number;
unitFrom: number;
unitTo: number;
} }

View file

@ -1,21 +1,29 @@
<mat-card class="rdio-list"> <mat-card appearance="outlined" class="rdio-list">
<mat-table [dataSource]="results"> <mat-table [dataSource]="results">
<ng-container matColumnDef="control"> <ng-container matColumnDef="control">
<mat-header-cell *matHeaderCellDef></mat-header-cell> <mat-header-cell *matHeaderCellDef></mat-header-cell>
<mat-cell *matCellDef="let row"> <mat-cell *matCellDef="let row">
<button *ngIf="downloadMode.checked && row" mat-icon-button (click)="download(+row.id)"> @if (downloadMode.checked && row) {
<button mat-icon-button (click)="download(+row.id)">
<mat-icon>save_alt</mat-icon> <mat-icon>save_alt</mat-icon>
</button> </button>
<button *ngIf="row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending" }
@if (row && !downloadMode.checked && !paused && row?.id != call?.id && row?.id != callPending) {
<button
mat-icon-button (click)="play(+row.id)"> mat-icon-button (click)="play(+row.id)">
<mat-icon>play_arrow</mat-icon> <mat-icon>play_arrow</mat-icon>
</button> </button>
<button *ngIf="row && !downloadMode.checked && row?.id == callPending" mat-icon-button> }
@if (row && !downloadMode.checked && row?.id == callPending) {
<button mat-icon-button>
<mat-icon class="spin">cached</mat-icon> <mat-icon class="spin">cached</mat-icon>
</button> </button>
<button *ngIf="row && !downloadMode.checked && row?.id == call?.id" mat-icon-button (click)="stop()"> }
@if (row && !downloadMode.checked && row?.id == call?.id) {
<button mat-icon-button (click)="stop()">
<mat-icon>stop</mat-icon> <mat-icon>stop</mat-icon>
</button> </button>
}
</mat-cell> </mat-cell>
</ng-container> </ng-container>
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
@ -75,7 +83,7 @@
</mat-paginator> </mat-paginator>
</div> </div>
</mat-card> </mat-card>
<mat-card class="rdio-filters"> <mat-card appearance="outlined" class="rdio-filters">
<form autocomplete="off" [formGroup]="form"> <form autocomplete="off" [formGroup]="form">
<mat-form-field> <mat-form-field>
<mat-label> <mat-label>
@ -105,9 +113,11 @@
<mat-option [value]="-1"> <mat-option [value]="-1">
All Systems All Systems
</mat-option> </mat-option>
<mat-option *ngFor="let system of optionsSystem; index as i" [value]="i"> @for (system of optionsSystem; track system; let i = $index) {
<mat-option [value]="i">
{{ system }} {{ system }}
</mat-option> </mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
@ -118,9 +128,11 @@
<mat-option [value]="-1"> <mat-option [value]="-1">
All Talkgroups All Talkgroups
</mat-option> </mat-option>
<mat-option *ngFor="let talkgroup of optionsTalkgroup; index as i" [value]="i"> @for (talkgroup of optionsTalkgroup; track talkgroup; let i = $index) {
<mat-option [value]="i">
{{ talkgroup }} {{ talkgroup }}
</mat-option> </mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
@ -131,9 +143,11 @@
<mat-option [value]="-1"> <mat-option [value]="-1">
All Groups All Groups
</mat-option> </mat-option>
<mat-option *ngFor="let group of optionsGroup; index as i" [value]="i"> @for (group of optionsGroup; track group; let i = $index) {
<mat-option [value]="i">
{{ group }} {{ group }}
</mat-option> </mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<mat-form-field> <mat-form-field>
@ -144,11 +158,20 @@
<mat-option [value]="-1"> <mat-option [value]="-1">
All Tags All Tags
</mat-option> </mat-option>
<mat-option *ngFor="let tag of optionsTag; index as i" [value]="i"> @for (tag of optionsTag; track tag; let i = $index) {
<mat-option [value]="i">
{{ tag }} {{ tag }}
</mat-option> </mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </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"> <div class="reset">
<button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()"> <button mat-raised-button type="button" [disabled]="resultsPending" (click)="resetForm()">
Reset Reset

View file

@ -5,13 +5,18 @@
user-select: none; user-select: none;
} }
.rdio-filters,
.rdio-list {
padding: 1rem;
}
.rdio-filters form { .rdio-filters form {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
.mat-form-field { .mat-mdc-form-field {
@for $i from 1 through 6 { @for $i from 1 through 7 {
&:nth-of-type(#{$i}) { &:nth-of-type(#{$i}) {
order: #{$i}; order: #{$i};
} }
@ -33,8 +38,8 @@
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
.mat-cell, .mat-mdc-cell,
.mat-header-cell { .mat-mdc-header-cell {
&:first-of-type { &:first-of-type {
padding-left: 0; padding-left: 0;
} }
@ -44,19 +49,19 @@
} }
&:nth-child(1) { &:nth-child(1) {
flex: 0 0 40px; flex: 0 0 48px;
} }
&:nth-child(2) { &:nth-child(2) {
flex: 0 0 50px; flex: 0 0 55px;
} }
&:nth-child(3):not(.time12h) { &:nth-child(3):not(.time12h) {
flex: 0 0 50px; flex: 0 0 53px;
} }
&:nth-child(3).time12h { &:nth-child(3).time12h {
flex: 0 0 70px; flex: 0 0 78px;
} }
&:nth-child(4), &:nth-child(4),
@ -71,12 +76,17 @@
} }
} }
.mdc-data-table__header-cell,
.mdc-data-table__cell {
padding: 0 8px;
}
.paginator { .paginator {
align-items: center; align-items: center;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding-left: 8px;; padding-left: 8px;
} }
.spin { .spin {
@ -98,13 +108,19 @@
} }
@media (max-width: 459px) { @media (max-width: 459px) {
.rdio-filters form .mat-form-field { .rdio-filters form .mat-mdc-form-field {
flex: 100%; 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) { @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); flex: calc(50% - 1em);
&:nth-of-type(2), &:nth-of-type(2),
@ -116,7 +132,7 @@
} }
@media (min-width: 980px) { @media (min-width: 980px) {
.rdio-filters form .mat-form-field { .rdio-filters form .mat-mdc-form-field {
flex: calc(100% / 3 - 2em); flex: calc(100% / 3 - 2em);
&:nth-of-type(2), &: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 * 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 * 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 { ChangeDetectorRef, Component, OnDestroy, ViewChild } from '@angular/core';
import { FormBuilder } from '@angular/forms'; import { FormBuilder, FormGroup } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator'; import { MatPaginator } from '@angular/material/paginator';
import { BehaviorSubject } from 'rxjs'; import { BehaviorSubject } from 'rxjs';
import { import {
@ -37,19 +37,13 @@ import { RdioScannerService } from '../rdio-scanner.service';
selector: 'rdio-scanner-search', selector: 'rdio-scanner-search',
styleUrls: ['./search.component.scss'], styleUrls: ['./search.component.scss'],
templateUrl: './search.component.html', templateUrl: './search.component.html',
standalone: false
}) })
export class RdioScannerSearchComponent implements OnDestroy, OnInit { export class RdioScannerSearchComponent implements OnDestroy {
call: RdioScannerCall | undefined; call: RdioScannerCall | undefined;
callPending: number | undefined; callPending: number | undefined;
form = this.ngFormBuilder.group({ form: FormGroup;
date: [null],
group: [-1],
sort: [-1],
system: [-1],
tag: [-1],
talkgroup: [-1],
});
livefeedOnline = false; livefeedOnline = false;
livefeedPlayback = false; livefeedPlayback = false;
@ -70,7 +64,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
private config: RdioScannerConfig | undefined; private config: RdioScannerConfig | undefined;
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event)); private eventSubscription;
private limit = 200; private limit = 200;
@ -82,7 +76,27 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
private rdioScannerService: RdioScannerService, private rdioScannerService: RdioScannerService,
private ngChangeDetectorRef: ChangeDetectorRef, private ngChangeDetectorRef: ChangeDetectorRef,
private ngFormBuilder: FormBuilder, 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 { download(id: number): void {
this.rdioScannerService.loadAndDownload(id); this.rdioScannerService.loadAndDownload(id);
@ -104,10 +118,6 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
this.eventSubscription.unsubscribe(); this.eventSubscription.unsubscribe();
} }
ngOnInit(): void {
}
play(id: number): void { play(id: number): void {
this.rdioScannerService.loadAndPlay(id); this.rdioScannerService.loadAndPlay(id);
} }
@ -125,7 +135,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
this.optionsSystem = this.config.systems this.optionsSystem = this.config.systems
.filter((system) => { .filter((system) => {
const group = selectedGroup === undefined || const group = selectedGroup === undefined ||
system.talkgroups.some((talkgroup) => talkgroup.group === selectedGroup); system.talkgroups.some((talkgroup) => talkgroup.groups.includes(selectedGroup));
const tag = selectedTag === undefined || const tag = selectedTag === undefined ||
system.talkgroups.some((talkgroup) => talkgroup.tag === selectedTag); system.talkgroups.some((talkgroup) => talkgroup.tag === selectedTag);
return group && tag; return group && tag;
@ -137,7 +147,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
: selectedSystem.talkgroups : selectedSystem.talkgroups
.filter((talkgroup) => { .filter((talkgroup) => {
const group = selectedGroup == undefined || const group = selectedGroup == undefined ||
talkgroup.group === selectedGroup; talkgroup.groups.includes(selectedGroup);
const tag = selectedTag == undefined || const tag = selectedTag == undefined ||
talkgroup.tag === selectedTag; talkgroup.tag === selectedTag;
return group && tag; return group && tag;
@ -147,14 +157,14 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
this.optionsGroup = Object.keys(this.config.groups) this.optionsGroup = Object.keys(this.config.groups)
.filter((group) => { .filter((group) => {
const system: boolean = selectedSystem === undefined || 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 || const talkgroup: boolean = selectedTalkgroup === undefined ||
selectedTalkgroup.group === group; selectedTalkgroup.groups.includes(group);
const tag: boolean = selectedTag === undefined || const tag: boolean = selectedTag === undefined ||
(selectedTalkgroup !== undefined && selectedTalkgroup.tag === selectedTag) || (selectedTalkgroup !== undefined && selectedTalkgroup.tag === selectedTag) ||
(this.config !== undefined && this.config.systems (this.config !== undefined && this.config.systems
.flatMap((system) => system.talkgroups) .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; return system && talkgroup && tag;
}) })
.sort((a, b) => a.localeCompare(b)) .sort((a, b) => a.localeCompare(b))
@ -166,10 +176,10 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
const talkgroup: boolean = selectedTalkgroup === undefined || const talkgroup: boolean = selectedTalkgroup === undefined ||
selectedTalkgroup.tag === tag; selectedTalkgroup.tag === tag;
const group: boolean = selectedGroup === undefined || const group: boolean = selectedGroup === undefined ||
(selectedTalkgroup !== undefined && selectedTalkgroup.group === selectedGroup) || (selectedTalkgroup !== undefined && selectedTalkgroup.groups.includes(selectedGroup)) ||
(this.config !== undefined && this.config.systems (this.config !== undefined && this.config.systems
.flatMap((system) => system.talkgroups) .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; return system && talkgroup && group;
}) })
.sort((a, b) => a.localeCompare(b)) .sort((a, b) => a.localeCompare(b))
@ -213,6 +223,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
system: -1, system: -1,
tag: -1, tag: -1,
talkgroup: -1, talkgroup: -1,
unit: -1,
}); });
this.paginator?.firstPage(); this.paginator?.firstPage();
@ -234,14 +245,14 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
const options: RdioScannerSearchOptions = { const options: RdioScannerSearchOptions = {
limit: this.limit, limit: this.limit,
offset: this.offset, offset: this.offset,
sort: this.form.value.sort, sort: this.form.get('sort')?.value ?? -1,
}; };
if (typeof this.form.value.date === 'string') { if (typeof this.form.value.date === 'string') {
options.date = new Date(Date.parse(this.form.value.date)); 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(); const group = this.getSelectedGroup();
if (group) { 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(); const system = this.getSelectedSystem();
if (system) { 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(); const tag = this.getSelectedTag();
if (tag) { 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(); const talkgroup = this.getSelectedTalkgroup();
if (talkgroup) { if (talkgroup) {
@ -297,7 +308,7 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
const index = this.results.value.findIndex((call) => call?.id === this.callPending); const index = this.results.value.findIndex((call) => call?.id === this.callPending);
if (index === -1) { if (index === -1) {
if (this.form.value.sort === -1) { if (this.form.get('sort')?.value === -1) {
this.paginator?.previousPage(); this.paginator?.previousPage();
} else { } else {
@ -349,22 +360,22 @@ export class RdioScannerSearchComponent implements OnDestroy, OnInit {
} }
private getSelectedGroup(): string | undefined { private getSelectedGroup(): string | undefined {
return this.optionsGroup[this.form.value.group]; return this.optionsGroup[this.form.get('group')?.value ?? -1];
} }
private getSelectedSystem(): RdioScannerSystem | undefined { 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 { private getSelectedTag(): string | undefined {
return this.optionsTag[this.form.value.tag]; return this.optionsTag[this.form.get('tag')?.value ?? -1];
} }
private getSelectedTalkgroup(): RdioScannerTalkgroup | undefined { private getSelectedTalkgroup(): RdioScannerTalkgroup | undefined {
const system = this.getSelectedSystem(); const system = this.getSelectedSystem();
return system 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; : undefined;
} }
} }

View file

@ -1,9 +1,12 @@
<fieldset *ngIf="categories?.length && (categories?.length || 0) >= 2" class="fieldset"> @if (categories?.length && (categories?.length || 0) >= 2) {
<fieldset class="fieldset">
<div> <div>
<button *ngFor="let category of categories" class="rdio-button" [ngClass]="category.status" @for (category of categories; track category) {
<button class="rdio-button" [ngClass]="category.status"
(click)="toggle(category)"> (click)="toggle(category)">
{{ category.label }} {{ category.label }}
</button> </button>
}
<button class="rdio-button-mini all-off" (click)="avoid({ all: false })"> <button class="rdio-button-mini all-off" (click)="avoid({ all: false })">
OFF OFF
</button> </button>
@ -11,29 +14,34 @@
ON ON
</button> </button>
</div> </div>
</fieldset> </fieldset>
<ng-container *ngFor="let system of systems"> }
<fieldset *ngIf="system.talkgroups.length" class="fieldset"> @for (system of systems; track system) {
@if (system.talkgroups.length) {
<fieldset class="fieldset">
<legend> <legend>
{{ system.label }} {{ system.label }}
</legend> </legend>
<div> <div>
<button *ngFor="let talkgroup of system.talkgroups" class="rdio-button" @for (talkgroup of system.talkgroups; track talkgroup) {
[ngClass]="{ off: !(map[system.id] && map[system.id][talkgroup.id]), on: map[system.id] && map[system.id][talkgroup.id] }" <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 })"> (click)="avoid({ system: system, talkgroup: talkgroup })">
{{ talkgroup.label }} {{ talkgroup.label }}
</button> </button>
<ng-container *ngIf="system.talkgroups.length > 1"> }
@if (system.talkgroups.length > 1) {
<button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })"> <button class="rdio-button-mini all-off" (click)="avoid({ system: system, status: false })">
OFF OFF
</button> </button>
<button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })"> <button class="rdio-button-mini all-on" (click)="avoid({ system: system, status: true })">
ON ON
</button> </button>
</ng-container> }
</div> </div>
</fieldset> </fieldset>
</ng-container> }
<ng-container *ngIf="systems?.length === 0"> }
@if (systems?.length === 0) {
No talkgroup configured! No talkgroup configured!
</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 * 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 * 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', './select.component.scss',
], ],
templateUrl: './select.component.html', templateUrl: './select.component.html',
standalone: false
}) })
export class RdioScannerSelectComponent implements OnDestroy { export class RdioScannerSelectComponent implements OnDestroy {
categories: RdioScannerCategory[] | undefined; categories: RdioScannerCategory[] | undefined;
@ -44,11 +45,11 @@ export class RdioScannerSelectComponent implements OnDestroy {
systems: RdioScannerSystem[] | undefined; systems: RdioScannerSystem[] | undefined;
tagsToggle: boolean | undefined; private eventSubscription;
private eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event)); constructor(private rdioScannerService: RdioScannerService) {
this.eventSubscription = this.rdioScannerService.event.subscribe((event: RdioScannerEvent) => this.eventHandler(event));
constructor(private rdioScannerService: RdioScannerService) { } }
avoid(options?: RdioScannerAvoidOptions): void { avoid(options?: RdioScannerAvoidOptions): void {
if (options?.all == true) { if (options?.all == true) {
@ -58,7 +59,7 @@ export class RdioScannerSelectComponent implements OnDestroy {
this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate); this.rdioScannerService.beep(RdioScannerBeepStyle.Deactivate);
} else if (options?.system !== undefined && options?.talkgroup !== undefined) { } 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.Deactivate
: RdioScannerBeepStyle.Activate : RdioScannerBeepStyle.Activate
); );
@ -84,10 +85,7 @@ export class RdioScannerSelectComponent implements OnDestroy {
} }
private eventHandler(event: RdioScannerEvent): void { private eventHandler(event: RdioScannerEvent): void {
if (event.config) { if (event.config) this.systems = event.config.systems;
this.tagsToggle = event.config.tagsToggle;
this.systems = event.config.systems;
}
if (event.categories) this.categories = event.categories; if (event.categories) this.categories = event.categories;
if (event.map) this.map = event.map; if (event.map) this.map = event.map;
} }

View file

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

View file

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

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