atmosx-nwws-parser 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +88 -0
- package/bootstrap.js +110 -0
- package/index.js +245 -0
- package/package.json +29 -0
- package/shapefiles/FireCounties.dbf +0 -0
- package/shapefiles/FireCounties.shp +0 -0
- package/shapefiles/FireZones.dbf +0 -0
- package/shapefiles/FireZones.shp +0 -0
- package/shapefiles/ForecastZones.dbf +0 -0
- package/shapefiles/ForecastZones.shp +0 -0
- package/shapefiles/Marine.dbf +0 -0
- package/shapefiles/Marine.shp +0 -0
- package/shapefiles/OffShoreZones.dbf +0 -0
- package/shapefiles/OffShoreZones.shp +0 -0
- package/shapefiles/USCounties.dbf +0 -0
- package/shapefiles/USCounties.shp +0 -0
- package/src/events.js +322 -0
- package/src/stanza.js +103 -0
- package/src/text.js +108 -0
- package/src/ugc.js +115 -0
- package/src/vtec.js +89 -0
- package/test.js +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Copyright (c) 2025 k3yomi
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to use,
|
|
5
|
+
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
|
6
|
+
and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
7
|
+
|
|
8
|
+
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
9
|
+
2. The author of the software must be credited in all copies or substantial portions of the Software, including but not limited to documentation, README files, and source code comments.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
12
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
13
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
14
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
15
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
16
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
17
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# AtmosphericX - NOAA Weather Wire Service Parser
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
This repository contains the primary parser for AtmosphericX's NOAA Weather Wire Service parser. It is designed to handle real time weather alerts and messages from the National Weather Service using XMPP. If you do not know basic programming, this parser is not for you. It is intended for developers who want to integrate alerts from NOAA Weather Wire easily into their applications or services without hassle. If you wish to use NWWS without programming, feel free to use our project where most of this code was used - [AtmosphericX](https://github.com/k3yomi/AtmosphericX).
|
|
5
|
+
|
|
6
|
+
## Installation Guide
|
|
7
|
+
To install this package, you can use **NPM** (Node Package Manager). Open your terminal and run the following command:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install atmosx-nwws-parser
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
```js
|
|
15
|
+
const AtmosXWireParser = require(`atmosx-nwws-parser`); // or require(`@k3y0mi/nwws-parser`);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Configuration and Initialization
|
|
19
|
+
|
|
20
|
+
There are several settings you can configure when intializing the parser. Below is the test.js example that shows some of the settings you can use:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
let Client = new AtmosXWireParser({
|
|
24
|
+
alertSettings: {
|
|
25
|
+
onlyCap: false, // Set to true to only receive CAP messages only
|
|
26
|
+
betterEvents: true, // Set to true to receive better event handling
|
|
27
|
+
ugcPolygons: false, // Set to true to receive UGC Polygons instead of reading from raw products.
|
|
28
|
+
filteredAlerts: [] // Alerts you want to only log, leave empty to receive all alerts (Ex. ["Tornado Warning", "Radar Indicated Tornado Warning"])
|
|
29
|
+
},
|
|
30
|
+
xmpp: {
|
|
31
|
+
reconnect: true, // Set to true to enable automatic reconnection if you lose connection
|
|
32
|
+
reconnectInterval: 60, // Interval in seconds to attempt reconnection
|
|
33
|
+
},
|
|
34
|
+
cacheSettings: {
|
|
35
|
+
maxMegabytes: 2, // Maximum cache size in megabytes
|
|
36
|
+
readCache: false, // Set to true if you wish to reupload the cache from earlier
|
|
37
|
+
cacheDir: `./cache`, // Directory for cache files
|
|
38
|
+
},
|
|
39
|
+
authentication: {
|
|
40
|
+
username: `USERNAME_HERE`, // Your XMPP username
|
|
41
|
+
password: `PASSWORD_HERE`, // Your XMPP password
|
|
42
|
+
display: `DISPLAY_NAME` // Display name for your XMPP client
|
|
43
|
+
},
|
|
44
|
+
database: `./database.db`, // Path to the SQLite database file (It will be created if it doesn't exist and will be used to store UGC counties and zones.)
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
## Event Handling
|
|
50
|
+
|
|
51
|
+
You can handle various events emitted by the parser. Here are some examples:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
Client.onEvent(`onAlert`, (alerts: Array) => {});
|
|
55
|
+
Client.onEvent(`onStormReport`, (report: Object) => {});
|
|
56
|
+
Client.onEvent(`onMesoscaleDiscussion`, (discussion: Object) => {});
|
|
57
|
+
Client.onEvent(`onMessage`, (stanza: Object) => {});
|
|
58
|
+
Client.onEvent(`onOccupant`, (occupant: Object) => {});
|
|
59
|
+
Client.onEvent(`onError`, (error: Object) => {});
|
|
60
|
+
Client.onEvent(`onReconnect`, (service: Object) => {});
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Functions and Methods
|
|
65
|
+
You can also use various functions provided by the parser. Here are some examples:
|
|
66
|
+
```js
|
|
67
|
+
// Debugging function to create your own alert manually
|
|
68
|
+
Client.forwardCustomStanza(stanza: String, attributes: Object);
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```js
|
|
72
|
+
// Function to set the display name of the XMPP client (Will only set upon reconnect)
|
|
73
|
+
Client.setDisplayName(displayName: String);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Error Handling
|
|
77
|
+
The parser can emit various errors. Here are some common errors you might encounter:
|
|
78
|
+
|
|
79
|
+
**not-authorized**: This error occurs when the parser is not authorized to connect to the XMPP server. Ensure that your username and password are correct.
|
|
80
|
+
|
|
81
|
+
**unreachable-host**: This error indicates that the parser cannot reach the XMPP server. Check your internet connection and ensure that the server address is correct.
|
|
82
|
+
|
|
83
|
+
**service-error**: This error occurs when there is an issue with the XMPP service. It could be due to server maintenance or other issues. You can try reconnecting after some time.
|
|
84
|
+
|
|
85
|
+
**no-database-dir**: This error occurs when the database directory does not exist. Ensure that the directory specified in the `database` setting exists or create it before running the parser.
|
|
86
|
+
|
|
87
|
+
## Credits
|
|
88
|
+
This parser is developed and maintained by [K3YOMI](https://github.com/K3YOMI) and the AtmosphericX Team. It is open-source and available for contributions and improvements. If you find any issues or have suggestions, feel free to open an issue or submit a pull request in the repository.
|
package/bootstrap.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
cache: {},
|
|
3
|
+
settings: {},
|
|
4
|
+
static: {},
|
|
5
|
+
packages: {},
|
|
6
|
+
definitions: {}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
module.exports.packages = {
|
|
10
|
+
fs: require(`fs`),
|
|
11
|
+
path: require(`path`),
|
|
12
|
+
events: require(`events`),
|
|
13
|
+
xmpp: require(`@xmpp/client`),
|
|
14
|
+
shapefile: require(`shapefile`),
|
|
15
|
+
xml2js: require(`xml2js`),
|
|
16
|
+
sqlite3: require(`better-sqlite3`),
|
|
17
|
+
mStanza: require(`./src/stanza.js`),
|
|
18
|
+
mVtec: require(`./src/vtec.js`),
|
|
19
|
+
mUGC: require(`./src/ugc.js`),
|
|
20
|
+
mText: require(`./src/text.js`),
|
|
21
|
+
mEvents: require(`./src/events.js`),
|
|
22
|
+
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports.definitions = {
|
|
26
|
+
events: { "AF": "Ashfall", "AS": "Air Stagnation", "BH": "Beach Hazard", "BW": "Brisk Wind", "BZ": "Blizzard", "CF": "Coastal Flood", "DF": "Debris Flow", "DS": "Dust Storm", "EC": "Extreme Cold", "EH": "Excessive Heat", "XH": "Extreme Heat", "EW": "Extreme Wind", "FA": "Areal Flood", "FF": "Flash Flood", "FG": "Dense Fog", "FL": "Flood", "FR": "Frost", "FW": "Fire Weather", "FZ": "Freeze", "GL": "Gale", "HF": "Hurricane Force Wind", "HT": "Heat", "HU": "Hurricane", "HW": "High Wind", "HY": "Hydrologic", "HZ": "Hard Freeze", "IS": "Ice Storm", "LE": "Lake Effect Snow", "LO": "Low Water", "LS": "Lakeshore Flood", "LW": "Lake Wind", "MA": "Special Marine", "MF": "Dense Fog", "MH": "Ashfall", "MS": "Dense Smoke", "RB": "Small Craft for Rough Bar", "RP": "Rip Current Risk", "SC": "Small Craft", "SE": "Hazardous Seas", "SI": "Small Craft for Winds", "SM": "Dense Smoke", "SQ": "Snow Squall", "SR": "Storm", "SS": "Storm Surge", "SU": "High Surf", "SV": "Severe Thunderstorm", "SW": "Small Craft for Hazardous Seas", "TO": "Tornado", "TR": "Tropical Storm", "TS": "Tsunami", "TY": "Typhoon", "UP": "Heavy Freezing Spray", "WC": "Wind Chill", "WI": "Wind", "WS": "Winter Storm", "WW": "Winter Weather", "ZF": "Freezing Fog", "ZR": "Freezing Rain", "ZY": "Freezing Spray" },
|
|
27
|
+
actions: { "W": "Warning", "F": "Forecast", "A": "Watch", "O": "Outlook", "Y": "Advisory", "N": "Synopsis", "S": "Statement"},
|
|
28
|
+
status: { "NEW": "Issued", "CON": "Updated", "EXT": "Extended", "EXA": "Extended", "EXB": "Extended", "UPG": "Upgraded", "COR": "Correction", "ROU": "Routine", "CAN": "Cancelled", "EXP": "Expired" },
|
|
29
|
+
awips: { SWOMCD: `mesoscale-discussion`, LSR: `local-storm-report`, SPS: `special-weather-statement`, LSR: "local-storm-report"},
|
|
30
|
+
expressions: {
|
|
31
|
+
vtec: `[OTEX].(NEW|CON|EXT|EXA|EXB|UPG|CAN|EXP|COR|ROU).[A-Z]{4}.[A-Z]{2}.[WAYSFON].[0-9]{4}.[0-9]{6}T[0-9]{4}Z-[0-9]{6}T[0-9]{4}Z`,
|
|
32
|
+
wmo: `[A-Z0-9]{6}\\s[A-Z]{4}\\s\\d{6}`,
|
|
33
|
+
ugc1: `(\\w{2}[CZ](\\d{3}((-|>)\\s?(\n\n)?))+)`,
|
|
34
|
+
ugc2: `(\\d{6}(-|>)\\s?(\n\n)?)`,
|
|
35
|
+
dateline: `/\d{3,4}\s*(AM|PM)?\s*[A-Z]{2,4}\s+[A-Z]{3,}\s+[A-Z]{3,}\s+\d{1,2}\s+\d{4}`
|
|
36
|
+
},
|
|
37
|
+
tags: {
|
|
38
|
+
"A LARGE AND EXTREMELY DANGEROUS TORNADO": "Large and Dangerous Tornado",
|
|
39
|
+
"THIS IS A PARTICULARLY DANGEROUS SITUATION": "Particularly Dangerous Situation",
|
|
40
|
+
"RADAR INDICATED ROTATION": "Radar Indicated Tornado",
|
|
41
|
+
"WEATHER SPOTTERS CONFIRMED TORNADO": "Confirmed by Storm Spotters",
|
|
42
|
+
"A SEVERE THUNDERSTORM CAPABLE OF PRODUCING A TORNADO": "Developing Tornado",
|
|
43
|
+
"LAW ENFORCEMENT CONFIRMED TORNADO": "Reported by Law Enforcement",
|
|
44
|
+
"A TORNADO IS ON THE GROUND": "Confirmed Tornado",
|
|
45
|
+
"WEATHER SPOTTERS REPORTED FUNNEL CLOUD": "Confirmed Funnel Cloud by Storm Spotters",
|
|
46
|
+
"PUBLIC CONFIRMED TORNADO": "Public reports of Tornado",
|
|
47
|
+
"RADAR CONFIRMED": "Radar Confirmed",
|
|
48
|
+
"TORNADO WAS REPORTED BRIEFLY ON THE GROUND": "Tornado no longer on ground",
|
|
49
|
+
"SPOTTERS INDICATE THAT A FUNNEL CLOUD CONTINUES WITH THIS STORM": "Funnel Cloud Continues",
|
|
50
|
+
"A TORNADO MAY DEVELOP AT ANY TIME": "Potentional still exists for Tornado to form",
|
|
51
|
+
"LIFE-THREATENING SITUATION": "Life Threating Situation",
|
|
52
|
+
"COMPLETE DESTRUCTION IS POSSIBLE": "Extremly Damaging Tornado",
|
|
53
|
+
"POTENTIALLY DEADLY TORNADO": "Deadly Tornado",
|
|
54
|
+
"RADAR INDICATED": "Radar Indicated",
|
|
55
|
+
"HAIL DAMAGE TO VEHICLES IS EXPECTED": "Damaging to Vehicles",
|
|
56
|
+
"EXPECT WIND DAMAGE": "Wind Damage",
|
|
57
|
+
"FREQUENT LIGHTNING": "Frequent Lightning",
|
|
58
|
+
"PEOPLE AND ANIMALS OUTDOORS WILL BE INJURED": "Capable of Injuring People and Animals",
|
|
59
|
+
"TRAINED WEATHER SPOTTERS": "Confirmed by Storm Spotters",
|
|
60
|
+
"SOURCE...PUBLIC": "Confirmed by Public",
|
|
61
|
+
"SMALL CRAFT COULD BE DAMAGED": "Potential Damage to Small Craft",
|
|
62
|
+
"A TORNADO WATCH REMAINS IN EFFECT": "Active Tornado Watch",
|
|
63
|
+
"TENNIS BALL SIZE HAIL": "Tennis Ball Size Hail",
|
|
64
|
+
"BASEBALL SIZE HAIL": "Baseball Size Hail",
|
|
65
|
+
"GOLF BALL SIZE HAIL": "Golf Ball Size Hail",
|
|
66
|
+
"QUARTER SIZE HAIL": "Quarter Size Hail",
|
|
67
|
+
"PING PONG BALL SIZE HAIL": "Ping Pong Ball Size Hail",
|
|
68
|
+
"NICKEL SIZE HAIL": "Nickel Size Hail",
|
|
69
|
+
"DOPPLER RADAR.": "Confirmed by Radar",
|
|
70
|
+
"DOPPLER RADAR AND AUTOMATED GAUGES.": "Confirmed by Radar and Gauges",
|
|
71
|
+
"FLASH FLOODING CAUSED BY THUNDERSTORMS.": "Caused by Thunderstorm",
|
|
72
|
+
"SOURCE...EMERGENCY MANAGEMENT.": "Confirmed by Emergency Management",
|
|
73
|
+
"FLASH FLOODING CAUSED BY HEAVY RAIN.": "Caused by heavy rain",
|
|
74
|
+
"SOURCE...LAW ENFORCEMENT REPORTED.": "Confirmed by Law Enforcement"
|
|
75
|
+
},
|
|
76
|
+
haultingConditions: [
|
|
77
|
+
{ error: "not-authorized", message: "You do not have the proper credentials to access this service.", code: "credential-error"},
|
|
78
|
+
{ error: "unreachable-host", message: "The host could not be reached. Please check your internet connection or the host address.", code: "xmpp-error" },
|
|
79
|
+
{ error: "service-error", message: "An error occurred while connecting to the NOAA Weather Wire Service. Please try again later.", code: "xmpp-error" },
|
|
80
|
+
{ error: "no-database-dir", message: "Database directory is not set. Please set the databaseDir in the metadata.", code: "no-database" },
|
|
81
|
+
{ error: "rapid-reconnect", message: "The client is reconnecting too rapidly. Please wait a moment before trying again.", code: "xmpp-error" }
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
module.exports.settings = {
|
|
85
|
+
alertSettings: {
|
|
86
|
+
ugcPolygons: false,
|
|
87
|
+
onlyCap: false,
|
|
88
|
+
betterEvents: false,
|
|
89
|
+
filteredAlerts: [],
|
|
90
|
+
},
|
|
91
|
+
xmpp: {
|
|
92
|
+
reconnect: true,
|
|
93
|
+
reconnectInterval: 60,
|
|
94
|
+
},
|
|
95
|
+
cacheSettings: {
|
|
96
|
+
readCache: false,
|
|
97
|
+
maxMegabytes: 1,
|
|
98
|
+
cacheDir: false,
|
|
99
|
+
},
|
|
100
|
+
database: module.exports.packages.path.join(process.cwd(), 'shapefiles.db'),
|
|
101
|
+
};
|
|
102
|
+
module.exports.cache = {
|
|
103
|
+
lastStanza: new Date().getTime(),
|
|
104
|
+
session: null,
|
|
105
|
+
isConnected: false,
|
|
106
|
+
attemptingReconnect: false,
|
|
107
|
+
totalReconnects: 0
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
module.exports.static.events = new module.exports.packages.events.EventEmitter();
|
package/index.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| '_ ` _ \ / _ \/ __| '_ \| '_ \ / _ \ '__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: k3yomi@GitHub
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let loader = require(`./bootstrap.js`);
|
|
15
|
+
|
|
16
|
+
class NoaaWeatherWireServiceCore {
|
|
17
|
+
constructor(metadata={}) {
|
|
18
|
+
this.packages = loader.packages;
|
|
19
|
+
this.metadata = metadata;
|
|
20
|
+
loader.settings = { ...loader.settings, ...metadata };
|
|
21
|
+
process.on('uncaughtException', (error) => {
|
|
22
|
+
const hault = loader.definitions.haultingConditions.find(e => error.message.includes(e.error));
|
|
23
|
+
if (hault) { loader.static.events.emit(`onError`, {error: `${hault ? hault.message : error.message}`, code: hault.code}); return; }
|
|
24
|
+
loader.static.events.emit(`onError`, { error: error.stack || error.message || `An unknown error occurred`, code: `uncaught-exception` });
|
|
25
|
+
});
|
|
26
|
+
this.initializeDatabase([{ id: `C`, file: `USCounties` }, { id: `Z`, file: `ForecastZones` }, { id: `Z`, file: `FireZones` }, { id: `Z`, file: `OffShoreZones` }, { id: `Z`, file: `FireCounties` }, { id: `Z`, file: `Marine` }]);
|
|
27
|
+
|
|
28
|
+
if (loader.settings.cacheSettings.readCache && loader.settings.cacheSettings.cacheDir) {
|
|
29
|
+
let target = `${loader.settings.cacheSettings.cacheDir}/nwws-raw-category-defaults-raw-vtec.bin`;
|
|
30
|
+
if (loader.packages.fs.existsSync(target)) {
|
|
31
|
+
this.forwardCustomStanza(loader.packages.fs.readFileSync(target, 'utf8'), { awipsid: 'alert', category: 'default', raw: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setInterval(() => {
|
|
36
|
+
if (loader.settings.cacheSettings.cacheDir) { this.garbageCollect(loader.settings.cacheSettings.maxMegabytes || 1); }
|
|
37
|
+
if (loader.settings.xmpp.reconnect) { this.isReconnectEligible(loader.settings.xmpp.reconnectInterval) }
|
|
38
|
+
}, 1 * 1000);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @function initializeDatabase
|
|
43
|
+
* @description Initializes the SQLite database and creates the shapefiles table if it doesn't exist.
|
|
44
|
+
* This also will read the shapefiles from the specified directory and insert them into the database.
|
|
45
|
+
*
|
|
46
|
+
* @param {Array} shapefiles - An array of shapefile objects containing `id` and `file` properties.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
initializeDatabase = async function(shapefiles = []) {
|
|
50
|
+
let { fs, path, sqlite3, shapefile } = loader.packages;
|
|
51
|
+
if (!fs.existsSync(loader.settings.database)) {
|
|
52
|
+
fs.writeFileSync(loader.settings.database, '', 'utf8');
|
|
53
|
+
}
|
|
54
|
+
let db = new sqlite3(loader.settings.database);
|
|
55
|
+
let tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='shapefiles'`).get();
|
|
56
|
+
if (!tableExists) {
|
|
57
|
+
db.prepare(`CREATE TABLE shapefiles (id TEXT PRIMARY KEY, location TEXT, geometry TEXT)`).run();
|
|
58
|
+
console.log(`\n\n[NOTICE] DO NOT CLOSE THIS PROJECT UNTIL THE SHAPEFILES ARE DONE COMPLETING!\n` +
|
|
59
|
+
`\t THIS COULD TAKE A WHILE DEPENDING ON THE SPEED OF YOUR STORAGE!!\n` +
|
|
60
|
+
`\t IF YOU CLOSE YOUR PROJECT, THE SHAPEFILES WILL NOT BE CREATED AND YOU WILL NEED TO DELETE ${loader.settings.database} AND RESTART TO CREATE THEM AGAIN!\n\n`);
|
|
61
|
+
for (let shapefileEntry of shapefiles) {
|
|
62
|
+
let { file, id: type } = shapefileEntry;
|
|
63
|
+
let filePath = path.join(__dirname, 'shapefiles', file);
|
|
64
|
+
let { features } = await shapefile.read(filePath, filePath);
|
|
65
|
+
console.log(`Creating ${file} shapefile...`);
|
|
66
|
+
for (let feature of features) {
|
|
67
|
+
let { properties, geometry } = feature;
|
|
68
|
+
let id, location;
|
|
69
|
+
if (properties.FIPS) {
|
|
70
|
+
id = `${properties.STATE}${type}${properties.FIPS.substring(2)}`; location = `${properties.COUNTYNAME}, ${properties.STATE}`;
|
|
71
|
+
} else if (properties.STATE) {
|
|
72
|
+
id = `${properties.STATE}${type}${properties.ZONE}`; location = `${properties.NAME}, ${properties.STATE}`;
|
|
73
|
+
} else if (properties.FULLSTAID) {
|
|
74
|
+
id = `${properties.ST}${type}-NoZone`; location = `${properties.NAME}, ${properties.STATE}`;
|
|
75
|
+
} else {
|
|
76
|
+
id = properties.ID; location = properties.NAME;
|
|
77
|
+
}
|
|
78
|
+
await db.prepare(`INSERT OR REPLACE INTO shapefiles (id, location, geometry) VALUES (?, ?, ?)`).run(id, location, JSON.stringify(geometry));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
console.log(`Shapefiles created successfully!`);
|
|
82
|
+
}
|
|
83
|
+
loader.static.db = db;
|
|
84
|
+
this.initializeSession();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @function initializeClient
|
|
89
|
+
* @description Authenticates the XMPP session for NOAA Weather Wire Service with the provided metadata.
|
|
90
|
+
*
|
|
91
|
+
* @param {Object} metadata - The metadata object containing authentication details.
|
|
92
|
+
* @param {string} metadata.username - The username for the XMPP session.
|
|
93
|
+
* @param {string} metadata.password - The password for the XMPP session.
|
|
94
|
+
* @param {string} [metadata.display] - The display name for the XMPP session (optional).
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
initializeClient = function(metadata = {}) {
|
|
98
|
+
if (loader.settings.database == null) { throw new Error(`no-database-dir`); }
|
|
99
|
+
loader.static.session = loader.packages.xmpp.client({
|
|
100
|
+
service: `xmpp://nwws-oi.weather.gov`,
|
|
101
|
+
domain: `nwws-oi.weather.gov`,
|
|
102
|
+
username: metadata.username || ``,
|
|
103
|
+
password: metadata.password || ``,
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @function initializeSession
|
|
109
|
+
* @description Creates a new XMPP session for NOAA Weather Wire Service and sets up event listeners for connection and stanza handling.
|
|
110
|
+
* Also handles reconnection logic if the session is disconnected.
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
initializeSession = function() {
|
|
114
|
+
if (this.metadata.authentication.display == undefined) this.metadata.authentication.display = this.metadata.authentication.username || ``;
|
|
115
|
+
this.initializeClient({ username: this.metadata.authentication.username, password: this.metadata.authentication.password, display: this.metadata.authentication.display });
|
|
116
|
+
loader.static.session.on(`online`, async () => {
|
|
117
|
+
if (loader.static.lastConnect && (new Date().getTime() - loader.static.lastConnect) < 10 * 1000) {
|
|
118
|
+
setTimeout(async () => {
|
|
119
|
+
await loader.static.session.stop().catch(() => {});
|
|
120
|
+
await loader.static.session.start().catch(() => {});
|
|
121
|
+
}, 2 * 1000);
|
|
122
|
+
throw new Error(`rapid-reconnect`);
|
|
123
|
+
}
|
|
124
|
+
loader.static.lastConnect = new Date().getTime();
|
|
125
|
+
loader.cache.isConnected = true;
|
|
126
|
+
loader.static.session.send(loader.packages.xmpp.xml('presence', { to: `nwws@conference.nwws-oi.weather.gov/${this.metadata.authentication.display}`, xmlns: 'http://jabber.org/protocol/muc' }))
|
|
127
|
+
loader.static.session.send(loader.packages.xmpp.xml('presence', { to: `nwws@conference.nwws-oi.weather.gov`, type: 'available' }))
|
|
128
|
+
loader.static.events.emit(`onConnection`, this.metadata.authentication.display);
|
|
129
|
+
if (loader.cache.attemptingReconnect) {
|
|
130
|
+
setTimeout(() => { loader.cache.attemptingReconnect = false; }, 15 * 1000);
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
loader.static.session.on(`offline`, () => {
|
|
134
|
+
loader.static.session.stop().catch(() => {});
|
|
135
|
+
loader.cache.isConnected = false;
|
|
136
|
+
throw new Error(`unreachable-host`);
|
|
137
|
+
})
|
|
138
|
+
loader.static.session.on(`error`, async (error) => {
|
|
139
|
+
throw new Error(error.message || `service-error`);
|
|
140
|
+
})
|
|
141
|
+
loader.static.session.on(`stanza`, (stanza) => {
|
|
142
|
+
loader.cache.lastStanza = new Date().getTime();
|
|
143
|
+
if (stanza.is('message')) {
|
|
144
|
+
let validateStanza = loader.packages.mStanza.newStanza(stanza)
|
|
145
|
+
if (validateStanza.ignore
|
|
146
|
+
|| (validateStanza.isCap && !loader.settings.alertSettings.onlyCap)
|
|
147
|
+
|| (!validateStanza.isCap && loader.settings.alertSettings.onlyCap)
|
|
148
|
+
|| (validateStanza.isCap && !validateStanza.hasCapArea)) return;
|
|
149
|
+
loader.packages.mStanza.createNewAlert(validateStanza);
|
|
150
|
+
loader.static.events.emit(`onMessage`, validateStanza);
|
|
151
|
+
}
|
|
152
|
+
if (stanza.is('presence') && stanza.attrs.from && stanza.attrs.from.startsWith('nwws@conference.nwws-oi.weather.gov/')) {
|
|
153
|
+
let occupant = stanza.attrs.from.split('/').slice(1).join('/');
|
|
154
|
+
loader.static.events.emit('onOccupant', { occupant, type: stanza.attrs.type === 'unavailable' ? 'unavailable' : 'available' });
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
loader.static.session.start();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* @function isReconnectEligible
|
|
162
|
+
* @description Checks if the session is eligible for reconnection based on the last stanza time
|
|
163
|
+
* and attempts to reconnect if necessary.
|
|
164
|
+
*
|
|
165
|
+
* @param {number} [minSeconds=60] - The minimum number of seconds since the last stanza to consider reconnection.
|
|
166
|
+
*/
|
|
167
|
+
|
|
168
|
+
isReconnectEligible = async function(minSeconds=60) {
|
|
169
|
+
if (loader.cache.isConnected && loader.static.session) {
|
|
170
|
+
let lastStanza = new Date().getTime() - loader.cache.lastStanza;
|
|
171
|
+
if (lastStanza > minSeconds * 1000) {
|
|
172
|
+
if (!loader.cache.attemptingReconnect) {
|
|
173
|
+
loader.cache.attemptingReconnect = true;
|
|
174
|
+
loader.cache.isConnected = false;
|
|
175
|
+
loader.cache.totalReconnects += 1;
|
|
176
|
+
loader.static.events.emit(`onReconnect`, { reconnects: loader.cache.totalReconnects, lastStanza: lastStanza / 1000, lastName: this.metadata.authentication.display});
|
|
177
|
+
await loader.static.session.stop().catch(() => {});
|
|
178
|
+
await loader.static.session.start().catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return { message: `Session is not connected or session is not available`, isConnected: loader.cache.isConnected, session: loader.static.session };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @function setDisplayName
|
|
187
|
+
* @description Sets the display name for the XMPP session.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} displayName - The display name to set for the XMPP session.
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
setDisplayName = async function(displayName) {
|
|
193
|
+
this.metadata.authentication.display = displayName;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* @function forwardCustomStanza
|
|
198
|
+
* @description Forwards a custom stanza message to the appropriate event handler.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} message - The custom message to forward.
|
|
201
|
+
* @param {object} attributes - The attributes of the custom message.
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
forwardCustomStanza = function(stanza, attrs) {
|
|
205
|
+
let validateStanza = loader.packages.mStanza.newStanza(stanza, { stanza, attrs });
|
|
206
|
+
loader.packages.mStanza.createNewAlert(validateStanza);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @function garbageCollect
|
|
211
|
+
* @description Deletes files in the database directory that exceed a specified size limit.
|
|
212
|
+
*
|
|
213
|
+
* @param {number} [maxMegabytes=1] - The maximum size in megabytes for files to keep. Files larger than this will be deleted.
|
|
214
|
+
*/
|
|
215
|
+
|
|
216
|
+
garbageCollect = function(maxMegabytes=1) {
|
|
217
|
+
if (!loader.settings.cacheSettings.cacheDir) return;
|
|
218
|
+
let maxBytes = maxMegabytes * 1024 * 1024, directory = loader.settings.cacheSettings.cacheDir
|
|
219
|
+
let stackFiles = [directory], files = []
|
|
220
|
+
while (stackFiles.length) {
|
|
221
|
+
let currentDirectory = stackFiles.pop();
|
|
222
|
+
loader.packages.fs.readdirSync(currentDirectory).forEach(file => {
|
|
223
|
+
let filePath = loader.packages.path.join(currentDirectory, file);
|
|
224
|
+
loader.packages.fs.statSync(filePath).isDirectory() ? stackFiles.push(filePath) : files.push({ file: filePath, size: loader.packages.fs.statSync(filePath).size });
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (!files.length) return;
|
|
228
|
+
files.forEach(({ file, size }) => { if (size > maxBytes) { loader.packages.fs.unlinkSync(file); } });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* @function onEvent
|
|
234
|
+
* @description Registers an event listener for the specified event.
|
|
235
|
+
* @param {string} event - The name of the event to listen for.
|
|
236
|
+
* @param {function} listener - The callback function to execute when the event is emitted.
|
|
237
|
+
*/
|
|
238
|
+
|
|
239
|
+
onEvent = function(event, listener) {
|
|
240
|
+
loader.static.events.on(event, listener)
|
|
241
|
+
return () => { loader.static.events.off(event, listener); };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = NoaaWeatherWireServiceCore;
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "atmosx-nwws-parser",
|
|
3
|
+
"version": "1.0.16",
|
|
4
|
+
"description": "NOAA Weather Wire Parser",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node test.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/K3YOMI/atmosx-nwws-parser.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"NWWS",
|
|
15
|
+
"NOAA Weather Wire Service"
|
|
16
|
+
],
|
|
17
|
+
"author": "K3YOMI, Starflight",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/K3YOMI/atmosx-nwws-parser/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/K3YOMI/atmosx-nwws-parser#readme",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@xmpp/client": "^0.7.3",
|
|
25
|
+
"better-sqlite3": "^11.10.0",
|
|
26
|
+
"shapefile": "^0.6.6",
|
|
27
|
+
"xml2js": "^0.6.2"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/src/events.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| '_ ` _ \ / _ \/ __| '_ \| '_ \ / _ \ '__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: k3yomi@GitHub
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let loader = require(`../bootstrap.js`);
|
|
15
|
+
|
|
16
|
+
class NoaaWeatherWireServiceEvents {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @function onEnhanced
|
|
20
|
+
* @description Enhances the event details based on the properties of the event.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} event - The event object containing properties to enhance.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
onEnhanced = function(event) {
|
|
26
|
+
let tags = [`No tags found`];
|
|
27
|
+
let eventName = event.properties.event
|
|
28
|
+
let dmgTheat = event.properties.parameters.thunderstormDamageThreat?.[0] || event.properties.parameters.tornadoDamageThreat?.[0] || `N/A`;
|
|
29
|
+
let description = event.properties.description.toLowerCase() || `No description available.`;
|
|
30
|
+
if (description.includes(`flash flood emergency`) && eventName == `Flash Flood Warning`) eventName = `Flash Flood Emergency`;
|
|
31
|
+
if (description.includes(`particularly dangerous situation`) && eventName == `Tornado Warning` && dmgTheat == `CONSIDERABLE`) eventName = `Particularly Dangerous Situation Tornado Warning`;
|
|
32
|
+
if (description.includes(`particularly dangerous situation`) && eventName == `Tornado Watch`) eventName = `Particularly Dangerous Situation Tornado Watch`;
|
|
33
|
+
if (description.includes(`extremely dangerous situation`) && eventName == `Severe Thunderstorm Warning`) eventName = `Extremely Dangerous Situation Severe Thunderstorm Warning`;
|
|
34
|
+
if (description.includes(`tornado emergency`) && eventName == `Tornado Warning` && dmgTheat == `CATASTROPHIC`) eventName = `Tornado Emergency`;
|
|
35
|
+
|
|
36
|
+
if (eventName == `Tornado Warning`) {
|
|
37
|
+
eventName = `Radar Indicated Tornado Warning`;
|
|
38
|
+
if (event.properties.parameters.tornadoDetection == `RADAR INDICATED`) eventName = `Radar Indicated Tornado Warning`;
|
|
39
|
+
if (event.properties.parameters.tornadoDetection == `OBSERVED`) eventName = `Confirmed Tornado Warning`;
|
|
40
|
+
}
|
|
41
|
+
if (eventName == `Severe Thunderstorm Warning`) {
|
|
42
|
+
if (dmgTheat == `CONSIDERABLE`) eventName = `Considerable Severe Thunderstorm Warning`;
|
|
43
|
+
if (dmgTheat == `DESTRUCTIVE`) eventName = `Destructive Severe Thunderstorm Warning`;
|
|
44
|
+
}
|
|
45
|
+
if (eventName == `Flash Flood Warning`) {
|
|
46
|
+
if (dmgTheat == `CONSIDERABLE`) eventName = `Considerable Flash Flood Warning`;
|
|
47
|
+
}
|
|
48
|
+
for (let [key, value] of Object.entries(loader.definitions.tags)) {
|
|
49
|
+
if (event.properties.description.toLowerCase().includes(key.toLowerCase())) {
|
|
50
|
+
tags = tags.includes(`No tags found`) ? [] : tags;
|
|
51
|
+
if (!tags.includes(value)) tags.push(value);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { event: eventName, tags: tags };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @function onFinished
|
|
59
|
+
* @description onFinishedes the alerts and emits an event with enhanced event details.
|
|
60
|
+
*
|
|
61
|
+
* @param {Array} alerts - An array of alert objects to be onFinisheded.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
onFinished = function(alerts) {
|
|
65
|
+
if (loader.settings.alertSettings.betterEvents) {
|
|
66
|
+
for (let i = 0; i < alerts.length; i++) {
|
|
67
|
+
let {event, tags} = this.onEnhanced(alerts[i]);
|
|
68
|
+
alerts[i].properties.event = event;
|
|
69
|
+
alerts[i].properties.tags = tags;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (loader.settings.alertSettings.filteredAlerts && loader.settings.alertSettings.filteredAlerts.length > 0) {
|
|
73
|
+
alerts = alerts.filter(alert => loader.settings.alertSettings.filteredAlerts.includes(alert.properties.event));
|
|
74
|
+
}
|
|
75
|
+
if (alerts.length === 0) { return; }
|
|
76
|
+
loader.static.events.emit(`onAlert`, alerts);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @function newCapEvent
|
|
81
|
+
* @description Creates a new CAP event from the provided stanza.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
84
|
+
*/
|
|
85
|
+
|
|
86
|
+
newCapEvent = async function(stanza) {
|
|
87
|
+
let message = stanza.message.substring(stanza.message.indexOf(`<?xml version="1.0"`), stanza.message.length);
|
|
88
|
+
let data = loader.packages.xml2js.Parser();
|
|
89
|
+
let result = await data.parseStringPromise(message);
|
|
90
|
+
let tracking = result.alert.info[0].parameter.find(p => p.valueName[0] == "VTEC")?.value[0] || "N/A";
|
|
91
|
+
let action = "N/A";
|
|
92
|
+
if (tracking !== "N/A") {
|
|
93
|
+
let splitVTEC = tracking.split(".");
|
|
94
|
+
tracking = `${splitVTEC[2]}-${splitVTEC[3]}-${splitVTEC[4]}-${splitVTEC[5]}`;
|
|
95
|
+
action = loader.definitions.status[splitVTEC[1]];
|
|
96
|
+
} else {
|
|
97
|
+
action = result.alert.msgType[0];
|
|
98
|
+
tracking = `${result.alert.info[0].parameter.find(p => p.valueName[0] == "WMOidentifier")?.value[0]}-${result.alert.info[0].area[0].geocode.filter(g => g.valueName[0] == "UGC").map(g => g.value[0]).join("-")}`;
|
|
99
|
+
}
|
|
100
|
+
let alert = {
|
|
101
|
+
id: `Wire-${tracking}`,
|
|
102
|
+
tracking: tracking,
|
|
103
|
+
action: action,
|
|
104
|
+
history: [{ description: result.alert.info[0].description[0], action: action, issued: new Date(stanza.attributes.issue) }],
|
|
105
|
+
properties: {
|
|
106
|
+
areaDesc: result.alert.info[0].area[0].areaDesc[0],
|
|
107
|
+
expires: new Date(result.alert.info[0].expires[0]),
|
|
108
|
+
sent: new Date(result.alert.sent[0]),
|
|
109
|
+
messageType: action,
|
|
110
|
+
event: result.alert.info[0].event[0],
|
|
111
|
+
sender: result.alert.sender[0],
|
|
112
|
+
senderName: result.alert.info[0].senderName[0],
|
|
113
|
+
description: result.alert.info[0].description[0],
|
|
114
|
+
geocode: { UGC: result.alert.info[0].area[0].geocode.filter(g => g.valueName[0] == "UGC").map(g => g.value[0]) },
|
|
115
|
+
parameters: {
|
|
116
|
+
WMOidentifier: [result.alert.info[0].parameter.find(p => p.valueName[0] == "WMOidentifier")?.value[0] || "N/A"],
|
|
117
|
+
tornadoDetection: result.alert.info[0].parameter.find(p => p.valueName[0] == "tornadoDetection")?.value[0] || result.alert.info[0].parameter.find(p => p.valueName[0] == "waterspoutDetection")?.value[0] || "N/A",
|
|
118
|
+
maxHailSize: result.alert.info[0].parameter.find(p => p.valueName[0] == "maxHailSize")?.value[0] || "N/A",
|
|
119
|
+
maxWindGust: result.alert.info[0].parameter.find(p => p.valueName[0] == "maxWindGust")?.value[0] || "N/A",
|
|
120
|
+
thunderstormDamageThreat: [result.alert.info[0].parameter.find(p => p.valueName[0] == "thunderstormDamageThreat")?.value[0] || result.alert.info[0].parameter.find(p => p.valueName[0] == "tornadoDamageThreat")?.value[0] || "N/A"],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
if (result.alert.info[0].area[0].polygon) {
|
|
125
|
+
alert.geometry = { type: "Polygon", coordinates: [result.alert.info[0].area[0].polygon[0].split(" ").map(coord => { let [lat, lon] = coord.split(",").map(parseFloat); return [lon, lat]; })] };
|
|
126
|
+
}
|
|
127
|
+
this.onFinished([alert]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @function newRawProductEvent
|
|
132
|
+
* @description Creates a new raw product event from the provided stanza.
|
|
133
|
+
*
|
|
134
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
135
|
+
*/
|
|
136
|
+
|
|
137
|
+
newRawProductEvent = async function(stanza) {
|
|
138
|
+
let message = stanza.message.split(/(?=\$\$)/g).map(msg => msg.trim());
|
|
139
|
+
let defaultWMO = stanza.message.match(new RegExp(loader.definitions.expressions.wmo, 'gimu'));
|
|
140
|
+
let alerts = []
|
|
141
|
+
for (let msg of message) {
|
|
142
|
+
let startTime = new Date().getTime();
|
|
143
|
+
let mVtec = await loader.packages.mVtec.getVTEC(msg, stanza.attributes);
|
|
144
|
+
let mUgc = await loader.packages.mUGC.getUGC(msg);
|
|
145
|
+
if (mVtec && mUgc) {
|
|
146
|
+
for (let i = 0; i < mVtec.length; i++) {
|
|
147
|
+
let vtec = mVtec[i];
|
|
148
|
+
if (vtec.wmo) defaultWMO = vtec.wmo;
|
|
149
|
+
let getTornado = loader.packages.mText.getString(msg, `TORNADO...`) || loader.packages.mText.getString(msg, `WATERSPOUT...`)
|
|
150
|
+
let getHail = loader.packages.mText.getString(msg, `MAX HAIL SIZE...`, [`IN`]) || loader.packages.mText.getString(msg, `HAIL...`, [`IN`]);
|
|
151
|
+
let getGusts = loader.packages.mText.getString(msg, `MAX WIND GUST...`) || loader.packages.mText.getString(msg, `WIND...`);
|
|
152
|
+
let getThreat = loader.packages.mText.getString(msg, `DAMAGE THREAT...`);
|
|
153
|
+
let senderOffice = loader.packages.mText.getOffice(msg) || vtec.tracking.split(`-`)[0];
|
|
154
|
+
let getCoordinates = loader.packages.mText.getPolygonCoordinates(msg);
|
|
155
|
+
let getDescription = loader.packages.mText.getCleanDescription(msg, vtec);
|
|
156
|
+
let alert = {
|
|
157
|
+
hitch: `${new Date().getTime() - startTime}ms`,
|
|
158
|
+
id: `Wire-${vtec.tracking}`,
|
|
159
|
+
tracking: vtec.tracking,
|
|
160
|
+
action: vtec.status,
|
|
161
|
+
history: [{description: getDescription, action: vtec.status, issued: new Date(vtec.issued)}],
|
|
162
|
+
properties: {
|
|
163
|
+
areaDesc: mUgc.locations.join(`; `) || `N/A`,
|
|
164
|
+
expires: new Date(vtec.expires) == `Invalid Date` ? new Date(new Date().getTime() + 999999 * 60 * 60 * 1000) : new Date(vtec.expires),
|
|
165
|
+
sent: new Date(vtec.issued),
|
|
166
|
+
messageType: vtec.status,
|
|
167
|
+
event: vtec.event || `Unknown Event`,
|
|
168
|
+
sender: senderOffice,
|
|
169
|
+
senderName: `${senderOffice}`,
|
|
170
|
+
description: getDescription,
|
|
171
|
+
geocode: {
|
|
172
|
+
UGC: mUgc.zones,
|
|
173
|
+
},
|
|
174
|
+
parameters: {
|
|
175
|
+
WMOidentifier: vtec.wmo?.[0] ? [vtec.wmo[0]] : defaultWMO?.[0] ? [defaultWMO[0]] : [`N/A`],
|
|
176
|
+
tornadoDetection: getTornado || `N/A`,
|
|
177
|
+
maxHailSize: getHail || `N/A`,
|
|
178
|
+
maxWindGust: getGusts || `N/A`,
|
|
179
|
+
thunderstormDamageThreat: [getThreat || `N/A`],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
geometry: { type: `Polygon`, coordinates: [getCoordinates] }
|
|
183
|
+
}
|
|
184
|
+
if (loader.settings.alertSettings.ugcPolygons) {
|
|
185
|
+
let coordinates = await loader.packages.mUGC.getCoordinates(mUgc.zones);
|
|
186
|
+
if (coordinates.length > 0) {
|
|
187
|
+
alert.geometry.coordinates = [coordinates];
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
alerts.push(alert);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
this.onFinished(alerts);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @function newSpecialEvent
|
|
199
|
+
* @description Creates a new special weather statement event from the provided stanza.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
202
|
+
*/
|
|
203
|
+
|
|
204
|
+
newSpecialEvent = async function(stanza) {
|
|
205
|
+
let message = stanza.message.split(/(?=\$\$)/g).map(msg => msg.trim());
|
|
206
|
+
let defaultWMO = stanza.message.match(new RegExp(loader.definitions.expressions.wmo, 'gimu'));
|
|
207
|
+
let alerts = [];
|
|
208
|
+
for (let msg of message) {
|
|
209
|
+
let startTime = new Date().getTime();
|
|
210
|
+
let mUgc = await loader.packages.mUGC.getUGC(msg);
|
|
211
|
+
if (mUgc) {
|
|
212
|
+
let getTornado = loader.packages.mText.getString(msg, `TORNADO...`) || loader.packages.mText.getString(msg, `WATERSPOUT...`)
|
|
213
|
+
let getHail = loader.packages.mText.getString(msg, `MAX HAIL SIZE...`, [`IN`]) || loader.packages.mText.getString(msg, `HAIL...`, [`IN`]);
|
|
214
|
+
let getGusts = loader.packages.mText.getString(msg, `MAX WIND GUST...`) || loader.packages.mText.getString(msg, `WIND...`);
|
|
215
|
+
let getThreat = loader.packages.mText.getString(msg, `DAMAGE THREAT...`);
|
|
216
|
+
let senderOffice = loader.packages.mText.getOffice(msg) || `NWS`;
|
|
217
|
+
let getCoordinates = loader.packages.mText.getPolygonCoordinates(msg);
|
|
218
|
+
let getDescription = loader.packages.mText.getCleanDescription(msg, null);
|
|
219
|
+
let alert = {
|
|
220
|
+
hitch: `${new Date().getTime() - startTime}ms`,
|
|
221
|
+
id: `Wire-${defaultWMO ? defaultWMO[0] : `N/A`}-${mUgc.zones.join(`-`)}`,
|
|
222
|
+
tracking: `${defaultWMO ? defaultWMO[0] : `N/A`}-${mUgc.zones.join(`-`)}`,
|
|
223
|
+
action: `Issued`,
|
|
224
|
+
history: [{description: getDescription, action: `Issued`, issued: new Date(stanza.attributes.issue)}],
|
|
225
|
+
properties: {
|
|
226
|
+
areaDesc: mUgc.locations.join(`; `) || `N/A`,
|
|
227
|
+
expires: new Date(new Date().getTime() + 1 * 60 * 60 * 1000),
|
|
228
|
+
sent: new Date(stanza.attributes.issue),
|
|
229
|
+
messageType: `Issued`,
|
|
230
|
+
event: `Special Weather Statement`,
|
|
231
|
+
sender: senderOffice,
|
|
232
|
+
senderName: `${senderOffice}`,
|
|
233
|
+
description: getDescription,
|
|
234
|
+
geocode: {
|
|
235
|
+
UGC: mUgc.zones,
|
|
236
|
+
},
|
|
237
|
+
parameters: {
|
|
238
|
+
WMOidentifier: defaultWMO?.[0] ? [defaultWMO[0]] : [`N/A`],
|
|
239
|
+
tornadoDetection: getTornado || `N/A`,
|
|
240
|
+
maxHailSize: getHail || `N/A`,
|
|
241
|
+
maxWindGust: getGusts || `N/A`,
|
|
242
|
+
thunderstormDamageThreat: [getThreat || `N/A`],
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
geometry: { type: `Polygon`, coordinates: [getCoordinates] }
|
|
246
|
+
}
|
|
247
|
+
if (loader.settings.alertSettings.ugcPolygons) {
|
|
248
|
+
let coordinates = await loader.packages.mUGC.getCoordinates(mUgc .zones);
|
|
249
|
+
if (coordinates.length > 0) {
|
|
250
|
+
alert.geometry.coordinates = [coordinates];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
alerts.push(alert);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
this.onFinished(alerts);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* @function newMesoscaleDiscussion
|
|
261
|
+
* @description Creates a new mesoscale discussion event from the provided stanza.
|
|
262
|
+
*
|
|
263
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
264
|
+
*/
|
|
265
|
+
|
|
266
|
+
newMesoscaleDiscussion = async function(stanza) {
|
|
267
|
+
let message = stanza.message.split(/(?=\$\$)/g).map(msg => msg.trim());
|
|
268
|
+
let defaultWMO = stanza.message.match(new RegExp(loader.definitions.expressions.wmo, 'gimu'));
|
|
269
|
+
for (let msg of message) {
|
|
270
|
+
let startTime = new Date().getTime();
|
|
271
|
+
let mUgc = await loader.packages.mUGC.getUGC(msg);
|
|
272
|
+
if (mUgc) {
|
|
273
|
+
let senderOffice = loader.packages.mText.getOffice(msg) || `NWS`;
|
|
274
|
+
let getDescription = loader.packages.mText.getCleanDescription(msg, null);
|
|
275
|
+
let tornadoIntensityProbability = loader.packages.mText.getString(msg, `MOST PROBABLE PEAK TORNADO INTENSITY...`)
|
|
276
|
+
let windIntensityProbability = loader.packages.mText.getString(msg, `MOST PROBABLE PEAK WIND GUST...`)
|
|
277
|
+
let hailIntensityProbability = loader.packages.mText.getString(msg, `MOST PROBABLE PEAK HAIL SIZE...`)
|
|
278
|
+
let alert = {
|
|
279
|
+
hitch: `${new Date().getTime() - startTime}ms`,
|
|
280
|
+
id: `Wire-${defaultWMO ? defaultWMO[0] : `N/A`}-${mUgc.zones.join(`-`)}`,
|
|
281
|
+
tracking: `${defaultWMO ? defaultWMO[0] : `N/A`}-${mUgc.zones.join(`-`)}`,
|
|
282
|
+
action: `Issued`,
|
|
283
|
+
history: [],
|
|
284
|
+
properties: {
|
|
285
|
+
areaDesc: mUgc.locations.join(`; `) || `N/A`,
|
|
286
|
+
expires: new Date(new Date().getTime() + 1 * 60 * 60 * 1000),
|
|
287
|
+
sent: new Date(stanza.attributes.issue),
|
|
288
|
+
messageType: `Issued`,
|
|
289
|
+
event: `Mesoscale Discussion`,
|
|
290
|
+
sender: senderOffice,
|
|
291
|
+
senderName: `${senderOffice}`,
|
|
292
|
+
description: getDescription,
|
|
293
|
+
geocode: {
|
|
294
|
+
UGC: mUgc.zones,
|
|
295
|
+
},
|
|
296
|
+
parameters: {
|
|
297
|
+
WMOidentifier: defaultWMO?.[0] ? [defaultWMO[0]] : [`N/A`],
|
|
298
|
+
tornadoIntensityProbability: tornadoIntensityProbability || `N/A`,
|
|
299
|
+
hailIntensityProbability: hailIntensityProbability || `N/A`,
|
|
300
|
+
windIntensityProbability: windIntensityProbability || `N/A`,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
loader.static.events.emit(`onMesoscaleDiscussion`, alert);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* @function newStormReport
|
|
311
|
+
* @description Creates a new storm report event from the provided stanza.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
314
|
+
*/
|
|
315
|
+
|
|
316
|
+
newStormReport = async function(stanza) {
|
|
317
|
+
loader.static.events.emit(`onStormReport`, loader.packages.mText.getCleanDescription(stanza.message, null));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = new NoaaWeatherWireServiceEvents();
|
package/src/stanza.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| '_ ` _ \ / _ \/ __| '_ \| '_ \ / _ \ '__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: k3yomi@GitHub
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let loader = require(`../bootstrap.js`);
|
|
15
|
+
|
|
16
|
+
class NoaaWeatherWireServiceStanza {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @function newStanza
|
|
20
|
+
* @description Creates a new alert stanza from the provided message and attributes.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
23
|
+
* @param {boolean} isDebug - Optional debug information.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
newStanza = function(stanza, isDebug=false) {
|
|
27
|
+
try {
|
|
28
|
+
if (isDebug != false) {
|
|
29
|
+
let message = isDebug.stanza
|
|
30
|
+
let attributes = isDebug.attrs;
|
|
31
|
+
let isCap = message.includes(`<?xml version="1.0"`)
|
|
32
|
+
let hasCapArea = message.includes(`<areaDesc>`);
|
|
33
|
+
let hasVtec = message.match(loader.definitions.expressions.vtec) != null;
|
|
34
|
+
let getId = this.getAwipsType(attributes)
|
|
35
|
+
return { message: message, attributes: attributes, isCap: isCap, hasCapArea: hasCapArea, hasVtec: hasVtec, id: getId, ignore: false }
|
|
36
|
+
}
|
|
37
|
+
if (stanza.is(`message`)) {
|
|
38
|
+
let cb = stanza.getChild(`x`);
|
|
39
|
+
if (cb?.children) {
|
|
40
|
+
let message = cb.children[0];
|
|
41
|
+
let attributes = cb.attrs;
|
|
42
|
+
let isCap = message.includes(`<?xml version="1.0"`)
|
|
43
|
+
let hasCapArea = message.includes(`<areaDesc>`);
|
|
44
|
+
let hasVtec = message.match(loader.definitions.expressions.vtec) != null;
|
|
45
|
+
let getId = this.getAwipsType(attributes)
|
|
46
|
+
this.saveCache(message, attributes, getId, isCap, hasVtec);
|
|
47
|
+
return { message: message, attributes: attributes, isCap: isCap, hasCapArea: hasCapArea, hasVtec: hasVtec, id: getId, ignore: false }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { message: null, arrtributes: null, isCap: null, hasCapArea: null, hasVtec: null, id: null, ignore: true}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
console.error(`[!] Error in newStanza: ${error.stack}`);
|
|
53
|
+
return { message: null, arrtributes: null, isCap: null, hasCapArea: null, hasVtec: null, id: null, ignore: true }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @function getAwipsType
|
|
59
|
+
* @description Determines the AWIPS type based on the attributes of the message.
|
|
60
|
+
*
|
|
61
|
+
* @param {object} attributes - The attributes of the message.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
getAwipsType = function(attributes) {
|
|
65
|
+
if (!attributes || !attributes.awipsid) return `unknown`;
|
|
66
|
+
for (let [prefix, type] of Object.entries(loader.definitions.awips)) { if (attributes.awipsid.startsWith(prefix)) return type; }
|
|
67
|
+
return `default`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @function createNewAlert
|
|
72
|
+
* @description Creates a new alert based on the stanza type and its properties.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} stanza - The stanza object containing message and attributes.
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
createNewAlert = async function(stanza) {
|
|
78
|
+
let type = stanza.id
|
|
79
|
+
let cap = stanza.isCap;
|
|
80
|
+
let vtec = stanza.hasVtec;
|
|
81
|
+
if (type == `default` && vtec && !cap) { loader.packages.mEvents.newRawProductEvent(stanza); return; }
|
|
82
|
+
if (type == `default` && vtec && cap) { loader.packages.mEvents.newCapEvent(stanza); return; }
|
|
83
|
+
if (type == `special-weather-statement`) { loader.packages.mEvents.newSpecialEvent(stanza); return; }
|
|
84
|
+
if (type == `mesoscale-discussion`) { loader.packages.mEvents.newMesoscaleDiscussion(stanza); return; }
|
|
85
|
+
if (type == `local-storm-report`) { loader.packages.mEvents.newStormReport(stanza); return; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @function saveCache
|
|
90
|
+
* @description Saves the raw message to a cache file for later reference.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} message - The message to save.
|
|
93
|
+
* @param {string} type - The type of the message (e.g., alert, warning).
|
|
94
|
+
* @param {boolean} isCap - Indicates if the message is in CAP format.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
saveCache = function(message, attributes, type, isCap, isVtec) {
|
|
98
|
+
if (!loader.settings.cacheSettings.cacheDir) return;
|
|
99
|
+
loader.packages.fs.appendFileSync(`${loader.settings.cacheSettings.cacheDir}/nwws-raw-category-${type}s-${isCap ? 'cap' : 'raw'}${isVtec ? '-vtec' : ''}.bin`, `=================================================\n${new Date().toISOString().replace(/[:.]/g, '-')}\n\n[${JSON.stringify(attributes)}]\n=================================================\n\n${message}\n\n`, `utf8`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = new NoaaWeatherWireServiceStanza();
|
package/src/text.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| '_ ` _ \ / _ \/ __| '_ \| '_ \ / _ \ '__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: k3yomi@GitHub
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let loader = require(`../bootstrap.js`);
|
|
15
|
+
|
|
16
|
+
class NoaaWeatherWireServiceText {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @function getString
|
|
20
|
+
* @description Extracts a specific string from a message, removing specified substrings.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} message - The message to search within.
|
|
23
|
+
* @param {string} string - The string to extract.
|
|
24
|
+
* @param {Array} removeIfExists - An array of substrings to remove from the result.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
getString = function(message, string, removeIfExists=[]) {
|
|
28
|
+
let lines = message.split('\n');
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
if (lines[i].includes(string)) {
|
|
31
|
+
let start = lines[i].indexOf(string) + string.length, result = lines[i].substring(start).trim();
|
|
32
|
+
for (let j = 0; j < removeIfExists.length; j++) result = result.replace(removeIfExists[j], '');
|
|
33
|
+
return result.replace(string, '').replace(/^\s+|\s+$/g, '').replace('<', '').trim();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @function getOffice
|
|
41
|
+
* @description Extracts the office information from a message.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} message - The message to search within.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
getOffice = function(message) {
|
|
47
|
+
return this.getString(message, `National Weather Service`) || this.getString(message, `NWS STORM PREDICTION CENTER `) || null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @function getPolygonCoordinates
|
|
52
|
+
* @description Extracts polygon coordinates from a message.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} message - The message containing polygon coordinates.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
getPolygonCoordinates = function(message) {
|
|
58
|
+
let coordinates = [], latLon = message.match(/LAT\.{3}LON\s+([\d\s]+)/i);
|
|
59
|
+
if (latLon && latLon[1]) {
|
|
60
|
+
let coordStrings = latLon[1].replace(/\n/g, ' ').trim().split(/\s+/);
|
|
61
|
+
for (let i = 0; i < coordStrings.length - 1; i += 2) {
|
|
62
|
+
let lat = parseFloat(coordStrings[i]) / 100, long = -1 * (parseFloat(coordStrings[i + 1]) / 100);
|
|
63
|
+
if (!isNaN(lat) && !isNaN(long)) coordinates.push([long, lat]);
|
|
64
|
+
}
|
|
65
|
+
if (coordinates.length > 2) coordinates.push(coordinates[0]);
|
|
66
|
+
}
|
|
67
|
+
return coordinates;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @function getCleanDescription
|
|
72
|
+
* @description Cleans the description of a message by removing unnecessary parts.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} message - The message to clean.
|
|
75
|
+
* @param {object} vtec - The VTEC object containing raw VTEC information.
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
getCleanDescription = function(message, vtec) {
|
|
79
|
+
let dateLineMatches = [...message.matchAll(/\d{3,4}\s*(AM|PM)?\s*[A-Z]{2,4}\s+[A-Z]{3,}\s+[A-Z]{3,}\s+\d{1,2}\s+\d{4}/gim)];
|
|
80
|
+
if (dateLineMatches.length) {
|
|
81
|
+
let dateLineMatch = dateLineMatches[dateLineMatches.length - 1];
|
|
82
|
+
let nwsStart = message.lastIndexOf(dateLineMatch[0]);
|
|
83
|
+
if (nwsStart !== -1) {
|
|
84
|
+
let latStart = message.indexOf("&&", nwsStart);
|
|
85
|
+
message = latStart !== -1 ? message.substring(nwsStart + dateLineMatch[0].length, latStart).trim() : message.substring(nwsStart + dateLineMatch[0].length).trim();
|
|
86
|
+
if (message.startsWith('/')) message = message.substring(1).trim();
|
|
87
|
+
if (vtec && vtec.raw && message.includes(vtec.raw)) {
|
|
88
|
+
let vtecIndex = message.indexOf(vtec.raw);
|
|
89
|
+
if (vtecIndex !== -1) {
|
|
90
|
+
message = message.substring(vtecIndex + vtec.raw.length).trim();
|
|
91
|
+
if (message.startsWith('/')) message = message.substring(1).trim();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
let vtecStart = message.indexOf(vtec.raw);
|
|
97
|
+
if (vtecStart !== -1) {
|
|
98
|
+
let afterVtec = message.substring(vtecStart + vtec.raw.length);
|
|
99
|
+
if (afterVtec.startsWith('/')) afterVtec = afterVtec.substring(1);
|
|
100
|
+
let latStart = afterVtec.indexOf("&&");
|
|
101
|
+
message = latStart !== -1 ? afterVtec.substring(0, latStart).trim() : afterVtec.trim();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return message
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
module.exports = new NoaaWeatherWireServiceText();
|
package/src/ugc.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| '_ ` _ \ / _ \/ __| '_ \| '_ \ / _ \ '__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: k3yomi@GitHub
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let loader = require(`../bootstrap.js`);
|
|
15
|
+
|
|
16
|
+
class NoaaWeatherWireServiceUGC {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @function getUGC
|
|
20
|
+
* @description Extracts UGC (FIPS) information from a message.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} message - The message containing UGC information.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
getUGC = async function(message) {
|
|
26
|
+
let header = this.getHeader(message);
|
|
27
|
+
let zones = this.getZones(header);
|
|
28
|
+
let locations = await this.getLocations(zones)
|
|
29
|
+
let ugc = zones.length > 0 ? { zones, locations} : null;
|
|
30
|
+
return ugc
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @function getHeader
|
|
35
|
+
* @description Extracts the UGC header from the message.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} message - The message containing the UGC header.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
getHeader = function(message) {
|
|
41
|
+
let start = message.search(new RegExp(loader.definitions.expressions.ugc1, "gimu"));
|
|
42
|
+
let end = message.substring(start).search(new RegExp(loader.definitions.expressions.ugc2, "gimu"));
|
|
43
|
+
let full = message.substring(start, start + end).replace(/\s+/g, '').slice(0, -1);
|
|
44
|
+
return full;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @function getLocations
|
|
49
|
+
* @description Retrieves the locations associated with the UGC zones.
|
|
50
|
+
* If a location is not found in the database, the zone ID is returned.
|
|
51
|
+
*
|
|
52
|
+
* @param {Array} zones - The UGC zones to retrieve locations for.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
getLocations = async function(zones) {
|
|
56
|
+
let locations = [];
|
|
57
|
+
for (let i = 0; i < zones.length; i++) {
|
|
58
|
+
let id = zones[i].trim();
|
|
59
|
+
let located = await loader.static.db.prepare(`SELECT location FROM shapefiles WHERE id = ?`).get(id);
|
|
60
|
+
located != undefined ? locations.push(located.location) : locations.push(id);
|
|
61
|
+
}
|
|
62
|
+
return Array.from(new Set(locations));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @function getCoordinates
|
|
67
|
+
* @description Retrieves the coordinates for the UGC zones.
|
|
68
|
+
*
|
|
69
|
+
* @param {Array} zones - The UGC zones to retrieve coordinates for.
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
getCoordinates = async function(zones) {
|
|
73
|
+
let coordinates = [];
|
|
74
|
+
for (let i = 0; i < zones.length; i++) {
|
|
75
|
+
let id = zones[i].trim();
|
|
76
|
+
let located = await loader.static.db.prepare(`SELECT geometry FROM shapefiles WHERE id = ?`).get(id);
|
|
77
|
+
if (located != undefined) {
|
|
78
|
+
let geometry = JSON.parse(located.geometry);
|
|
79
|
+
if (geometry?.type == 'Polygon') {
|
|
80
|
+
coordinates.push(...geometry.coordinates[0].map(coord => [coord[0], coord[1]]));
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return coordinates;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @function getZones
|
|
90
|
+
* @description Parses the UGC header to extract zone IDs.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} header - The UGC header string.
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
getZones = function(header) {
|
|
96
|
+
let ugcSplit = header.split('-'), zones = [], state = ugcSplit[0].substring(0, 2), format = ugcSplit[0].substring(2, 3);
|
|
97
|
+
for (let i = 0; i < ugcSplit.length; i++) {
|
|
98
|
+
if (/^[A-Z]/.test(ugcSplit[i])) {
|
|
99
|
+
state = ugcSplit[i].substring(0, 2);
|
|
100
|
+
if (ugcSplit[i].includes('>')) {
|
|
101
|
+
let [start, end] = ugcSplit[i].split('>'), startNum = parseInt(start.substring(3), 10), endNum = parseInt(end, 10);
|
|
102
|
+
for (let j = startNum; j <= endNum; j++) zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
|
|
103
|
+
} else zones.push(ugcSplit[i]);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ugcSplit[i].includes('>')) {
|
|
107
|
+
let [start, end] = ugcSplit[i].split('>'), startNum = parseInt(start, 10), endNum = parseInt(end, 10);
|
|
108
|
+
for (let j = startNum; j <= endNum; j++) zones.push(`${state}${format}${j.toString().padStart(3, '0')}`);
|
|
109
|
+
} else zones.push(`${state}${format}${ugcSplit[i]}`);
|
|
110
|
+
}
|
|
111
|
+
return zones.filter(item => item !== '');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = new NoaaWeatherWireServiceUGC();
|
package/src/vtec.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/*
|
|
2
|
+
_ _ __ __
|
|
3
|
+
/\ | | | | (_) \ \ / /
|
|
4
|
+
/ \ | |_ _ __ ___ ___ ___ _ __ | |__ ___ _ __ _ ___ \ V /
|
|
5
|
+
/ /\ \| __| '_ ` _ \ / _ \/ __| '_ \| '_ \ / _ \ '__| |/ __| > <
|
|
6
|
+
/ ____ \ |_| | | | | | (_) \__ \ |_) | | | | __/ | | | (__ / . \
|
|
7
|
+
/_/ \_\__|_| |_| |_|\___/|___/ .__/|_| |_|\___|_| |_|\___/_/ \_\
|
|
8
|
+
| |
|
|
9
|
+
|_|
|
|
10
|
+
|
|
11
|
+
Written by: k3yomi@GitHub
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let loader = require(`../bootstrap.js`);
|
|
15
|
+
|
|
16
|
+
class NoaaWeatherWireServiceVtec {
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @function getVTEC
|
|
20
|
+
* @description Extracts VTEC information from a message and its attributes.
|
|
21
|
+
* @param {string} message - The message containing VTEC information.
|
|
22
|
+
* @param {object} attributes - The attributes of the message.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
getVTEC = function(message, attributes) {
|
|
26
|
+
let matches = message.match(new RegExp(loader.definitions.expressions.vtec, 'g'));
|
|
27
|
+
if (!matches) return null;
|
|
28
|
+
let vtecs = matches.map(match => {
|
|
29
|
+
let splitVTEC = match.split(`.`);
|
|
30
|
+
let vtecDates = splitVTEC[6].split(`-`);
|
|
31
|
+
return {
|
|
32
|
+
raw: match,
|
|
33
|
+
tracking: this.getTrackingIdentifier(splitVTEC),
|
|
34
|
+
event: this.getEventName(splitVTEC),
|
|
35
|
+
status: this.getEventStatus(splitVTEC),
|
|
36
|
+
wmo: message.match(new RegExp(loader.definitions.expressions.wmo, 'gimu')),
|
|
37
|
+
expires: this.getExpires(vtecDates),
|
|
38
|
+
issued: attributes.issue
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return vtecs;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @function getTrackingIdentifier
|
|
46
|
+
* @description Constructs a tracking identifier from the VTEC components.
|
|
47
|
+
* @param {Array} args - The components of the VTEC string.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
getTrackingIdentifier = function(args) {
|
|
51
|
+
return `${args[2]}-${args[3]}-${args[4]}-${args[5]}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @function getEventName
|
|
56
|
+
* @description Constructs a an event name from the VTEC components.
|
|
57
|
+
* @param {Array} args - The components of the VTEC string.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
getEventName = function(args) {
|
|
61
|
+
return `${loader.definitions.events[args[3]]} ${loader.definitions.actions[args[4]]}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @function getEventStatus
|
|
66
|
+
* @description Retrieves the status of the event from the VTEC components. (Issued, Updated, Extended, etc.)
|
|
67
|
+
* @param {Array} args - The components of the VTEC string.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
getEventStatus = function(args) {
|
|
71
|
+
return loader.definitions.status[args[1]]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @function getExpires
|
|
76
|
+
* @description Converts the VTEC expiration date into a standardized format.
|
|
77
|
+
* @param {Array} args - The components of the VTEC string.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
getExpires = function(args) {
|
|
81
|
+
if (args[1] == `000000T0000Z`) return `Invalid Date Format`;
|
|
82
|
+
let expires = `${new Date().getFullYear().toString().substring(0, 2)}${args[1].substring(0, 2)}-${args[1].substring(2, 4)}-${args[1].substring(4, 6)}T${args[1].substring(7, 9)}:${args[1].substring(9, 11)}:00`;
|
|
83
|
+
let local = new Date(new Date(expires).getTime() - 4 * 60 * 60000);
|
|
84
|
+
let pad = n => n.toString().padStart(2, '0');
|
|
85
|
+
return `${local.getFullYear()}-${pad(local.getMonth() + 1)}-${pad(local.getDate())}T${pad(local.getHours())}:${pad(local.getMinutes())}:00.000-04:00`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = new NoaaWeatherWireServiceVtec();
|
package/test.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
AtmosXWireParser = require(`./index.js`);
|
|
2
|
+
|
|
3
|
+
let Client = new AtmosXWireParser({
|
|
4
|
+
alertSettings: {
|
|
5
|
+
onlyCap: false, // Set to true to only receive CAP messages only
|
|
6
|
+
betterEvents: true, // Set to true to receive better event handling
|
|
7
|
+
ugcPolygons: false, // Set to true to receive UGC Polygons instead of reading from raw products.
|
|
8
|
+
filteredAlerts: [] // Alerts you want to only log, leave empty to receive all alerts (Ex. ["Tornado Warning", "Radar Indicated Tornado Warning"])
|
|
9
|
+
},
|
|
10
|
+
xmpp: {
|
|
11
|
+
reconnect: true, // Set to true to enable automatic reconnection if you lose connection
|
|
12
|
+
reconnectInterval: 60, // Interval in seconds to attempt reconnection
|
|
13
|
+
},
|
|
14
|
+
cacheSettings: {
|
|
15
|
+
maxMegabytes: 2, // Maximum cache size in megabytes
|
|
16
|
+
cacheDir: `./cache`, // Directory for cache files
|
|
17
|
+
readCache: false, // Set to true if you wish to reupload the cache from earlier
|
|
18
|
+
},
|
|
19
|
+
authentication: {
|
|
20
|
+
username: `USERNAME_HERE`, // Your XMPP username
|
|
21
|
+
password: `PASSWORD_HERE`, // Your XMPP password
|
|
22
|
+
display: `DISPLAY_NAME` // Display name for your XMPP client
|
|
23
|
+
},
|
|
24
|
+
database: `./database.db`, // Path to the SQLite database file (It will be created if it doesn't exist and will be used to store UGC counties and zones.)
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
Client.onEvent(`onAlert`, (alert) => {console.log(alert)});
|
|
28
|
+
Client.onEvent(`onStormReport`, (report) => {});
|
|
29
|
+
Client.onEvent(`onMesoscaleDiscussion`, (discussions) => {});
|
|
30
|
+
Client.onEvent(`onMessage`, (message) => {});
|
|
31
|
+
Client.onEvent(`onOccupant`, (occupant) => {});
|
|
32
|
+
Client.onEvent(`onError`, (error) => {console.log(error)});
|
|
33
|
+
Client.onEvent(`onReconnect`, (service) => {
|
|
34
|
+
Client.setDisplayName(`${username} (x${service.reconnects})`)
|
|
35
|
+
})
|